System Design ·

CQRS in Practice: Separating Reads and Writes for Scalable Systems

A practical guide to the CQRS pattern: when it genuinely helps, TypeScript implementation patterns, handling eventual consistency, the relationship with event sourcing, and production considerations for debugging and migration.

CQRS in Practice: Separating Reads and Writes for Scalable Systems

Most systems start with a single database model that serves every use case. You read from it, write to it, and run queries that mix concerns freely. For a long time, this is fine. Then you hit the wall: your reads need denormalized, precomputed views, but your writes need normalized, consistent state. You try to satisfy both with one schema and end up satisfying neither.

CQRS (Command Query Responsibility Segregation) is the pattern that names this tension and gives you a structured way out. The core idea is simple: separate the model you write to from the model you read from. But the implementation details, the tradeoffs, and the operational consequences are where most explanations fall short.

What CQRS Actually Is

The pattern originated with Bertrand Meyer’s command-query separation principle at the method level: a function either returns a value (a query) or it changes state (a command), never both. CQRS lifts this to the architectural level.

In a CQRS architecture:

  • Commands express intent to change state. They are imperative (CreateOrder, CancelReservation, TransferFunds). They go to a write model optimized for consistency and business rule enforcement.
  • Queries retrieve state for display or computation. They go to a read model optimized for the shape callers actually need.

The write side and read side can use the same database or different ones. They can run in the same process or separate services. They can synchronize synchronously or through an event stream. The range of implementation is wide, which is why the pattern often gets conflated with heavier architectures it does not require.

Here is the minimal TypeScript structure:

// Commands: express intent, validate business rules
interface CreateOrderCommand {
  customerId: string;
  lineItems: Array<{ productId: string; quantity: number; unitPrice: number }>;
  shippingAddress: Address;
}

interface OrderCommandHandler {
  createOrder(command: CreateOrderCommand): Promise<{ orderId: string }>;
  cancelOrder(command: CancelOrderCommand): Promise<void>;
}

// Queries: return shaped views, no side effects
interface OrderSummaryQuery {
  customerId: string;
  status?: "pending" | "fulfilled" | "cancelled";
  page: number;
  pageSize: number;
}

interface OrderQueryHandler {
  getOrderSummaries(query: OrderSummaryQuery): Promise<OrderSummaryView[]>;
  getOrderDetail(orderId: string): Promise<OrderDetailView | null>;
}

// Write model: normalized, consistent
interface OrderAggregate {
  id: string;
  customerId: string;
  lineItems: LineItem[];
  status: OrderStatus;
  createdAt: Date;
  version: number; // optimistic locking
}

// Read model: denormalized, precomputed for the UI
interface OrderSummaryView {
  orderId: string;
  customerName: string;
  totalAmount: number;
  itemCount: number;
  status: string;
  createdAt: string; // pre-formatted for display
}

Notice the read model includes customerName and totalAmount as precomputed fields. The write model does not store these, it stores the components. That asymmetry is the whole point.

When CQRS Genuinely Helps

CQRS solves real problems in specific situations. It is not a universal default.

Divergent read and write shapes. When your write model is a normalized domain aggregate but your read model is a flat, joined, precomputed view, forcing one schema to serve both creates friction on both sides. Separate models eliminate that friction.

Asymmetric load. Most applications have far more reads than writes. Separating the models lets you scale read replicas independently without touching the write path. You can also add caching layers to the read side without any risk to write consistency.

Read performance under joins. If a query needs to join five tables to produce a view, a precomputed read model eliminates that join at read time. You pay the cost once at write time and serve the result cheaply at scale.

Complex business logic on writes. When write operations involve invariants, domain rules, and multi-step validation, a dedicated command handler enforces those rules without the read path getting in the way.

When it is over-engineering. If your reads and writes need the same shape, if your load is modest, or if your team is small and moving fast, CQRS adds significant overhead for no gain. You now maintain two models, synchronize them, and debug consistency lag. A single well-indexed relational model with read replicas handles most applications at scale. Add CQRS when you feel specific pain, not in anticipation of it.

Practical Implementation: Write Side

The write side handles commands. Each command goes through validation, business rule enforcement, and a state change. Here is a realistic command handler:

