"Is CQRS a good idea for us?" The adoption ladder

"A team asks whether they should adopt CQRS. Walk them up the ladder and tell them where to stop."

What it is

CQRS (Command Query Responsibility Segregation) separates the model used to change state from the model used to read it. Greg Young's original framing is narrower than the reputation: it is a statement about models, not about databases, message buses or event sourcing.

The crucial point for this question: CQRS is not one thing you adopt, it is a ladder with four rungs, and each rung has a different cost and a different benefit. Teams that ask "should we do CQRS" are almost always imagining rung 4 and would benefit from rung 1.

RungWhat separatesConsistencyCost
1. Separate handlersCommand and query code pathsStrongHours
2. Separate modelsWrite model and read DTOs, one databaseStrongDays
3. Separate read storeA denormalised store updated in the same transactionStrongWeeks
4. Async projectionsRead store updated by events, eventuallyEventualMonths, permanently

The consistency column is the whole decision. Rungs 1 to 3 are code organisation and are reversible. Rung 4 changes what your users experience and what your support team has to explain, and it is very hard to reverse.

Commonly confused with event sourcing. They are frequently deployed together and are independent: you can have CQRS with no events, and event sourcing with a single model. Conflating them is the most common error in this area and it is what makes CQRS sound far more expensive than rungs 1 to 3 actually are.

Also commonly confused with "read replicas". A read replica has the same schema; CQRS means a different model, shaped for reading.

The problem it solves

One model serving both reads and writes gets pulled in opposite directions.

The write side wants normalisation and invariants. An Order aggregate with its line items, loaded whole, so order.cancel() can enforce that cancellation after shipment is illegal. Small, consistent, transactional.

The read side wants denormalisation and shape. The order history screen wants order number, date, status, item count, total, seller name and thumbnail, for 50 orders, in one query. Through the write model that is 50 aggregate loads plus joins, or an N+1.

The symptoms that indicate the tension is real:

  • Queries joining 6 or more tables to render one screen.
  • An ORM entity with 40 fields because different screens need different subsets.
  • Lazy-loading configuration that is right for one use case and wrong for the next.
  • Read traffic and write traffic with wildly different scaling needs, so you are provisioning for the sum.
  • Reporting queries locking rows that transactional writes need.

If none of those is happening, the answer is to stop at rung 0 and not adopt anything, and saying that clearly is a better answer than describing rung 4.

Mechanics

Rung 1: separate handlers

No infrastructure. Split the code path.

# Commands: return nothing (or an id). They exist to change state.
@dataclass(frozen=True)
class CancelOrder:
    order_id: OrderId
    reason: str

def handle_cancel_order(cmd: CancelOrder, uow: UnitOfWork) -> None:
    with uow:
        order = uow.orders.get(cmd.order_id)     # loads the aggregate whole
        order.cancel(cmd.reason)                 # invariants enforced here

# Queries: return data. They never change state, so they can take
# whatever shortcut is fastest.
@dataclass(frozen=True)
class GetOrderHistory:
    customer_id: CustomerId
    limit: int = 50

def handle_get_order_history(q: GetOrderHistory, db) -> list[OrderSummary]:
    # Straight to SQL. No aggregate, no ORM entity, no lazy loading.
    return db.fetch_all(ORDER_HISTORY_SQL, q.customer_id, q.limit)

This rung alone captures a large share of the value. The query path stops being constrained by the write model, N+1 problems disappear on reads, and each side becomes independently testable. Cost: a few hours and no operational change.

Rung 2: separate models, one database

The read side gets its own types and its own queries, still against the same tables.

-- Shaped for the screen, not for the domain. One query, no joins at
-- render time, and the write side never sees this.
SELECT o.id, o.number, o.placed_at, o.status,
       o.total_cents, o.currency,
       count(i.id)          AS item_count,
       min(i.thumbnail_url) AS first_thumbnail,
       s.display_name       AS seller_name
FROM orders o
JOIN order_items i ON i.order_id = o.id
JOIN sellers s     ON s.id = o.seller_id
WHERE o.customer_id = $1
GROUP BY o.id, s.display_name
ORDER BY o.placed_at DESC
LIMIT $2;

