CQRS: When It Pays

The internal admin tool let you change a customer’s plan. Free to Pro, Pro to Enterprise, the kind of thing three people click on a slow day. Changing it went like this. The UI dispatched a ChangePlanCommand. A command bus routed it to a ChangePlanHandler. The handler loaded an aggregate, ran the rules, emitted a PlanChanged event, and appended that event to a store. A moment later a projector woke up and updated the customer_plan_view table that the screen actually read from. Six files to move one enum from one value to another.

And because the projector ran asynchronously, the admin who clicked Save watched the row still say Free for a second, assumed it had failed, clicked again, and filed a bug.

That system had one write path with any real traffic, no audit requirement anyone had ever asked for, and reads that a single indexed SELECT answered in under a millisecond. Someone had read about CQRS and event sourcing, liked the diagrams, and applied both to what was, underneath all the ceremony, a CRUD admin panel. Every change after that had to thread the whole apparatus, and nobody could remove any of it because nobody could tell what still depended on what.

CQRS is real, and for a narrow band of genuinely hard systems it is the right call. For most of what you will build it is that ChangePlanHandler: a lot of machinery standing guard over a problem you do not have. So this goes two directions at once. I will teach you what CQRS is and when it earns its keep, and I will spend at least as long trying to talk you out of it.

Start with the part that is always good

Underneath the architecture sits a small principle that costs nothing and helps everywhere. It is called CQS, Command Query Separation, and it predates the big version by decades. A method should do one of two things. Change state, and it is a command. Answer a question, and it is a query. Not both. The old one-liner captures it: asking a question should never change the answer.

COMMANDchangePlan(id, next)state changesreturns nothing you rely onQUERYgetPlan(id)state untouchedreturns the data you asked for
CQS at the method level. A command changes state and hands back nothing you lean on. A query returns data and leaves the state exactly as it found it.

JavaScript breaks its own rule constantly, and its standard library is the worst offender. Watch sort:

const items = [3, 1, 2];
const sorted = items.sort(); // returns the array AND reorders items in place
items;                       // [1, 2, 3], the original is gone

sort is a command (it mutates) and a query (it returns the array) welded into one call, and that double life is exactly why const sorted = items.sort() quietly wrecks items for everyone else holding a reference. push returns the new length while mutating. Map.prototype.set returns the map. Each is a tiny CQS violation, and each has produced a real bug where someone trusted the return value and forgot the side effect standing right behind it.

Modern JavaScript went back and added the clean query halves. toSorted, toReversed, toSpliced, and with do the same jobs and return a fresh array, touching nothing:

const items = [3, 1, 2];
const sorted = items.toSorted(); // new array; items stays [3, 1, 2]

That is CQS shipped as a language feature. sort is the command-and-query tangle; toSorted is the honest query. When you have the choice, reach for the one that only answers.

The same split runs through HTTP. A GET is a query and must be safe, meaning it changes no state. A POST, PUT, PATCH, or DELETE is a command. Safe versus unsafe methods is CQS wearing an HTTP hat. Keep this discipline at every layer. It makes code easier to read, test, and reorder, and unlike its famous cousin it costs you nothing.

CQRS is CQS with the volume turned up

Here is the whole leap. CQS is a rule about one method. CQRS draws the same line around your entire data layer. One model handles the write side. A different model, sometimes several, handles the read side. That is it. The name is a mouthful. The idea is “stop forcing one model to do two jobs it wants to do differently.”

ONE MODELwritesreadsshared modelone shape for bothorders (db)reads and writes fight over one shapeTWO MODELScommandwrite model · ruleswrite storeprojectread storeread model · denormalisedqueryeach side shaped for its own job
One model serving reads and writes, versus splitting into a write model that enforces rules and a read model shaped for how screens actually read.

The write side is where commands land. Validation runs here, business rules run here, invariants are enforced here. It is normalised and tuned for one thing: keeping data correct. The read side serves queries, and each read model is shaped exactly the way one screen wants to consume it. Denormalised, pre-joined, indexed for that query, fast. The two are kept in step, often asynchronously, which is where the interesting problems live.

Why go through this at all? Because reads and writes usually have wildly different shapes and wildly different scale. A dashboard is read a thousand times for every time the underlying order is written. The write path wants normalisation and strict invariants. The read path wants a flat, pre-computed blob it can paint without joining anything.

Here is the tension in one model, doing both jobs and neither of them well:

// one Order entity, carrying every concern at once
class Order {
  // write concerns: the invariants the domain must never violate
  place() { /* check stock, apply pricing rules, reserve inventory */ }
  cancel() { /* refund-window checks, restock, ... */ }
}

// the query the account dashboard actually runs, on every paint
const rows = await db.order.findMany({
  where: { customerId },
  include: {
    customer: true,
    items: { include: { product: true } },
    shipment: true,
  },
  orderBy: { placedAt: "desc" },
  take: 20,
});
// five tables joined, a per-row product lookup lurking, run constantly

