Command Query Responsibility Segregation (CQRS) Architecture
Introduction to CQRS
Command Query Responsibility Segregation (CQRS) is a pattern that separates the responsibilities of reading and writing data in an application. By maintaining distinct models for Commands
(write operations that modify state) and Queries
(read operations that retrieve state), CQRS enables better scalability, performance, and clearer domain modeling. This approach is particularly valuable in complex domains where read and write workloads have different requirements or when implementing event sourcing.
CQRS Architecture Diagram
The diagram visualizes the CQRS pattern with clear separation between command and query flows. A Client
sends Commands
(yellow) to the Command Handler
which updates the Write Model
(event store), while Queries
(blue) are served from the optimized Read Model
. The Event Processor
synchronizes data between models asynchronously (green). Critical components are color-coded for clarity.
API Gateway
which routes them to the appropriate handler, maintaining clear separation of concerns and enabling centralized cross-cutting concerns like authentication and rate limiting.
Key Components
The core components of a CQRS architecture include:
- Command Side:
Command
: Immutable request to perform an action (e.g., "PlaceOrder")Command Handler
: Validates and executes commands against domain modelEvent Store
: Append-only log of all state changes as domain events
- Query Side:
Query
: Request for data without side effects (e.g., "GetOrderStatus")Query Handler
: Retrieves data from optimized read modelsRead Database
: Denormalized, query-optimized data store (SQL, MongoDB, etc.)
- Synchronization:
Event Processor
: Subscribes to events and updates read modelsProjections
: Transformations that convert events to read model formats
Benefits of CQRS
- Performance Optimization:
- Read models can use denormalized schemas optimized for queries
- Write models avoid query-related overhead
- Scalability:
- Read and write components scale independently
- Read databases can be replicated geographically
- Domain Clarity:
- Separate models prevent query concerns from complicating domain logic
- Explicit modeling of command-side business rules
- Flexibility:
- Different storage technologies for read and write paths
- Easier to evolve read models without affecting writes
Implementation Considerations
Implementing CQRS effectively requires addressing several architectural concerns:
- Eventual Consistency:
- Read models will be temporarily stale after writes
- Design UIs to handle this (optimistic updates, loading states)
- Event Processing:
- Implement idempotent event handlers to handle retries
- Consider event ordering and exactly-once processing
- Complexity Management:
- Only use CQRS for bounded contexts where benefits outweigh costs
- Start simple and add complexity as needed
- Monitoring:
- Track event processing latency between write and read models
- Monitor for projection failures
- Testing:
- Test command validation and event emission
- Verify read model projections against sample events
Example: CQRS with Event Sourcing
Below is a TypeScript implementation demonstrating CQRS with event sourcing:
// Command Types type Command = | { type: 'OpenAccount'; accountId: string; owner: string } | { type: 'Deposit'; accountId: string; amount: number }; // Event Types type Event = | { type: 'AccountOpened'; accountId: string; owner: string } | { type: 'Deposited'; accountId: string; amount: number }; // Write Model (Command Side) class AccountAggregate { private events: Event[] = []; execute(command: Command): Event[] { switch (command.type) { case 'OpenAccount': const openedEvent: Event = { type: 'AccountOpened', accountId: command.accountId, owner: command.owner }; this.events.push(openedEvent); return [openedEvent]; case 'Deposit': const depositedEvent: Event = { type: 'Deposited', accountId: command.accountId, amount: command.amount }; this.events.push(depositedEvent); return [depositedEvent]; } } } // Read Model (Query Side) class AccountView { private accounts: Map = new Map(); applyEvent(event: Event) { switch (event.type) { case 'AccountOpened': this.accounts.set(event.accountId, { owner: event.owner, balance: 0 }); break; case 'Deposited': const account = this.accounts.get(event.accountId); if (account) { account.balance += event.amount; } break; } } getAccount(accountId: string) { return this.accounts.get(accountId); } } // Usage const aggregate = new AccountAggregate(); const view = new AccountView(); // Process commands const events1 = aggregate.execute({ type: 'OpenAccount', accountId: '123', owner: 'Alice' }); events1.forEach(e => view.applyEvent(e)); const events2 = aggregate.execute({ type: 'Deposit', accountId: '123', amount: 100 }); events2.forEach(e => view.applyEvent(e)); // Query read model console.log(view.getAccount('123')); // { owner: 'Alice', balance: 100 }
Example: Projection for Read Model
Creating a read model projection from events:
// Projection to create customer order history view class CustomerOrderHistoryProjection { private history: Map> = new Map(); handleEvent(event: Event) { if (event.type === 'OrderPlaced') { if (!this.history.has(event.customerId)) { this.history.set(event.customerId, []); } this.history.get(event.customerId)!.push({ orderId: event.orderId, amount: event.amount }); } } getCustomerHistory(customerId: string) { return this.history.get(customerId) || []; } } // Example usage with event store const projection = new CustomerOrderHistoryProjection(); eventStore.getEvents().forEach(event => projection.handleEvent(event)); // Query optimized read model console.log(projection.getCustomerHistory('cust-456'));