Lesson 1 of 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.
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.
| Level | What it means | Buys / costs |
|---|---|---|
| 1. Separate types | Commands and queries are distinct types in one codebase, over one model and one database | Buys clarity and simpler reads. Costs almost nothing. Nearly always worth it |
| 2. Separate paths | Queries bypass the domain model and read the database directly into screen-shaped results | Buys much less mapping and per-screen tuning. Costs a second code path and explicit authorisation on it |
| 3. Separate schema | Denormalised read tables or views in the same database, updated in the same transaction | Buys fast reads with no staleness. Costs write amplification and more schema to migrate |
| 4. Separate store | A different database for reads, updated asynchronously from write events | Buys independent scaling and specialised stores. Costs eventual consistency, sync machinery, and a whole class of new failures |
// 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?RevealHide
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.