Skip to content
aipatterns.dev

Design patternData Patterns

CQRS

Split the command model from per-view query models, joined by projections - each side shaped for its job, at the price of two models and eventual consistency.

Complexity
High
Seen in the wild
Medium
Also known as
Command-Query Responsibility Segregation

Intent

Use a different model to update information than the model you use to read it. Commands go through the write model - validation, invariants, business rules; queries go against read models shaped for specific views - denormalized, pre-joined, maybe in another store. Projections update the read models from the write side's changes. Each model is simpler than the merged one that tried to serve both, and the views get exactly the shape their queries need.

Forces

  • One model vs two jobs: reads want denormalized, view-shaped rows; writes want normalized structures that protect invariants. One model does both worse.
  • Consistency vs read speed: projections lag the write, so a query right after a command may not see it - read-your-writes becomes a design decision.
  • Simplicity for most of the system vs power where it pays: CQRS is worthwhile on the few contentious parts of a system; applying it everywhere converts a codebase into two codebases.
  • Query freedom vs schema coupling: free-form read models break when commands change shape, and every projection is another thing to rebuild.

Therefore

Keep commands on a write model that enforces invariants, publish its changes, and maintain per-view read models through projections - then prove the read lag, the rebuild story, and the second model are each worth their cost before writing either.

CQRS is a scaling decision, not an architecture. Most of a system fits CRUD comfortably, and Fowler's caution stands: be very cautious, because the merged model he saw replaced with CQRS was usually doing neither job well - but the replacement cost more than it saved more often than not. Adopt it on the bounded piece where reads demonstrably fight writes.

Structure

Diagram source (Mermaid)
classDiagram
  class OrderCommands {
    +place(order)
    +ship(order)
  }
  class OrderStore {
    +append(event)
  }
  class HistoryProjection {
    +onOrderPlaced(event)
    +onOrderShipped(event)
  }
  class OrderHistory {
    +rows
  }
  OrderCommands --> OrderStore : commits changes
  OrderStore --> HistoryProjection : streams changes
  HistoryProjection --> OrderHistory : maintains

Participants

  • Write model (OrderCommands): handles commands. Validates, enforces invariants, and records what changed as events or rows. The only writer.
  • Change stream (OrderStore): the write side's committed changes, in order - event rows, an outbox, or change-data-capture.
  • Projection (HistoryProjection): consumes the stream and maintains one read model. Dumb and rebuildable: drop it, replay the stream, get the same rows.
  • Read model (OrderHistory): denormalized rows shaped for one query family. No invariants, no logic - it serves.

Use it when

  • Reads and writes fight over shape: the query the UI needs needs a join of six tables the write side must keep normalized, and the fight shows in both.
  • Read and write scale differently - a read-heavy dashboard over a write-protected ledger - and one store cannot be tuned for both.
  • You need several materially different views of the same data (search index, reporting table, API view), and each is rebuilt from changes anyway.
  • The team can operate eventual consistency on the read side, including the "query after command" answer.

Do not use it when

  • The system fits CRUD. Most do; forcing CQRS on them doubles every change for nothing.
  • The team has not operated eventual consistency before. The read lag will surface as "the API lies" bugs at the worst time.
  • There is one view per entity and no scaling pressure - a well-indexed table with a view is the same result at a tenth of the machinery.
  • You have not measured the contention. CQRS applied to an uncontended part of the system is the textbook poor fit Fowler describes.

Application grill

Inspect first: the reads that fight the write model (aggregate queries, joins at request time) and their measured cost; whether the database already has a smaller answer (indexes, materialized views, read replicas); existing events, outbox or queue infrastructure a projection could ride.

Missing pieces: a projection pipeline needs an event source (outbox, queue, or CDC such as Debezium), a worker to apply events, and a read store per view. The smallest step is denormalized tables or materialized views in the same database, refreshed in-transaction or on a short interval; a separate search index (Typesense, Meilisearch, Elasticsearch) only for full-text or faceted reads. Every piece is something to operate.

Ask before you build:

  • Scale: which read is actually slow, measured - and how many views genuinely need their own model?
  • Performance: how stale may a view be, and must a user see their own writes immediately (read-your-writes routing)?
  • Reliability: when a projection falls behind or its worker dies, what does the UI show and who gets paged?
  • Budget: a second store plus a pipeline, or one denormalized table in the existing database?
  • Implementation: is there a rebuild runbook (replay events from zero), and who has executed it?