Every dashboard load hammers five tables through a model whose real job is protecting order invariants. You either eat the joins and the N+1 hazard on every read, or you start bolting read-only fields onto the write entity until it serves neither side cleanly.

The split pulls them apart. Commands operate on the normalised write model. A denormalised read model answers the dashboard in one indexed lookup:

// write side: a command, narrow and rule-heavy
async function placeOrder(cmd) {
  // validate, enforce invariants, persist to the normalised store
  // ...and announce that it happened, so read models can catch up
}

// read side: a table shaped exactly like the screen
const rows = await db.orderSummary.findMany({
  where: { customerId },
  orderBy: { placedAt: "desc" },
  take: 20,
});
// each row already holds customerName, itemCount, total, status, so zero joins

The read model is denormalised on purpose. customerName is copied in rather than joined. itemCount and total are computed once at write time, not recomputed on every read. You are trading storage and a sync step for a read that never joins and never surprises you. Shaping data for how it is read is the entire craft on the query side.

The two have to be kept in step, and here is the crack

A read model is a copy, and copies go stale. Keeping it current is the real work, and you have two ways to do it.

Synchronously. Update the read model in the same transaction as the write, an inline projection. Either both land or neither does, so there is no lag and no window. The price is coupling: every write now pays the projection cost, and the two models cannot scale or fail independently.

Asynchronously. The write commits, emits an event, and a projector consumes that event and updates the read model a moment later. The two sides decouple, they scale on their own, and you can add new read models without touching the write path. The price is a fresh one, and it is the price people underestimate: the read model is now eventually consistent with the write side. The async version is the one people mean when they say CQRS, and it is the one that bites.

A write commits before the read model catches upcommandwrite modelvalidatewrite storecommittedeventprojectorread storeupdated latereventual consistency windowuser reads here andstill sees the old valuethe gap is small, but it is real, and every read-after-write screen has to plan for it
The write is durable the instant it commits. The read model catches up later, and anyone who reads inside that window sees the old value.

That window is the whole tax. Between “write committed” and “read model updated” sits a gap, and a user who writes then immediately reads can miss their own change. “I saved it, it is not there, I hit refresh and now it is.” That is not a bug in your code. It is the architecture you signed up for, and you now owe every read-after-write screen a mitigation: read that user’s own writes straight from the write model, or paint an optimistic UI, or gate the read on a version token the write handed back. This is event-driven design’s central cost, and CQRS pays it by the pound.

Add an order below and watch it. The write store shows it the instant you commit. The per-customer summary, a separate read model, catches up only after the projector runs. Flip the projector to slow, place an order, and query the read side before it settles.

interactiveWrite commits now, the read model catches up later

Place an order on the slow setting and hit “Query the read side now” straight away. The read model answers with the old totals, in red, while the write side already knows the truth. Wait a beat and it reconciles. That half-second of disagreement is not a defect. It is the shape of every asynchronous read model, and designing around it is the job you take on when you split.

Event sourcing, the companion you can say no to

CQRS and event sourcing travel together so often that people assume they are one thing. They are not, and you can run either without the other. Event sourcing means your source of truth is the append-only log of everything that happened, OrderPlaced, ItemAdded, OrderPaid, OrderShipped, and current state is derived by replaying that log. Read models stop being the truth and become projections of it, which makes them disposable. Delete one, replay the log, and it comes back. Want a new read shape next year? Project the same history from the beginning.

One log of what happened, many views derived from itevent logsource of truth · append-onlyOrderPlacedItemAddedOrderPaidOrderShippednew events append at the bottomnothing is ever edited or deletedorder summarythe dashboard read modelsales by dayanalytics rollupsearch indexfull-text lookupdelete any projection and replay the log to rebuild it; new read shapes come from the same history
One append-only log is the source of truth. Every read model is a projection of it, derived and rebuildable, never the original.

Why they pair so naturally: once your writes are already an event stream, building read models by folding those events is the obvious next step, and CQRS’s read side falls out for free. You also inherit an audit trail nobody had to build, which for finance, trading, healthcare, or anything regulated is frequently the real reason to be here. You can answer “what did this account look like at 3:47pm, and who changed it” because you never threw a single step away.

Now the bill, in full. You own an event store. Event schemas drift over time and old events live forever, so you inherit upcasting old shapes into new ones. Long streams need snapshots to replay quickly. And “delete my data” over an immutable log is a genuine engineering problem, solved with crypto-shredding or tombstones, not a DELETE. There are solid JavaScript-native options for this now, event-store databases with Node clients and libraries built specifically for event sourcing on Postgres, so it is no longer exotic. It is still a large house to keep clean.

Why you probably should not

