CQRS: When to Split Your Read and Write Models

- CRUD
- CQRS
- Loglune
CQRS (Command Query Responsibility Segregation) is a design pattern that splits the write model from the read model: requests that change state go to a command model, and requests that read state go to a separate query model. Commands update data and return little or nothing, and queries return data and never change it. CQRS fits systems whose reads and writes differ sharply in volume, shape, or validation needs, and it costs you more moving parts and, when the two models use separate stores, a read side that lags behind the write side.
This guide is for developers deciding whether a separate read model is worth that cost in a system they already run. It shows how the two models differ, how consistency behaves when the stores are separate, and which conditions favor CQRS or argue against it, so you can name the read or write problem it would solve. It does not cover language-specific implementations such as a CommandBus in NestJS. The example section shows a real run in which Loglune, a feature-flag platform that separates flag writes from flag reads, writes a new flag version and then reads that same version through two query operations.
What does CQRS mean?
CQRS stands for Command Query Responsibility Segregation. In a plain CRUD design, one model handles both updates and reads. CQRS gives the responsibility for commands (writes) and the responsibility for queries (reads) to different models, and each model can then be shaped for its own job: commands change state, and queries read a model built for reading.
The idea extends command-query separation (CQS), a principle from Bertrand Meyer that says a method should either perform an action or return data, but not both. CQS is a rule for single methods; CQRS applies the same split at the level of models and services, and the two models can use different code, schemas, and databases. The name is attributed to Greg Young in 2010, with Udi Dahan publishing on the approach from 2008. Martin Fowler’s CQRS entry from July 14, 2011 says he first heard the pattern described by Greg Young.
How do the command model and the query model differ?
The command model accepts requests to change state, checks them against business rules, and records the change. The query model answers requests for data in the shape the caller needs, with no business logic.
| Aspect | Command model (write side) | Query model (read side) |
|---|---|---|
| Input | A command that names a business task, such as “Book hotel room” | A query with filters, such as “rooms available on May 3” |
| Output | Success or failure, sometimes an identifier | Data shaped for one screen or one API response |
| Logic | Validation, authorization, domain rules | None beyond filtering and formatting |
| Optimized for | Correct, consistent updates | Fast reads, often from a denormalized view |
| Changes stored data | Yes | No |
Name commands after business tasks, such as Book hotel room, rather than low-level updates such as Set ReservationStatus to Reserved. Queries never alter data, and they return data transfer objects (DTOs) without domain logic.
A CQRS request flow has seven steps: a command arrives through an API, the command side validates, authorizes, and runs it, the data is written to the write database, events then update the read database, and queries are served from the read database.
A small example is a product catalog. A RateProduct command goes through a command handler that loads the product, applies the rating, and saves it, while a separate ProductsDao read interface returns display objects such as ProductDisplay.
Do you need two databases? Read models and projections
You do not need two databases. A read model is a copy of the data shaped for queries, and a projection is the process, or the result, of turning the write side’s data or events into that shape. Read models are disposable: if a read model is wrong or a new screen needs a different shape, you rebuild it from the write side.
There are two ways to place the read model.
- Separate models in a single data store. The read model and the write model share one database but use different code paths and types. This is the foundational level of CQRS. There is no synchronization problem, because both models read the same tables.
- Separate models in different data stores. The read model has its own database, which can use a different technology, for example a document database for reads and a relational database for writes. The write side publishes events when it updates its store, and the read side consumes them to refresh its data. Use the Transactional Outbox pattern to store the change and the event atomically, and make read-model consumers idempotent to tolerate duplicate delivery.
Separate databases are the choice when the read side needs its own schema, technology, or scaling, and they bring the synchronization and eventual consistency work described in the next section.
There are four store combinations: relational for both sides with read replicas, relational for commands and NoSQL for queries, NoSQL for both, and NoSQL for commands and relational for queries. One worked example writes to Amazon DynamoDB and streams changes through AWS Lambda into an Amazon Aurora table for complex queries.
Is CQRS eventually consistent?
CQRS is eventually consistent when the read model lives in a separate store that is updated after the write. A query issued right after a command can return the old data until the read model catches up. With a single shared store, reads see the write as soon as the transaction commits.
Eventual consistency is a consideration: the read data might not show the most recent changes immediately, so the pattern typically results in eventual consistency between the data stores. It also means CQRS fits only when eventual consistency is acceptable for the read queries. Chris Richardson’s Pattern: CQRS on microservices.io names “replication lag/eventually consistent views” as a drawback.
Three practical consequences follow when the stores are separate:
- A user who saves a change and is sent to a list page may not see the change yet. Common fixes are to return the new state in the command’s response, or to have the client wait until the read model reports the version it wrote.
- A command may act on stale data that the user read earlier. Commands need their own checks, such as an expected version number, instead of trusting what the read side showed.
- Read-model consumers must handle duplicate and out-of-order events, because message delivery is usually at least once.
When should you use CQRS?
Use CQRS when the reads and the writes of one part of a system have different needs that a single model serves badly:
- Reads far outnumber writes, and you want to scale the read side on its own. The read model can scale horizontally for large query volumes while the write model runs on fewer instances.
- Screens need data in shapes that differ from how it is written, so queries need complex joins on the write model.
- The write side carries real business rules, such as validation, permissions, and conflicts between concurrent users, that would clutter a read path.
- You already keep state as an event log, so you need projections to answer queries at all.
- In microservices, each service owns its own database, and a query needs data from several services. CQRS is a common answer to this case. Microservices.io describes a view database that subscribes to domain events from the services that own the data and keeps a read-only replica shaped for the query.
When should you avoid CQRS?
Avoid CQRS when one model already serves both sides well. The pattern might not be suitable when the domain or business rules are simple, or when a simple CRUD-style user interface and data access are sufficient.
Martin Fowler’s entry is more cautious. He writes that “for most systems CQRS adds risky complexity”, that many systems fit a CRUD mental model and should be built that way, and that CQRS should be used on specific portions of a system, a bounded context in domain-driven design terms, and not on the system as a whole. For a domain that does not need CQRS but has a few demanding queries, he suggests a reporting database that takes only those queries.
A short test: if you cannot name a read that is slow or awkward on the write model, or a write rule that the read path should not see, CQRS will add code without solving a problem you have.
CQRS and event sourcing
CQRS and event sourcing are separate patterns that are often used together. CQRS splits writes from reads. Event sourcing stores state as an append-only log of change events instead of overwriting rows. When they are combined, the write side appends events, and the read side builds its read models by projecting those events.
In the combination of event sourcing and CQRS, the event store is the write model and the single source of truth, and the read model generates materialized views from the events. Because the events are kept, a read model can be thrown away and rebuilt by replaying them. The cost is more complex design, eventual consistency between the event store and the views, and slow view generation over long histories, which snapshots reduce.
You can use either pattern alone. CQRS works with an ordinary relational write model, and event sourcing works with reads that replay events directly. Our guide to event sourcing covers the event log, projections, snapshots, and rebuilding the state at a past time in detail.
Example: writing a flag version and reading the same version back
A feature flag configuration is a compact case of CQRS combined with event sourcing. Loglune’s operations are divided into reads and writes. Defining a flag and publishing are writes. The projection of a version, the diff between two versions, and the audit list of change events are reads, and reads leave the stored data and the flag delivery unchanged. On the write side, each edit is appended as a change event to a history that is the source of truth, and publishing turns the current draft into an immutable version whose number increases within the workspace. On the read side, a projection rebuilds a version from that history, and the same input always rebuilds the same version.
The run below is a local run of Loglune’s shipped flag store and console code on workerd’s local D1 and R2 under Vitest, on October 6, 2026, with an editorial example flag, not a customer case. The flag new-checkout serves true to accounts whose plan attribute is in a list. The run sent two define commands and two publish commands, read the live version between the second edit and its publish, then ran three queries against the version it had just written and counted the stored change events before and after the queries. The output below is copied word for word from what the run printed.
=== write side (commands)
define -> event seq=1 op=create at=2026-09-01T09:00:00.000Z plans=["pro","enterprise"]
publish -> version=1 publishedAt=2026-09-01T10:00:00.000Z
define -> event seq=2 op=update at=2026-09-03T09:00:00.000Z plans ["pro","enterprise"] -> ["enterprise"]
read before publish: live version=1 plans=["pro","enterprise"]
publish -> version=2 publishedAt=2026-09-03T10:00:00.000Z
=== read side (queries) for version 2
projection version=2 new-checkout plans=["enterprise"] default=false
projection at T=2026-09-04T12:00:00.000Z -> version=2 plans=["enterprise"]
diff version 1 -> 2: new-checkout change=changed plans ["pro","enterprise"] -> ["enterprise"] findings=0
change events before reads=2 after reads=2
The output maps to the CQRS concepts above.
- Commands record changes. Each define command appended one change event with a sequence number and the values before and after, and each publish command created a numbered, immutable version.
- The write side and the read side are different models. After the second define command, the live version was still version 1 with
["pro","enterprise"]. The edit existed as change event 2 in the draft, but readers saw it only after the publish command created version 2. - Several read models come from one write. The projection of version 2, the projection at a time T on September 4, and the diff from version 1 to version 2 are three query shapes built from the same two change events, and all three agree that
new-checkoutserves["enterprise"]in version 2. The diff also reports the publish guard findings for the same two versions, which were 0 here. - Queries do not change state. The history held 2 change events before the queries and 2 after.
- Consistency in this run: the query issued right after the publish returned version 2. The projection is rebuilt from the same change history the commands write to, so there was no separate read store waiting to catch up.
The same split appears on every surface. Loglune’s console, CLI, API, and AI agents call the same operations, and a write that reaches billing or state requires an explicit confirmation argument on every call.
Applying CQRS to feature flag changes
A flag change has a clear command side and a clear query side. The command side is the edit and the publish: it needs validation, an actor, a time, and an immutable result. The query side is everything people ask afterward: what did version 2 serve, what changed between version 1 and version 2, and which version was live at a given time T. Loglune keeps those apart. Edits and publishes append to the change history, and projections and diffs read from it without changing it, so you can ask the same question about the same version as many times as an investigation needs.
Reads of a flag version do not count toward Loglune billing: the projection of a version, the diff between two versions, and the audit list of change events are read operations. A reproduction, which delivers a past version for reproducing a bug, is a write and counts as one reproduction, and the Free plan includes 5.
Try CQRS on your own feature flags, from the write to the read of one version
In Loglune, defining and publishing a flag are writes that append change events and create an immutable version, and the projection and the diff of that version are reads that leave the stored data unchanged. The reads do not count toward billing, so you can query the same version as often as you need. The Free plan keeps 7 days of history.