DataCQRScommandqueryread modelwrite model

CQRS

Command Query Responsibility Segregation splits the model that accepts writes from the model that serves reads, so each can have its own shape, storage and scaling — at the price of a projection that lags, a rebuild story, and two models to keep in agreement; most applications should stop at CQRS-lite.

▶ InteractiveInterview questionDebug it
Progress
What problem does this solve?

One normalised model is asked to do two jobs: validate and apply changes (which wants a small, consistent, well-constrained shape) and answer screens and reports (which want wide, denormalised, pre-joined shapes at 10–100× the write rate). CQRS lets the write side stay strict and the read side be whatever the queries need, scaled and stored separately.

One model, two jobs

The order write path needs orders, order_items, payments in third normal form with foreign keys and invariants: an order cannot have a negative total; an item cannot reference a missing SKU. The order *page* needs the order, its items with product names and images, the payment status, the shipment tracking number and the customer's display name — a six-table join, run every time the user refreshes, and by support staff, and by the mobile app, ten thousand times for every order created. The same Normalization: 1NF to BCNF that keeps writes correct makes reads expensive, and the Denormalization on Purpose that would make reads cheap makes writes risky. Indexes, caching and read replicas (Replication and Read Scaling) push this a long way — far enough for most systems.

CQRS is the next step when they are not enough: keep the write model normalised and authoritative, and maintain one or more read models — precomputed, denormalised documents shaped exactly like the screens that read them — updated from the write model's events. Commands (PlaceOrder, CancelOrder) go to the write side and are validated there. Queries (GetOrderPage, ListMyOrders) go to a read side that never joins anything because the join was done once, at write time.

Commands to the write model, queries from the read model
PlaceOrdervalidate + commitOrderPlaced (outbox)upsert documentGetOrderPageClientAPIWrite model (commands)Orders DB (normalised)EventsProjectorRead model (order_page docs)
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Why separate models: shape, scale, storage

Three things change once reads have their own model. Shape: the read model is a document per screen — order_page:{orderId} holding everything the page shows, or a my_orders list per customer already sorted. No joins, no N+1, one key lookup. Scale: reads and writes scale independently; the read store can be replicated to ten nodes or held in Redis (Redis: Data Structures, Not a Cache) while the write database stays a single strongly consistent primary. Storage: the read model can live in a different kind of store — a search index for full-text, a wide-column store for time series, a cache for hot keys — chosen per query rather than compromising one database for all of them.

The projector is a consumer of the write model's events (Event-Driven Architecture), delivered through an outbox so no state change is lost (Distributed Transactions). It is idempotent — it upserts documents keyed by id and ignores events older than the document's version — because it will see duplicates. Several projectors can feed several read models from the same events; adding a new screen means adding a projector, not touching the write model.

What it costs

The read model is eventually consistent with the write model. After PlaceOrder returns, the projector has not necessarily run; a client that immediately queries GetOrderPage may see the old state or nothing — the read-model-shows-stale-order challenge. The lag is normally milliseconds, but it is unbounded under backlog, and the UI must be designed for it: return the new state in the command response, redirect with the id and show a "processing" placeholder, read-your-own-writes by routing the user's next query to the write model for a few seconds, or wait until the projection version ≥ the command's version. Pretending the lag is zero is the bug.

Projection rebuilds: a bug in the projector or a new field on the screen means rebuilding the read model from scratch — from an event log if you keep one (Event Sourcing), or from the write database by re-reading everything. That needs a rebuild path that runs alongside live traffic and swaps atomically. Two models to maintain: every domain change is now a write-model change, an event change, a projector change and a read-model change, with versioning between them. And debugging spans an asynchronous boundary: "the page shows the wrong total" may be the command, the event, the projector, or a duplicate applied out of order.

When CQRS pays for itself
SignalWithout CQRSWith CQRS
Read/write ratioUnder 10:1 — indexes and a replica suffice100:1+ with expensive joins per read
Read shapesA few, close to the tablesMany screens, each wanting a different denormalised document
Storage needsOne relational store fits everythingReads want search / cache / time series that the write DB is bad at
Consistency needUsers must see their write immediately everywhereMilliseconds of lag is acceptable and the UI can handle it
TeamOne team, one deployableRead and write sides owned or scaled separately

Most apps need CQRS-lite, not CQRS

CQRS as a *principle* — separate the code that handles commands from the code that handles queries — costs nothing and improves any codebase: a PlaceOrderHandler that validates and writes, and an OrderQueries module with hand-written SQL that joins whatever the screen needs, reading from the same database (or its read replica). No events, no projector, no lag, no rebuild. Add a cache in front of the hot queries (Caching Architecture). This is where the vast majority of applications should stop, and it captures most of the clarity benefit.

