Event-Driven Architecture: Patterns and Pitfalls
A pragmatic guide to event-driven systems covering event sourcing, CQRS, sagas, and the failure modes you need to plan for.
Event-driven architecture (EDA) decouples systems by communicating through events rather than direct calls. Instead of Service A calling Service B's API, Service A publishes an event ("order was placed") and Service B subscribes to it. This decoupling enables independent scaling, deployment, and evolution of services. But EDA also introduces complexity — eventual consistency, event ordering, duplicate delivery, and debugging distributed workflows.
Event Sourcing: Store Events, Not State
In traditional systems, you store the current state (an order has status 'shipped'). In event sourcing, you store the events that led to the state (order created → payment received → items picked → order shipped). The current state is derived by replaying events. This gives you a complete audit log, the ability to reconstruct state at any point in time, and powerful debugging capabilities.
// Events are immutable facts that happened in the past
type OrderEvent =
| { type: "OrderPlaced"; orderId: string; items: OrderItem[]; total: number; timestamp: Date }
| { type: "PaymentReceived"; orderId: string; paymentId: string; amount: number; timestamp: Date }
| { type: "OrderShipped"; orderId: string; trackingNumber: string; timestamp: Date }
| { type: "OrderDelivered"; orderId: string; deliveredAt: Date; timestamp: Date };
// Derive current state by folding events
function applyEvent(state: OrderState, event: OrderEvent): OrderState {
switch (event.type) {
case "OrderPlaced":
return { ...state, status: "placed", items: event.items, total: event.total };
case "PaymentReceived":
return { ...state, status: "paid", paymentId: event.paymentId };
case "OrderShipped":
return { ...state, status: "shipped", trackingNumber: event.trackingNumber };
case "OrderDelivered":
return { ...state, status: "delivered", deliveredAt: event.deliveredAt };
}
}CQRS: Separate Reads from Writes
Command Query Responsibility Segregation (CQRS) uses different models for reading and writing data. The write model (command side) validates and processes business logic. The read model (query side) is optimized for the specific queries your UI needs. This is especially powerful with event sourcing: events from the write side are projected into denormalized read models optimized for each query pattern.
The Saga Pattern: Distributed Transactions
In a monolith, you wrap multi-step operations in a database transaction. In a distributed system, there's no distributed transaction (and two-phase commit is too slow and fragile for most use cases). Sagas are the alternative: a sequence of local transactions where each step publishes an event that triggers the next step, and each step has a compensating action for rollback.
Event-driven architecture introduces eventual consistency. This means the read model may be slightly behind the write model. If your users can't tolerate seeing stale data for even a few seconds (financial trading, real-time bidding), EDA may not be the right fit — or you need careful UX design to handle the consistency gap.
Common Pitfalls
- Event schema evolution: Changing event schemas after they're in production is painful. Use schema registries and plan for backwards compatibility from day one.
- Event ordering: Events within a partition are ordered, but across partitions they're not. Design your system to handle out-of-order events.
- Debugging distributed flows: A single business operation spans multiple services and events. Without distributed tracing and correlation IDs, debugging is nearly impossible.
- Over-engineering: Not every service needs event sourcing. Use it for domains with complex state transitions and audit requirements. Simple CRUD services work fine with traditional databases.
“Event-driven architecture is a powerful tool, but it's not a default choice. Start with simple request-response. Introduce events where you need decoupling. Add event sourcing where you need audit trails and temporal queries. Complexity should be earned, not assumed.”
— Thomas Weber, Vaarak Architecture
Thomas Weber
Principal Software Architect