CQRS
CQRS — Command Query Responsibility Segregation — is an architectural pattern that separates the model used to update data (commands) from the model used to read data (queries). Rather than a single model that handles both reads and writes, you maintain two distinct models optimized independently for their specific purpose.
The Problem CQRS Solves
In a traditional CRUD architecture, a single data model serves both reads and writes. This creates tension:
- Write models need normalization. To maintain consistency and avoid update anomalies, the write model is normalized — data is stored once, in the right place, with constraints enforced.
- Read models need denormalization. To serve complex UI queries efficiently (a dashboard that aggregates data across 10 tables), the read model benefits from denormalized, pre-joined views. Normalization forces expensive JOINs at query time.
- Scaling asymmetry. In most systems, reads vastly outnumber writes (often 10:1 to 100:1). A single database serves both, which means the write-optimized schema must handle read-scale, or vice versa.
- Complex domain logic conflicts with query needs. A rich domain model with encapsulation, invariants, and business rules is poorly suited as a query DTO. Exposing the write model to read clients leaks domain complexity and creates coupling.
What Is CQRS
CQRS separates these concerns at the model level. Commands change state; queries read state. They use separate code paths, and in more advanced implementations, separate data stores.
| Commands | Queries | |
|---|---|---|
| Purpose | Change state | Read state |
| Return value | Typically none (or just an ID) | Data (DTOs, view models) |
| Side effects | Yes — mutates state, raises events | None — pure reads |
| Model optimized for | Consistency, invariants, business rules | Query performance, shape of UI |
| Validation | Full domain validation before applying | No validation — just fetch and return |
Simple CQRS
The simplest form of CQRS separates command and query code paths within a single codebase and single database — no separate data stores. Commands go through domain objects with encapsulated business logic. Queries bypass the domain model and query the database directly, returning lightweight DTOs.
// Command path — through domain model
class PlaceOrderCommand {
orderId: string
customerId: string
items: OrderItem[]
}
class OrderCommandHandler {
handle(cmd: PlaceOrderCommand) {
const order = Order.place(cmd.orderId, cmd.customerId, cmd.items)
// validates, raises OrderPlaced event, enforces invariants
this.orderRepository.save(order)
}
}
// Query path — direct DB read, no domain model
class OrderQueryService {
getOrderSummary(orderId: string): OrderSummaryDTO {
return this.db.query(
'SELECT o.id, o.status, c.name, SUM(i.price) as total FROM orders o ...',
[orderId]
)
}
}
This is a significant improvement over CRUD without any operational overhead. The domain model stays clean; queries stay simple. Most teams implementing CQRS for the first time should start here.
CQRS with Separate Data Stores
The more powerful (and complex) form uses separate databases for reads and writes. The write database is normalized and optimized for consistency. The read database is denormalized and optimized for query performance — different schema, possibly a different database technology.
How synchronization works:
- A command is processed. The write model updates the write database and publishes a domain event.
- An event handler (projection builder) receives the event and updates the read database to reflect the change.
- Read queries go directly to the read database — no joins, pre-computed aggregations, query is fast.
Example — e-commerce order list:
- Write store: PostgreSQL with normalized orders, order_items, customers tables. Enforces constraints, handles transactions.
- Read store: A denormalized
order_list_viewtable in PostgreSQL, or Elasticsearch, or Redis — with{orderId, customerName, status, itemCount, total, createdAt}pre-joined and pre-aggregated. - When
OrderPlacedfires, the projection handler inserts a row into the read store. WhenOrderShippedfires, it updates the status. The list query hits the read store directly — no joins, instant.
Technology choices for the read store:
- Relational (PostgreSQL read replica): Simplest. Familiar tooling, supports complex queries, slight replication lag.
- Elasticsearch: For full-text search and faceted filtering on read models.
- Redis: For sub-millisecond read latency on hot, simple view models (e.g., a user’s session data or a leaderboard).
- ClickHouse / TimescaleDB: For analytical read models over large time-series datasets.
CQRS with Event Sourcing
CQRS pairs naturally with event sourcing. The event log (write model) is append-only and normalized to events. Read models (projections) are built by consuming the event stream. This is the full CQRS + ES stack:
- Write side: Commands → domain aggregate → events appended to event store.
- Read side: Event stream → projection builders → multiple read models, each optimized for a specific query.
The major benefit: because read models are built from events, they can be rebuilt at any time by replaying the event log. Change a projection’s schema, drop and rebuild from scratch. Add a new read model retroactively — it processes all historical events and is immediately up to date.
The full CQRS + Event Sourcing stack is powerful but introduces significant complexity: eventual consistency between write and read models, event schema evolution, snapshot management, and projection rebuild procedures. It’s a serious investment. Use it in domains where the benefits — auditability, retroactive queries, full history — justify that cost. Don’t use it as a default architecture for simple CRUD features.
Trade-offs
Eventual consistency. With separate read and write stores, there is a window (milliseconds to seconds) between a command being processed and the read model reflecting the change. A user who places an order and immediately navigates to "My Orders" might not see it yet. This requires UI design that accounts for propagation delay — optimistic updates, loading states, or explicit refresh prompts.
Complexity of maintaining two models. Two code paths, two data stores, synchronization logic, and projection builders are more code to write and more infrastructure to operate. The simpler single-model CRUD approach should always be considered first.
Testing complexity. With separate models, tests must cover the write path, the projection update, and the read path independently and together. Integration tests that verify the full command → event → projection → query cycle are essential.
Consistency within a command. Since reads use the read model (eventually consistent), a command handler that needs to read current state to make a decision must read from the write model, not the read model. Mixing read and write models in a command handler is a common mistake.
Design Considerations
- Start with simple CQRS. Separate command and query code paths in a single codebase and database before introducing separate data stores. Many systems never need more than this, and it delivers most of the architectural clarity benefit.
- Apply CQRS selectively. Not every feature in your system needs CQRS. Apply it to bounded contexts where the read/write asymmetry is real and the query complexity justifies it. Simple admin CRUD screens don’t need CQRS.
- Commands should not return data. A command handler that returns a query result is blurring the boundary. Return only the ID of the created resource, then let the client issue a query. This enforces the separation and prevents commands from embedding implicit query logic.
- Use correlation IDs. When a command triggers an event that updates a read model, trace the correlation ID through the entire chain. This makes debugging eventual consistency issues tractable — you can follow a single transaction across write, event, and read model in your logs.
- Monitor projection lag. The time between event production and read model update is your consistency window. Alert when this lag exceeds your SLA. A stuck projection handler (due to a bug or a poison event) can stop all read model updates silently.