Domain Events are a fundamental building block in DDD, if you want to indicate an event that is significant to your app, raise this event and let other modules of your app subscribe and react to it.
This project exemplifies a CreateUser
use case and how we can trigger an event, signaling we have a new user onboard.
Domain events (e.g. UserCreatedEvent) are dispatched after the aggregates (User) changes are persisted in the database. We can subscribe to it from the same module (SomeWork) or a different one (StoreEvent, NotifySlackChannel, CreateAccount).
Communication in the same module (as the case of SomeWork) is given as an example but using domain events for intra-module communication may involve adding an indirection that doesn't add value and a direct/explicit flow is more convenient.
The lambda entry point for CreateUser
use case is src/modules/users/useCases/createUser/index.ts, there we:
- Create CreateUser controller
- In
CreateUser.constructor
we registerUserCreatedEvent
to an intermediate lambda DistributeDomainEvents that will invoke all its subscribers (StoreEvent
,CreateAccount
,NotifySlackChannel
andSomeWork
lambdas).
This is the transaction tracing from Lumigo:
If it's been some time since last request, we get cold starts and the execution of createUser
takes ~1,2s, while all the invoked lambdas (distributeDomainEvents
, createAccount
, notifySlackChannel
, storeEvent
and someWrok
) take an extra ~2,1s, so the whole distributed transaction takes ~3,3s:
If we repeat a request in the next 5m, we don't have cold starts, createUser
takes 240ms, all the invoked lambdas an extra ~280ms, so the whole distributed transaction takes ~520ms:
For cross-cutting concerns these decorators are used:
- ReturnUnexpectedError: When we receive a client request and the server throws an unexpected error, we log the error, request and context; and return a well-formed error response to the client. Use cases: CreateUser, GetAccountByUserID, CreateTransaction and Transfer
- Transaction: All command use cases that use the SQL DB should be wrapped in a serializable transaction (for query use cases don't). Use cases: CreateUser, CreateAccount, CreateTransaction and Transfer
- DBretry: Handle retries for DB/Sequelize connection failures. Use cases: CreateUser, CreateAccount, GetAccountByUserID, CreateTransaction and Transfer
- Cache is query specific: GetAccountByUserIdCache
Unit tests (with faked repos):
- Value Objects:
- Users: UserName, UserEmail, UserPassword, Alias
- Accounts: Amount, Description
- Aggregates:
- Users: User
- Accounts: Account (with internal entity Transaction)
- Services:
- Accounts: AccountService
- Use cases/controllers:
- Users: CreateUser, SomeWork
- Notification: NotifySlackChannel, NotifyFE
- Accounts: CreateTransaction, GetAccountByUserId, Transfer
- Audit: StoreEvent
Integration tests (real repos):
- Users:
- CreateUser, event registration and dispatching CreateUserEvents
- Accounts:
- GetAccountByUserId
- CreateTransaction, event registration and dispatching CreateTransactionEvents
- Transfer, dispatching the creation of 2 transactions TransferEvents
E2E tests:
- Users:
- Accounts:
- Notifications:
Blue solid lines are extends, while green dashed ones are implements.
- DBs: PostgreSQL CockroachDB Serverless and DynamoDB
- ORM: Sequelize
- IaC: SST Serverless Stack
- AWS services: Lambda, AppSync, Systems Manager Parameter Store
- Testing: Jest
I've used SST Serverless Stack as it allows debugging lambda code locally while being invoked remotely by resources in AWS.
I started this project using Khalil Stemmler's white-label users
module and applied some concepts based on Vladimir Khorikov courses where he tackles DDD in a great way.
I also added modules accounts
, audit
, notifications
, decorators for cross-cutting concerns to support SQL transactions and DB retry, refactored from REST to GraphQL API, turned it into serverless and wrote a bunch of tests.
Use same Node 16 version as in the pipeline, using nvm you can:
# set Node 16 in current terminal
nvm use 16
# set Node 16 as default (new terminals will use 16)
nvm alias default 16
Install and run tests:
npm ci
npm test
It may take a few minutes to run all unit tests. For new projects, I recommend using
vitest
, which is much faster.