Issues, Special Considerations, and Performance Implications
CQRS is not free: the moment you split one model into a write side (commands that mutate state and emit events) and a read side (denormalized views rebuilt by projectors that consume those events), you inherit an asynchronous gap between "the write committed" and "every read reflects it" — and because the two sides live in different stores, you also lose the single ACID transaction that CRUD gave you for free. Everything below is a consequence of those two facts.
The mechanism the analogies skipped: how the read model actually gets updated
In a real CQRS deployment the read model is not updated by the same code path that handles the command. The command handler writes to the authoritative write store and emits a domain event (e.g. OrderPlaced); that event is published to a log or broker (Kafka, a transactional outbox, an event store); a separate projector consumes the event and upserts into one or more read stores (a denormalized Postgres view, an Elasticsearch index, a Redis cache). The lag between commit and projection is the "stale read" window — it is a physical property of the pipeline, not a bug. Typical numbers on a healthy Kafka pipeline: single-digit to low-tens of milliseconds; under consumer lag or a rebuild, seconds to minutes.
Worked trace: a customer places an order, then immediately refreshes
E-commerce checkout. Write model owns orders (normalized); read model serves GET /orders from a denormalized order_summary table updated by a Kafka consumer. The client fires the query 20ms after the command returns 202 Accepted.
| t (ms) | Actor | Action | Read store state for order #4711 |
|---|---|---|---|
| 0 | Command handler | PlaceOrder commits row to write store | absent |
| +2 | Outbox | Row written to outbox in the same DB transaction | absent |
| +5 | Relay | Publishes OrderPlaced{id:4711} to Kafka | absent |
| +20 | Client | GET /orders reads read store | absent → UI shows "no orders" |
| +40 | Projector | Consumes event (consumer poll interval) | absent |
| +45 | Projector | Upserts order_summary row | present |
| +60 | Client (retry) | GET /orders again | present → correct |
The user hit a 45ms hole and saw an empty list on a purchase they just made. Nothing failed; the pipeline is working exactly as designed. Mitigations, in order of cost: (1) after a command, have the UI optimistically render the known result instead of re-querying; (2) return the new entity's version/offset and let the read side read-your-writes by waiting for the projector to reach that offset; (3) route the immediately-following read to the write store; (4) accept it and set user expectations. Do not "fix" it by writing to the read store synchronously inside the command handler — that recouples the two sides and reintroduces the dual-write problem below.
The transaction problem, mechanically — and its real fix
The original page raised "what if one command in a multi-step transaction fails?" but stopped at the wall-painting analogy. The mechanism: across services (Order, Payment, Inventory) there is no distributed ACID transaction — you cannot ROLLBACK a payment that already captured money in another service's database. The standard answer is a saga: model the flow as a sequence of local transactions, each emitting an event, and for every step define a compensating transaction that semantically undoes it.
OrderCreated(Order svc, local commit)PaymentCaptured(Payment svc, local commit)ReserveInventoryfails — out of stock- Saga fires compensations in reverse:
RefundPayment, thenCancelOrder. Note the order is not deleted — it moves to aCANCELLEDstate. Compensation is forward motion, not an erase.
This is why CQRS pushes you toward event sourcing and sagas: once the read model is event-driven, modeling the write side as events makes compensation and audit natural. The cost is that every operation now needs a designed inverse, and intermediate states (payment captured, order not yet confirmed) are briefly visible.
Pitfalls
- Dual-write with no outbox. Writing the DB and then publishing to Kafka as two separate operations: if the process dies between them, the write is committed but the event is lost, and the read model silently diverges forever. Fix: transactional outbox (write event to same DB txn, relay publishes) or a change-data-capture stream — never two independent writes.
- Non-idempotent projectors. At-least-once delivery means the projector will occasionally see the same event twice. If projection does
balance += amount, a redelivery double-counts. Fix: idempotent upserts keyed by event id / offset, or track last-applied offset per aggregate. - Unbounded stale window under lag. Your 45ms window becomes 90 seconds when a consumer falls behind or during a full projection rebuild. If any business rule assumes fresh reads (e.g. "don't let them order the same seat twice"), enforce that invariant on the write side, never by querying the read model.
- Debugging across the async seam. A wrong number in a report may originate three services and one replay ago. Without a correlation/causation id threaded through every event, you cannot reconstruct the chain. Instrument it from day one; add it retroactively and you'll be grep-ing timestamps.
- Schema drift on rebuild. Read models are disposable and often rebuilt from the log. If old events don't deserialize into the new projector code, the rebuild stalls. Version your events and keep upcasters.
When to use CQRS — and when not to
Reach for it when concrete signals line up: a highly skewed read:write ratio (say 100:1) where you want to scale and cache reads independently; queries that need a shape the write schema can't serve cheaply (denormalized dashboards, full-text search, graph-like joins); a collaborative/high-contention domain where separating writes reduces lock pressure; or a domain that already benefits from event sourcing and audit.
Prefer simpler tools when those signals are absent:
- vs. a single shared CRUD model: CQRS gains independent scaling and query-optimized shapes; it costs two models to keep in sync, an eventual-consistency window, and real operational surface (broker, projectors, replay). Choose the shared model when reads and writes have the same shape and load — most CRUD apps.
- vs. read replicas of the same schema: replicas scale reads with almost no code and still give you the identical (normalized) schema — but they can't give you a different shape (no search index, no precomputed aggregates), and they carry their own replication lag. Choose replicas when you only need read throughput; choose CQRS when you need read throughput and a different model.
- vs. database materialized views: a materialized view is CQRS-lite inside one database — cheap, transactional-ish refresh, no broker. Choose it when the read model can live in the same DB and staleness of a periodic refresh is acceptable; graduate to full CQRS when the read side must be a different store or scale separately.
Rule of thumb: add CQRS to the specific bounded context that has the skew or the shape mismatch — never blanket the whole system. Most services should stay CRUD.
Takeaways
- The stale read is a physical property of an event-driven projection pipeline (commit → publish → consume → upsert), not a defect — measure the window and design reads-your-writes only where the UX demands it.
- You trade one ACID transaction for sagas + compensating transactions; every write step needs a designed inverse, and intermediate states become observable.
- Correctness across the async seam rests on three disciplines: transactional outbox (no lost events), idempotent projectors (no double-apply), and correlation ids (debuggability).
- Apply CQRS to the one context with a read/write skew or shape mismatch; a shared CRUD model, read replicas, or materialized views win everywhere else.
Sources: Chris Richardson, Microservices Patterns (Manning) — CQRS, Saga, and Transactional Outbox chapters; Martin Fowler, "CQRS" and "Event Sourcing" (martinfowler.com); Greg Young's original CQRS/event-sourcing talks; Microsoft Azure Architecture Center CQRS & Materialized View pattern guides; Confluent documentation on Kafka consumer lag and exactly-once/idempotent processing. Re-authored/Deepened for this guide.
🤖 Don't fully get this? Learn it with Claude
Stuck on Issues, Special Considerations, and Performance Implications? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Issues, Special Considerations, and Performance Implications** (System Design) and want to truly understand it. Explain Issues, Special Considerations, and Performance Implications from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Issues, Special Considerations, and Performance Implications** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Issues, Special Considerations, and Performance Implications** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Issues, Special Considerations, and Performance Implications** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.