Why Domain Events Matter
When an order is placed in an e-commerce system, many things need to happen — inventory must decrease, payment must be processed, a confirmation email must be sent, and analytics need to be recorded. Domain events provide a clean way to decouple these responsibilities. Domain events: Design Patterns best practices explained helps you structure these workflows so that your core business logic remains focused while side effects happen reliably afterward.
Domain events are facts about what happened in your system, captured at the moment they occur within your aggregate. They're not commands (requests to do something) and they're not queries (requests for data). They're notifications that something significant has already transpired in your domain.
Raise in the Aggregate — order.place() Records OrderPlaced
The aggregate is responsible for maintaining its own consistency and for announcing what occurred. When you call order.place(), the order aggregate should record that an OrderPlaced event happened. This event lives in memory during the transaction and gets persisted along with the aggregate's state.
Let's look at a practical example. An Order aggregate handles placement logic, validates business rules, and records the event:
class Order {
private id: string;
private customerId: string;
private items: OrderItem[];
private status: OrderStatus;
private events: DomainEvent[] = [];
static create(
customerId: string,
items: OrderItem[]
): Order {
const order = new Order(
generateId(),
customerId,
items,
OrderStatus.PENDING
);
return order;
}
place(): void {
if (!this.items.length) {
throw new Error('Order must have at least one item');
}
if (!this.customerId) {
throw new Error('Customer must be specified');
}
this.status = OrderStatus.PLACED;
this.recordEvent(
new OrderPlaced(
this.id,
this.customerId,
this.items,
new Date()
)
);
}
private recordEvent(event: DomainEvent): void {
this.events.push(event);
}
getUncommittedEvents(): DomainEvent[] {
return this.events;
}
markEventsAsCommitted(): void {
this.events = [];
}
}
class OrderPlaced implements DomainEvent {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly items: OrderItem[],
public readonly occurredAt: Date
) {}
}
The aggregate records events internally before they're published. This means the event is part of the transaction — if the aggregate fails to save, the event isn't recorded either. The aggregate is a cohesive unit that owns its state and its history.
Dispatch After Commit — Handlers Run Once the Transaction Succeeds
Recording an event and publishing it are separate concerns. Events should only be dispatched to handlers after the database transaction commits successfully. This prevents handlers from running when the aggregate state fails to persist, which would leave your system in an inconsistent state.
A common pattern is the Unit of Work, which coordinates the save and dispatch:
class UnitOfWork {
private orderRepository: OrderRepository;
private eventDispatcher: EventDispatcher;
private connection: DatabaseConnection;
async execute(work: () => Promise): Promise {
const transaction = await this.connection.beginTransaction();
try {
await work();
await transaction.commit();
// Dispatch events AFTER commit succeeds
await this.dispatchPendingEvents();
} catch (error) {
await transaction.rollback();
throw error;
}
}
async placeOrder(
customerId: string,
items: OrderItem[]
): Promise {
await this.execute(async () => {
const order = Order.create(customerId, items);
order.place();
await this.orderRepository.save(order);
});
}
private async dispatchPendingEvents(): Promise {
const aggregates = this.orderRepository.getAggregatesWithEvents();
for (const aggregate of aggregates) {
const events = aggregate.getUncommittedEvents();
for (const event of events) {
await this.eventDispatcher.dispatch(event);
}
aggregate.markEventsAsCommitted();
}
}
}
This approach ensures that handlers only execute when the aggregate's changes have been durably persisted. If your application crashes between commit and dispatch, you'll need an outbox pattern or event log to recover and replay the events, which we'll explore in a follow-up article.
Side Effects Stay Out — Emails and Integrations Live in Handlers, Not Entities
The aggregate must never directly send emails, call external APIs, or trigger other side effects. These concerns belong in event handlers, which run after the aggregate is safely persisted. This keeps your domain model pure, testable, and decoupled from infrastructure.
Consider this mistake:
// WRONG: Don't do this in the aggregate
class Order {
place(): void {
this.status = OrderStatus.PLACED;
this.recordEvent(new OrderPlaced(this.id, this.customerId, this.items, new Date()));
// Side effect in domain logic - BAD
this.emailService.send(
this.customerId,
'Your order has been placed'
);
}
}
Instead, create focused handlers for each side effect:
class OrderPlacedEmailHandler {
constructor(private emailService: EmailService) {}
async handle(event: OrderPlaced): Promise {
const customer = await this.customerRepository.findById(
event.customerId
);
await this.emailService.send({
to: customer.email,
subject: 'Order Confirmation',
body: `Your order ${event.orderId} has been placed.`
});
}
}
class OrderPlacedInventoryHandler {
constructor(private inventoryService: InventoryService) {}
async handle(event: OrderPlaced): Promise {
for (const item of event.items) {
await this.inventoryService.decreaseStock(
item.productId,
item.quantity
);
}
}
}
class OrderPlacedAnalyticsHandler {
constructor(private analyticsService: AnalyticsService) {}
async handle(event: OrderPlaced): Promise {
await this.analyticsService.recordEvent('order.placed', {
orderId: event.orderId,
customerId: event.customerId,
itemCount: event.items.length
});
}
}
Each handler is independently testable and can fail without affecting the aggregate's persistence. If an email handler fails, you can retry it separately or log it for manual review, rather than rolling back the entire order placement.
Becomes Integration Events — Publish Selected Events to Kafka for Other Services
Not all domain events need to leave your service boundary, but some do. When an order is placed, your inventory service, billing service, and shipping service all need to know. At that point, you publish selected domain events as integration events to a message broker like Kafka.
Integration events are typically a projection or transformation of domain events, tailored for external consumption:
class OrderPlacedIntegrationEvent {
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly totalAmount: number,
public readonly items: Array<{ productId: string; quantity: number }>,
public readonly occurredAt: Date,
public readonly version: number = 1
) {}
}
class EventPublisher {
constructor(private kafkaProducer: KafkaProducer) {}
async publish(event: DomainEvent): Promise {
if (event instanceof OrderPlaced) {
const integrationEvent = new OrderPlacedIntegrationEvent(
event.orderId,
event.customerId,
event.items.reduce((sum, item) => sum + item.price, 0),
event.items.map(item => ({
productId: item.productId,
quantity: item.quantity
})),
event.occurredAt
);
await this.kafkaProducer.send({
topic: 'orders.events',
partition: 0,
messages: [
{
key: event.orderId,
value: JSON.stringify(integrationEvent)
}
]
});
}
}
}
By publishing to Kafka, you allow other services to subscribe to the events they care about without creating tight coupling. The shipping service listens for OrderPlaced events and initiates fulfillment. The billing service processes payment. Each service can evolve independently while staying informed about domain events from other parts of the system.
The key is to only publish events that represent true business milestones. Publishing every internal domain event would create noise and make it harder for consumers to filter relevant signals.
Common Mistakes
Dispatching events outside the transaction: If you dispatch events before commit, handlers run against stale or partially committed data. Always dispatch after the aggregate is safely persisted.
Side effects in the aggregate: Putting email sends, API calls, or database writes inside the aggregate makes it impossible to test in isolation and creates hidden dependencies. Keep the aggregate pure and move infrastructure concerns to handlers.
Event handlers that throw without retry logic: A handler that fails will halt the entire event dispatch chain. Wrap handlers in try-catch, log failures, and implement retry or dead-letter queues for failed events.
Publishing all domain events as integration events: Not every domain event is relevant to other services. Be selective. Publishing too much creates noise and makes it harder for consumers to track important state changes across the system.
Losing events on crash: If your application crashes between commit and dispatch, uncommitted events are lost. Use an outbox table or event log to durably track events and replay them on recovery.
Key Takeaways
- Record domain events in the aggregate during state changes; they capture what happened and keep your model focused on business logic.
- Dispatch events only after the database transaction commits to guarantee consistency between the aggregate's state and the events that describe it.
- Move all side effects — emails, API calls, external integrations — into separate event handlers; this keeps the aggregate testable and the system decoupled.
- Transform selected domain events into integration events and publish them to a message broker so other services can react without tight coupling.
- Handle failures in event handlers gracefully with retries and dead-letter queues; don't let one handler's failure cascade to the others.






Comments
Loading comments…