class OrderCommandHandler {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly productCatalog: ProductCatalog,
    private readonly eventBus: EventBus
  ) {}

  async createOrder(command: CreateOrderCommand): Promise<{ orderId: string }> {
    // Validate business rules
    const products = await this.productCatalog.getProducts(
      command.lineItems.map((item) => item.productId)
    );

    for (const item of command.lineItems) {
      const product = products.get(item.productId);
      if (!product) throw new OrderError(`Product ${item.productId} not found`);
      if (!product.isAvailable) throw new OrderError(`Product ${item.productId} is unavailable`);
      if (item.unitPrice !== product.currentPrice) {
        throw new OrderError(`Price mismatch for product ${item.productId}`);
      }
    }

    // Create and persist the aggregate
    const order: OrderAggregate = {
      id: crypto.randomUUID(),
      customerId: command.customerId,
      lineItems: command.lineItems,
      status: "pending",
      shippingAddress: command.shippingAddress,
      createdAt: new Date(),
      version: 0,
    };

    await this.orderRepository.save(order);

    // Publish domain event for read model synchronization
    await this.eventBus.publish({
      type: "OrderCreated",
      orderId: order.id,
      customerId: order.customerId,
      lineItems: order.lineItems,
      createdAt: order.createdAt,
    });

    return { orderId: order.id };
  }
}

The command handler is responsible for one thing: enforcing business rules and producing a valid state change. It does not return a view. It returns an identifier or nothing.

Practical Implementation: Read Side

The read side subscribes to domain events and maintains its own projections:

class OrderReadModelProjector {
  constructor(
    private readonly readDb: ReadDatabase,
    private readonly customerService: CustomerService
  ) {}

  async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
    const customer = await this.customerService.getCustomer(event.customerId);

    const totalAmount = event.lineItems.reduce(
      (sum, item) => sum + item.quantity * item.unitPrice,
      0
    );

    await this.readDb.upsert("order_summaries", {
      orderId: event.orderId,
      customerId: event.customerId,
      customerName: customer.displayName,
      totalAmount,
      itemCount: event.lineItems.length,
      status: "pending",
      createdAt: event.createdAt.toISOString(),
    });
  }

  async onOrderCancelled(event: OrderCancelledEvent): Promise<void> {
    await this.readDb.update("order_summaries", event.orderId, {
      status: "cancelled",
      cancelledAt: event.cancelledAt.toISOString(),
    });
  }
}

class OrderQueryHandler {
  constructor(private readonly readDb: ReadDatabase) {}

  async getOrderSummaries(query: OrderSummaryQuery): Promise<OrderSummaryView[]> {
    return this.readDb.query("order_summaries", {
      customerId: query.customerId,
      status: query.status,
      page: query.page,
      pageSize: query.pageSize,
    });
  }
}

The read model is a direct projection of the events. It can be rebuilt at any time by replaying the event history, which is a significant operational advantage.

Handling Eventual Consistency

When the read model is updated asynchronously, a window exists where the write succeeds but the read model has not caught up. This is the most operationally significant consequence of CQRS with async projections.

Strategies for handling it:

Return the projection from the command. For commands that immediately need to display the result, compute and return the read shape in the command response. This breaks the strict separation but is pragmatic for user-facing writes.

async createOrder(command: CreateOrderCommand): Promise<OrderSummaryView> {
  // ... create and save the order aggregate ...

  // Synchronously project the view for immediate response
  return {
    orderId: order.id,
    customerName: command.customerName, // passed in the command for this purpose
    totalAmount: computeTotal(order.lineItems),
    itemCount: order.lineItems.length,
    status: "pending",
    createdAt: order.createdAt.toISOString(),
  };
}

Optimistic UI with polling. After a write, the client immediately shows the expected result and polls until the read model confirms. Works well for async operations where consistency can lag a few seconds.

Version-aware queries. Include the write model’s version in the command response. The query can wait until the read model reaches at least that version.

interface CommandResult {
  id: string;
  expectedVersion: number; // poll until read model reaches this version
}

async getOrderWhenReady(orderId: string, minVersion: number): Promise<OrderDetailView> {
  const deadline = Date.now() + 5000;
  while (Date.now() < deadline) {
    const view = await this.readDb.get("order_details", orderId);
    if (view && view.version >= minVersion) return view;
    await sleep(100);
  }
  throw new Error("Read model did not catch up in time");
}

For internal services where humans are not watching the result, eventual consistency is usually fine. For user-facing writes, synchronous projection or optimistic UI is the better default.

CQRS and Event Sourcing: Separate Concepts

CQRS and event sourcing are frequently taught together, and frequently confused. They are independent patterns that compose well but do not require each other.

Event sourcing means the write model’s state is derived from an append-only log of events. Instead of storing current state, you store every event that led to it and recompute (or snapshot) the current state on read.

