Domain Events in Domain-Driven Design
How Aggregates Communicate Without Coupling
When state changes in one part of the system, other parts often need to know. The most direct response is to call them from the code that made the change. A new reaction means a new call added to the same place. Over time, the code responsible for a single state change ends up coordinating an ever-growing list of consequences it was never meant to own.
Domain events separate those responsibilities. An aggregate records what happened as a fact, in the language of the domain. What the rest of the system does with that fact is its own concern.
The previous article established aggregates as the consistency boundary that enforces invariants across a group of objects. This article covers what happens after a state change: how aggregates communicate what occurred without coupling to the parts of the system that care.
Consequences the Aggregate Should Not Own
On the learning platform, publishing a course is a meaningful state change. Enrolled students expect an email notification. The search index should reflect the new course. The recommendation engine may need to update its data. Several systems care, and the aggregate knows none of them.
One place to put post-publish logic is on the aggregate itself. The Course root calls the notification service directly, updates the search index, and notifies the recommendation engine. It now has four responsibilities: enforcing course rules, managing course state, sending notifications, and updating external indexes. Every new consumer is another dependency on the aggregate.
Moving those calls out of the aggregate and into the code that invokes it seems like a better fit. The aggregate stays focused. But the problem does not go away. Every code path that can publish a course now needs those same calls: a background job that bulk-publishes courses, an admin tool that bypasses the main flow, a scheduled job that auto-publishes courses on a set date. Each one accumulates the same responsibilities. When a new consumer appears, every entry point needs updating, and some will be missed.
The aggregate knows what it changed. It does not need to know what that means to the rest of the system.
Events Are Facts, Not Commands
A domain event represents something that happened. It is a fact, stated in past tense, in the ubiquitous language of the context.1 CoursePublished, not PublishCourse. EnrollmentCompleted, not EnrollmentStatusUpdated. The name captures business intent, not a technical operation.
This distinction changes the relationship between the aggregate and its consequences. When the aggregate raises a CoursePublished event, it is recording what occurred. It is not issuing a command to the notification service. It is not telling the search index what to do. What handlers do with that fact is their own responsibility. The aggregate does not know who is listening, and it does not need to.
The practical consequence is that adding a new consumer never requires touching the aggregate. The recommendation engine starts listening to CoursePublished. The aggregate is unchanged. A handler that no longer makes sense is removed. Again, the aggregate is unchanged. The aggregate’s job is to enforce its rules and record what happened.
Raising Events
An event raised by the aggregate needs to reach the code that reacts to it. That code is a handler. It registers interest in a specific event type and runs when that event is dispatched. There are three common patterns for how events move from aggregate to handler, and the choice between them has real consequences for testability and safety.
The most common pattern is an internal collection. The aggregate accumulates events in a list during a command. After the transaction commits, the calling code reads that list and dispatches the events to handlers. After calling course.publish(), the caller inspects course.domainEvents() and dispatches whatever is there.
The aggregate does not dispatch anything itself. This pattern is straightforward to test: call the method, inspect the events list, verify what was raised. No notification services or search clients need to be involved in a unit test at all.
A second pattern has the command method return events directly instead of collecting them. The method returns a value that includes both the updated state and the events it produced. The caller handles dispatch from there. This works cleanly in functional styles but requires the caller to always handle the return value correctly.
The third pattern uses a static or ambient event bus that the aggregate calls directly. Events are raised immediately at the point they occur. This is the most convenient to write but the hardest to test, and the most likely to produce ordering surprises when one handler raises additional events.
Each handler owns one consequence, and no handler knows about the others. Adding a consequence means writing a new handler. Nothing else changes. The aggregate does not know about the new handler. Existing handlers are untouched. On the learning platform, adding the recommendation engine’s listener to CoursePublished required touching exactly one file.
When Handlers Fail
How a handler is invoked determines what happens when it fails. Synchronous handlers run in the same transaction as the aggregate’s state change. If a handler throws, the transaction rolls back and the aggregate’s state change is undone along with it. The operation fails as a unit. Async handlers run outside the transaction. If an async handler fails, the aggregate’s state change has already committed. The consequence is lost unless the handler retries.
Designing for retry means designing handlers to be idempotent. A handler that emails enrolled students when a course is published must be able to run twice without sending two emails. One approach checks for the existing consequence before applying it. Before sending the notification, verify that no email for this course and this student has already gone out.
A more general approach records processed event identifiers in a persistent store. The handler checks whether it has already processed this particular event and skips if it has. Whichever approach fits the situation, idempotency must be considered from the start. A handler written only for the happy path will cause duplicate consequences the first time a message broker delivers an event twice.
This connects to the transactional outbox pattern covered in the earlier article on integrating bounded contexts. The internal collection pattern dispatches events after the transaction commits. If the process crashes between the commit and the dispatch, no handlers run. The outbox solves this by writing events to a table inside the same transaction, so the commit guarantees they will eventually be dispatched even if the process fails at the worst moment.
What Events Buy and What They Cost
Direct method calls are behaviorally coupled. The caller decides what happens. When the code that publishes a course directly calls the notification service and the search index updater, that code controls the sequence, the conditions, and the timing. Adding a new consequence means changing the caller.
Events eliminate behavioral coupling. The aggregate records what happened and leaves everything that follows to the handlers. Neither the aggregate nor the code that triggered it controls what comes next.
But async handlers introduce temporal coupling. An instructor might see the course listed as published immediately, while the notification email to enrolled students is still waiting in a queue. A recommendation engine might serve stale results while its update is pending. The aggregate’s state changed; the consequences have not yet caught up.
This is a real trade-off, not a flaw to be engineered away. Synchronous in-process dispatch preserves immediate consistency. If the notification handler runs in the same transaction, either everything succeeds or nothing does. Async dispatch buys resilience. A slow or temporarily unavailable notification service cannot block a course from being published. The right choice depends on how much the system can tolerate consequences happening later.
Making this trade-off explicit, in code and in design discussions, is part of what domain events contribute. Direct calls to other parts of the system accumulate gradually, one requirement at a time. Using events is a deliberate choice to decouple, and that intent is visible in the design even when the consequences are harder to trace in a single place.
Designing Domain Events Well
Once the pattern for raising events is in place, a separate question remains. What should an event actually contain? Put too little in and handlers have to call back into the domain to get what they are missing. Put too much in and the event becomes a contract that every handler depends on, making it hard to change.
At minimum, an event should carry the aggregate’s identifier and the relevant identifiers of anything involved in the change. A CoursePublished event carries the CourseId. An EnrollmentCompleted event carries the EnrollmentId and the CourseId. Handlers load what they need from there.
Richer events carry more state at the time of the change. A handler that builds a search index does not want to reload the course just to get its title. Including the title in the event avoids an extra round trip and preserves a snapshot of the state at the moment it occurred. The tradeoff is that richer events are harder to evolve. A field added to the event is a contract change for every handler that reads it.
Event granularity matters. One coarse event for every state change tends to produce handlers that branch heavily on what actually changed. Several fine-grained events make each handler smaller and more focused, but a cascade of events from a single command is harder to trace. If handlers regularly need very different data from the same event, the event design may need revisiting.
Not every event that crosses a boundary should look the same as one used internally. The distinction between domain events and integration events was covered in the earlier article on Integrating Bounded Contexts. A domain event carries the internal vocabulary and detail of its context. What other contexts receive is a deliberately designed integration event that exposes only what they need and can evolve independently.
When Not to Use Domain Events
Within the same aggregate, domain events add noise without benefit. If adding a lesson to a course needs to update an internal counter, a method call handles it. There is no audience for an event here. The aggregate owns both sides of the operation. A method call is direct, easy to trace, and impossible to miss.
When the consequence is deliberate and coupling is acceptable, a direct call is clearer. If an enrollment cannot proceed without first confirming payment with a billing system, an event is the wrong tool. The operation should either succeed with the billing confirmation or fail. A direct call with a clear failure mode communicates that intent; an event handled asynchronously obscures it.
Events are for consequences the aggregate genuinely should not know about. When every state change raises an event and every handler raises another, the chain becomes a form of indirection. Tracing why something happened means following that chain across multiple files rather than reading a single method.
On the learning platform, a LessonViewed event that triggers ProgressUpdated, which triggers CompletionChecked, which triggers CertificateEligibilityEvaluated might look like a clean decomposition at first. Six months later, it is a debugging exercise. That cost is worth paying when the aggregate should genuinely be shielded from its consequences. It is not worth paying when a method call would be just as clear.
What Comes Next
The aggregate enforces its rules, records what happened, and raises events that handlers can react to. But something still has to orchestrate each use case. Who loads the aggregate? Who calls its method, persists the result, and dispatches the events afterward? That coordination does not belong on the aggregate itself.
On the learning platform, enrolling a student means loading the right aggregate, calling a method on it, saving the updated state, and ensuring the resulting event reaches its handlers. Where does that code live, and how does persistence work without leaking infrastructure concerns into the domain model? The next article covers repositories and application services: the two patterns that tie the tactical building blocks into a working unit.
Vaughn Vernon, Implementing Domain-Driven Design. Addison-Wesley, 2013. ↩︎
This article is part of the Domain-Driven Design series
- 1. What Is Domain-Driven Design?
- 2. Subdomains and Bounded Contexts in Domain-Driven Design
- 3. Context Mapping in Domain-Driven Design
- 4. Integrating Bounded Contexts
- 5. Matching Architecture to the Subdomain
- 6. Entities and Value Objects in Domain-Driven Design
- 7. Aggregates in Domain-Driven Design
- 8. Domain Events in Domain-Driven Design