What the CQRS pattern is (and what it is not)
Command query responsibility segregation (CQRS) splits an application's operations into two kinds and gives each its own model. A command changes state. It validates input, enforces invariants such as "an order cannot ship before payment clears," and either succeeds or is rejected. A query returns data and has no side effects. Once the two are separated, each side can be shaped for its own job. The write model holds the rules. The read models are structured around what callers actually ask for.
The idea grows out of Bertrand Meyer's command-query separation principle, which applies at the level of a single method: a method should either change an object's state or return a value, not both. CQRS takes that rule from methods up to the architecture, where entire models, and sometimes entire data stores, sit on one side of the line or the other.
CQRS is not event sourcing. The two are independent. You can run CQRS against an ordinary relational database, with no event store involved. They often appear together because an event-sourced system stores state as a sequence of events, and querying a log of events directly is impractical. Separate read models fix that problem. Starting with event sourcing by default is expensive, though. It brings event versioning and upcasting of old event schemas, and many teams take on that work without needing it.
This guide is for backend and platform engineers whose services show read and write workloads moving apart in volume, shape, or latency requirements.
// CQRS without event sourcing: one relational DB, two models.
// Minimal SQL client interface so the example stays library-agnostic.
interface Db {
query<T>(sql: string, params: unknown[]): Promise<T[]>;
execute(sql: string, params: unknown[]): Promise<void>;
}
// --- Command side: changes state, enforces invariants, returns nothing ---
class ShipOrderHandler {
constructor(private db: Db) {}
async handle(cmd: { orderId: string }): Promise<void> {
const [order] = await this.db.query<{ status: string }>(
'SELECT status FROM orders WHERE id = $1', [cmd.orderId]);
if (!order) throw new Error('Order not found');
// Invariant lives only in the write model.
if (order.status !== 'PAID') throw new Error('Cannot ship before payment clears');
await this.db.execute("UPDATE orders SET status = 'SHIPPED' WHERE id = $1", [cmd.orderId]);
// Keep the read model in sync (same transaction or async projector in practice).
await this.db.execute(
"UPDATE order_summaries SET status = 'SHIPPED' WHERE order_id = $1", [cmd.orderId]);
}
}
// --- Query side: no side effects, shaped for what callers ask for ---
interface OrderSummary { orderId: string; customerName: string; status: string; total: number }
class OrderSummaryQueries {
constructor(private db: Db) {}
// Reads a denormalized table: no joins, no business rules.
listForCustomer(customerId: string): Promise<OrderSummary[]> {
return this.db.query<OrderSummary>(
`SELECT order_id AS "orderId", customer_name AS "customerName", status, total
FROM order_summaries WHERE customer_id = $1`, [customerId]);
}
}
A command handler that enforces the ship-after-payment invariant and a side-effect-free query reading a denormalized summary table, both against an ordinary relational database with no event store.
How CQRS works
At runtime, a CQRS system has two paths through it that share no model code.
The command path. A client sends a command, an intent such as ShipOrder or ChangeShippingAddress, not a generic "update row." A command handler loads the current state the decision depends on, usually one aggregate, checks the invariants, and persists the change. It returns success or a rejection, plus at most the identifiers, version, or minimal result the caller needs. The write model is normalized and organized around consistency boundaries, not around screens.
The propagation step. After the write commits, the change has to reach every read model that depends on it. Depending on how far you take the pattern, this happens in the same database transaction, through a scheduled refresh of a view, or asynchronously: the write emits an event (via an outbox, change data capture, or a broker), and a projector consumes it and updates the read model. This step is where consistency guarantees are decided, and it is covered in detail below.
The query path. A client sends a query, and a query handler reads a model that is already in the shape the caller wants: a denormalized table, a view, a document, or a search index. Query handlers contain no business rules and do no writes. Because each read model serves a known set of queries, it can be indexed, partitioned, and scaled for those queries alone.
The key property is that the two paths change for different reasons. A new dashboard means a new or modified read model and leaves the write model alone. A new business rule means a change to a command handler and leaves the read models alone, unless the data they display changes too.
CQRS as a spectrum: from split handlers to separate read stores
CQRS is not a single architecture. It is a set of choices that range from a code-organization habit to a distributed data pipeline. Each level adds a capability and a cost.
Level 1: Split handlers, one model
Commands and queries go through separate code paths, such as command handlers that load aggregates and enforce invariants, and query services that read the same tables directly. There is one schema, and transactions behave as before. The benefit is clarity: query code no longer has to go through domain objects built for validation.
Level 2: Dedicated read models in the same database
You add views, materialized views, or denormalized tables shaped for specific screens or APIs. If the write transaction updates them, reads stay strongly consistent. If they are refreshed on a schedule, reads lag by up to one refresh interval. Either way, everything lives in one database with one backup and one failure domain.
Level 3: Separate read stores fed by asynchronous projections
The data flow becomes: a command updates the write store, a change event is recorded, a projector consumes it, and the projector updates a read store that may use a different engine entirely.
| Level | Consistency | New infrastructure | Main cost |
|---|---|---|---|
| Split handlers | Strong | None | Code structure discipline |
| Same-DB read models | Strong or bounded lag | None | Schema and refresh logic |
| Async read stores | Eventual | Broker, projectors, second store | Lag, replay, ordering, ops |
Most of the cost sits at level 3. Reliable event capture, idempotent projectors, rebuilds, and user-visible staleness are all new problems that the lower levels avoid. Pick the lowest level that removes your actual bottleneck.
LEVEL 1: split handlers, one model
+----------+ +-----------------+ +----------+
| Command |--->| Command handler |--->| |
+----------+ +-----------------+ | Database |
+----------+ +-----------------+ | (tables) |
| Query |--->| Query service |--->| |
+----------+ +-----------------+ +----------+
LEVEL 2: dedicated read models in the same database
+----------+ +-----------------+ +-------------------------+
| Command |--->| Command handler |--->| tables |
+----------+ +-----------------+ | | same tx or refresh |
+----------+ +-----------------+ | v |
| Query |--->| Query service |--->| views / denorm. tables |
+----------+ +-----------------+ +-------------------------+
LEVEL 3: separate read store via async projection (most of the cost)
+---------+ +---------+ +-------------+ +--------+ +-----------+
| Command |-->| Handler |-->| Write store |-->| Change |-->| Projector |
+---------+ +---------+ +-------------+ | events | +-----------+
+--------+ |
+---------+ +---------------+ +-------------------------+ |
| Query |-->| Query service |-->| Read store (any engine) |<---+
+---------+ +---------------+ +-------------------------+
eventual: lags the write store
The three levels of CQRS: split code paths over one database, read models in the same database, and a separate read store fed asynchronously by a projector, which is where most of the cost appears.
When separate read models pay off and when to avoid them
Separate read models are worth their cost when the write schema actively fights the queries you need. Watch for these signals:
- Asymmetric load or latency. Reads outnumber writes by orders of magnitude, or reads need tighter latency than writes, so the two need to scale and tune independently.
- Join-heavy or aggregate queries. Dashboards and list views repeatedly join or sum across normalized tables designed to protect invariants.
- Many consumers, many shapes. A mobile feed, an admin console and a partner API each want the same data arranged differently.
- Capabilities the write store lacks. Full-text search, faceting or analytical scans.
Avoid them in simple CRUD domains, where a command, a projector and a second schema add indirection and boilerplate for a screen that mirrors a table. Also avoid applying CQRS across a whole system. Use it in the specific bounded contexts that show the signals above.
In a microservice architecture, CQRS can live inside a single service. It also solves cross-service queries. A service subscribes to events from others and maintains its own read model, so it can answer queries without making synchronous calls to other services at query time.
Before building a projection, check whether a simpler option is enough:
| Option | Solves | Leaves unsolved |
|---|---|---|
| Read replica | Read throughput | Query shape is the same as the write schema |
| Cache | Repeated hot reads | Invalidation, cold misses |
| Materialized view | Precomputed joins within one database | Cross-service data, refresh cost |
| Search index | Text search, facets | Becomes a read model you must keep in sync |
| Projected read model | Any shape, any store | Event capture, rebuilds, staleness |
Who should use CQRS
CQRS fits teams and systems with a specific combination of problem and capacity.
Good fits:
- Teams that own a domain with real business rules. If commands carry invariants worth protecting (payments, inventory, scheduling, entitlements), separating them from read concerns keeps that logic small and testable.
- Services with read-heavy, multi-shaped consumers. Product catalogs, order histories, feeds, and dashboards that several clients read in different shapes.
- Microservice teams that need data owned by other services. A local read model built from another service's events replaces fan-out queries at request time.
- Systems that already use event sourcing. Here read models are effectively mandatory, because the event log cannot be queried directly in any practical way.
Poor fits:
- Small CRUD applications and internal tools where screens map one-to-one to tables and a single model is easy to understand.
- Teams without the operational capacity for asynchronous pipelines. Level 3 requires someone to own lag alerts, dead-letter queues, reconciliation, and rebuilds. If nobody can be on call for that, stay at level 1 or 2.
- Domains where every read must reflect the latest write, such as balance checks that gate a transaction. Those reads belong on the write model regardless of what else you separate.
Level 1, splitting command and query handlers in code, is cheap enough that most teams with non-trivial domain logic can adopt it. The levels above it should be reserved for the bounded contexts that show the signals listed in the previous section.
Alternatives to CQRS
CQRS is one answer to "reads and writes want different things." Several other approaches address parts of that problem with less machinery. Consider them first, and combine them where they fit.
- A single well-designed model (layered CRUD). One schema, one set of repositories, with careful indexing and query tuning. This is the right default until a concrete query problem appears, and it keeps strong consistency everywhere.
- Read replicas. Database replication offloads read traffic from the primary. Replicas add throughput but do not change query shape, and asynchronous replication introduces its own read-after-write lag.
- Caching. An in-process or shared cache in front of hot queries cuts load and latency for repeated reads. Invalidation becomes the hard part, and the cache does nothing for queries that are slow on a cold miss.
- Database views and materialized views. Precomputed joins and aggregates inside the same database. This is effectively level 2 CQRS without the name, and often enough.
- A dedicated search index. Indexing documents into a search engine adds full-text search and faceting. Keeping it in sync is the same problem as maintaining a projected read model, so treat it as one.
- Reporting databases and data warehouses. Batch or streaming ETL into an analytical store separates reporting from transactional load. This works when analytical queries can tolerate staleness measured in minutes or hours and do not need to serve user-facing screens.
- API composition. For cross-service views, an aggregating layer calls each owning service at request time and merges the results. This avoids duplicated data, but couples query latency and availability to every service involved.
The deciding question is whether the problem is throughput, query shape, or data ownership. Replicas and caches address throughput. Views and search indexes address shape within one store. Projected read models are the general solution when you need a different shape, a different engine, or data from other services, and you accept eventual consistency in return.
Building and updating read models
Every read model needs a reliable path from a committed write to an updated projection. There are four common mechanisms, and they differ mainly in where the consistency guarantee lives.
| Mechanism | Consistency | Main risk |
|---|---|---|
| Same-transaction update | Immediate | Couples the write path to every read schema |
| Transactional outbox | Eventual, no lost events | Relay lag; outbox table growth |
| Change data capture | Eventual, from the log | Events mirror table rows, not business intent |
| Domain events via broker | Eventual | Dual write if published outside the transaction |
Same-transaction updates only work when the read model shares the write database. The outbox avoids dual writes: the event row commits atomically with the state change, and a relay publishes it afterwards. CDC gives the same atomicity without changing application code, but downstream consumers end up coupled to your table layout.
All three asynchronous options deliver at least once. A naive handler that increments a counter or appends a row will double-count on redelivery. If two updates arrive in the wrong order, it overwrites newer state with older state. Neither failure raises an error, so the read model drifts silently.
The fix is to carry a per-aggregate version or sequence number on every event and store the last applied version alongside each projected row. The handler skips any event whose version is less than or equal to the stored one. If it sees a gap, it either holds the event or triggers a refetch. Applying the same message twice then has no effect.
A poison message fails on every attempt. Retry it a bounded number of times, then route it to a dead-letter queue with its error attached and alert on it. If you don't, one malformed event blocks its partition indefinitely.
class GapDetected(Exception): pass # transient: an earlier version hasn't arrived yet
class PoisonMessage(Exception): pass # permanent: retrying will never succeed
UPSERT = """
INSERT INTO order_summary (order_id, status, total_cents, last_version)
VALUES (%s, %s, %s, %s)
ON CONFLICT (order_id) DO UPDATE
SET status = EXCLUDED.status, total_cents = EXCLUDED.total_cents,
last_version = EXCLUDED.last_version
WHERE order_summary.last_version < EXCLUDED.last_version -- guard against concurrent writers
"""
def apply_order_event(conn, event):
try:
oid, ver = event["aggregate_id"], int(event["version"])
status, total = event["status"], int(event["total_cents"])
except (KeyError, TypeError, ValueError) as e:
raise PoisonMessage(f"malformed event: {e}") from e
with conn: # psycopg2: commit on success, roll back on exception
cur = conn.cursor()
cur.execute("SELECT last_version FROM order_summary"
" WHERE order_id = %s FOR UPDATE", (oid,))
row = cur.fetchone()
current = row[0] if row else 0
if ver <= current:
return "skipped" # redelivery or stale event: no effect
if ver != current + 1:
raise GapDetected(f"{oid}: have v{current}, got v{ver}")
# Write absolute state, never increments, so a replay can't double-count
cur.execute(UPSERT, (oid, status, total, ver))
return "applied"
def handle(conn, event, attempt, max_attempts, dead_letter):
try:
return apply_order_event(conn, event)
except PoisonMessage as e:
dead_letter(event, str(e)) # don't retry; park it with the error and alert
except Exception as e:
if attempt >= max_attempts:
dead_letter(event, str(e)) # bounded retries so one event can't block the partition
else:
raise # let the consumer redeliver later (gaps often resolve on retry)
An idempotent projection handler that skips duplicates and stale events using a stored per-aggregate version, rejects gaps for later retry, and sends poison messages to a dead-letter queue after bounded retries.
Designing for eventual consistency
Every lag in the projection pipeline shows up somewhere a user can see it. The typical failure looks like this: a user saves a change, the command succeeds, the UI redirects to a list page served from the read model, and the old value is still there. The data is not lost. The projector simply hasn't processed the event yet. The user can't see the difference, though. They retry, file a bug, or submit the change twice. This is the read-your-own-writes problem. Other users seeing slightly stale data is usually acceptable. The author of a change seeing it is usually not.
Mitigations
Return the result from the command. If the command handler returns the updated state, or enough of it to render the next screen, the client can show the change without querying the read model. This covers the common case cheaply. It fails when the next screen needs data that only the projection computes, such as aggregates or joined views.
Return a version or position token. The command responds with the aggregate version or event-log position it produced. The client passes that token on its next query, and the read side either waits briefly until its projection has reached that position or returns a "not yet" response the client can retry. This keeps reads on the read model while making staleness explicit.
Route recent writers to the write model. For a short window after a write, serve that user's reads from the write side. This works, but it adds load to the write store and requires every routed query to be answerable there.
It's not only a backend decision
Each option changes what the UI does. The UI might show an optimistic update, display a "saving" state, or label data as "updated a few seconds ago." Which screens can tolerate lag, and for how long, is a product question. Settle it with frontend and product owners before choosing a mechanism.
// Command side returns the event-log position its write produced.
// The read side waits briefly for the projection to reach that position.
interface Checkpoint { position(): Promise<number>; } // last position the projector applied
interface OrderView { findByCustomer(customerId: string): Promise<unknown[]>; }
type ReadResult =
| { kind: "ok"; orders: unknown[] }
| { kind: "not-yet"; retryAfterMs: number }; // staleness made explicit
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export async function listOrders(
customerId: string,
minPosition: number | undefined, // token from the client's last command, if any
checkpoint: Checkpoint,
view: OrderView,
maxWaitMs = 1000,
): Promise<ReadResult> {
if (minPosition !== undefined) {
const deadline = Date.now() + maxWaitMs;
// Poll the projector's checkpoint so the author sees their own write.
while ((await checkpoint.position()) < minPosition) {
// Don't hold the request open indefinitely; let the UI show "saving..." and retry.
if (Date.now() >= deadline) return { kind: "not-yet", retryAfterMs: 300 };
await sleep(50);
}
}
// Readers without a token get whatever the projection has; slight lag is acceptable for them.
return { kind: "ok", orders: await view.findByCustomer(customerId) };
}
// Client usage (sketch):
// const { position } = await placeOrder(cmd); // command returns its log position
// const res = await listOrders(customerId, position, checkpoint, view);
// if (res.kind === "not-yet") { /* keep showing the optimistic row, retry later */ }
A read-side query handler that accepts a position token from the client's last command and waits briefly for the projection to catch up, returning an explicit "not yet" result instead of silently serving stale data.
Operating, rebuilding, and evolving read models
Once read models run in production, most incidents fall into four categories:
- Projection lag. Consumers fall behind. Alert on the age of the oldest unprocessed message, because queue depth alone hides how stale the data is.
- Broker outages. Every projection stalls at once. Consumers should resume from a stored checkpoint and treat redelivered messages idempotently.
- Poison messages. An event that a projection cannot process blocks its partition indefinitely. After a bounded number of retries, route it to a dead-letter queue.
- Silent divergence. A bug skips an update and nothing raises an error. Scheduled reconciliation jobs that compare counts or checksums against the write store are the only reliable way to catch it.
Rebuilding safely
Read models are derived data, so you can discard one and replay it from a durable source of truth such as an event log, an outbox table, or the write database. This only works if the write side stays the authority. A read model should never accept writes or hold the only copy of anything.
To change a projection, run the new version alongside the old one. Let it catch up, verify its output, switch reads over, and then retire the old version. Throttle replays and read from a replica or an archived log, so a rebuild does not starve production traffic on the source system.
The cost of copies
Every duplicate multiplies the work for schema changes, backfills, and GDPR-style deletion. An erasure request must reach every read model, and also any replayable log that stores personal data.
Propagate a correlation ID from each command through message headers into projection logs and trace spans. When a user asks why an update hasn't appeared, you can then follow that single action end to end.
Incremental adoption path and impact on cost and engineering time
Each adoption step should justify the next one. Treat each as a checkpoint.
- Split command and query handlers in code while keeping one database. This costs little and creates a seam you can move later.
- Measure where query pain actually is. Use per-handler p95 latency, slow-query logs, and lock contention between reads and writes. Look for the query whose shape conflicts with the write schema, not the one that is simply popular.
- Introduce one read model for that query only.
- Add lag monitoring and reconciliation before a second read model. Run a scheduled job that compares counts or checksums against the write store. Without it, you cannot tell a slow projection from a broken one.
| Dimension | Added cost | Engineering time saved |
|---|---|---|
| Infrastructure | Broker, projector workers, a second store | Fewer cross-service query calls to assemble a view |
| Storage | Data duplicated per read model | Read paths indexed and tuned independently |
| Code | Event handlers, mapping boilerplate | Query changes no longer touch write-side invariants |
| Operations | Lag alerts, rebuilds, on-call pages | Read-load incidents stay off the write path |
Illustrative estimate (assumed numbers, not a benchmark)
Net days saved per month = (Q × S) − (M + O)
- Q = query changes per month that currently touch aggregate code: 4 (assumed)
- S = days saved per change by skipping invariant review and regression testing: 1 (assumed)
- M = maintenance of the projector and read store: 1 day (assumed)
- O = on-call and reconciliation work: 1 day (assumed)
Result: (4 × 1) − (1 + 1) = 2 days per month. If Q drops to 2, the net saving is zero. At that point the read model costs more than it returns.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to cqrs pattern, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- The dependency graph answers "what does this touch?" in seconds instead of a manual audit.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.