Domain Events
Model 'something happened' as a first-class object. Events decouple aggregates and enable eventual consistency across a boundary, building on the observer pattern.
On this page
The aggregate rule "one transaction, one aggregate" raises an obvious question: what happens when something in the business spans several aggregates? A posted transaction must update an account, notify the customer, and feed a fraud check - three different aggregates or subsystems. The DDD answer is the domain event: model "something meaningful happened" as a first-class object, publish it, and let interested parties react. It's the Observer pattern elevated to a domain concept.
An event is a business fact
A domain event captures something that happened in the domain, in the past tense, in the ubiquitous
language: TransactionPosted, AccountOverdrawn, OrderShipped. It's an immutable value object recording the
fact and its relevant data:
record TransactionPosted( // past tense - it already happened, it's a fact
TransactionId transactionId,
AccountId accountId,
Money amount,
Instant occurredAt
) {}The aggregate that owns the change raises the event as part of its operation, without knowing or caring who will handle it:
class Account {
private final List<DomainEvent> events = new ArrayList<>();
public void post(Money amount, String memo) {
// ... update balance and postings (the aggregate's own invariant) ...
events.add(new TransactionPosted(txnId, id, amount, clock.instant())); // record the fact
}
public List<DomainEvent> pullEvents() { var e = List.copyOf(events); events.clear(); return e; }
}After the aggregate's transaction commits, the infrastructure publishes the collected events; handlers in other
aggregates or subsystems react. In Spring, this is ApplicationEventPublisher / @DomainEvents (on a JPA
aggregate root) plus @EventListener - the framework's built-in Observer, now speaking domain language.
Eventual consistency between aggregates
Here's the key architectural consequence. The account update and the customer notification are in different aggregates, so they must not share one transaction (that would violate aggregate boundaries and create a giant lock). Instead:
Transaction 1: Account.post() commits → raises TransactionPosted
↓ (event published after commit)
Transaction 2: NotificationHandler reacts, sends the receipt (its own transaction)
Transaction 2': FraudHandler reacts, scores the transaction (its own transaction)Each aggregate stays strongly consistent within itself; the aggregates become eventually consistent with each other - there's a brief window where the transaction is posted but the receipt hasn't sent yet. That's usually fine (and often required for scale), but it's a real design decision: you're trading immediate cross-aggregate consistency for decoupling and scalability. Name that trade explicitly.
Events cross boundaries; direct calls don't have to
Not every cross-object interaction needs an event. Within a single aggregate, just call methods - events there are overkill. Reach for a domain event when the reaction belongs to a DIFFERENT aggregate or bounded context, or when you want the publisher decoupled from a growing, varying set of reactions. And beware the reliability gap: if the process dies between committing the account and publishing the event, the receipt is lost - production systems use the transactional outbox pattern (persist the event in the same transaction, publish it separately) to close that hole.
Imagine a ship's captain who, on every event, personally phones the engine room, the galley, and head office (direct coupled calls) - they can't focus on sailing, and adding a new department means another call to place. A domain event is instead an entry in the ship's log: 'Cargo loaded at 14:00' - an immutable record of a fact. Whoever needs to act reads the log and responds in their own time (the quartermaster updates stores, accounting bills the client). The captain records what happened and sails on, indifferent to who reacts. The log entry is past-tense and factual; the reactions are decoupled and eventually catch up.
In ledger-legacy, when a large transaction posts, three things must happen: the account balance updates, the customer gets an email receipt, and a fraud-scoring service evaluates it. Currently it's one giant transaction touching all three, causing lock contention and a failure in the email server rolling back the whole posting. Redesign this with domain events, and state the consistency trade-off you're accepting.
Why do aggregates communicate via domain events instead of one shared transaction?
Key takeaways
- A domain event is an immutable, past-tense object capturing a business fact (TransactionPosted) in the ubiquitous language.
- The owning aggregate raises the event during its operation without knowing who handles it - the Observer pattern as a domain concept.
- Events let one transaction modify one aggregate while other aggregates react in separate transactions, honoring aggregate boundaries.
- This makes aggregates eventually consistent with each other - a deliberate trade of immediate cross-aggregate consistency for decoupling and scale.
- Use events across aggregate/context boundaries (not within one aggregate), and use a transactional outbox to avoid losing events on a crash.