Still strongly consistent, because it is the same transaction boundary. This is where most teams should stop, and stating that is the core of the answer.

Rung 3: a separate read store, updated synchronously

def handle_cancel_order(cmd: CancelOrder, uow: UnitOfWork) -> None:
    with uow:                                   # one transaction
        order = uow.orders.get(cmd.order_id)
        order.cancel(cmd.reason)
        # The projection is updated in the SAME transaction, so a read
        # immediately after the write sees the change. No eventual
        # consistency, and no user-visible surprise.
        uow.order_summaries.refresh(order)

You get a denormalised read store (a materialised view, a summary table, a search index kept in the same transaction) while keeping strong consistency. The cost is write latency, because every write now updates two places, and a coupling between the write path and the read schema.

The trap: if the read store is OpenSearch or a separate database, "same transaction" is a lie. Two systems cannot commit atomically without a distributed transaction, and the practical answer is the transactional outbox pattern, which puts you at rung 3.5 with bounded lag rather than true synchrony. Being precise about this is a strong signal.

Rung 4: async projections

Command -> write model -> domain event -> outbox -> Kafka
                                                     |
                              +----------------------+---------------+
                              v                      v               v
                        OpenSearch index    order summary table   analytics

Now reads and writes scale independently, projections can be rebuilt from the event stream, and you can add a new read model without touching the write side.

And the read is stale, typically by 50 ms to a few seconds, sometimes by minutes under lag. That is not a technical detail; it is a product decision, and it has to be made by someone who can decide what the user experiences.

The mitigations, none of which are free:

Read-your-own-writes  Route a user's reads to the write model for N
                      seconds after their own write. Works, and it is
                      per-user state you now maintain.
Optimistic UI         Render the expected result client-side. Works
                      until the write fails and the UI has lied.
Version tokens        The write returns a version; the read waits for
                      the projection to reach it. Correct, and it adds
                      latency and a polling loop.

The operational surface at rung 4 is the honest cost: projection lag monitoring, replay tooling, poison-message handling, ordering guarantees per aggregate, idempotent projection handlers (because at-least-once delivery is what you get), and a rebuild procedure that has been rehearsed. That is not a project; it is a permanent capability.

Placing a team on the ladder

Are reads and writes competing in the SAME model, causing measurable
harm (N+1s, 6-way joins, lock contention)?
   No  -> rung 0. Do nothing. Say so.
   Yes -> rung 1 (hours). Re-measure.

Is the read query still awkward because the write schema is wrong for it?
   Yes -> rung 2 (days). Re-measure.

Is a single query still too slow at your data volume, or do read and
write loads need to scale independently?
   Yes -> rung 3 (weeks), synchronous projection first.

Can your PRODUCT tolerate a user not seeing their own write immediately,
and can you fund permanent projection operations?
   No  -> stop at 3. This is a legitimate final answer.
   Yes -> rung 4.

The question at rung 4 is a product question, not an engineering one, and pushing it to the product owner rather than deciding it yourself is the staff-level move.

A worked example

A marketplace order service. The team proposes "CQRS with event sourcing" after a conference talk. Walk them up.

Measure first, before agreeing to anything:

Order history endpoint:  p99 = 2.4 s
  EXPLAIN: 7-table join, 340 ms of query, plus N+1 on seller names
  (52 extra queries per request from lazy loading)

Write path:              p99 = 180 ms, no complaints
Read:write ratio:        94:6
Reporting queries:       hold RowExclusiveLock long enough to have
                         caused 3 write timeouts last month

Rung 1, one afternoon. Split the handler. The history query stops going through the ORM entity and becomes explicit SQL.

Result: p99 2.4 s -> 310 ms. The N+1 disappeared, because it was
        lazy loading on the aggregate, not a query problem.

That is an 87 percent improvement for four hours of work, and it is the argument against starting at rung 4.

Rung 2, three days. A dedicated OrderSummary DTO and a single hand-written query replacing the ORM projection.

Result: p99 310 ms -> 95 ms. Read code no longer constrained by the
        write schema. Still one database, still strongly consistent.

Now re-ask the question. 95 ms at p99 with strong consistency. Is there still a problem?