Full CQRS with separate stores earns its keep when the read load or the read shapes measurably cannot be served from the write database, or when the read models need a store the write side should not use. It is not a prerequisite for microservices, not required by event-driven architecture, and not made necessary by event sourcing — though event sourcing makes it nearly unavoidable, because an event log cannot be queried directly and needs projections to be readable. Adopt it because a query is slow and cannot be indexed into shape, not because a diagram looked cleaner with two boxes.

Key points

  • Writes want a strict normalised model; reads want wide denormalised documents at 10–100× the rate. CQRS gives each its own model.
  • Read models are projections built by an idempotent consumer of the write model's events, keyed by id and versioned.
  • Read models lag: design the UI for it — return state in the command response, read-your-own-writes, or wait for the projection version.
  • Costs: eventual consistency, projection rebuilds, two models plus events to keep in sync, debugging across an async boundary.
  • CQRS-lite (separate query code, same database, maybe a cache) is the right answer for most apps; full CQRS only for a measured read problem.

Commands, events, read models

Commands, events, read models
Writes go to normalised tables; a projection builds a denormalised row for reads. Set the projection lag and see what a query right after the write returns.
PlaceOrderOrderPlacedupsertGET /orders/1042fallbackCommandsQueriesWrite model (normalised)EventsProjectionRead model: order_summary
Mitigation for stale reads
write model
(begin transaction)
read model
(no row for 1042 yet)
now
t+0 ms
read model
stale / missing
query result
lag
800 ms
The command is validated against the write model — normalised tables, constraints, the place where invariants live.

Two models to keep consistent is the price of CQRS: every read path needs an answer to "what if the projection is behind?". CQRS-lite — separate read queries or views against the same database, no projection, no lag — is often enough and gives you most of the code clarity for none of the consistency work.

1/5 · PlaceOrder command · t+0 ms

How data moves through it

One request or event, hop by hop.

  1. 1Client → API → Write model: PlaceOrder; validated against the normalised model; committed with an outbox OrderPlaced row.
  2. 2Write model → Client: 201 { orderId, status: pending, version: 1 } — the response carries the state so the client need not query yet.
  3. 3Outbox → Events → Projector: OrderPlaced consumed; the projector joins product names and customer display name once.
  4. 4Projector → Read store: upsert order_page:ord_91 with version: 1; ignore if the stored version is newer.
  5. 5Client → API → Read store: GetOrderPage(ord_91); one key lookup; if version < expected, the API waits briefly or falls back to the write model.

When to use — and when not

Use it when
  • Reads outnumber writes by 100:1 or more and each read is a multi-table join that indexes cannot make cheap.
  • Different screens want different shapes, or some queries need a store the write side should not be (search index, time series, cache).
  • Read load and write load must scale independently, or are owned by different teams.
  • An event log already exists (event sourcing) and needs projections to be readable at all.
Avoid it when
  • Reads are served fine by indexes, a read replica and a cache — the usual case.
  • Users must see their own writes immediately in every view and the UI cannot be adapted; the lag will be a stream of bug reports.
  • The team is small and the domain simple; two models, events and a projector triple the surface area for no measured gain.

Tradeoffs

Complexity
low → high
Ops cost
low → high
Latency
low → high
Consistency
weak → strong
Scalability
poor → strong

Reads become one key lookup and scale on their own store; you pay with projection lag, rebuild tooling and every change touching two models.

How it fails

  • Client reads the read model right after a command and sees stale or missing data; the UI shows "order not found" for the order just placed.
  • Projector applies duplicate or out-of-order events without versioning: the document flips back to an older state.
  • Projector falls behind under load; nobody alerts on projection lag; every page is minutes stale while the write side looks healthy.
  • No rebuild path: a projector bug corrupts documents and the only fix is a hand-written migration against live traffic.
  • Read model treated as authoritative: a command validates against the (stale) read model and accepts an order for stock that was just sold.

How it scales

  • Read models scale on their own store — Redis, a replicated document store, a search cluster — independent of the write database.
  • Multiple projectors consume the same events into multiple read models; partition events by aggregate id so each document is updated in order.
  • Projection lag is the metric; scale projectors on lag and batch document upserts.
  • The write side scales as any transactional store does — Scaling from One User to Millions applies unchanged.

How it interacts with databases, queues, caches, APIs and external systems

  • Database (write): normalised, transactional, with an outbox; the only place invariants are enforced.
  • Cache/Redis or document store (read): precomputed documents keyed by id; replicated and disposable — rebuildable from events.
  • Queue/log: carries domain events from write to read; at-least-once, so projectors upsert with versions.
  • Search index: a read model of its own, fed by the same events.
  • API: routes commands and queries to different sides; can implement read-your-own-writes by version or by routing.