Separate the model you write with from the model you read with. A powerful idea that is applied far more often than it’s needed.
Prerequisites: Event-Driven Architecture, Caching Time to read: ~20 minutes
One data model serves both writes and reads, and they want opposite things.
Writes want normalization, constraints, and small focused transactions — one source of truth for every fact, so updates are safe.
Reads want exactly the shape the screen needs, with no joins, precomputed.
-- The write model, properly normalized
orders, order_items, products, customers, addresses, shipments
-- The read the dashboard needs, 5,000 times a second
SELECT o.id, c.name, c.email, SUM(oi.qty * oi.price) AS total,
COUNT(oi.id) AS item_count, s.tracking_number, a.city
FROM orders o
JOIN customers c ON ... JOIN order_items oi ON ...
JOIN shipments s ON ... JOIN addresses a ON ...
GROUP BY ...
🚨 A six-way join with an aggregation, at 5,000 QPS. You can index it, cache it, and add replicas — but the fundamental mismatch remains: the shape that’s correct for writing is the wrong shape for reading.
CQRS separates them.
flowchart LR
C[Command<br/>PlaceOrder] --> W[Write model<br/>normalized, validated]
W --> DB[(Write store)]
DB -->|events / CDC| P[Projection]
P --> RD[(Read store<br/>denormalized)]
Q[Query<br/>GetOrderSummary] --> RD
Commands change state. They’re validated against the write model, enforce invariants, and typically return nothing but success or failure.
Queries read state. They hit a read model shaped exactly for the query, requiring no joins.
A projection keeps the read model updated from the write model — usually via events or CDC.
🚨 The crucial insight: the read model is a derived, disposable cache. It can be rebuilt from the write model at any time. That’s what makes it safe to have several of them, each optimized differently.
🚨 CQRS is a spectrum, and most people jump straight to the expensive end. Naming the levels is a strong signal.
# Commands return nothing; queries have no side effects
def place_order(cmd) -> None: ...
def get_order_summary(order_id) -> OrderSummaryDTO: ...
Just clean design (Command-Query Separation). No infrastructure. Do this always.
Write through domain objects; read through purpose-built projections or views.
CREATE MATERIALIZED VIEW order_summaries AS SELECT ... ;
REFRESH MATERIALIZED VIEW CONCURRENTLY order_summaries;
✅ Reads are fast and simple. Still one database, one transaction, immediate or near-immediate consistency. ❌ Refresh cost.
🎙️ This level solves most real problems and almost nobody considers it. “Before separating stores, I’d use materialized views — same database, so no consistency gap, and it removes the join cost entirely.”
Writes to Postgres; reads from Elasticsearch, Redis, or a denormalized store, updated by events.
✅ Independent scaling and technology choice per side. Reads can be a completely different engine. ❌ 🚨 Eventual consistency, plus a projection pipeline to build and operate.
The write side stores events; read models are projections of the event stream.
✅ Complete audit trail; new read models can be built by replaying history. ❌ Substantial complexity. → Event Sourcing
🚨 CQRS and event sourcing are independent. You can do either without the other. They’re often discussed together because event sourcing makes projections natural — but conflating them makes CQRS sound far more expensive than it needs to be.
1. Independent scaling. Most systems are 100:1 read-heavy. Scale the read side to 50 nodes and the write side to 3.
2. Query-shaped storage. Each read model matches exactly one screen. No joins at read time.
3. Multiple read models from one write model:
Write model: normalized orders
├── Elasticsearch → full-text order search
├── Redis → "my recent orders" per user
├── ClickHouse → analytics and reporting
└── Postgres view → the admin dashboard
🚨 This is the strongest argument for CQRS, and it’s underappreciated. One authoritative write model, many purpose-built read models — each optimized for its access pattern, each independently rebuildable.
4. A simpler write model. Freed from serving queries, the domain model can focus purely on invariants and business rules.
5. Team independence. Read models can be added by other teams without touching the write side.
⚖️ Be honest about this — it’s the difference between a good answer and a buzzword.
🚨 1. Eventual consistency, and it’s user-visible. The user places an order and their order list doesn’t show it yet. This is the problem that dominates real CQRS implementations.
Mitigations:
2. Two models to maintain. Every write-model change may require a projection change. They drift.
3. Projection lag and failure. The projection can fall behind or break, and now your read model is wrong with no error surfaced. You must monitor projection lag as a first-class metric.
4. Rebuild time. Reprojecting a billion records after a bug fix takes real time — hours or days — during which reads are stale or unavailable.
5. More infrastructure, more failure modes, more to debug.
6. Cognitive load. Every developer must know which model to use and understand the consistency implications.
From events:
def on_order_placed(event):
read_db.upsert("order_summaries", {
"order_id": event.order_id,
"customer_name": event.customer_name, # denormalized at write time
"total_cents": event.total_cents,
"status": "placed",
})
From CDC — read the write database’s replication log. No application changes, and nothing is missed. → CDC
🚨 Projections must be idempotent. They’ll reprocess events on restart, on redelivery, and on rebuild. Use upserts keyed on the entity ID, and track the last processed position. → Idempotency
Handling out-of-order events: include a version or sequence number and ignore anything older than what you’ve applied:
UPDATE order_summaries SET status = :status, version = :version
WHERE order_id = :id AND version < :version;
Rebuilding must be possible and practised. If you can’t rebuild a read model on demand, you don’t really have a derived store — you have a second source of truth that will diverge. Build the rebuild path early and run it periodically, not just when something breaks.
Good fits:
Poor fits — and these are the common mistakes:
🎙️ The answer that scores well: “Before CQRS I’d try read replicas and a cache — that handles read scaling with far less complexity. CQRS earns its place when the read *shape is the problem, not just read volume — when we need several genuinely different projections of the same data.”*
🚨 That distinction — volume vs shape — is the key one. Read replicas solve volume. CQRS solves shape.
| Level | Gain | Cost |
|---|---|---|
| Separate methods (CQS) | Cleaner code | None — just do it |
| Materialized views | Fast reads, immediate consistency, one database | Refresh cost |
| Separate read store | Independent scaling; purpose-built storage | Eventual consistency; a projection pipeline |
| Multiple read models | Each optimized for its use | Each must be maintained and rebuilt |
| With event sourcing | Full audit; rebuild any projection from history | Substantial complexity |
REFRESH MATERIALIZED VIEW CONCURRENTLY gives you a fast read model without
leaving your database or accepting a consistency gap.1. Feel the shape problem. Build a normalized schema and write the “order summary” query with five joins and an aggregation. Time it over a million orders. Then create a materialized view and time the same query. The difference is the argument for CQRS, and materialized views get you most of it for very little.
2. Build a projection. Publish OrderPlaced events and maintain a denormalized order_summaries
table from them. Then:
3. Experience the consistency gap. Add 500 ms of artificial projection lag. Place an order and immediately load the order list. Watch it not be there. Then implement read-your-own-writes and feel the difference.
4. Break a projection silently. Introduce a bug that makes the projection skip some events, with no error. Notice that nothing alerts. Then add projection lag and event-count monitoring.