All chapters in this track
- 01Chapter 1: Why Patterns Exist — Vocabulary, Not a Checklist
- 02Chapter 2: Patterns and SOLID: How They Relate
- 03Chapter 3: Creational Patterns I: Factory Method & Abstract Factory
- 04Chapter 4: Creational Patterns II: Builder, Singleton, Prototype
- 05Chapter 5: Structural Patterns I: Adapter & Facade
- 06Chapter 6: Structural Patterns II: Decorator & Proxy
- 07Chapter 7: Structural Patterns III: Composite, Bridge & Flyweight
- 08Chapter 8: Behavioral Patterns I: Strategy & Template Method
- 09Chapter 9: Behavioral Patterns II: Observer & Mediator
- 10Chapter 10: Behavioral Patterns III: Command & Memento
- 11Chapter 11: Behavioral Patterns IV: State & Chain of Responsibility
- 12Chapter 12: Behavioral Patterns V: Iterator & Visitor
- 13Chapter 13: Anti-Patterns and Overuse: Recognizing "Pattern Fever"
- 14Chapter 14: How Dynamic and Functional Languages Solve These Problems Differently
- 15Chapter 15: Architectural Patterns I: MVC/MVVM & Layered/Hexagonal Architecture
- 16Chapter 16: Architectural Patterns II: Event-Driven Architecture & CQRS
- 17Chapter 17: Architectural Patterns III: The Repository Pattern
- 18Chapter 18: Recognizing Which Pattern Fits an Interview Design Prompt
Chapter 16 of 18
Chapter 16: Architectural Patterns II: Event-Driven Architecture & CQRS
Event-Driven Architecture
The problem it solves: in a system built from multiple services or components, having each one directly call the others creates the same tangled, tightly-coupled web the Mediator pattern (Chapter 9) fixed at the class level — but at the scale of entire services, direct calls also mean the caller is blocked waiting on the callee, and every caller needs to know every callee that might care about an event.
Before — services call each other directly, tightly coupled and synchronously:
class OrderService {
constructor(
private inventoryService: InventoryService,
private emailService: EmailService,
private analyticsService: AnalyticsService
) {} // OrderService must know about every service that cares when an order is placed
placeOrder(orderId: string): void {
this.inventoryService.reserve(orderId);
this.emailService.sendConfirmation(orderId);
this.analyticsService.track(orderId);
// Adding a new service that cares about orders means editing OrderService directly.
}
}
After — services publish events; interested parties subscribe independently:
interface EventBus {
publish(eventName: string, payload: unknown): void;
subscribe(eventName: string, handler: (payload: unknown) => void): void;
}
class SimpleEventBus implements EventBus {
private handlers = new Map<string, ((payload: unknown) => void)[]>();
publish(eventName: string, payload: unknown): void {
(this.handlers.get(eventName) ?? []).forEach(h => h(payload));
}
subscribe(eventName: string, handler: (payload: unknown) => void): void {
const list = this.handlers.get(eventName) ?? [];
list.push(handler);
this.handlers.set(eventName, list);
}
}
class OrderService {
constructor(private eventBus: EventBus) {} // knows only about the event bus, not any specific subscriber
placeOrder(orderId: string): void {
this.eventBus.publish("order.placed", { orderId });
}
}
// Each interested service subscribes independently — OrderService never changes when they're added:
eventBus.subscribe("order.placed", (payload) => inventoryService.reserve((payload as any).orderId));
eventBus.subscribe("order.placed", (payload) => emailService.sendConfirmation((payload as any).orderId));
[SENIOR INSIGHT] This is the Observer pattern (Chapter 9) again, now operating between whole services instead of between objects in the same process — and in real production systems, the "event bus" is usually a message broker (Kafka, RabbitMQ, SQS/SNS, or similar) rather than an in-process object, which additionally gives durability (events aren't lost if a subscriber is temporarily down) and decoupled deployment (publishers and subscribers can be deployed independently, and don't even need to be running the same code at the same time).
When to use it: multiple independent services/components need to react to the same occurrence, publishers shouldn't need to know who's listening, and/or the reacting work can happen asynchronously rather than blocking the original request — order processing pipelines, notification fan-out, audit logging, analytics.
When to avoid it: simple, synchronous request/response flows where the caller genuinely needs an immediate answer — event-driven architecture trades immediacy and easy traceability (a request's full effect is no longer visible in one call stack) for decoupling and resilience, and that trade isn't free.
Complexity cost: significant at the system level — debugging "what happened as a result of this event" now means tracing across services and logs rather than reading one call stack; eventual consistency (subscribers process the event at some point after publish, not necessarily instantly) needs to be an acceptable trade-off for the use case.
CQRS (Command Query Responsibility Segregation)
The problem it solves: in many systems, the model that's efficient for writing data (normalized, validated, transactional) is not the model that's efficient for reading it (often denormalized, aggregated, optimized for specific display needs) — using one model for both forces a compromise that serves neither well as the system's read and write patterns diverge.
// Command side — handles writes, enforces business rules and consistency.
class PlaceOrderCommand {
constructor(public customerId: string, public items: { sku: string; qty: number }[]) {}
}
class OrderCommandHandler {
handle(command: PlaceOrderCommand): void {
// Validates business rules, writes to the normalized transactional model.
console.log(`Order placed for ${command.customerId}, ${command.items.length} items`);
}
}
// Query side — handles reads, from a model shaped specifically for display needs.
class OrderSummaryQuery {
constructor(public customerId: string) {}
}
class OrderQueryHandler {
handle(query: OrderSummaryQuery): { customerId: string; totalOrders: number; totalSpentCents: number } {
// Reads from a denormalized, pre-aggregated read model — optimized purely for this query shape.
return { customerId: query.customerId, totalOrders: 12, totalSpentCents: 45000 };
}
}
from dataclasses import dataclass
@dataclass
class PlaceOrderCommand:
customer_id: str
items: list[dict]
class OrderCommandHandler:
def handle(self, command: PlaceOrderCommand) -> None:
print(f"Order placed for {command.customer_id}")
@dataclass
class OrderSummaryQuery:
customer_id: str
class OrderQueryHandler:
def handle(self, query: OrderSummaryQuery) -> dict:
return {"customer_id": query.customer_id, "total_orders": 12}
[SENIOR INSIGHT] CQRS is, at its core, the Single Responsibility Principle (OOP book, Chapter 9) applied to an entire data model: "the model needs to serve both writes and reads well" is exactly the "two reasons to change" problem SRP identifies, just applied to a schema/model instead of a class. This is frequently paired with Event-Driven Architecture: the command side writes and publishes an event, and the query side's read model is updated by subscribing to that event and maintaining its own denormalized copy — meaning the read model is often eventually consistent with the write model, not instantaneously.
When to use it: read and write patterns have genuinely diverged enough that one shared model compromises both — high-read, complex-aggregation systems (dashboards, reporting) built on top of a transactional core are the classic case.
When to avoid it: most applications, most of the time. CQRS is one of the most commonly over-applied architectural patterns precisely because "separate my reads and writes" sounds appealing in the abstract — but it doubles the models and the code paths to maintain, and introduces eventual-consistency complexity that a single shared model with straightforward queries doesn't have. It should be adopted in response to a measured, real scaling or complexity problem, not proactively.
Complexity cost: high — two models instead of one, a synchronization mechanism between them (often event-driven), and eventual consistency to reason about everywhere the read model is used. This is squarely a "pay this cost only for a real, measured need" pattern, more so than almost anything else in this book.
Common mistakes
- Adopting CQRS "for scalability" with no actual measured read/write divergence problem — this is Chapter 13's pattern fever, at architectural scale, and the cost here is much higher than a misapplied class-level pattern.
- Using Event-Driven Architecture for a flow that genuinely needs a synchronous, immediate answer, forcing an awkward "wait for the event to be processed" pattern on top of an inherently asynchronous mechanism.
- Underestimating debugging difficulty in event-driven systems — not investing in distributed tracing or correlation IDs early enough, making "what happened as a result of this one user action" nearly impossible to reconstruct after the fact.
- Letting a CQRS read model silently drift out of sync with the write model without monitoring the lag/consistency between them — eventual consistency needs to be actively observed, not assumed to always resolve quickly.
Discussion / interview questions
-
"What problem does Event-Driven Architecture solve, and what does it cost?" Strong answer: Decouples publishers from needing to know their subscribers, enables independent scaling/deployment and resilience to a subscriber being down; costs traceability (no single call stack shows a request's full effect) and requires accepting eventual consistency for many use cases.
-
"How does CQRS relate to the Single Responsibility Principle?" Strong answer: CQRS separates the write model and read model because a single model serving both is analogous to a class with two reasons to change — SRP's "one reason to change" idea, applied to an entire data model rather than a class.
-
"When would you specifically avoid CQRS, even for a system with meaningful traffic?" Strong answer: When read and write patterns haven't actually diverged enough to justify two models and a synchronization mechanism — most applications don't need it, and adopting it without a measured need doubles the maintenance surface for no real benefit (this book's recurring pattern-fever theme, at architectural scale).
-
"What's a practical mitigation for the debugging difficulty introduced by event-driven architectures?" Strong answer: Distributed tracing and correlation IDs that follow a request/event across every service it touches, invested in from early on rather than retrofitted after a production incident makes the gap painfully obvious.
Chapter summary / cheat sheet
- Event-Driven Architecture: publishers emit events without knowing subscribers — Observer (Chapter 9) at the service-to-service scale, usually via a message broker in production. Trades immediacy and easy traceability for decoupling and resilience.
- CQRS: separates write and read models — Single Responsibility (OOP book, Chapter 9) applied to a data model. High complexity cost (two models, sync mechanism, eventual consistency); justified only by a real, measured read/write divergence.
- Both are frequently over-applied "because they sound scalable" — this chapter's patterns carry the highest cost of premature adoption in this entire book, precisely because they operate at system scale rather than class scale.
- Both are commonly paired together: command writes publish events; query-side read models update by subscribing to those events.