Command Query Responsibility Segregation (CQRS)
Status: Complete
Category: Architecture
Default enforcement: Advisory
Author: PushBackLog team
Tags
- Topic: architecture, quality
- Skillset: backend
- Technology: generic
- Stage: execution, review
Summary
CQRS separates the model used to read data (queries) from the model used to write data (commands). Each side can be optimised independently — reads can use denormalised, projection-friendly models while writes enforce business invariants. This pattern is particularly valuable in event-driven and highly scaled systems.
Rationale
The impedance mismatch between reads and writes
In a traditional CRUD system, a single model serves both reads and writes. This is fine for simple systems. As a system grows in complexity, the write model — which must enforce business rules, maintain invariants, and manage transactions — accumulates constraints that are irrelevant to reads. The read model — which needs to answer dashboard queries, list views, and reporting efficiently — requires denormalised, joined, pre-aggregated data that the write model can’t provide without expensive queries.
CQRS, coined by Greg Young (building on Bertrand Meyer’s Command-Query Separation principle), resolves this by using separate models for each:
- The command side handles writes: it validates business rules, enforces invariants, and persists to the authoritative store
- The query side handles reads: it serves pre-built, denormalised projections optimised for display
CQRS and event sourcing
CQRS is often paired with event sourcing — but the two are independent. CQRS can be applied without event sourcing (just use separate read and write models against the same database), and event sourcing can be used without CQRS. For most teams, CQRS without event sourcing is the pragmatic starting point.
When CQRS is and isn’t appropriate
CQRS adds complexity. It is justified when:
- Read and write models have meaningfully different shapes or optimisation requirements
- The system has high read-to-write ratios that benefit from read replicas or read-optimised stores
- The domain is genuinely complex with rich business rules on the write side
- You are building event-driven systems where projections aggregate across events
For simple CRUD applications (list, create, update, delete with no business rules), CQRS introduces overhead for no gain.
Guidance
Logical vs physical separation
| Separation level | Description |
|---|---|
| Logical | Separate command and query classes/methods, same database |
| Physical | Separate write store and read store; async synchronisation |
Start logical. Evolve to physical only when the performance or data-shape argument is concrete.
Command pattern
// Commands express intent to change state
class PlaceOrderCommand {
constructor(
public readonly customerId: string,
public readonly items: OrderItemDto[]
) {}
}
class PlaceOrderHandler {
async handle(command: PlaceOrderCommand): Promise<void> {
const customer = await this.customerRepo.findById(command.customerId);
const order = Order.place(customer, command.items); // Domain logic in write model
await this.orderRepo.save(order);
this.eventBus.publish(order.pullDomainEvents());
}
}
Query pattern
// Queries fetch pre-shaped read models — no domain objects, no business rules
class GetOrderSummaryQuery {
constructor(public readonly customerId: string) {}
}
class GetOrderSummaryHandler {
async handle(query: GetOrderSummaryQuery): Promise<OrderSummaryDto[]> {
// Direct SQL or view query — no domain model involved
return this.readDb.query(
'SELECT id, status, total, created_at FROM order_summaries WHERE customer_id = $1',
[query.customerId]
);
}
}
Examples
Before CQRS: single model struggles
// Order entity tries to be both write model and read model
class OrderService {
getOrdersForDashboard(userId: string) {
// Fetches Orders with all their business complexity just to display
// a simple list — forces joins, eager loading, and complex queries
return this.orderRepo.findByUserWithAllRelations(userId);
}
}
After CQRS: read model purpose-built for the view
// A flat view specifically for the dashboard
// Populated by a projection/event handler as orders are placed
CREATE MATERIALIZED VIEW order_dashboard AS
SELECT o.id, o.status, o.total, c.name AS customer_name, o.created_at
FROM orders o JOIN customers c ON o.customer_id = c.id;
// Query handler: trivially fast, no domain complexity
class GetDashboardOrdersHandler {
handle(query: GetDashboardQuery): Promise<DashboardOrderDto[]> {
return this.db.query('SELECT * FROM order_dashboard WHERE customer_id = $1', [query.userId]);
}
}
Anti-patterns
1. CQRS for simple CRUD
Separating a UserController into CreateUserCommand and GetUserQuery with no business logic adds two call-hops and two files for zero architectural benefit. CQRS is a solution to a specific problem; don’t introduce it without that problem.
2. Silently stale reads
When the read store is asynchronously updated from the write store, reads can return stale data. This is correct and expected in eventually consistent CQRS — but it must be communicated to users and designed into the UX (“your changes will appear shortly”). Silently stale reads that users interpret as data loss are a product failure.
3. Leaking domain logic into queries
Query handlers performing business calculations or enforcing rules. Query handlers should return pre-computed data; business logic belongs in command handlers and domain objects.
4. Separate physical stores before the need is Clear
Introducing separate databases (write to Postgres, read from Elasticsearch) before there is a concrete performance or data-shape requirement creates synchronisation complexity that the team will spend months managing.
Related practices
Part of the PushBackLog Best Practices Library. Suggest improvements →