CQRS Explained: Separate Read and Write Models for Scalable System Design
CQRS splits your application into two distinct models โ one for writes (commands) and one for reads (queries) โ so each side can be optimised, scaled, and evolved independently. This guide covers projections, eventual consistency, when CQRS pays off, and its relationship to event sourcing.
CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates the model used to write data (commands) from the model used to read data (queries), allowing each side to be structured, scaled, and optimised in isolation. First articulated by Greg Young building on Bertrand Meyer's Command-Query Separation principle, CQRS has become a foundational pattern in domain-driven design, event-sourced systems, and any architecture where read and write workloads have meaningfully different shapes.
In a traditional CRUD application, a single model handles both reads and writes. This works well for simple domains, but breaks down when your write model captures complex business rules and invariants while your read model needs to serve dozens of differently-shaped query results fast. The write model must be normalised to enforce consistency; the read model wants to be denormalised to avoid joins. You cannot optimise one without hurting the other. CQRS solves this by making the split explicit.
The Core Idea: Two Models, One System
CQRS divides every operation in your system into one of two categories. A command expresses intent to change state โ PlaceOrder, CancelSubscription, TransferFunds. It is handled by the write model, which enforces all business rules and persists the change to the write store (typically a normalised relational database or an event log). A query asks for data โ GetOrderSummary, ListUserActivity. It is served entirely by the read model, which is a separately maintained, denormalised projection purpose-built for that query. Commands return nothing (or just an acknowledgement); queries return data and never mutate state.
Projections: Building the Read Model from Writes
The bridge between the write side and the read side is called a projection (sometimes an event handler or read-model updater). Whenever the write model successfully persists a change โ whether as a row in a database or as a domain event on an event log โ the projection listens, transforms the data into the shape the query needs, and writes it into the read store. The read store can be a separate relational database, a Redis cache, Elasticsearch, or even a pre-rendered HTML fragment. The key insight is that the read store is derived data: it can always be rebuilt by replaying the write-side history from scratch.
Because projections are just subscribers that transform events into query-friendly rows, you can have multiple projections building different read models from the same write history. An OrdersProjection might populate a relational table optimised for paginated admin queries, while a SalesDashboardProjection maintains aggregated counters in Redis. The write model never changes; only the projections and read stores proliferate. This is one of CQRS's most powerful properties: adding a new query shape never touches the write-side code.
Independent Scaling: Read and Write Sides Under Load
In most real-world systems, reads vastly outnumber writes โ sometimes by orders of magnitude. A social feed, a product catalogue, a dashboard: all are read-heavy. A traditional unified model means you must scale the whole thing together, wasting resources. With CQRS, you scale each side to its actual demand. The write-side cluster can be small and heavily consistent (a primary-replica Postgres, for instance). The read-side cluster can be a horizontally-scaled fleet of read replicas, a CDN-cached Redis, or a search cluster โ whatever makes queries fast โ and you add nodes to it without touching the write side.
Eventual Consistency: The Write-to-Read Lag
Because the read model is updated asynchronously by a projection, there is a brief window after a command is processed during which the write store reflects the new state but the read store has not yet caught up. This is eventual consistency: the read model will converge to the latest state, but not instantaneously. For most web applications this lag is milliseconds to low single-digit seconds, which is imperceptible to users. However, it has implications you must design for: if a user places an order and immediately navigates to their order history, they might not see it yet. Strategies include: showing optimistic UI updates locally, returning the new entity directly from the command handler for immediate display, or querying the write store directly for the confirmation page.
CQRS and Event Sourcing: Closely Related, Not the Same
CQRS is frequently discussed alongside event sourcing, and the two pair naturally, but they are independent patterns. Event sourcing stores the write side as an append-only log of domain events rather than the current state of rows; the current state is derived by replaying the log. CQRS then provides the strategy for serving reads from that log: projections subscribe to the event stream and materialise query-optimised read models. When combined, you get a powerful architecture: the event log is the single source of truth, you can build any number of read models from it, and you can reconstruct any past state by replaying events up to a point. Without event sourcing, CQRS still works โ the write store is a normal database and projections listen to change-data-capture events or outbox messages โ but the two patterns amplify each other.
When CQRS Is Worth It โ and When It Is Overkill
CQRS introduces real complexity: two codepaths, two data stores, projection infrastructure, and eventual consistency to reason about. For a simple CRUD service with modest traffic, this complexity is pure overhead. The pattern earns its cost when at least one of the following is true: (1) your read and write workloads have fundamentally different shapes that cannot be efficiently served by a single data model; (2) you need to scale reads and writes independently by orders of magnitude; (3) you are implementing a complex domain with rich business rules that require a carefully guarded write model; or (4) you need multiple different query views over the same underlying business data. If none of these apply, a well-indexed single-model approach is almost always simpler and faster to build.
| Dimension | Traditional CRUD | CQRS |
|---|---|---|
| Data model | Single model for reads and writes | Separate write model (normalised) + read model(s) (denormalised) |
| Scaling | Scale the whole database together | Scale read and write sides independently |
| Read performance | Limited by write-model normalisation; joins needed | Read model pre-shaped for query; no joins, fast |
| Write complexity | Simple; one path for all mutations | Commands go through domain logic; stricter invariants |
| Consistency | Immediate (strong) consistency | Eventual consistency between write and read stores |
| Operational cost | Low โ one DB, one model | Higher โ projection infra, two stores, lag monitoring |
| Best for | Simple domains, low traffic, CRUD apps | Read-heavy systems, complex domains, high scale, event-sourced architectures |
A Minimal CQRS Implementation in Python
Even without a heavy framework, CQRS can be sketched cleanly. Below is a minimal Python example showing a command handler that writes to the write store, fires an event, and a projection that updates the read model. Real implementations add message buses, transactional outboxes, and idempotency guards โ but the structure is the same.
from dataclasses import dataclass, field
from typing import Dict, List
import uuid, datetime
# ---- Write side ----
@dataclass
class PlaceOrderCommand:
user_id: str
items: List[str]
total_usd: float
@dataclass
class OrderPlacedEvent:
order_id: str
user_id: str
items: List[str]
total_usd: float
placed_at: str
# Write store: normalised rows (simplified as a dict here)
write_store: Dict[str, dict] = {}
# In-memory event bus (in production: Kafka, RabbitMQ, etc.)
event_handlers = []
def handle_place_order(cmd: PlaceOrderCommand) -> str:
"""Command handler: validate, persist, emit event."""
order_id = str(uuid.uuid4())
# Enforce business rule: total must be positive
if cmd.total_usd <= 0:
raise ValueError("Order total must be positive")
# Persist to write store
write_store[order_id] = {
"user_id": cmd.user_id,
"items": cmd.items,
"total_usd": cmd.total_usd,
"status": "placed",
}
# Emit domain event
event = OrderPlacedEvent(
order_id=order_id,
user_id=cmd.user_id,
items=cmd.items,
total_usd=cmd.total_usd,
placed_at=datetime.datetime.utcnow().isoformat(),
)
for handler in event_handlers:
handler(event) # projection picks this up
return order_id # return only the ID, not the full state
# ---- Projection (bridges write -> read) ----
# Read store: denormalised, query-optimised (e.g. per-user list)
read_store: Dict[str, List[dict]] = {} # user_id -> [order summaries]
def order_placed_projection(event: OrderPlacedEvent):
"""Update the read model whenever an order is placed."""
uid = event.user_id
if uid not in read_store:
read_store[uid] = []
read_store[uid].append({
"order_id": event.order_id,
"item_count": len(event.items),
"total_usd": event.total_usd,
"placed_at": event.placed_at,
})
event_handlers.append(order_placed_projection)
# ---- Query side ----
def query_user_orders(user_id: str) -> List[dict]:
"""Query handler: reads only from the read store, never the write store."""
return read_store.get(user_id, [])
# ---- Usage ----
cmd = PlaceOrderCommand(user_id="u1", items=["book", "pen"], total_usd=29.99)
oid = handle_place_order(cmd)
print(query_user_orders("u1"))
# [{'order_id': '...', 'item_count': 2, 'total_usd': 29.99, 'placed_at': '...'}]
Frequently Asked Questions
Does CQRS require event sourcing?
No. CQRS and event sourcing are independent patterns that work well together but do not require each other. You can implement CQRS with a conventional relational write store and use database triggers, a transactional outbox, or change-data-capture (CDC) tools like Debezium to feed projections. Event sourcing is an excellent companion because the event log makes projection rebuilding trivial โ you replay events from position zero โ but it adds its own complexity and is not mandatory. Start with CQRS plus a simple outbox pattern; add event sourcing when the domain demands full audit history or temporal queries.
How do you handle the eventual consistency lag in the UI?
The most practical strategies are: optimistic UI updates โ the client reflects the expected new state immediately based on the command it just sent, before the read model catches up; command-side return โ the command handler returns the new entity directly in its HTTP response, so the confirmation page does not need to query the (possibly stale) read model; and read-your-writes consistency โ for critical flows, query the write store directly for the first request after a mutation, then switch back to the read model. For the vast majority of UX flows, the projection lag is under a second and users never notice it.
What is the difference between CQRS and a read replica?
A read replica is a byte-for-byte copy of the write database updated via replication; the data model is identical and you save the primary from read traffic, but you gain no flexibility in how the data is shaped for queries. CQRS goes further: the read model can be an entirely different data structure โ a document store, a search index, pre-aggregated counters in Redis โ shaped precisely for the queries it serves. Multiple independent projections can produce multiple read models from the same write history. A read replica is a scaling tactic; CQRS is an architectural pattern that enables both scaling and fundamentally different read-model designs.
CQRS is not about having two databases โ it is about accepting that reading and writing are fundamentally different concerns, and designing each one to be excellent at its own job.
โ alokknight Engineering
