Event-Driven Architecture
Event-driven architecture (EDA) is a design paradigm where the flow of the program is determined by events. Instead of services calling each other directly (request-response), services produce and consume events asynchronously. This decoupling enables independent scaling, better fault isolation, and more flexible system evolution.
What is an Event?
An event is an immutable record of something that happened in the system. Unlike a command (which tells a system what to do), an event states what has already occurred.
Events vs. Commands vs. Queries
| Concept | Intent | Direction | Example |
|---|---|---|---|
| Event | Notification of something that happened | One-to-many (broadcast) | OrderPlaced, UserRegistered |
| Command | Request to perform an action | One-to-one (directed) | PlaceOrder, RegisterUser |
| Query | Request for information | One-to-one (directed) | GetOrderStatus, GetUser |
// Event structure
interface DomainEvent {
eventId: string; // Unique identifier
eventType: string; // e.g., "OrderPlaced"
aggregateId: string; // Entity this event belongs to
aggregateType: string; // e.g., "Order"
timestamp: Date;
version: number; // For ordering within an aggregate
payload: Record<string, unknown>;
metadata: {
correlationId: string; // Track related events across services
causationId: string; // The event/command that caused this event
userId?: string; // Who triggered this
};
}
// Example events
const orderPlacedEvent: DomainEvent = {
eventId: "evt_abc123",
eventType: "OrderPlaced",
aggregateId: "order_456",
aggregateType: "Order",
timestamp: new Date(),
version: 1,
payload: {
customerId: "cust_789",
items: [
{ productId: "prod_001", quantity: 2, price: 29.99 },
],
totalAmount: 59.98,
},
metadata: {
correlationId: "corr_xyz",
causationId: "cmd_place_order_111",
userId: "cust_789",
},
};Event Sourcing
Event sourcing is a pattern where the state of an entity is derived from a sequence of events rather than stored as a snapshot. Instead of updating a row in a database, you append an event to an event store. The current state is reconstructed by replaying all events for that entity.
// Event-sourced Order aggregate
class Order {
private id: string = "";
private status: string = "";
private items: OrderItem[] = [];
private totalAmount: number = 0;
// Rebuild state from events
static fromEvents(events: DomainEvent[]): Order {
const order = new Order();
for (const event of events) {
order.apply(event);
}
return order;
}
private apply(event: DomainEvent): void {
switch (event.eventType) {
case "OrderCreated":
this.id = event.aggregateId;
this.status = "CREATED";
this.items = event.payload.items as OrderItem[];
this.totalAmount = event.payload.totalAmount as number;
break;
case "OrderPaid":
this.status = "PAID";
break;
case "OrderShipped":
this.status = "SHIPPED";
break;
case "OrderCancelled":
this.status = "CANCELLED";
break;
case "ItemAdded":
this.items.push(event.payload.item as OrderItem);
this.totalAmount += (event.payload.item as OrderItem).price;
break;
}
}
}
// Event Store interface
interface EventStore {
append(aggregateId: string, events: DomainEvent[], expectedVersion: number): Promise<void>;
getEvents(aggregateId: string, fromVersion?: number): Promise<DomainEvent[]>;
getEventsByType(eventType: string, fromTimestamp?: Date): Promise<DomainEvent[]>;
}
// Usage
async function getOrder(orderId: string): Promise<Order> {
const events = await eventStore.getEvents(orderId);
return Order.fromEvents(events);
}Benefits of Event Sourcing
- Complete audit trail: Every change is recorded, enabling full history reconstruction
- Temporal queries: Answer questions like "what was the state at time T?"
- Debugging: Replay events to reproduce any historical state
- Flexibility: Create new read models by replaying events through new projections
- Event replay: Fix bugs and rebuild state by replaying corrected event handlers
CQRS (Command Query Responsibility Segregation)
CQRS separates read and write operations into different models. The write model handles commands and produces events. The read model is optimized for queries and is built by consuming events.
// Write side: handles commands, emits events
class OrderCommandHandler {
async handle(command: PlaceOrderCommand): Promise<void> {
// Validate business rules
const customer = await customerRepo.findById(command.customerId);
if (!customer.isActive) throw new Error("Customer inactive");
// Create event
const event: DomainEvent = {
eventId: generateId(),
eventType: "OrderPlaced",
aggregateId: generateOrderId(),
aggregateType: "Order",
timestamp: new Date(),
version: 1,
payload: {
customerId: command.customerId,
items: command.items,
totalAmount: command.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
},
metadata: { correlationId: command.correlationId, causationId: command.commandId },
};
// Persist event
await eventStore.append(event.aggregateId, [event], 0);
// Publish to event bus
await eventBus.publish(event);
}
}
// Read side: consumes events, builds query-optimized views
class OrderReadModelProjection {
async handleEvent(event: DomainEvent): Promise<void> {
switch (event.eventType) {
case "OrderPlaced":
await readDb.orders.insert({
orderId: event.aggregateId,
customerId: event.payload.customerId,
status: "PLACED",
totalAmount: event.payload.totalAmount,
itemCount: (event.payload.items as any[]).length,
createdAt: event.timestamp,
});
break;
case "OrderShipped":
await readDb.orders.update(
{ orderId: event.aggregateId },
{ status: "SHIPPED", shippedAt: event.timestamp }
);
break;
}
}
}
// Query side: optimized read model
async function getCustomerOrders(customerId: string) {
// Query the read-optimized view, not the event store
return readDb.orders.find({ customerId }).sort({ createdAt: -1 });
}Event Bus and Event Store
The event bus (or message broker) is the backbone of an event-driven system. It receives events from producers and delivers them to consumers.
| Technology | Type | Best For |
|---|---|---|
| Apache Kafka | Distributed log / event streaming | High throughput, event replay, stream processing |
| RabbitMQ | Message broker | Complex routing, task queues, lower throughput needs |
| AWS SNS + SQS | Managed pub/sub + queue | Serverless, fan-out patterns, AWS-native apps |
| Redis Streams | In-memory stream | Low-latency, simpler use cases |
| EventStoreDB | Purpose-built event store | Event sourcing with built-in projections |
Benefits and Challenges
| Benefits | Challenges |
|---|---|
| Loose coupling between services | Eventual consistency (not always acceptable) |
| Independent scaling of producers and consumers | Debugging is harder (tracing events across services) |
| Natural fit for async workflows | Event ordering guarantees are complex |
| Easy to add new consumers without changing producers | Schema evolution (changing event formats over time) |
| Better fault isolation | Idempotency is required (events may be delivered more than once) |
When to Use and When Not To
Use Event-Driven Architecture When:
- Multiple services need to react to the same business event
- You need loose coupling between bounded contexts
- Workflows are naturally asynchronous (order processing, notifications)
- You need a complete audit trail of all changes
- You want to independently scale read and write workloads (CQRS)
Avoid Event-Driven Architecture When:
- You need synchronous, strongly consistent responses (e.g., "is this username available?")
- The system is simple enough for direct service-to-service calls
- Your team lacks experience with distributed systems debugging
- The domain is inherently request-response (CRUD applications)
- Latency requirements are extremely tight and every millisecond counts