Guide
CQRS explained
Most web apps use one database schema for everything: the same orders table
that accepts inserts also powers search filters, admin dashboards, and CSV exports.
That works until read patterns diverge from write patterns — complex joins for list
screens slow down checkout, and normalized write models fight denormalized reporting
needs. CQRS (Command Query Responsibility Segregation) splits the
problem: a command side optimized for business rules and mutations,
and a query side optimized for fast reads. CQRS often appears alongside
event sourcing, but you
can adopt it independently in a monolith or
microservice.
This guide explains what CQRS actually buys you, how read models stay in sync, when
eventual consistency is acceptable, and when a single CRUD stack is the smarter bet.
Commands vs queries — the core split
Greg Young formalized CQRS as a response to a simple observation: the model that
makes writes easy is rarely the model that makes reads fast. A command is an
intention to change state: PlaceOrder, CancelSubscription,
TransferFunds. A query is a read with no side effects:
GetOrderHistory, SearchProducts,
GetDashboardMetrics.
In strict CQRS, commands and queries travel through separate code paths and often separate storage. The write model enforces invariants — “cannot ship an unpaid order” — while the read model stores pre-joined, pre-aggregated data shaped exactly for UI screens. The two models are related but not identical: your command side might store an order aggregate with line items nested; your query side might flatten orders into a search index with customer name, SKU tags, and fulfillment status denormalized for sub-50ms autocomplete.
This is not the same as CQS (Command-Query Separation) at the function level, where a single method either mutates or returns data. CQRS is an architectural boundary: different handlers, schemas, scaling policies, and sometimes different databases entirely.
Why teams adopt CQRS
- Read/write scaling asymmetry — catalog browsing might be 1000:1 reads versus writes. Scale read replicas, caches, or Elasticsearch without touching the write path.
- Complex queries without polluting the domain — reporting needs wide denormalized tables; the write model stays focused on business rules instead of accumulating JOIN-friendly columns.
- Multiple views of the same data — one order stream can feed a customer-facing timeline, a warehouse pick list, and a finance revenue rollup.
- Team boundaries — a payments team owns command handlers; a analytics team owns projections without sharing one ORM entity graph.
- Performance isolation — a heavy BI query cannot lock rows the checkout service needs for writes.
CQRS does not make business logic simpler. It trades schema duplication and synchronization complexity for independent optimization of each path.
CQRS without event sourcing
You do not need an append-only event log to use CQRS. A common “lite” pattern keeps a normalized PostgreSQL write database and maintains one or more read databases or materialized views updated synchronously or asynchronously after each successful command.
Synchronous projection (same transaction)
After the command handler commits the write, it updates the read table in the same database transaction. Users always see consistent data; latency stays low. This works when read models live in the same DB and updates are cheap. The downside: you have not truly decoupled — a slow projection still blocks the command response.
Asynchronous projection (message-driven)
The command handler publishes a OrderPlaced integration event to a
message queue. A
separate projector consumer updates Elasticsearch, Redis caches, or a read-replica
schema. Commands return before all read models catch up — introducing
eventual consistency.
Product UX must tolerate brief staleness (“your order may take a few seconds to
appear”) or use read-your-writes tricks (route the user to a detail page that
reads from the write DB until the projector confirms).
CQRS with event sourcing
When the write side is event-sourced, CQRS becomes natural: the event stream is the command-side system of record, and every read model is a disposable projection that can be rebuilt by replaying events. Lose a corrupted search index? Replay from offset zero. Need a new analytics view? Deploy a new projector without migrating the write schema.
The pairing is powerful but heavy. Event sourcing adds stream versioning, snapshot strategies, and schema evolution for events. CQRS adds N read models to keep in sync. Teams sometimes event-source one bounded context (payments ledger) while leaving catalog CRUD untouched — you do not need whole-company CQRS.
Designing the command side
Command handlers should be small, explicit, and safe to retry where possible.
- Validate early — reject malformed commands before touching storage. Return domain errors, not database exceptions.
- One aggregate per transaction — modify a single order or account per command to avoid distributed locks. Cross-aggregate workflows use sagas or outbox patterns.
- Idempotency — network retries duplicate commands. Accept an idempotency key and return the same result if the command already succeeded.
- Optimistic concurrency — include expected version numbers so concurrent edits fail fast instead of silently overwriting.
- No queries in command handlers — if the handler needs current state, load it from the write model or aggregate repository, not from a stale read replica.
Commands return acknowledgment (success, new ID, version) — not the full updated entity graph. Clients that need fresh read data issue a separate query (possibly against a read endpoint that may lag milliseconds behind).
Designing the query side
Read models are deliberately denormalized. Design them from UI requirements backward: what columns does the orders list need? What facets does search expose? Store exactly that — no more.
Projection strategies
- Table per screen —
order_list_viewwith customer name, total, status badge color precomputed. - Search indexes — Elasticsearch or OpenSearch documents updated by projectors; tuned for full-text and aggregations.
- Cache layers — Redis keyed by entity ID for hot detail pages; invalidate or overwrite on projector events.
- Materialized views — PostgreSQL
REFRESH MATERIALIZED VIEWon a schedule for analytics that tolerate minutes of lag. - GraphQL read APIs — resolvers hit read stores exclusively; mutations route to command handlers.
Projectors must be deterministic: processing the same event twice should produce the same read state (upsert by event ID, not blind insert). Track processed event offsets so crash recovery resumes without double-counting.
Consistency models users will notice
CQRS almost always introduces lag between write and read models. Product and support teams need explicit rules:
- Stale list, fresh detail — after creating a record, redirect to a detail page served from the write DB or a “confirmed” read model.
- Version tokens — return
readModelVersionfrom commands; queries acceptminVersionand poll until caught up. - UI copy — “Processing…” spinners beat silent wrong data.
- Strong consistency where money moves — balance displays after transfers may need synchronous projection or read-from-write for that one field.
Not every screen needs the same SLA. Admin dashboards can lag 30 seconds; payment confirmation cannot.
CQRS vs other patterns
| Pattern | Overlap | Difference |
|---|---|---|
| CRUD + read replicas | Separates read scaling | Same schema; replicas are copies, not purpose-built views |
| Event sourcing | Often paired | Event sourcing is about write-side history; CQRS is about read/write split |
| Cache-aside | Speeds reads | Cache is optional layer; CQRS read model is authoritative for queries |
| API Gateway BFF | Aggregates for UI | BFF shapes HTTP; CQRS shapes data ownership and storage |
| Database sharding | Scales writes | Sharding partitions data; CQRS duplicates shape for access patterns |
When CQRS is overkill
- Small CRUD apps — a blog, internal tool, or MVP with uniform access patterns does not need separate models.
- Read and write shapes match — if list screens are simple
SELECT * FROM users ORDER BY created_at, duplication buys nothing. - Team cannot operate projectors — stuck read models at 3 a.m. are worse than slow queries you can EXPLAIN ANALYZE.
- Strong consistency everywhere — if every read must reflect the last write instantly, synchronous CQRS in one DB may be the only option — and that is often just CRUD with extra tables.
- Premature microservices — CQRS adds moving parts; fix indexing and caching first per the indexing guide.
Common mistakes
- Dual writes without a queue — updating PostgreSQL and Elasticsearch in the handler without outbox/transactions; one succeeds, one fails, data diverges forever.
- Querying the write DB from read endpoints — defeats the purpose and reintroduces join storms under load.
- Non-idempotent projectors — replaying events double-increments counters.
- One giant read model — a single “everything” table becomes as hard to maintain as the original monolith schema.
- Ignoring monitoring — projector lag metrics are as critical as API latency; alert when read models fall minutes behind.
- Leaking command DTOs into queries — read APIs should expose view models, not internal aggregate internals.
Production checklist
- Document which endpoints are command vs query — enforce in routing layers.
- Define per-read-model freshness SLAs and user-visible staleness behavior.
- Use outbox or transactional messaging so write + event publish are atomic.
- Make projectors idempotent; store last-processed event ID per projector.
- Expose projector lag dashboards (events behind, processing rate, error queue depth).
- Plan replay runbooks — how to rebuild a read model from scratch without downtime.
- Version integration events; projectors ignore unknown fields forward-compatibly.
- Load-test the query side independently; command traffic should not starve reads.
- Keep command handlers free of presentation logic — no HTML in the domain layer.
- Re-evaluate yearly: if read models mirror write tables 1:1, consider collapsing back to CRUD.
Key takeaways
- CQRS separates optimized write (command) and read (query) models — not just separate functions.
- Adopt it when read patterns, scale, or team boundaries diverge sharply from writes.
- Pair with event sourcing for replayable projections, or use lite CQRS with dual tables and queues.
- Eventual consistency is a product decision — design UX and SLAs explicitly.
- Idempotent, deterministic projectors and lag monitoring separate production CQRS from science projects.
- Default to CRUD; graduate to CQRS when measurement proves you need it.
Related reading
- Event sourcing explained — append-only logs and projections that CQRS often consumes
- Microservices architecture explained — bounded contexts where CQRS boundaries align with service seams
- Idempotency explained — safe command retries and duplicate event handling
- Event-driven architecture explained — pub/sub delivery that feeds read-model projectors