CQRS Pattern
Separate read and write models to optimize each for its own workload — when CQRS pays off, when it's overkill, and how it pairs with event sourcing.
Overview
CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates the models used for writing data from the models used for reading it. Instead of a single data model handling both commands and queries, CQRS uses a Command Model for writes and a Query Model for reads, each optimized for its own workload.
The idea comes from Bertrand Meyer’s CQS principle — a method should either change state (command) or return data (query), never both. Greg Young scaled that method-level rule up to whole models around 2010, giving it the CQRS name. The canonical implementation pairs it with event sourcing: events from the write side become the source data for read-model projections.
A warning up front: CQRS is one of the most over-applied patterns in enterprise software. Martin Fowler’s bliki says it plainly — it belongs at the edges of a system where read and write needs genuinely diverge, not across an entire codebase.
When to Use
Use the CQRS Pattern when:
- Read and write workloads have very different performance characteristics or scale independently
- You need several read models for the same data (search, reports, API listings) without complicating the write model
- Event sourcing is already in use, making read-model projections natural
- Different teams own reads vs. writes, and decoupling reduces coordination
- Examples: analytics dashboards, e-commerce catalogs, social media feeds, reporting systems
When to Avoid
- Simple CRUD apps — if reads and writes look alike, one model is strictly simpler
- Balanced read/write ratios — the payoff comes from divergent needs, not from the pattern itself
- Small teams — projections, rebuilds, and event consumers are real operational surface
- Systems where eventual consistency is unacceptable — a read model that lags 200ms can be a dealbreaker for users who just saved something
- “It’s a microservice, so it needs CQRS” — per-service CRUD doesn’t become CQRS by being distributed
Two Levels of CQRS
The pattern has two distinct flavors, and most of the confusion comes from mixing them:
| Level | What separates | Complexity | When it’s enough |
|---|---|---|---|
| Simple CQRS | Separate models/objects, same database | Low | Most apps that “do CQRS” |
| Full CQRS | Separate models AND separate stores, sync via events | High | Divergent scale, audit, multiple projections |
Start with the left level. The jump to separate stores is a one-way door — a write store plus N read stores plus a bus plus projections is the full event-driven machinery, not just “a bit more architecture”.
Solution
Python
from dataclasses import dataclass, field
from typing import List, Dict
from datetime import datetime
# Write Model (Command Side)
@dataclass
class Order:
order_id: str
customer_id: str
items: List[dict] = field(default_factory=list)
status: str = "pending"
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
class OrderCommandHandler:
def __init__(self):
self.orders: Dict[str, Order] = {}
def create_order(self, order_id: str, customer_id: str) -> Order:
order = Order(order_id=order_id, customer_id=customer_id)
self.orders[order_id] = order
return order
def add_item(self, order_id: str, product: str, qty: int, price: float):
order = self.orders[order_id]
order.items.append({"product": product, "qty": qty, "price": price})
def confirm(self, order_id: str):
self.orders[order_id].status = "confirmed"
# Read Model (Query Side) — denormalized DTO optimized for reads
@dataclass
class OrderSummary:
order_id: str
customer_id: str
total: float
item_count: int
status: str
class OrderQueryHandler:
def __init__(self, command_store: Dict[str, Order]):
self.command_store = command_store
def get_summary(self, order_id: str) -> OrderSummary:
order = self.command_store[order_id]
total = sum(i["qty"] * i["price"] for i in order.items)
return OrderSummary(
order_id=order.order_id,
customer_id=order.customer_id,
total=total,
item_count=len(order.items),
status=order.status
)
def list_by_customer(self, customer_id: str) -> List[OrderSummary]:
return [
self.get_summary(o.order_id)
for o in self.command_store.values()
if o.customer_id == customer_id
]
# Usage
commands = OrderCommandHandler()
commands.create_order("ORD-1", "CUST-1")
commands.add_item("ORD-1", "Laptop", 1, 999.99)
commands.add_item("ORD-1", "Mouse", 2, 29.99)
commands.confirm("ORD-1")
queries = OrderQueryHandler(commands.orders)
summary = queries.get_summary("ORD-1")
print(f"Order {summary.order_id}: ${summary.total:.2f} ({summary.item_count} items)")
JavaScript
// Write Model (Command Side)
class OrderCommandHandler {
constructor() {
this.orders = new Map();
}
createOrder(orderId, customerId) {
this.orders.set(orderId, {
orderId, customerId, items: [], status: "pending",
createdAt: new Date().toISOString()
});
}
addItem(orderId, product, qty, price) {
this.orders.get(orderId).items.push({ product, qty, price });
}
confirm(orderId) {
this.orders.get(orderId).status = "confirmed";
}
}
// Read Model (Query Side)
class OrderQueryHandler {
constructor(commandStore) {
this.store = commandStore;
}
getSummary(orderId) {
const order = this.store.get(orderId);
const total = order.items.reduce((sum, i) => sum + i.qty * i.price, 0);
return {
orderId: order.orderId,
customerId: order.customerId,
total,
itemCount: order.items.length,
status: order.status
};
}
listByCustomer(customerId) {
return Array.from(this.store.values())
.filter(o => o.customerId === customerId)
.map(o => this.getSummary(o.orderId));
}
}
// Usage
const commands = new OrderCommandHandler();
commands.createOrder("ORD-1", "CUST-1");
commands.addItem("ORD-1", "Laptop", 1, 999.99);
commands.confirm("ORD-1");
const queries = new OrderQueryHandler(commands.orders);
console.log(queries.getSummary("ORD-1"));
Java
import java.util.*;
// Write Model — domain entity enforces invariants
class Order {
String orderId;
String customerId;
List<Item> items = new ArrayList<>();
String status = "pending";
String createdAt = new Date().toString();
record Item(String product, int qty, double price) {}
void confirm() {
if (items.isEmpty()) throw new IllegalStateException("Cannot confirm empty order");
this.status = "confirmed";
}
}
class OrderCommandHandler {
private final Map<String, Order> orders = new HashMap<>();
public void createOrder(String orderId, String customerId) {
Order o = new Order();
o.orderId = orderId;
o.customerId = customerId;
orders.put(orderId, o);
}
public void addItem(String orderId, String product, int qty, double price) {
orders.get(orderId).items.add(new Order.Item(product, qty, price));
}
public void confirm(String orderId) {
orders.get(orderId).confirm();
}
public Map<String, Order> getStore() { return orders; }
}
// Read Model — flat DTO, no behavior
class OrderSummary {
public String orderId;
public String customerId;
public double total;
public int itemCount;
public String status;
}
class OrderQueryHandler {
private final Map<String, Order> store;
public OrderQueryHandler(Map<String, Order> store) {
this.store = store;
}
public OrderSummary getSummary(String orderId) {
Order o = store.get(orderId);
OrderSummary s = new OrderSummary();
s.orderId = o.orderId;
s.customerId = o.customerId;
s.total = o.items.stream().mapToDouble(i -> i.qty() * i.price()).sum();
s.itemCount = o.items.size();
s.status = o.status;
return s;
}
}
// Usage — file: CqrsDemo.java
public class CqrsDemo {
public static void main(String[] args) {
OrderCommandHandler commands = new OrderCommandHandler();
commands.createOrder("ORD-1", "CUST-1");
commands.addItem("ORD-1", "Laptop", 1, 999.99);
commands.confirm("ORD-1");
OrderQueryHandler queries = new OrderQueryHandler(commands.getStore());
System.out.println(queries.getSummary("ORD-1").total);
}
}
Explanation
CQRS splits two concerns that a single model forces together:
- Command Model: optimized for writes — validates business rules, enforces invariants, processes state changes. Often maps closely to the domain.
- Query Model: optimized for reads — denormalized, shaped by what the UI or API consumer needs, often stored in a separate database (Elasticsearch for search, Redis for fast lookups).
The two models synchronize either synchronously (same transaction — simple CQRS) or asynchronously (event-driven projections — full CQRS). In the async variant, writes publish events and projection handlers rebuild the read model:
// Projection handler — rebuilds the read model from events
class OrderProjectionHandler {
constructor(private readDb: ReadDatabase) {}
async handle(event: OrderEvent): Promise<void> {
switch (event.type) {
case 'OrderCreated':
await this.readDb.orders.insert({
orderId: event.orderId,
customerId: event.customerId,
total: event.items.reduce((s, i) => s + i.price * i.quantity, 0),
status: 'pending',
createdAt: event.timestamp
});
break;
case 'OrderConfirmed':
await this.readDb.orders.update(
{ orderId: event.orderId },
{ status: 'confirmed', confirmedAt: event.timestamp }
);
break;
}
}
}
Consistency Models
Choosing the sync level is the real architectural decision:
| Model | How | Trade-off |
|---|---|---|
| Strong | Read and write in the same transaction | Simple, but defeats independent scaling |
| Eventual | Read model updates asynchronously | Latency gap — users may not see their own writes |
| Read-your-writes | Route recent reads to the write model temporarily | Best UX, adds routing complexity |
Read-your-writes is the usual answer to the refresh problem: after a write, serve that user’s reads from the write store for a few seconds while the projection catches up.
Variants
| Variant | Description | Use Case |
|---|---|---|
| Single DB | Separate models, same database | Simple CQRS; lower complexity |
| Dual DB | Write to SQL, read from NoSQL/search | Complex queries; high read scale |
| Event Sourcing + CQRS | Events are source of truth; read models are projections | Audit trails; temporal queries |
| API Segregation | Separate REST/GraphQL endpoints for commands and queries | Microservices; team boundaries |
Read-model storage choices map to query shape more than to preference:
| Read model | Best for | Storage |
|---|---|---|
| Materialized View | Pre-computed aggregates | Document DB |
| Search Index | Full-text, faceted search | Elasticsearch |
| Graph Projection | Relationship queries | Neo4j |
| Cache | Hot, short-lived reads | Redis |
| Event Stream | Real-time analytics | Kafka/Kinesis |
What Works
- Start simple — separate models within the same database before introducing dual storage
- Use eventual consistency for read models when async projections are acceptable
- Version your read models when the query schema changes — a rebuild is cheaper than a migration
- Monitor projection lag — read models drifting too far behind writes break user trust silently
- Keep commands small and focused — one command should do one thing
- Make consumers idempotent — replayed events will hit your projections more than once
Common Mistakes
- Premature separation — adding CQRS to simple CRUD buys complexity, nothing else. It’s the single most common CQRS failure.
- Letting the read model bypass business rules — validation belongs in commands; a read-side shortcut around an invariant corrupts the write side later.
- Ignoring the refresh bug — user saves, hits F5, doesn’t see the change (projection lag). Without read-your-writes or optimistic UI, users report it as a bug.
- Read-model explosion — one projection per endpoint turns the codebase into a projection maintenance project. Consolidate shapes.
- Trying to make separate stores strongly consistent — that’s a distributed transaction, the exact thing CQRS exists to avoid. Accept eventual consistency or stay on one store.
- Forgetting rebuilds — projection logic changes; if you can’t replay events (or rebuild from snapshots), the read model can’t be fixed.
Real-World Example — E-commerce Orders
A TypeScript order system on Kafka + Elasticsearch, with read-your-writes:
Write: POST /api/orders -> CreateOrderCommand -> Order aggregate (PostgreSQL)
Read: GET /api/orders?q=laptop -> OrderQueryHandler -> Elasticsearch
Sync: OrderCreated/Confirmed events -> Kafka topic -> projection consumer
Read-your-writes flow:
1. Create returns the order ID immediately
2. Frontend renders the new order from local state (optimistic)
3. Poll GET /api/orders/:id until the projection lands (~50-200ms lag)
4. 5s timeout → fall back to the write model
Typical sizing:
Write: 2 PostgreSQL (primary + replica), p95 < 50ms
Read: 3 Elasticsearch nodes, p95 < 20ms
Bus: Kafka, 12 partitions
Lag: < 500ms
The pattern pays for itself here because search/filters hit Elasticsearch — a query the write model couldn’t serve well — while the aggregate keeps invariants in Postgres.
Troubleshooting
- Read model shows stale data: projection lag or a dead consumer — check consumer group offsets and the projection handler logs.
- Read model drifted and can’t be trusted: replay events (stop consumer → truncate read store → replay). For big stores, run a shadow projection alongside the current one, verify, then switch.
- “User saved but can’t see it”: that’s the refresh bug — add read-your-writes routing or optimistic UI, not faster sync.
- Projections exploding in number: merge overlapping shapes; keep one read model per genuinely distinct query family.
- Commands failing silently after write: events emitted but consumers down — add DLQ/alerting on projection consumers, and make events idempotent.
Production Notes
- Track projection lag as a first-class metric (target under ~500ms) — it’s the canary for the whole read side.
- Version every projection’s schema; a bump should trigger a rebuild, not a hand migration.
- Projection consumers must be idempotent — replays and redeliveries are normal.
- Keep the write model lean; read-side concerns (indexes, search fields, denormalized shapes) belong on the query side.
- Document when to stop using CQRS on each bounded context — it’s a per-context decision, not a default.
Key Takeaways
- CQRS separates writes (commands, invariants) from reads (queries, denormalized shapes) — the split is about models, not necessarily stores.
- There are two levels: same-DB (cheap, most teams) and dual-store + events (expensive, justified only by divergent scale).
- Eventual consistency is the price; read-your-writes is usually the mitigation.
- The most common mistake is applying it where CRUD would do — match the pattern to the divergence, not to the buzzword.
Further Reading
- Martin Fowler — CQRS — the authoritative primer and the “don’t use it everywhere” warning.
- Microsoft — CQRS pattern — implementation guidance with cloud context.
- Event Store — CQRS — the pattern from the event-sourcing side.
- Event Sourcing Pattern — the companion pattern this one most often pairs with.
- Runnable demos in the companion repository — command/query handlers in Python, Java, and JavaScript, plus an event-projection demo.
Frequently Asked Questions
Does CQRS require Event Sourcing?
No — the two are independent. Event sourcing pairs naturally because events are perfect projection input, but CQRS works fine with a single relational store: separate models + same transaction.
How do read and write models stay in sync?
In single-DB CQRS, in the same transaction. In dual-store CQRS, write events and let projection consumers rebuild the read model — accept the lag or add read-your-writes.
When should I use separate databases?
Only when the read and write sides genuinely can't share a store — different scaling needs (search vs. ACID), different availability requirements, or a read shape the write model can't serve (aggregates, full-text). Otherwise the extra store is operational debt.
What breaks when a read model gets corrupted?
Nothing on the write side — that's the point. The fix is a rebuild: stop consumers, truncate the read store, replay events. If you can't replay, you don't have event sourcing and rebuilds get harder.
How do I handle the lag in read models?
Three options: read-your-writes routing for the writer's own reads, optimistic UI updates that reflect the change immediately, or polling with a version check until the projection lands.
Is CQRS still relevant without microservices?
Yes — the pattern is about separating read/write models, which pays off in a monolith too (a reporting view next to a domain model). Microservices just make the store separation easier, not the pattern more necessary.
Related Resources
Event Sourcing Pattern
Store the state of an application as a sequence of events rather than storing only the current state. An architectural pattern for audit-friendly systems.
PatternSaga Pattern: Orchestration vs Choreography
Manage distributed transactions across multiple services by chaining local transactions with compensating actions for rollbacks. A microservices pattern.
PatternMaterialized View Pattern
Precompute and store expensive query results in a read-optimized cache to avoid repeated costly aggregation or joins across large datasets.
PatternDatabase per Service Pattern
Give each microservice its own private database to ensure loose coupling, independent deployment, and technology heterogeneity across the application portfolio.
PatternEvent-Carried State Transfer Pattern
Replicate state changes across services by publishing events that carry the full updated entity state, enabling consumers to maintain their own local copies without querying the source.
PatternCache-Aside Pattern
Load data into the cache on demand from the backing store. A caching pattern that gives the application full control over what and when to cache.