Skip to content

CQRS

Your cross-service queries started as simple API compositions, and for a while that was fine. Then the queries grew teeth: a dashboard that lists the highest-value orders per region, a search box that filters customers by spend across several services, a report that sorts and paginates over millions of rows. Composing these at request time means pulling enormous result sets from each service and joining them in memory on every page load. It is too slow, and it leans on services that were never built to answer that question.

The same data model rarely serves both jobs well. A write model is normalized and shaped around enforcing business rules one record at a time. A demanding query wants data pre-joined, denormalized, indexed, and sometimes spanning what several services own. Forcing one model to do both makes writes awkward and reads slow.

How do you serve expensive, cross-cutting, or oddly shaped queries efficiently without distorting the model that handles your writes?

CQRS — Command Query Responsibility Segregation — splits the model in two. The command side handles writes: it owns the authoritative state, enforces invariants, and on every change publishes a domain event. The query side maintains one or more read models — denormalized views shaped exactly for the queries they serve. A read model is built by subscribing to those events and projecting them into its own store; queries then hit this pre-joined view directly.

The read model can be any technology that fits the query — a denormalized SQL table, a document store, a search index — and it can combine events from several services into one view, which is what makes CQRS so good at cross-service queries.

flowchart LR
  Client[Client] -->|command| CmdSide[Command Side]
  CmdSide --> WDB[(Write Model)]
  CmdSide -->|domain events| Bus[(Event Bus)]
  Bus --> Proj[Projection / Event Handler]
  Proj --> RDB[(Read Model - denormalized)]
  Client -->|query| QSide[Query Side]
  QSide --> RDB
Writes flow through the command side and emit events; projections build read models that the query side serves

Below, a command handler updates the authoritative state and emits an event. A separate projection consumes that event and upserts a denormalized row; the query side just reads that row. The two halves never share a model.

// --- Command side: handle a write, emit an event ---
async function placeOrder(cmd: { orderId: string; customerId: string; total: number }) {
await writeDb.insertOrder(cmd.orderId, cmd.customerId, cmd.total, 'CONFIRMED');
await eventBus.publish({
type: 'OrderPlaced',
orderId: cmd.orderId,
customerId: cmd.customerId,
total: cmd.total,
});
}
// --- Query side: a projection keeps a denormalized read model in sync ---
eventBus.on('OrderPlaced', async (e) => {
const customer = await customerSvc.getCustomer(e.customerId);
await readDb.upsertOrderSummary({
orderId: e.orderId,
total: e.total,
customerName: customer.name, // pre-joined at projection time
});
});
// A query is now a single fast lookup against the read model.
const topOrders = () => readDb.query('SELECT * FROM order_summary ORDER BY total DESC LIMIT 20');

What you gain:

  • Efficient queries. The read model is pre-joined and indexed for exactly the queries it serves, so even cross-service or large-scale queries become single fast lookups.
  • Independent scaling. Reads and writes scale separately; a read-heavy workload can have many replicas of the read model without touching the write side.
  • Right tool per side. Use a relational store for writes and a search index or document store for reads, each optimized for its job.

What it costs you:

  • More moving parts. You now operate an event bus, projections, and at least two stores instead of one. This is real operational and cognitive overhead.
  • Eventual consistency. The read model lags the write model by the time it takes an event to propagate and project. A user who just wrote may briefly read stale data, so the UI must account for it.
  • Duplicated data and replayable projections. The read model duplicates data, and you need a way to rebuild it from scratch when the view changes or a projection has a bug.

Because of the overhead, reach for CQRS only when a query genuinely needs it. For modest cross-service queries, the simpler API Composition is usually the better choice.

  • API Composition — the simpler alternative; prefer it until a query proves too expensive to compose live.
  • Event Sourcing — a natural source of the events that feed CQRS projections.
  • Saga — read models often expose the intermediate state a saga produces.
What does CQRS separate?
How is a CQRS read model kept up to date?
Which is an inherent drawback of CQRS?
When should you prefer API Composition over CQRS?