CQRS and Its Relationship to DDD
What This Concept Is
CQRS -- Command Query Responsibility Segregation -- is the architectural choice to model writes (commands) and reads (queries) with different models, possibly stored in different data stores.
- Write model. The aggregate. Small, normalized, optimized for enforcing invariants. One row per aggregate per shipment, rate, invoice.
- Read model. A denormalized projection optimized for a specific query (a dashboard, a search page, a report). Built by subscribing to domain events and materializing views.
The split ranges on a spectrum:
| Level | Description |
|---|---|
| CQRS-lite | Same database; separate classes / methods for queries vs commands |
| CQRS-views | Same database; commands write through aggregates, queries read from SQL views or denormalized tables |
| Classical CQRS | Separate read store (Postgres view, Elasticsearch, Redis, materialized table) built by event handlers |
| CQRS + Event Sourcing | Write log = events; read model built from events (see concept 14) |
You do not need event sourcing to do CQRS; the supporting label on this concept reflects its role as a pattern you opt into when read and write needs diverge, not a default.
What CQRS buys you
- reads can use a schema unlike the write schema (search index, graph DB, matview, flat table for reports)
- reads scale independently of writes
- writes are simpler -- the aggregate does not have to answer ad-hoc query shapes
- read projections can be rebuilt from events when you change their schema
What CQRS costs you
- eventual consistency between write and read models (milliseconds to seconds)
- two (or more) models to keep aligned
- ops complexity (event stream, projections, replays)
- cognitive overhead for new engineers
The rule of thumb: opt into CQRS per bounded context, when the read and write shapes meaningfully diverge. Do not impose it on a whole system.
Why It Matters Here
Aggregates (concept 10) are deliberately not designed for ad-hoc queries. As soon as a dashboard wants shipments where status in (...) and origin.country in (...) and weight_between ..., the aggregate is the wrong tool. CQRS is how DDD handles this without corrupting the aggregate.
CQRS also pairs naturally with the domain events of concept 11. The outbox is already publishing events; a projection is just another subscriber.
Concrete Example
Case: Parcel Shipping -- Tracking read models
The write side
# Write: one aggregate per journey, optimized for invariants
class ShipmentJourney: # aggregate root
shipment_id: str
status: str
scans: list[CarrierScan]
version: int
# ... (concept 10, 11)
The ShipmentJourney stores only what it needs to enforce invariants and emit events. It knows nothing about customer names, carrier nicknames, or surcharge categories.
Two read models, consumed by two screens
Read model A -- "Customer-facing tracking page"
-- customer_tracking_view (denormalized)
-- One row per shipment, updated by event handler
CREATE TABLE customer_tracking_view (
shipment_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
status TEXT NOT NULL,
friendly_status TEXT NOT NULL, -- 'Arriving tomorrow' etc.
last_event_at TIMESTAMPTZ,
last_event_city TEXT,
eta TIMESTAMPTZ,
origin_city TEXT,
destination_city TEXT,
carrier_display TEXT -- 'FedEx Ground'
);
This table is built by consuming:
shipping.shipment.booked.v1(initializes row)carrier.scan.v1(updates last event + ETA projection)shipping.shipment.delivered.v1(flips status, sets friendly_status)
Read model B -- "Operations dashboard"
-- ops_dashboard_view (aggregated for grid / drill-down)
CREATE TABLE ops_dashboard_view (
bucket_date DATE,
origin_hub TEXT,
service_class TEXT,
status TEXT,
count INT,
p95_hours_in_transit INT,
PRIMARY KEY (bucket_date, origin_hub, service_class, status)
);
A totally different shape, totally different consumer.
The event handler that maintains Read Model A
class CustomerTrackingProjection:
def on(self, event: dict):
e_type = event["schema"]
if e_type == "shipping.shipment.booked.v1":
self._init_row(event)
elif e_type == "carrier.scan.v1":
self._apply_scan(event)
elif e_type == "shipping.shipment.delivered.v1":
self._finalize(event)
def _apply_scan(self, event):
self._db.execute("""
UPDATE customer_tracking_view
SET status = :status,
friendly_status = :friendly,
last_event_at = :at,
last_event_city = :city,
eta = :eta
WHERE shipment_id = :sid
""", {
"sid": event["shipment_id"],
"status": event["unified_status"],
"friendly": _friendly(event["unified_status"]),
"at": event["occurred_at"],
"city": event["location"]["city"],
"eta": _project_eta(event),
})
Query side: no aggregates, just a view
class CustomerTrackingQuery:
def status(self, shipment_id: str) -> dict:
row = self._db.fetchone(
"SELECT * FROM customer_tracking_view WHERE shipment_id = :sid",
{"sid": shipment_id},
)
return row # no aggregate involved
Eventual consistency -- plan for it
The customer's tracking page will lag the write by ~1s on a healthy day. Options:
- display
last_event_atso the user sees the time of the displayed fact - after the user completes a write from the UI (e.g., cancels a shipment), auto-refresh via a small server push rather than assume the read reflects instantly
- expose a
consistency_tokenper aggregate that the UI can poll
Do not pretend CQRS is strongly consistent.
When NOT to CQRS
- Read and write shapes are the same. A CRUD-shaped entity that is only ever displayed as a row does not need CQRS.
- Low volume / low complexity. If you can answer every query straight off the write model, CQRS is overhead.
- Team is small and missing the ops muscle to run projections reliably.
Most bounded contexts need 0, 1, or 2 read models -- not 10.
Common Confusion / Misconception
"CQRS requires event sourcing." No. Event sourcing is one way to build read models; they can also be built from outbox events off a classical write store (as above).
"CQRS means two databases." Could be two tables, two schemas, two databases, or two clusters. Shape the choice to the read's actual needs.
"CQRS makes the system more complex, so we should avoid it." CQRS trades complexity for flexibility. Applied to the wrong context it is pure cost; applied where reads are truly different from writes, it is net simpler than shoehorning both into one model.
"Projections always need to be perfectly kept in sync." They need to be eventually correct and monotonically converging. Temporary staleness is the nature of the pattern.
"CQRS is a system-wide architecture." It is a per-context choice. Some contexts benefit; others don't.
"Read models are just reports." They can be any read shape -- search, dashboards, transactional reads from adjacent contexts, caches, analytical roll-ups.
How To Use It
Decide per bounded context:
- List the queries you need. Cluster by shape.
- For each query cluster, ask: can the write model answer this efficiently as-is?
- If yes, no CQRS needed for this cluster.
- If no, design a read model: schema, invariants (if any), source events, projection code, rebuild strategy.
- Plan for eventual consistency: observability on lag, rebuild tooling, consistency cues in the UI.
- Only if the write side also needs log-based semantics (audit, replay, temporal queries), move to event sourcing (concept 14).
Check Yourself
- Name two concrete benefits of separating the read model from the write model.
- Why does CQRS inherently introduce eventual consistency?
- Give an example of a context where CQRS would be overkill.
Mini Drill or Application
Take the e-commerce checkout from concepts 10-12 and the Order aggregate that comes from it.
- Enumerate five queries the team will want (customer order history, per-SKU best-sellers this week, refund queue, etc.).
- For each, decide: answer from the write model, or build a read model?
- Pick one read model and fully design it: schema, event subscriptions, projection handler pseudocode, rebuild plan.
- Describe the UI cue you will use to handle eventual consistency for that view.