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 screenorder_list_view with 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 VIEW on 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 readModelVersion from commands; queries accept minVersion and 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

PatternOverlapDifference
CRUD + read replicasSeparates read scalingSame schema; replicas are copies, not purpose-built views
Event sourcingOften pairedEvent sourcing is about write-side history; CQRS is about read/write split
Cache-asideSpeeds readsCache is optional layer; CQRS read model is authoritative for queries
API Gateway BFFAggregates for UIBFF shapes HTTP; CQRS shapes data ownership and storage
Database shardingScales writesSharding 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