50%

الدرس 1 من 2

CQRS Is a Spectrum, Not a Switch

At its simplest CQRS is two sets of types in one codebase. At its most elaborate it is two stores kept in sync asynchronously. Most of the value is available near the bottom.

قراءة 10 دقيقة

CQRS stands for Command Query Responsibility Segregation. The idea, from Greg Young, builds on Bertrand Meyer's command-query separation and says: the model you use to change state need not be the model you use to read state. That is all. It does not require event sourcing, it does not require two databases, and it does not require a message bus — those are things people often adopt alongside it, which is the source of most of the confusion.

LevelWhat it meansBuys / costs
1. Separate typesCommands and queries are distinct types in one codebase, over one model and one databaseBuys clarity and simpler reads. Costs almost nothing. Nearly always worth it
2. Separate pathsQueries bypass the domain model and read the database directly into screen-shaped resultsBuys much less mapping and per-screen tuning. Costs a second code path and explicit authorisation on it
3. Separate schemaDenormalised read tables or views in the same database, updated in the same transactionBuys fast reads with no staleness. Costs write amplification and more schema to migrate
4. Separate storeA different database for reads, updated asynchronously from write eventsBuys independent scaling and specialised stores. Costs eventual consistency, sync machinery, and a whole class of new failures
Four levels. Each row buys something specific and costs something specific; you should be able to name both before moving up.
Level 2. The write path defends invariants; the read path answers a screen, and never constructs a domain object.typescript
// WRITE — rich model, refuses invalid changes, one aggregate at a time.
export async function submitAttempt(command: SubmitAttempt): Promise<AttemptSummary> {
  return withTransaction(async (tx) => {
    const revision = await quizRevisions(tx).published(command.quizId);
    const attempt = Attempt.from(revision, command.answers, command.learnerId);
    const graded = attempt.grade(revision.passingScore); // invariants live here
    await attempts(tx).save(graded);
    return graded.summary();
  });
}

// READ — one query, exactly the columns the screen shows. No entities, no
// mappers, no repository that pretends this is an aggregate.
export async function learnerAttemptHistory(learnerId: string): Promise<AttemptRow[]> {
  return db.$queryRaw`
    SELECT a.id, a.submitted_at, a.percent, a.passed, q.title AS quiz_title
    FROM attempts a
    JOIN quizzes q ON q.id = a.quiz_id
    WHERE a.learner_id = ${learnerId}
    ORDER BY a.submitted_at DESC
    LIMIT 50`;
}

What separates CQRS from its usual companions

  • **Event sourcing** stores state as a sequence of events rather than as current values. It pairs naturally with CQRS — you need a read model, because you cannot query an event log for "all overdue invoices" — but CQRS does not require it and event sourcing is a much larger commitment.
  • **Separate databases** are level 4 only. Levels 1 to 3 use one database, and most of the benefit is available there.
  • **Message buses** are an implementation detail of level 4. Level 3 updates read tables in the same transaction, with no messaging at all.
  • **Eventual consistency** arrives only at level 4. Below it, a read immediately after a write sees the write, which is what users expect and what most teams forget they are giving up.

In practice

Stopping at level 3

A marketplace had a seller dashboard that took 4 to 9 seconds to load. It aggregated orders, payouts, disputes and ratings, and the team proposed a read database populated from a Kafka topic.

Constraints

  • 12,000 sellers, each loading the dashboard several times a day
  • Sellers expect an order placed a minute ago to appear
  • One PostgreSQL instance at 30% utilisation
  • No existing streaming infrastructure

Decision

Add a denormalised `seller_dashboard` table in the same database, updated in the same transaction as the writes that affect it. Load time fell to about 180 milliseconds.

Why

The problem was query shape, not database throughput — the instance was not near its limits. A level 3 read model fixed the shape while keeping reads immediately consistent, which mattered because sellers do check for an order they know exists. Level 4 would have added streaming infrastructure, a second store to operate, and a staleness window in exchange for scaling headroom nobody needed.

What it cost

Writes to orders, payouts, disputes and ratings now also update the dashboard row, which added roughly 4 milliseconds to each and coupled four write paths to one table's shape. The team accepted that and added a rebuild job so the table can be regenerated from source data if it ever drifts — which is the safety net any derived data needs, at any level.

A colleague says "we should use CQRS" because a report takes 12 seconds. What do you need to know before agreeing?Reveal

Where the 12 seconds actually goes, because CQRS addresses only some causes. If the query is slow because it loads two thousand aggregates through a domain model to compute six numbers, a separate read path is the right answer and probably costs a day. If it is slow because of a missing index, a full table scan or a query that does not use the primary key, an index fixes it in an hour and CQRS fixes nothing. If it is slow because the report genuinely aggregates ten million rows, you need a summary table or an analytical store — which is a level 3 or 4 read model, but the justification is the volume, not the pattern. Measure first: the pattern name is a solution, and the question is which problem you have.

Key takeaways

  • CQRS is only the claim that the write model need not be the read model.
  • It is a spectrum of four levels; most of the value arrives at level 2, and most teams need no more.
  • Event sourcing, message buses and separate databases are companions, not requirements.
  • A direct read path inherits no authorisation — apply it deliberately and make forgetting a compile error.
  • Diagnose why a read is slow before reaching for the pattern; an index is often the real answer.
CQRS Is a Spectrum, Not a Switch · Architecture Atlas