CQRS: When Reads and Writes Need Different Models

Series: System Design · Architecture Patterns — Pillar 7 of 8
Systems Design
| # | Post | What it covers |
|---|---|---|
| 00 | Architecture Patterns: How Systems Are Structured | Twenty patterns covering monoliths, microservices, events, resilience, deployment, and data processing. How to structure systems that survive growth. |
| 01 | Monolithic Architecture: The Default That Gets Abandoned Too Early | Monoliths are fast to build and easy to operate. Learn when they're the right choice, when they break down, and how to know the difference. |
| 02 | Microservices: The Architecture You Earn, Not Choose | Microservices enable independent scaling and team autonomy — but at significant cost. Learn what you actually get, what you pay, and when it's worth it. |
| 03 | Serverless: Pay for What You Use, Not What You Provision | Serverless scales to zero and charges per invocation. Learn where it shines, where it fails, and how to design around cold starts and vendor lock-in. |
| 04 | Event-Driven Architecture: Decoupling Through Events | Event-driven systems communicate via events rather than direct calls. Learn how producers, consumers, and event brokers work — and the consistency tradeoffs involved. |
| 05 | Message Queues: Decoupling Produce from Consume | Message queues decouple producers and consumers, enable load levelling, and provide durability. Learn how they work and when to use Kafka vs SQS vs RabbitMQ. |
| 06 | Pub/Sub: Broadcasting Events to Multiple Consumers | Pub/sub decouples publishers from subscribers through topics. Learn how it differs from message queues and when to use Kafka, SNS, or Google Pub/Sub. |
| 07 | CQRS: When Reads and Writes Need Different Models ← you are here | CQRS separates writes from reads so each can be optimised independently. Learn how it works, when it's worth the complexity, and when it isn't. |
| 08 | Event Sourcing: The Ledger, Not the Balance | Event sourcing stores state as a sequence of events. Learn how it works, what you get (audit log, time travel), and what it costs (complexity, schema evolution). |
| 09 | The Saga Pattern: Distributed Transactions Without Locks | The Saga pattern manages distributed transactions across services using compensating transactions. Learn choreography vs orchestration and when to use each. |
| 10 | The Outbox Pattern: Atomic Writes and Event Publishing | The Outbox pattern solves the dual-write problem — publishing an event and writing to a database atomically. Learn how it works using CDC or polling. |
| 11 | The Circuit Breaker: Stopping Cascading Failures | Circuit breakers prevent cascading failures by fast-failing calls to unhealthy dependencies. Learn the three states, how to configure them, and where to apply them. |
| 12 | The Bulkhead Pattern: Containing Failures Through Resource Isolation | Bulkheads isolate thread pools and connections per dependency so one failure can't exhaust resources needed by others. Learn how to apply them in practice. |
| 13 | The Sidecar Pattern: Cross-Cutting Concerns Without Code Changes | The sidecar pattern deploys a helper process alongside each service for logging, metrics, TLS, and service discovery — without modifying the service itself. |
| 14 | Service Mesh: A Programmable Network for Microservices | A service mesh handles service-to-service traffic, mTLS, circuit breaking, and observability via a fleet of sidecar proxies. Learn how it works and when to use it. |
| 15 | Service Discovery: Finding Services in a Dynamic Environment | Service discovery lets services find each other in dynamic environments. Learn client-side vs server-side discovery, health checks, and DNS vs registry approaches. |
| 16 | The Strangler Fig: Replacing a Legacy System Without Burning It Down | The Strangler Fig replaces a legacy system incrementally by routing specific functionality to new implementations while the old system keeps running. |
| 17 | Backend for Frontend: One API Per Client Type | BFF creates dedicated API backends per client type. Learn why one general API struggles to serve mobile and web well, and how BFF solves it. |
| 18 | ETL Pipelines: Moving Data from Operations to Analytics | ETL moves data from operational systems into analytical stores. Learn how pipelines work, what ELT is, and how to design reliable data movement at scale. |
| 19 | Batch vs Stream Processing: How Fresh Do Your Answers Need to Be? | Batch processes accumulate data then processes in bulk; streaming processes each event as it arrives. Learn the tradeoffs and when each is right. |
| 20 | MapReduce: Processing Petabytes in Parallel | MapReduce processes massive datasets in parallel by splitting work into map and reduce phases. Learn how it works and why Spark has largely replaced it. |
| 21 | Architecture Patterns: Wrap-Up | A recap of all 20 architecture patterns across decomposition, async communication, data patterns, resilience, and data processing. How they connect. |
CQRS: When Reads and Writes Need Different Models
The problem
Your URL shortener's analytics dashboard shows a per-link breakdown: total clicks, clicks by country, clicks by device, clicks by day for the last 30 days. This requires aggregating billions of click events across multiple dimensions.
The write model is simple: when a click happens, insert a row. The read model for the dashboard is complex: aggregate counts by multiple dimensions, filtered by link, for a time range, sorted by recency.
If both reads and writes go to the same database with the same data model, you're optimising for neither. The write path wants a simple, fast INSERT. The read path wants pre-aggregated views, multiple indexes, and denormalised data structures that answer dashboard queries instantly.
A single data model that serves both well ends up serving both poorly.
The core idea
CQRS (Command Query Responsibility Segregation) separates the write side (commands that change state) from the read side (queries that return data) into distinct models, each optimised for its purpose. Commands go to a write model (normalised, transactional). Queries go to one or more read models (denormalised, indexed, pre-aggregated) that are derived from the write model.
The analogy: a bank's back office and customer statements
A bank's back office (write model) maintains a ledger of every transaction: debits, credits, transfers, in chronological order. It's normalised, accurate, and transactional.
Your monthly statement (read model) is a derived view: it pre-computes your balance, groups transactions by category, highlights large transactions. It's derived from the back office ledger but optimised for human readability and quick comprehension — not for recording new transactions.
The bank doesn't ask the back office to re-derive your statement from the raw ledger every time you open the banking app. It maintains a pre-computed read model for queries.
How CQRS works
The two models
Write side (command model):
- Receives commands:
CreateLink,RecordClick,UpdateDestination - Validates and executes business logic
- Writes to a normalised, transactional store (PostgreSQL, the source of truth)
- Publishes events describing what changed
Read side (query model):
- Maintains one or more read-optimised projections
- Updated by consuming events from the write side
- Can use a completely different data store (Elasticsearch, Redis, a pre-aggregated PostgreSQL table, Cassandra)
- Answers queries against the projection — never writes to the write side's store
Command: RecordClick { link_id, ip, country, device, timestamp }
→ Write Model (Cassandra, append-only)
→ Publishes: ClickRecorded event
ClickRecorded event consumed by Read Projections:
→ Projection 1: per-link daily aggregate table (Redis counter)
UPDATE link_daily_stats SET clicks += 1 WHERE link_id = X AND date = today
→ Projection 2: per-link country breakdown (PostgreSQL JSONB column)
→ Projection 3: full-text search index in Elasticsearch
Query: GetLinkAnalytics(link_id, from_date, to_date)
→ Reads from Projection 1 (fast aggregates, not raw click log)
Synchronous vs asynchronous projection updates
Synchronous: the command handler updates both the write store and the read projections in the same transaction (or in the same request). Consistent immediately. Couples write latency to projection update complexity.
Asynchronous: the command handler writes to the source, publishes an event, and returns. The read projection is updated by a separate consumer. Decoupled, scalable, but eventually consistent — the read model may be seconds or minutes behind the write model.
Asynchronous is more common and more powerful; synchronous CQRS within a single service is sometimes called "simple CQRS" and is appropriate when immediate consistency is required.
Multiple read models for multiple consumers
The same write event can drive multiple read models, each optimised for a different consumer:
ClickRecorded event →
Real-time Redis counters (for the live click counter widget)
Daily aggregate table (for analytics dashboard)
Elasticsearch document update (for the click search feature)
Cassandra row (for raw data export)
Each read model answers queries that would be expensive against the raw write store.
When CQRS adds value vs adds complexity
CQRS adds genuine value when:
- Read and write workloads have significantly different characteristics (scale, latency requirements, data shape)
- Multiple distinct read models are needed (dashboard vs export vs search)
- The write model must be simple and transactional while reads require denormalisation
- Event Sourcing is used (CQRS and Event Sourcing are natural complements — the event log is the write model; projections are read models)
CQRS is over-engineering when:
- The application's read and write patterns are similar (basic CRUD)
- The team doesn't have event-driven infrastructure to propagate changes
- Eventual consistency would confuse users (you update a profile, refresh the page, see the old profile — inconsistent for seconds)
- The system is small enough that the complexity overhead exceeds the benefit
Tradeoffs
Query performance vs consistency. Read models are optimised for queries — dashboards load in milliseconds instead of seconds. The cost: the read model may lag behind the write model. A user who just created a link and immediately queries the analytics dashboard may see "0 clicks" before the read model catches up.
Eventual consistency. Managing user expectations around stale reads requires care. For most analytics data (a dashboard that's one second old is fine), this is acceptable. For user-facing operations ("I just updated my password, am I seeing the updated view?"), it isn't.
Operational complexity. More data stores, more event consumers, more projections to maintain. Schema changes require updating both the write model and every projection that consumes its events. Adding a read model requires a historical replay of all events to populate it from the beginning.
The one thing to remember
CQRS separates the model for changing state from the model for reading state. The write model is simple, transactional, and correct — it owns the truth. The read model is denormalised, indexed, and pre-computed — it answers queries fast. The event stream connecting them makes the read model eventually consistent. Apply CQRS when reads and writes have genuinely different needs; don't apply it as a default — the consistency and operational complexity it introduces only pays off when the optimisation benefit is real.
← Previous: Pub/Sub — message queues route to one consumer; pub/sub broadcasts to all subscribers. Here's how the fan-out model works and when you need it.
→ Next: Event Sourcing — instead of storing current state, store the sequence of events that produced it. A natural complement to CQRS, and a pattern with significant power and significant commitment.