Say it plainly, because the diagrams make CQRS look tidier than living with it feels. Splitting the models costs you, permanently:

  • Two models instead of one. Two things to change when the domain changes, two places for a field to live, two mental models a new hire has to hold.
  • A sync mechanism between them. Events, a projector, retries, dead-letter handling when a projection throws. That is a distributed system you now operate.
  • Eventual consistency you design around. Every read-after-write screen needs a story, forever.
  • A worse debugging day. A wrong number on a screen might live in the command, the event, the projector, or the read model, and you get to find out which. Read models drift and silently rot, so you also build and run rebuild tooling. Observability stops being optional.

For the overwhelming majority of applications, one model with a decent ORM and a few good indexes is not a compromise you settle for. It is correct. The single model is transactional, read-your-writes by default, easy to reason about, and understood by a new engineer in an afternoon. Reaching past it should feel like a decision you were forced into, not one you were excited about. This is the over-engineering trap in its most expensive form, because the machinery is architectural and you cannot quietly delete it in a slow week.

Name the anti-pattern out loud so you catch it in review: CQRS applied to the whole system, or to a plain CRUD app, “so that it scales.” It does not scale you into anything but a maze. The todo app with a command bus is a real thing people ship and then quietly resent.

Before you split the models, climb the cheaper rungs first, in this order, and stop the moment one works:

  • Slow read? Add the index. Most “we need CQRS for performance” turns out to be a missing index. An index is an afternoon, not an architecture, and it fixes an astonishing share of read pain.
  • Reads swamping writes? Add a read replica. Same model, same schema. The database copies your primary and you point reads at the copy. You get real read scaling with none of the two-model cost. Replica lag exists, but it is bounded, monitorable, and the database’s problem, not a distributed system you hand-built.
  • Same read, over and over? Cache it. A cache in front of a hot query buys you most of what a read model would, and you can throw it away.
  • One screen needs a denormalised shape? Build that one read model. A materialized view, or a plain table you refresh from your writes, and keep the single write path. That is the useful slice of CQRS without the command bus, the event store, or eventual consistency smeared across the whole app.
Climb only when the rung below actually failedfull CQRS (maybe event sourcing)rarely, and in one bounded contextcomplex domain,audit, huge asymmetryread replica · cache · materialized viewoften enough · still one write pathsingle model + ORM + indexesstart here · roughly 99% of apps stop hereeach step up is permanent architecture, not a tweak you can undo on a slow Friday
A ladder you climb only when the rung below has genuinely failed. Most applications never leave the bottom, and every step up is architecture you keep forever.

That middle rung is where you should try hard to live. A read replica plus a materialized view gives you most of what people go to CQRS hoping for, and it keeps a single, boring, correct write path that you can still reason about at 3am.

When it actually earns it

There is a real list, and you should be able to point at yourself on it before you commit. CQRS pays when:

  • The domain is genuinely complex. A real bounded context where the write model’s invariants and the read model’s shapes have diverged so far that forcing one model to serve both makes both worse. Not “we have a lot of tables.” Complex like insurance underwriting, trading, or logistics, the sort of domain that already wanted careful modelling.
  • The read/write asymmetry is real and measured, and independent scaling of the two sides is the actual point, and a replica and a cache honestly were not enough.
  • Audit or regulation is a first-class requirement. Here event sourcing’s log is the feature you are buying, and CQRS tags along because projecting read models off the log is natural.
  • Several very different read shapes ride on the same writes, a search index and a live dashboard and a mobile summary, where one write fanning out to tailored projections beats many services each contorting one shared model.

Summary

  • CQS is the free part, keep it always. A method changes state (a command) or returns data (a query), never both. Asking a question should not change the answer. JavaScript violates this with sort, push, and Map.set, and modern JS added the clean query halves like toSorted. It is CQS wearing an HTTP hat in safe versus unsafe methods.
  • CQRS is that split scaled to the whole data layer. One model for the write side (normalised, rules, invariants) and one or more read models (denormalised, pre-joined, shaped per screen). The idea is small; the operational weight is not.
  • Keeping the read model current is the real work. Do it synchronously (no lag, tight coupling) or asynchronously (decoupled, scalable, and eventually consistent). The async version is what people mean by CQRS, and its tax is a read-after-write gap you must design around on every relevant screen.
  • Event sourcing is a common companion, not a requirement. An append-only log as source of truth, with read models as disposable projections. It buys a free audit trail and rebuildable views, and it charges you an event store, schema versioning, snapshots, and a hard answer to “delete my data.” Adopting it because you wanted CQRS is how a task becomes a migration.
  • You probably should not. Two models, a sync system, eventual consistency, and a worse debugging day are permanent costs. For almost every app, a single model with a good ORM and the right indexes is correct, not a compromise.
  • Exhaust the cheap rungs first: an index, a read replica, a cache, then one denormalised read model with a single write path. That middle ground is where most systems that “need CQRS” actually belong.
  • When to use it: a genuinely complex bounded context, a measured read/write asymmetry that replicas and caches did not solve, hard audit or regulatory requirements, or several very different read shapes over the same writes. When to avoid it: basically everywhere else, and especially across a whole system or on a CRUD app “so it scales.” Use it in one corner or not at all.