Remaining issues:
  - Reporting still contends with writes.       -> read replica, 1 day,
                                                   not CQRS at all
  - Sellers want full-text search over orders.  -> genuinely needs a
                                                   different store

Rung 3 for the search case only, and here the interesting reasoning happens. OpenSearch cannot join the write transaction, so a synchronous projection is not actually available. The honest options are the transactional outbox (bounded lag, usually under a second) or a periodic reindex. Search results being one second stale is acceptable to the product; order status being one second stale is not.

So the answer is: rung 4 for the search projection, rung 2 for everything else.

Orders (transactional, strongly consistent)     -> rung 2
Order search index (eventually consistent, ~1s) -> rung 4, via outbox
Reporting                                       -> read replica

And event sourcing: not adopted, because nobody could name a requirement it served. The team wanted audit history, and an append-only order_events audit table gave them that for a fraction of the cost, without making event replay the system's recovery path.

The summary to deliver to the team: "You asked for rung 4 everywhere. What you needed was rung 1, which took an afternoon and got 87 percent of the latency, plus rung 4 for exactly one projection where the product genuinely tolerates staleness. And you did not need event sourcing at all; you needed an audit table."

Production evidence

Greg Young's original CQRS writing and his later "CQRS is not an architecture" talks are explicit that CQRS is a pattern applied to a bounded context, not a system-wide architecture, and that most systems should not use it everywhere. He has also publicly regretted how tightly it became associated with event sourcing.

Martin Fowler's CQRS article takes a deliberately cautious position: useful in a few places, and "CQRS should only be used on specific portions of a system", with an explicit warning that it adds significant risk.

Microsoft's Azure Architecture Center documents CQRS with the eventual-consistency consequences stated plainly, and its guidance separates the pattern from event sourcing and from the materialised view pattern.

The transactional outbox pattern (Chris Richardson's microservices.io catalogue, and Debezium's outbox event router) is the standard answer to the dual-write problem that appears the moment the read store is a different system, and it is what makes rung 3.5 practical.

The debate

The case for going high on the ladder: independent scaling of reads and writes, purpose-built read stores (search, graph, analytics) that a normalised transactional schema cannot serve, the ability to add a read model without touching the write side, and rebuildable projections that let you fix a bug by replaying rather than by migrating.

The case against: eventual consistency is a user-visible product change that generates support load and requires read-your-own-writes machinery; projection lag becomes a permanent on-call concern; debugging spans two systems; and the operational capability is not a project you finish.

My position: default to rung 2, and treat every step above it as requiring a named requirement that the rung below cannot meet. In practice rungs 1 and 2 deliver most of the benefit teams are actually seeking, in days rather than months, with no consistency change and full reversibility.

The specific line I hold: rung 4 is a product decision, not an engineering one. The question "can a user place an order and not see it in their order list for two seconds" is answered by whoever owns the customer experience, and an engineer deciding it unilaterally because the architecture is more elegant has made a product change without authority. When I present the ladder, I present rung 4 with the staleness window as a named consequence and ask for a decision, and I have had that come back "no" often enough to know it is a real question.

And I would separate event sourcing explicitly every time, because the conflation is what makes teams think CQRS costs months. Most teams asking for event sourcing want an audit log, and an append-only audit table delivers that without making event replay the system's recovery path.

Follow-up Q&A

"Is CQRS a good idea for us?" It is four different questions, because CQRS is a ladder. Rung 1 is separate command and query handlers, which takes hours and no infrastructure. Rung 2 adds separate read models against the same database, which takes days. Rung 3 adds a separate read store updated in the same transaction, weeks. Rung 4 makes projections asynchronous, which takes months and permanently changes consistency. Almost everyone asking the question is imagining rung 4 and needs rung 1 or 2. So my answer is: what specifically is going wrong, and let us see which rung fixes it.

"How do you know when to stop climbing?" Re-measure after each rung. In the case I described, rung 1 took the p99 from 2.4 seconds to 310 milliseconds in an afternoon, because the problem was lazy loading on the aggregate rather than anything requiring architecture. Rung 2 took it to 95 milliseconds. At that point the original complaint was gone and the remaining issues were a read replica for reporting and a search index, which are two different answers. You stop when the measured problem stops.