Implementation steps

  1. Bound it: pick the one subdomain where reads demonstrably fight writes - measured, not felt. Everything else stays CRUD.
  2. Keep the write model in charge of truth: commands validate and commit changes, and record what changed (event rows are the simplest stream).
  3. Start with one projection to one read model that already hurts. The projection is a loop: read unprocessed changes in order, apply, record the position, commit.
  4. Make projections rebuildable from zero. If you cannot drop the read model and replay, the stream is not the source of truth and the pattern is not really in place.
  5. Decide the read-lag contract per view: which queries may serve slightly stale rows, and which must read the write model directly. Read-your-writes is per-endpoint, not global.
  6. Version the stream: projections must tolerate events written by older command versions, or every command change becomes a migration.
  7. Monitor projection lag per view. Lag is the system honestly reporting its consistency; hide it and you have hidden the pattern's main cost.

Example

An orders subdomain: commands append changes to the write store; a history projection maintains the flat rows the support UI queries. A query right after a command shows the lag honestly - the projection has not ticked yet.

type OrderEvent =
  | { type: "placed"; orderId: string; sku: string; totalCents: number }
  | { type: "shipped"; orderId: string };

// Write side: the only writer, enforcing invariants.
class OrderCommands {
  private shipped = new Set<string>();
  constructor(private readonly store: OrderEvent[]) {}

  place(orderId: string, sku: string, totalCents: number): void {
    this.store.push({ type: "placed", orderId, sku, totalCents });
  }
  ship(orderId: string): void {
    if (!this.store.some((e) => e.type === "placed" && e.orderId === orderId))
      throw new Error(`unknown order ${orderId}`); // invariant lives here only
    this.shipped.add(orderId);
    this.store.push({ type: "shipped", orderId });
  }
}

// Read side: denormalized rows shaped for the support UI's one query.
type HistoryRow = {
  orderId: string;
  sku: string;
  totalCents: number;
  status: string;
};

class HistoryProjection {
  readonly rows = new Map<string, HistoryRow>();
  private position = 0;

  tick(store: OrderEvent[]): void {
    for (; this.position < store.length; this.position++) {
      const e = store[this.position];
      if (e.type === "placed")
        this.rows.set(e.orderId, {
          orderId: e.orderId,
          sku: e.sku,
          totalCents: e.totalCents,
          status: "placed",
        });
      else this.rows.get(e.orderId)!.status = "shipped";
    }
  }
}

const store: OrderEvent[] = [];
const commands = new OrderCommands(store);
const projection = new HistoryProjection();

commands.place("o-1", "tea-1", 450);
console.log(`rows before projection tick: ${projection.rows.size}`); // 0 - the lag
projection.tick(store);
console.log(projection.rows.get("o-1")); // placed

commands.ship("o-1");
projection.tick(store);
console.log(projection.rows.get("o-1")!.status); // shipped

// try { commands.ship("o-1"); } catch (e) { console.log((e as Error).message); }
// commands.ship("o-99") would throw: invariants live on the write side only

The write side's ship rejecting an unknown order is the point of the split: invariants live only where commands run, and read models never re-check them.

Trade-offs

Gains

  • Queries get exactly their shape - pre-joined, pre-computed, indexed for the one question they ask.
  • Write model stays small and invariant-focused; business rules stop negotiating with query convenience.
  • Read models scale and evolve independently - add a view, replay the stream, no write-side change.

Costs

  • Two models for one concept, plus a projection each - every command-side change touches read-side code.
  • Eventual consistency on reads is a product decision, per view, forever.
  • Projections must handle old event versions and be rebuildable; both are ongoing obligations.
  • Applies well to a bounded piece of a system and poorly to a whole one - the boundary is a permanent design object.

Relations

  • Transactional Outbox: the write side's changes reach projections through one - the stream is an outbox read by projection relays.
  • Cache-Aside: the lighter tool for the same pressure - when one cached read model answers the need, prefer it and keep the single model.
  • Saga: the write side of a saga publishes step results through the stream that projections consume.

Agent checklist

  • The boundary is one measured, contentious subdomain - the rest of the system stays CRUD.
  • Invariants live only in the write model; read models never re-check them.
  • Every projection can be dropped and rebuilt by replaying the stream.
  • Read-lag expectations are decided per view, and read-your-writes endpoints bypass the projection.
  • Projection lag is monitored per view.
  • Event/projection versioning has an answer before the first command changes.

See also

Original writing and examples. Topic coverage follows martinfowler.com, which explains this topic with its own illustrations.