CQRS Implementation Guide: Separating Commands from Queries
Most applications use a single model to read and write data. That works until read and write requirements diverge — complex queries slow down writes, or write validation logic pollutes read paths. Command Query Responsibility Segregation (CQRS) solves this by splitting a system into two distinct models: one optimized for writes and another optimized for reads.
The Core Idea#
CQRS traces back to Bertrand Meyer's Command-Query Separation (CQS) principle: a method should either change state (command) or return data (query), never both. CQRS elevates that principle to the architectural level.
┌──────────────┐ ┌───────────────────┐
│ Client │────────▶│ Command Side │
│ │ │ (Write Model) │
└──────────────┘ │ - Validates │
│ │ - Enforces rules │
│ │ - Persists events │
│ └────────┬──────────┘
│ │ events
│ ┌────────▼──────────┐
│ │ Projection / │
└────────────────▶│ Query Side │
│ (Read Model) │
│ - Denormalized │
│ - Fast lookups │
└───────────────────┘
Commands express intent — PlaceOrder, CancelSubscription, ApproveRefund. Queries express questions — GetOrderHistory, ListActiveSubscriptions. Each side can scale, deploy, and evolve independently.
Separate Read and Write Models#
Write Model#
The write model owns domain logic and invariants. It validates commands, applies business rules, and persists state transitions. It does not care about how data will be displayed.
class OrderAggregate:
def handle_place_order(self, cmd: PlaceOrder):
if self.status != "draft":
raise InvalidOrderState("Order already placed")
if cmd.total <= 0:
raise ValidationError("Order total must be positive")
self.apply(OrderPlaced(order_id=cmd.order_id, total=cmd.total))
def on_order_placed(self, event: OrderPlaced):
self.status = "placed"
self.total = event.total
Read Model#
The read model is a denormalized projection tailored to specific query needs. It can be a flat table, a materialized view, a search index, or even a graph.
-- Projection: order_summary (optimized for dashboard)
CREATE TABLE order_summary (
order_id UUID PRIMARY KEY,
customer TEXT,
status TEXT,
total DECIMAL,
placed_at TIMESTAMP
);
Because the read model is derived, you can rebuild it from scratch whenever the schema changes — no migrations required.
Event Sourcing + CQRS#
CQRS pairs naturally with event sourcing, where the write model persists a sequence of domain events rather than current state.
Command ──▶ Aggregate ──▶ Event(s) ──▶ Event Store
│
┌──────▼───────┐
│ Projector │
│ (builds read │
│ models) │
└──────┬───────┘
│
┌──────▼───────┐
│ Read DB │
└──────────────┘
Benefits of combining the two:
- Full audit trail — every state change is an immutable event.
- Temporal queries — reconstruct state at any point in time.
- Multiple projections — one event stream feeds dashboards, search, analytics, and notifications simultaneously.
- Replay — rebuild projections or fix bugs by replaying the event log.
Eventual Consistency#
With separate models, the read side lags behind the write side. This is eventual consistency — after a command succeeds, queries may return stale data until the projection catches up.
Strategies to manage this:
- Causal consistency tokens — the write side returns a version token; the client includes it in subsequent reads, and the read side waits until it has processed that version.
- Read-your-own-writes — route the issuing user's next read to the write database (or a synchronous projection) so they see their own change immediately.
- Polling with backoff — the client polls the read model until the expected state appears, with exponential backoff.
- UI optimistic updates — update the UI immediately on command acceptance and reconcile when the projection catches up.
Consistency lag is typically measured in milliseconds to low seconds with well-tuned projections.
When CQRS Is Overkill#
CQRS adds moving parts. Avoid it when:
- Read and write models are nearly identical — a single model with an ORM is simpler.
- Low traffic — the operational complexity is not justified.
- Simple CRUD — if your domain has no complex invariants, a standard repository pattern suffices.
- Small team — CQRS requires discipline around event schemas, projection maintenance, and consistency trade-offs.
A good litmus test: if you find yourself building separate database views or caching layers to speed up reads while your write path has complex validation, CQRS likely pays for itself.
Tooling#
Axon Framework (JVM)#
Axon provides first-class support for CQRS and event sourcing on the JVM. It handles command routing, event storage, saga orchestration, and projection tracking.
@Aggregate
public class OrderAggregate {
@CommandHandler
public void handle(PlaceOrderCommand cmd) {
AggregateLifecycle.apply(new OrderPlacedEvent(cmd.getOrderId(), cmd.getTotal()));
}
@EventSourcingHandler
public void on(OrderPlacedEvent event) {
this.status = "placed";
}
}
EventStoreDB#
EventStoreDB is a purpose-built database for event sourcing. It stores events in immutable streams, supports catch-up subscriptions for projections, and provides built-in optimistic concurrency control.
# Append an event to a stream
curl -X POST https://localhost:2113/streams/order-123 \
-H "Content-Type: application/json" \
-H "ES-EventType: OrderPlaced" \
-d '{"orderId": "123", "total": 99.95}'
Other Notable Tools#
- Marten (.NET) — event sourcing and document DB on PostgreSQL.
- Eventuous (.NET) — lightweight event sourcing library.
- Apache Kafka — serves as a durable event log for CQRS projections at scale.
- Debezium — change data capture from existing databases into event streams.
Implementation Checklist#
- Identify bounded contexts where read and write concerns diverge.
- Define commands and events with explicit schemas (Avro, Protobuf, or JSON Schema).
- Build the write model with domain aggregates that enforce invariants.
- Build projections that subscribe to events and populate read-optimized stores.
- Handle idempotency — projections must tolerate duplicate events.
- Monitor projection lag — alert when the read side falls too far behind.
- Plan for schema evolution — version your events and use upcasters for backward compatibility.
Key Takeaways#
CQRS is not a universal pattern — it is a targeted tool for systems where read and write workloads have fundamentally different shapes. When combined with event sourcing, it unlocks powerful capabilities like full audit trails, temporal queries, and independent scaling. The trade-off is operational complexity: eventual consistency, projection maintenance, and event schema evolution all require deliberate engineering.
Start with a single bounded context, prove the value, and expand from there.
Want to sharpen your system design skills? Explore 322 more articles on Codelit.dev covering distributed systems, architecture patterns, and real-world engineering.
Try it on Codelit
GitHub Integration
Paste any repo URL to generate an interactive architecture diagram from real code
Related articles
Try these templates
Build this architecture
Generate an interactive architecture for CQRS Implementation Guide in seconds.
Try it in Codelit →
Comments