CQRS (Command Query Responsibility Segregation) is a pattern that separates read and write operations into different models. While often paired with event sourcing, CQRS is a valuable pattern on its own. Let’s explore what it is, why it helps, and how to implement it effectively.
Table of contents
- The Problem with Unified Models
- Enter CQRS
- Benefits of CQRS
- The Trade-off: Eventual Consistency
- Implementing CQRS
- When to Use CQRS
- Next Steps
The Problem with Unified Models
In traditional architectures, we use a single model for both reading and writing data. This seems simple, but it creates friction:
- Read optimization conflicts with write optimization: Queries often need denormalized data with joins, while writes need normalized data for consistency
- Different scalability requirements: Reads often outnumber writes 100:1, but they share resources
- Complex domain models: Business logic for writes gets tangled with query requirements
Enter CQRS
CQRS proposes a simple but powerful idea: use different models for reading and writing.
┌─────────────┐ ┌─────────────────┐
│ Command │────▶│ Write Model │
│ (Write) │ │ (Aggregates) │
└─────────────┘ └────────┬────────┘
│
▼
┌─────────────────┐
│ Events │
└────────┬────────┘
│
▼
┌─────────────┐ ┌─────────────────┐
│ Query │◀────│ Read Model │
│ (Read) │ │ (Projections) │
└─────────────┘ └─────────────────┘
The Write Side (Commands)
The write side handles all state changes through commands. Commands represent user intentions:
CreateOrderShipOrderCancelOrder
Commands are processed by aggregates—domain objects that enforce business rules and emit events when state changes.
The Read Side (Queries)
The read side handles all data retrieval through queries. Queries are served by projections—pre-computed views optimized for specific use cases:
OrderSummaryProjection: Lightweight view for order listsOrderDetailProjection: Full order details with line itemsCustomerOrderHistoryProjection: All orders for a customer
Benefits of CQRS
1. Independent Scaling
Scale your read and write infrastructure independently. Most applications are read-heavy; CQRS lets you add read replicas without affecting write performance.
2. Optimized Models
Each side can use the most appropriate storage and data structure:
- Write side: Normalized relational database for consistency
- Read side: Denormalized documents, search indices, or caches for speed
3. Simplified Domain Logic
Your write model focuses purely on business rules, not query optimization. This leads to cleaner, more maintainable code.
4. Better Performance
Queries hit pre-computed projections instead of computing joins at query time. This can dramatically reduce query latency.
The Trade-off: Eventual Consistency
With CQRS, there’s a lag between when a command is processed and when the read model reflects the change. This is eventual consistency.
For many use cases, this is fine—users don’t notice a 100ms delay. But some scenarios require immediate consistency:
// After creating an order, immediately show it
const orderId = await createOrder(orderData);
// Problem: the projection might not be updated yet
const order = await getOrder(orderId); // Might return null!
Solutions include:
- Return data from command: Include the created entity in the command response
- Read-your-writes: Query the write model directly after a write
- Synchronous projections: Update critical projections in the same transaction
Implementing CQRS
You don’t need special frameworks to implement CQRS. Start simple:
1. Separate Your Interfaces
// Command interface
interface OrderCommands {
createOrder(data: CreateOrderData): Promise<OrderId>;
shipOrder(orderId: OrderId): Promise<void>;
}
// Query interface
interface OrderQueries {
getOrderSummary(orderId: OrderId): Promise<OrderSummary>;
getCustomerOrders(customerId: CustomerId): Promise<OrderSummary[]>;
}
2. Create Dedicated Read Models
Instead of querying your entities directly, create view-specific projections:
// Projection optimized for the order list page
interface OrderListItem {
id: string;
customerName: string;
totalAmount: number;
status: string;
createdAt: Date;
}
3. Keep Projections Updated
When events occur, update your projections:
async function handleOrderCreated(event: OrderCreated) {
await db.orderListProjection.insert({
id: event.orderId,
customerName: event.customerName,
totalAmount: event.totalAmount,
status: 'pending',
createdAt: event.timestamp
});
}
When to Use CQRS
CQRS adds complexity. Use it when:
- Read and write patterns are significantly different
- You need to scale reads and writes independently
- Your domain logic is complex and benefits from separation
- You’re already using event sourcing (they pair naturally)
Skip it when:
- Your application is simple CRUD
- Read and write patterns are similar
- The team is unfamiliar with the pattern
Next Steps
In our next post, we’ll explore projections in depth—how to design them, handle failures, and rebuild them when requirements change.
Want to implement CQRS without building the infrastructure yourself? Learn about Stateful Data or join our pilot program.