~/blog/unleash-the-power-of-efficiency-with-command-query-responsibility-segregation-cqrs

CQRS: Separating the Write Model from the Read Model

Part 2 of an event sourcing series: splitting commands from queries, what that fixes as data grows, and what it costs to run two models instead of one.

Krzysztof Słomka4 min readupdated

series · Event Sourcing & CQRS in Practice2 / 4

  1. 01Event Sourcing: State as a History of Events
  2. 02CQRS: Separating the Write Model from the Read Model
  3. 03Event Store Bottleneck: Scaling with NestJS Microservices
  4. 04Securing Event-Sourced Financial Systems

next in series →

CQRS separating write commands from read queries for better scalability

Photo by Mael BALLAND on Unsplash

A single model serving both writes and reads works fine until the two start wanting different things from the same table. The write side wants normalised rows and strict invariants. The read side wants a shape that matches a screen, assembled from five joined tables, filtered and sorted in ways the write model never needed to care about. Command-Query Responsibility Segregation is the decision to stop asking one model to do both jobs.

This part covers the one-model problem CQRS actually fixes, how a command and a query differ in what they're allowed to return, the read model as a disposable projection rather than a second source of truth, the staleness the read side is honest about and a single shared model hides, and the cost this pattern adds that single-model systems don't pay.

The problem with one model doing two jobs#

In a single-model system, the same entity class, and usually the same database table, handles both "change this order" and "list these orders for the dashboard." Early on that's fine. It stops being fine once the dashboard needs a sortable, filterable view across joined tables and paginated results, and every index added to make that query fast is an index the write path now has to maintain on every insert. The two sides of the same table are optimising for opposite things: the write side wants few indexes and strict constraints, the read side wants denormalised, indexed-everywhere data shaped for exactly the screen asking for it.

OrderPlaced event

Command

Write model: Order aggregate

Projector

Read model: order_summary table

Query

CQRS splits that into two models that happen to describe the same business concept. The write model enforces invariants and is the only thing allowed to change state. The read model is a projection built from what the write side published, shaped for exactly the queries the application actually runs, and discarded and rebuilt if the shape needs to change.

What a command is allowed to return, and what a query is not allowed to do#

// A command changes state and returns nothing useful to read from.
// Its result is "did this succeed," not "here is the current state."
final class PlaceOrderHandler
{
    public function __invoke(PlaceOrder $command): void
    {
        $order = Order::place($command->customerId, $command->lineItems);
        $this->orders->save($order);
    }
}

// A query reads state and changes nothing. It is free to hit a completely
// different table, even a different database, than the one PlaceOrderHandler
// wrote to.
final class OrderSummaryQuery
{
    public function forCustomer(string $customerId): array
    {
        return $this->readDb->fetchAll(
            'SELECT order_id, status, total FROM order_summary WHERE customer_id = ?',
            [$customerId],
        );
    }
}

Two details matter in that split. First, OrderSummaryQuery never touches the Order aggregate at all; it reads a table built specifically for this question. Second, nothing about PlaceOrderHandler changes if the read side later needs a new column, a new filter, or a completely different storage engine for order_summary. That independence is the entire payoff: changing how orders are queried no longer risks the invariants that protect how orders are placed.

The read model is a cache you're honest about#

The read model is built from events the write side published, by a projector that listens for OrderPlaced, OrderShipped, and updates order_summary accordingly. That makes it, structurally, a cache: derived data, rebuildable from the events that produced it, never the source of truth.

That's also where the pattern's real cost shows up. Between an event being published and the projector updating the read table, the read model is stale. A single-model system doesn't have this window, not because it avoids the problem, but because it hides it behind one shared row that is always "current" by definition, even when "current" means a half-committed write another request is racing with. CQRS doesn't introduce staleness. It makes staleness visible and gives it a name, eventual consistency, that is honest about a lag already present in most systems that use caching, read replicas, or search indexes behind a single model.

Single modelCQRS
Write invariantsEnforced, but shares schema with readsEnforced, isolated from read shape
Read performance at scaleDegrades as query complexity growsShaped per query, independently scalable
ConsistencyImmediately consistent, implicitlyEventually consistent, explicitly
Operational costOne model, one schemaTwo models, a projector, a migration story for both

When the cost is not worth it#

CQRS is the wrong default for a CRUD screen with no scaling pressure and no divergence between what gets written and what gets displayed. The projector, the second schema, and the eventual-consistency conversations with whoever owns the UI are a real, ongoing cost, not a one-time setup fee. It earns that cost when read and write load genuinely pull in different directions, which is usually the same moment event sourcing starts looking attractive for the write side, since the two patterns share an event as the natural handoff between them.

$ related posts