Domain Events
Domain events are the most misused pattern in DDD. Teams either ignore them entirely (missing decoupling opportunities) or spam them everywhere (creating a distributed monolith that's harder to understand than the original monolith). The difference is in how you name them, size them, and decide when to publish.
A domain event is not a database trigger with a fancy name. It represents something that happened in the domain — past tense, meaningful to a domain expert, not to a developer.
Naming: Past Tense, Domain Language
The naming rule is simple: past tense, in the language your domain expert uses. Not the language your ORM uses.
WRONG: RIGHT:
OrderUpdated OrderConfirmed
UserModified CustomerOnboarded
PaymentInserted PaymentCaptured
StatusChanged SubscriptionSuspended
ItemAdded ProductAddedToCartIf a domain expert wouldn't recognise the event name in a business conversation, rename it. "OrderUpdated" tells you nothing. "OrderConfirmed" tells you the order moved through the approval workflow. These are different events even if they both set status = 'confirmed' on the same row.
Granularity: One Fact Per Event
An event should record one thing that happened, at the granularity that matters to downstream consumers.
The test: if you have two subscribers that need to react to the same occurrence but for completely different reasons (warehouse starts picking, finance generates an invoice), and both can work from the same event payload without needing to re-query, your event is well-sized.
// Too coarse: "something changed" — forces re-querying to find out what
interface OrderUpdated {
orderId: string;
timestamp: Date;
// Consumer has to call back to find out what changed
}
// Too fine: internal state leak — couples consumers to implementation details
interface OrderLineItemUnitPriceRecalculated {
orderId: string;
lineItemId: string;
oldUnitPrice: number;
newUnitPrice: number;
}
// Just right: one business fact, self-contained payload
interface OrderConfirmed {
orderId: string;
customerId: string;
confirmedAt: Date;
lineItems: Array<{
productId: string;
sku: string;
quantity: number;
unitPrice: { amount: number; currency: string };
}>;
totalAmount: { amount: number; currency: string };
}The OrderConfirmed event carries everything the warehouse and finance contexts need. Neither has to call back to the order service to do their job.
Collecting Events Inside the Aggregate
Events are raised inside the aggregate, not outside. The aggregate decides when something domain-significant happened. The infrastructure publishes after the aggregate is saved.
public class Order {
private final List<DomainEvent> domainEvents = new ArrayList<>();
public void confirm() {
if (lineItems.isEmpty()) throw new DomainException("Cannot confirm empty order");
this.status = OrderStatus.CONFIRMED;
this.confirmedAt = Instant.now();
domainEvents.add(new OrderConfirmed(
this.id,
this.customerId,
this.confirmedAt,
Collections.unmodifiableList(this.lineItems),
this.total()
));
}
public List<DomainEvent> pullDomainEvents() {
List<DomainEvent> events = List.copyOf(domainEvents);
domainEvents.clear();
return events;
}
}// Application service: save first, then publish
@Transactional
public void confirmOrder(ConfirmOrderCommand command) {
Order order = orderRepository.findById(command.orderId())
.orElseThrow(OrderNotFound::new);
order.confirm();
orderRepository.save(order);
List<DomainEvent> events = order.pullDomainEvents();
eventPublisher.publishAll(events); // after commit
}The pullDomainEvents() pattern is critical. The aggregate accumulates events during its lifecycle. The application service pulls and publishes them after the transaction commits. This keeps events consistent with the state change.
The Outbox Pattern: Don't Lose Events
Publishing after the transaction commit is correct in principle, but what if the publisher crashes between the commit and the publish? You saved the state but lost the event.
The outbox pattern solves this by writing events to an outbox table inside the same transaction as the aggregate state.
@Transactional
public void confirmOrder(ConfirmOrderCommand command) {
Order order = orderRepository.findById(command.orderId()).orElseThrow();
order.confirm();
orderRepository.save(order);
List<DomainEvent> events = order.pullDomainEvents();
// Written to outbox in same transaction — atomically consistent
outboxRepository.saveAll(events.stream()
.map(OutboxMessage::from)
.toList());
// A separate relay process reads the outbox and publishes to the broker
}Versioning Events
Events are part of your public API if anything outside the bounded context subscribes to them. Version them from day one.
// v1
interface OrderConfirmedV1 {
version: 1;
orderId: string;
totalAmount: number; // bad: no currency
}
// v2: backward compatible addition
interface OrderConfirmedV2 {
version: 2;
orderId: string;
totalAmount: { amount: number; currency: string }; // fixed
customerId: string; // new field
}Embed the version in the event payload, not just the topic/queue name. This lets consumers handle multiple versions during migration.
Key Takeaways
- Name events in past tense using domain language — if a domain expert wouldn't say it in a meeting, rename the event.
- Size events to carry one business fact with enough payload that consumers don't need to call back to re-query state.
- Collect events inside the aggregate, pull them in the application service, and publish after the transaction commits.
- Use the outbox pattern to guarantee events are published even if the process crashes between commit and publish.
- Events consumed outside your bounded context are a versioned public API — version them from the first subscriber.
- Avoid "status changed" events; name the specific business transition that occurred instead.