CQRS splits read and write models in distributed systems. Learn when it helps, how commands and queries flow, and when simpler CRUD is enough.
CQRS—Command Query Responsibility Segregation—is an architectural pattern that uses separate models for updating information (commands) and reading information (queries). Instead of one database schema serving every access pattern, writes follow a command path optimized for business rules while reads hit structures tuned for screens, reports, and search.
The pattern sounds heavyweight because it often appears alongside event sourcing and microservices. In practice, CQRS is a spectrum: you can apply it inside a single service with two repositories, or spread it across event streams and read replicas. This article explains the idea, typical flows, and honest tradeoffs so you can decide if it belongs in your next system.
CRUD as the default—and its limits
Most web APIs expose REST resources: POST /orders creates, GET /orders/{id} reads, PATCH updates. One relational table (or document) backs both paths. That works until:
- Read traffic dwarfs writes and complex joins slow every page load
- Different clients need different shapes of the same data (mobile summary vs admin detail)
- Business rules on writes grow tangled with presentation-friendly fields
- You need audit history without polluting query tables
CQRS does not forbid a single database. It forbids assuming one model must serve every operation equally well.
Commands vs queries
| Side | Responsibility | Characteristics |
|---|---|---|
| Command | Change state | Validates business rules, emits events or persists domain state |
| Query | Return data | No side effects, optimized indexes/projections, may be stale |
Martin Fowler's formulation: commands should not return domain data except success/failure (or identifiers). Queries never mutate state.
Naming reinforces intent: PlaceOrderCommand, GetOrderSummaryQuery—not updateOrder() doing double duty.
A simple CQRS layout
┌─────────────┐
HTTP │ API │
POST ───►│ (commands) │──► Command handler ──► Write DB / event store
└─────────────┘
┌─────────────┐
HTTP │ API │
GET ───►│ (queries) │──► Query handler ──► Read DB / cache / search index
└─────────────┘
The write side enforces invariants: "cannot ship an unpaid order." The read side might denormalize order_summary with customer name, line items, and shipment status in one row for a dashboard.
Synchronization between sides can be:
- Synchronous — same transaction writes domain table and projection (simplest)
- Asynchronous — command persists event; projector updates read models (scales better, introduces lag)
Example: order placement
Command handler (write model)
async function handlePlaceOrder(cmd: PlaceOrderCommand) {
const customer = await customers.findById(cmd.customerId);
if (!customer) throw new DomainError('Customer not found');
const order = Order.create({
customerId: cmd.customerId,
lines: cmd.lines,
pricing: pricingService.quote(cmd.lines, customer),
});
await orderRepository.save(order);
await eventBus.publish(new OrderPlacedEvent(order));
return { orderId: order.id };
}
Projector (updates read model)
async function onOrderPlaced(event: OrderPlacedEvent) {
await readDb.orderSummaries.insert({
order_id: event.orderId,
customer_name: event.customerName,
total: event.total,
status: 'placed',
placed_at: event.timestamp,
});
}
Query handler
async function getOrderSummary(orderId: string) {
return readDb.orderSummaries.findOne({ order_id: orderId });
}
Clients listing orders never join five normalized tables—the read model already matches the UI.
CQRS without event sourcing
Event sourcing stores state as a sequence of events and rebuilds aggregates by replaying them. CQRS pairs naturally with event sourcing but does not require it.
You can CQRS with:
- One Postgres database, separate tables for write aggregates and read projections
- Read replicas where writes hit primary, reports hit replica (loose CQRS)
- Materialized views refreshed on a schedule
Choose the minimum machinery that solves your bottleneck.
When CQRS earns its complexity
Strong signals:
- Read/write asymmetry — 100:1 read ratio with expensive joins
- Multiple read representations — same domain, many specialized views
- Collaborative domains — rules-heavy writes (finance, inventory, booking)
- Independent scaling — spike traffic on search/read path should not starve writes
Weak signals (probably skip full CQRS):
- Admin CRUD with low traffic
- Prototype or MVP where schema churn is high
- Team lacks operational experience with eventual consistency
Eventual consistency and user experience
Async projection means a user might place an order and not see it in a list for hundreds of milliseconds—or seconds if the projector lags. Mitigations:
- Read-your-writes — route the creator's immediate GET to the write model or a version keyed by session
- UI optimism — show pending state until projection confirms
- Monitoring — alert on projector backlog depth
Document SLAs for read freshness. "Eventually consistent" is not an excuse without a bound.
CQRS in microservices
In distributed systems, CQRS often appears at service boundaries: the Orders service owns commands; a Reporting service maintains query models fed by events on a message bus (Kafka, SNS/SQS, RabbitMQ).
Benefits: teams evolve read schemas without migrations on the write service. Costs: distributed tracing, idempotent consumers, schema versioning for events, and debugging across services.
Comparison with related patterns
Traditional layered architecture — Controllers call services that read/write the same entities. Simpler mental model.
Event sourcing — Append-only event log as source of truth. CQRS is common but not mandatory.
Database per service — Microservice isolation; CQRS may appear inside each service's read/write split.
Caching — Caching query results is a light form of read optimization without formal command/query separation.
Testing strategy
- Command handlers — unit test business rules with in-memory repositories
- Projectors — feed fixture events, assert read model rows
- Queries — integration tests against read schema with seed data
- End-to-end — command → wait for projection → query (use test harness with synchronous bus in CI)
Common mistakes
CQRS everywhere
Splitting read/write for a UserPreferences table with two fields adds ceremony without benefit.
Leaking write models into queries
Exposing aggregate internals on GET couples clients to domain refactors.
Ignoring projector failures
Poison events stall the queue; build dead-letter handling and replay tools.
Same database, no boundaries
If handlers share ORM entities across command and query, you lose separation benefits.
Lightweight adoption path
- Identify one hot read endpoint causing pain
- Build a denormalized table or materialized view for that endpoint only
- Update it in the same transaction as the write (sync CQRS)
- Measure latency and load
- If write path suffers, move projection async with monitoring
You have applied CQRS incrementally without rebranding the entire platform.
FAQ
Is CQRS the same as having separate read and write databases?
Often, but not always. The defining split is responsibility and model shape, not necessarily physical isolation.
Does CQRS require Kafka?
No. In-process events or database triggers can synchronize models for smaller systems.
How does this relate to DDD?
Domain-Driven Design aggregates map well to command handlers. CQRS is a complementary pattern, not a DDD requirement.
When is eventual consistency unacceptable?
Financial ledger settlement, inventory deduction with hard stock limits—domains where read staleness causes real errors. Use sync projections or read from the write model for those operations.
Closing perspective
CQRS trades simplicity for flexibility under load and evolving read requirements. It is not a default architecture—it is a targeted response when unified models become the bottleneck.
Reach for clear command and query paths when measurements show read complexity or scaling pressure, not when a well-indexed CRUD API would suffice. The best implementations start small, prove value on one bounded context, and expand only where data justifies the operational cost.
Comments
Loading comments…