CQRS means separating the read model from the write model. The write model can be event-sourced or it can be a plain relational table. The read model can be rebuilt from events or kept in sync through triggers.

You can have CQRS without event sourcing: write to a normalized SQL table, sync changes to a denormalized read store through a change data capture pipeline. Most teams start here because it is simpler.

You can have event sourcing without CQRS: store all state as events, but serve reads directly from the event log with projection snapshots. Uncommon in practice because reads get expensive without a separate read model.

They compose well because event sourcing gives you a durable, replayable history that makes read model rebuilds trivial. But the decision to adopt event sourcing carries its own significant complexity, and it should not be bundled with the CQRS decision.

Tradeoffs

DimensionSingle ModelCQRS (Sync)CQRS (Async)
ConsistencyStrongStrongEventual
Read performanceBounded by joinsHigh (precomputed)High (precomputed)
Write performanceBounded by readsDecoupledDecoupled
Operational complexityLowMediumHigh
Schema migrationsOne migrationTwo coordinatedTwo + event replay
Debugging difficultyLowMediumHigh
Team size fitSmallMediumMedium to large
Rebuild read modelN/ARebuild from DBReplay events

Production Considerations

Debugging. When a read model shows stale or incorrect data, the investigation path is: check the event was published, check the subscriber consumed it, check the projector wrote it correctly. Structured logging at each step is not optional. Include correlation IDs across the command, event, and projection.

async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
  const log = logger.child({ correlationId: event.correlationId, orderId: event.orderId });
  log.info("projecting OrderCreated event");

  try {
    await this.projectOrderSummary(event);
    log.info("projection complete");
  } catch (err) {
    log.error({ err }, "projection failed, event will be retried");
    throw err; // let the consumer retry
  }
}

Testing. Test the write side by asserting which events are published. Test the read side by feeding events to the projector and asserting the resulting view. This separation makes unit tests clean and independent.

describe("OrderCommandHandler", () => {
  it("publishes OrderCreated on valid command", async () => {
    const eventBus = new InMemoryEventBus();
    const handler = new OrderCommandHandler(repo, catalog, eventBus);

    await handler.createOrder(validCommand);

    expect(eventBus.published).toContainEqual(
      expect.objectContaining({ type: "OrderCreated" })
    );
  });
});

describe("OrderReadModelProjector", () => {
  it("creates order summary with correct total", async () => {
    const db = new InMemoryReadDatabase();
    const projector = new OrderReadModelProjector(db, mockCustomerService);

    await projector.onOrderCreated(orderCreatedEvent);

    const summary = await db.get("order_summaries", orderCreatedEvent.orderId);
    expect(summary.totalAmount).toBe(150);
    expect(summary.customerName).toBe("Jane Smith");
  });
});

Migration strategy. If you are adding CQRS to an existing system, do not rewrite. Add the read model alongside the existing model. Start by syncing through a change data capture mechanism (database triggers or a CDC tool) rather than rearchitecting writes immediately. Once the read model is stable, migrate write operations to the command handler pattern. This lets you validate the read model independently before touching write paths.

Schema evolution. When you change an event’s shape, you need a migration strategy for all past events that projectors may need to replay. A versioned event schema with explicit upcasters is the standard approach:

function upcaseOrderCreatedEvent(raw: unknown): OrderCreatedEvent {
  const v = (raw as any).version ?? 1;
  if (v === 1) {
    // v1 did not have shippingAddress
    return { ...(raw as OrderCreatedEventV1), shippingAddress: null, version: 2 };
  }
  return raw as OrderCreatedEvent;
}

Projection lag monitoring. Track the lag between event publication time and projection time as a metric. Alert on lag exceeding your SLA. When projectors fall behind, readers see stale data without any error signal, which is operationally dangerous and easy to miss without explicit monitoring.

The Decision Heuristic

CQRS is worth the complexity when two conditions hold together: your read and write models have meaningfully different shapes, and the performance or scalability requirements make serving both from one model genuinely painful. One condition alone is usually not enough justification.

Start with a well-indexed relational model and read replicas. Add CQRS when you measure the pain, not before.

When you do add it, start with synchronous projection: the command handler updates both the write model and the read model in the same transaction. You get the benefits of separate models without the consistency complexity of async. Only move to async projection when you need independent scaling or when the write path latency becomes unacceptable due to read model updates.

The pattern is a tool with a specific job. It does that job well when the job actually exists.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.