"What actually changes at rung 4?" Consistency, and it is user-visible. A customer places an order and their order list may not show it for a second or two. That is a product decision, not an engineering one, and I would take it to whoever owns the customer experience rather than deciding it because the architecture is nicer. The engineering cost is also permanent rather than one-off: projection lag monitoring, replay tooling, idempotent handlers because delivery is at-least-once, per-aggregate ordering, and a rehearsed rebuild procedure.

"Isn't CQRS the same as event sourcing?" No, and the conflation is why teams think this costs months. You can have CQRS with no events at all, which is rungs 1 through 3. You can have event sourcing with a single model. Greg Young has said publicly that he regrets how tightly the two became associated. Most teams asking for event sourcing want an audit trail, and an append-only audit table gives them that without making event replay the system's recovery path.

"You said rung 3 is synchronous. What if the read store is OpenSearch?" Then synchronous is not available, because two systems cannot commit atomically without a distributed transaction. The practical answer is the transactional outbox: write the domain change and an outbox row in one local transaction, and a relay publishes from the outbox. That gives bounded lag, usually under a second, rather than true synchrony. I would call that rung 3.5 and be precise about it, because claiming "same transaction" across two systems is the kind of imprecision that produces incidents.

"How do you handle read-your-own-writes?" Three options with different costs. Route a user's reads to the write model for a few seconds after their own write, which works and is per-user state you now maintain. Optimistic UI, which works until the write fails and the interface has lied to someone. Or version tokens, where the write returns a version and the read waits for the projection to catch up, which is correct and adds latency plus a polling loop. I would pick per-endpoint rather than globally, because most endpoints do not need it.

"What's the failure mode of rung 4 in production?" Projection lag under load, which turns "eventually consistent" into "consistent in eleven minutes" during a backlog, and users see stale data long past what anyone designed for. So lag is an SLO with an alert, not a graph. The second failure is a poison message stalling a partition, which stops one aggregate's projections while everything else looks healthy. And the third is a projection bug that has been writing wrong data for a week, where the fix is a replay, which is why rebuild tooling has to be rehearsed rather than written during the incident.

Common misconceptions

"CQRS means eventual consistency." Only rung 4 does. Rungs 1 through 3 are strongly consistent.

"CQRS requires event sourcing." They are independent patterns that are often deployed together.

"CQRS means two databases." Rungs 1 and 2 use one database and one schema.

"It's an architecture." Greg Young is explicit that it applies to a bounded context. Applying it system-wide is the documented misuse.

"Read replicas are CQRS." A replica has the same model. CQRS means a model shaped differently for reading.

Interview delivery note

Refuse the binary framing immediately, because that is the answer: "CQRS isn't one thing you adopt, it's a ladder with four rungs and each one has a different cost. Separate handlers is hours. Separate read models on the same database is days. A separate read store updated in the same transaction is weeks. Asynchronous projections is months, and it permanently changes consistency. Almost everyone asking the question is imagining rung four and needs rung one."

Then give the measured example, because it makes the point concrete: "In one case the team asked for CQRS with event sourcing. Splitting the handlers took an afternoon and moved the p99 from 2.4 seconds to 310 milliseconds, because the actual problem was lazy loading on the aggregate. A dedicated read DTO took it to 95. At that point the complaint was gone."

The line that separates staff from senior: "and rung four is a product decision, not an engineering one. 'Can a customer place an order and not see it in their order list for two seconds' is answered by whoever owns the customer experience. I present the ladder with that consequence named and ask for a decision, because deciding it myself because the architecture is more elegant is making a product change without authority."

Close by separating the two patterns, since the conflation is what inflates the perceived cost: "and I'd separate event sourcing explicitly. They're independent, and most teams asking for event sourcing want an audit trail. An append-only audit table gives them that without making event replay the recovery path for the whole system."

Further reading

  • Greg Young's CQRS documents and his "CQRS is not an architecture" talks, for the original scope of the pattern.
  • Martin Fowler, "CQRS" (martinfowler.com), for the deliberately cautious framing.
  • Microsoft Azure Architecture Center, "CQRS pattern" and "Materialized View pattern", for the consequences stated plainly.
  • Chris Richardson, microservices.io, "Transactional outbox" and "Transaction log tailing", for the dual-write problem at rung 3.5.