~/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.
series · Event Sourcing & CQRS in Practice2 / 4
- 01Event Sourcing: State as a History of Events
- 02CQRS: Separating the Write Model from the Read Model
- 03Event Store Bottleneck: Scaling with NestJS Microservices
- 04Securing Event-Sourced Financial Systems

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.
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 model | CQRS | |
|---|---|---|
| Write invariants | Enforced, but shares schema with reads | Enforced, isolated from read shape |
| Read performance at scale | Degrades as query complexity grows | Shaped per query, independently scalable |
| Consistency | Immediately consistent, implicitly | Eventually consistent, explicitly |
| Operational cost | One model, one schema | Two 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
· 19 min
What This Architecture Cost, Measured Rather Than ArguedNEW
What hexagonal architecture costs in Symfony 8: ports, layers and buses counted from the repository, and the thresholds below which it is not worth it. Part 6.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 6#ddd#hexagonal-architecture#symfony#php#software-architecture#cqrs#refactoring19 min
· 17 min
Event Sourcing Behind the Port You Already Have
Event sourcing in Symfony 8: an event-sourced repository swapped in by one DI alias, proven by the same contract test, and what the alias did not cover. Part 5.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 5#ddd#hexagonal-architecture#symfony#php#event-sourcing#postgresql#cqrs17 min
· 18 min
The Application Layer, the Transaction, and a Working API
Application layer in Symfony 8: use cases on a Messenger command bus, the transaction boundary in config, optimistic locking and RFC 9457 errors. Part 2.
▸ DDD, CQRS and Hexagonal Architecture in Symfony 8 · 2#ddd#hexagonal-architecture#symfony#php#cqrs#messenger#software-architecture18 min