System Design: CQRS Pattern (Command Query Responsibility Segregation)
Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates read operations from write operations into distinct models. Instead of using the same data structures and...
Key Insights
- CQRS separates your application into distinct read and write models, allowing each to be optimized independently—but this power comes with significant complexity that’s only justified for specific use cases.
- The pattern shines when read and write workloads have vastly different scaling requirements or when your domain logic is complex enough that a single model becomes a liability.
- CQRS pairs naturally with event sourcing but doesn’t require it—start with the simpler separation before adding event-driven complexity.
Introduction to CQRS
Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates read operations from write operations into distinct models. Instead of using the same data structures and logic for both reading and writing, you build two separate systems optimized for their specific purposes.
The pattern evolved from Bertrand Meyer’s Command Query Separation (CQS) principle, which states that methods should either change state (commands) or return data (queries), but never both. CQRS takes this further by applying the separation at the architectural level.
Traditional CRUD architectures use a single model for everything. This works fine until it doesn’t. You start adding computed fields for display, denormalized data for performance, and validation logic that only applies to writes. Your “simple” model becomes a bloated compromise that serves neither purpose well.
Consider an e-commerce system where product listings need to display average ratings, inventory status, and pricing tiers. The write model cares about individual rating submissions and inventory adjustments. The read model needs pre-computed aggregates. Forcing both into the same structure creates unnecessary coupling and performance bottlenecks.
Core Concepts and Architecture
The command side handles all write operations. Commands represent intentions to change state: PlaceOrder, UpdateInventory, CancelSubscription. They’re validated, processed, and result in state changes. The command model is optimized for consistency and business rule enforcement.
The query side handles all read operations. Queries retrieve data without side effects: GetOrderHistory, SearchProducts, FetchDashboardMetrics. The query model is optimized for read performance, often denormalized and shaped exactly as the UI needs it.
// Command model - focused on behavior and invariants
interface PlaceOrderCommand {
customerId: string;
items: Array<{ productId: string; quantity: number }>;
shippingAddress: Address;
paymentMethod: PaymentDetails;
}
interface CommandHandler<TCommand> {
execute(command: TCommand): Promise<CommandResult>;
}
// Query model - focused on data retrieval
interface OrderSummaryQuery {
customerId: string;
dateRange?: { start: Date; end: Date };
status?: OrderStatus;
}
interface QueryHandler<TQuery, TResult> {
execute(query: TQuery): Promise<TResult>;
}
// Read model DTO - shaped for display
interface OrderSummaryDto {
orderId: string;
orderDate: string;
totalAmount: number;
itemCount: number;
status: string;
estimatedDelivery: string | null;
}
Data stores can be shared or separate. In simpler implementations, both models hit the same database but use different repositories. In more sophisticated setups, writes go to a normalized relational database while reads come from denormalized views, search indices, or document stores.
Eventual consistency is the trade-off. When using separate stores, the read model lags behind the write model. Your system must tolerate this gap, and your users must understand that recently submitted data might not appear immediately in queries.
Implementation Patterns
The mediator pattern provides clean separation between the caller and handlers. Libraries like MediatR (C#) or custom implementations route commands and queries to appropriate handlers without tight coupling.
// Command handler with validation
class PlaceOrderCommandHandler implements CommandHandler<PlaceOrderCommand> {
constructor(
private orderRepository: OrderRepository,
private inventoryService: InventoryService,
private eventPublisher: EventPublisher
) {}
async execute(command: PlaceOrderCommand): Promise<CommandResult> {
// Validate business rules
const inventoryCheck = await this.inventoryService.checkAvailability(
command.items
);
if (!inventoryCheck.allAvailable) {
return CommandResult.failure('INSUFFICIENT_INVENTORY',
inventoryCheck.unavailableItems);
}
// Create and persist aggregate
const order = Order.create({
customerId: command.customerId,
items: command.items,
shippingAddress: command.shippingAddress
});
await this.orderRepository.save(order);
// Publish domain events for read model updates
await this.eventPublisher.publish(new OrderPlacedEvent({
orderId: order.id,
customerId: command.customerId,
items: command.items,
totalAmount: order.calculateTotal(),
placedAt: new Date()
}));
return CommandResult.success({ orderId: order.id });
}
}
// Query handler returning DTOs
class GetOrderHistoryQueryHandler
implements QueryHandler<OrderSummaryQuery, OrderSummaryDto[]> {
constructor(private readDb: ReadDatabase) {}
async execute(query: OrderSummaryQuery): Promise<OrderSummaryDto[]> {
// Query optimized read store directly
return this.readDb.query(`
SELECT order_id, order_date, total_amount, item_count,
status, estimated_delivery
FROM order_summaries
WHERE customer_id = $1
AND ($2::date IS NULL OR order_date >= $2)
AND ($3::date IS NULL OR order_date <= $3)
AND ($4::text IS NULL OR status = $4)
ORDER BY order_date DESC
`, [query.customerId, query.dateRange?.start,
query.dateRange?.end, query.status]);
}
}
Synchronization between stores happens either synchronously (updating read models in the same transaction) or asynchronously (publishing events that read model updaters consume). Asynchronous is more scalable but introduces consistency delays.
CQRS with Event Sourcing
Event sourcing stores state as a sequence of events rather than current values. Instead of saving “order status = shipped,” you save “OrderShipped event occurred at timestamp X.” This pairs naturally with CQRS because events become the synchronization mechanism between write and read models.
// Domain events
abstract class DomainEvent {
readonly occurredAt: Date = new Date();
abstract readonly eventType: string;
}
class OrderPlacedEvent extends DomainEvent {
readonly eventType = 'OrderPlaced';
constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly items: OrderItem[],
public readonly totalAmount: number
) {
super();
}
}
class OrderShippedEvent extends DomainEvent {
readonly eventType = 'OrderShipped';
constructor(
public readonly orderId: string,
public readonly trackingNumber: string,
public readonly carrier: string,
public readonly estimatedDelivery: Date
) {
super();
}
}
// Projection that builds read model from events
class OrderSummaryProjection {
constructor(private readDb: ReadDatabase) {}
async apply(event: DomainEvent): Promise<void> {
switch (event.eventType) {
case 'OrderPlaced':
await this.handleOrderPlaced(event as OrderPlacedEvent);
break;
case 'OrderShipped':
await this.handleOrderShipped(event as OrderShippedEvent);
break;
}
}
private async handleOrderPlaced(event: OrderPlacedEvent): Promise<void> {
await this.readDb.execute(`
INSERT INTO order_summaries
(order_id, customer_id, order_date, total_amount, item_count, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
`, [event.orderId, event.customerId, event.occurredAt,
event.totalAmount, event.items.length]);
}
private async handleOrderShipped(event: OrderShippedEvent): Promise<void> {
await this.readDb.execute(`
UPDATE order_summaries
SET status = 'shipped',
tracking_number = $2,
estimated_delivery = $3
WHERE order_id = $1
`, [event.orderId, event.trackingNumber, event.estimatedDelivery]);
}
}
Projections can be rebuilt from scratch by replaying all events. This enables adding new read models without data migrations—just create a new projection and replay the event history.
Benefits and Trade-offs
Benefits:
- Independent scaling of read and write workloads
- Read models optimized for specific query patterns
- Write models focused on business logic without display concerns
- Natural audit trail when combined with event sourcing
- Flexibility to use different storage technologies per side
Trade-offs:
- Increased system complexity and more moving parts
- Eventual consistency requires careful UX consideration
- Debugging spans multiple components
- More infrastructure to deploy and monitor
- Steeper learning curve for the team
Decision Matrix:
| Factor | Favors CQRS | Favors Traditional |
|---|---|---|
| Read/write ratio | 10:1 or higher | Balanced |
| Domain complexity | High, many aggregates | Simple CRUD |
| Scale requirements | Asymmetric | Uniform |
| Team experience | Distributed systems background | General web development |
| Consistency needs | Eventual acceptable | Strong required |
Real-World Implementation Example
Here’s a complete mini-implementation for an e-commerce order system:
// Complete flow: Command -> Event -> Read Model Update
// 1. API receives command
app.post('/orders', async (req, res) => {
const command: PlaceOrderCommand = {
customerId: req.user.id,
items: req.body.items,
shippingAddress: req.body.shippingAddress,
paymentMethod: req.body.paymentMethod
};
const result = await mediator.send(command);
if (result.success) {
// Return immediately - read model updates async
res.status(202).json({
orderId: result.data.orderId,
message: 'Order placed. Details available shortly.'
});
} else {
res.status(400).json({ error: result.error });
}
});
// 2. Event subscriber updates read model
eventBus.subscribe('OrderPlaced', async (event: OrderPlacedEvent) => {
await orderSummaryProjection.apply(event);
await customerStatsProjection.apply(event);
await inventoryProjection.apply(event);
});
// 3. Query endpoint reads from optimized store
app.get('/orders', async (req, res) => {
const query: OrderSummaryQuery = {
customerId: req.user.id,
status: req.query.status as OrderStatus
};
const orders = await mediator.query(query);
res.json(orders);
});
When to Use CQRS
Ideal use cases:
- High read-to-write ratios (dashboards, reporting systems)
- Complex domains with rich business logic
- Systems requiring different scaling strategies per operation type
- Applications needing multiple read representations of the same data
- Microservices architectures with event-driven communication
Anti-patterns:
- Simple CRUD applications with straightforward data access
- Small teams without distributed systems experience
- Systems requiring strong consistency everywhere
- Prototypes or MVPs where speed matters more than architecture
Adoption checklist:
- Read and write patterns are demonstrably different
- Team understands eventual consistency implications
- Infrastructure supports message queues or event buses
- Monitoring and debugging tools are in place
- Business stakeholders accept consistency trade-offs
Start simple. Apply CQRS to specific bounded contexts where it provides clear value rather than adopting it system-wide. The pattern’s power comes from targeted application, not universal adoption.