Event-Driven Architecture Lesson 3 of 6 ⏱️ 4 min read

CQRS: Command Query Responsibility Segregation

Separate write models from read models, build projections from events, and handle eventual consistency in the UI.

Updated Oct 30, 2026

What is CQRS?

CQRS (Command Query Responsibility Segregation) splits an application's model into two sides:

  • The command (write) side handles changes — validating business rules and recording what happened.
  • The query (read) side serves data — using models shaped exactly for the screens and APIs that read them.

Instead of one set of tables trying to serve both perfectly, each side is optimised for its own job.

Plaintext
              commands                      events                 queries
Client ──▶ [Write model] ──▶ write DB ──▶ (OrderPlaced…) ──▶ [Projections] ──▶ read DB ◀── Client
            validates rules                                    build views        fast lookups

Why separate reads and writes?

Real systems are usually read-heavy and their reads look very different from their writes:

  • The write model needs normalised data and strict rules (an order can't be paid twice).
  • A dashboard needs denormalised, pre-joined, pre-aggregated data ("revenue per course this month").
  • A search box needs a search engine index.

Forcing one schema to serve all of these leads to slow joins, complicated queries and fragile code. CQRS lets you build several read models from the same writes.

Projections: building read models from events

A projection listens to events and updates a read-optimised view.

Python
from collections import defaultdict

events = [
    {"type": "EnrollmentCreated", "course": "Python", "student": "ayesha", "fee": 5000},
    {"type": "EnrollmentCreated", "course": "Python", "student": "bilal", "fee": 5000},
    {"type": "EnrollmentCreated", "course": "Java", "student": "sara", "fee": 7000},
    {"type": "EnrollmentCancelled", "course": "Python", "student": "bilal", "fee": 5000},
]

class CourseStatsProjection:
    """Read model: students and revenue per course, ready for a dashboard."""

    def __init__(self):
        self.view = defaultdict(lambda: {"students": 0, "revenue": 0})

    def apply(self, event: dict) -> None:
        row = self.view[event["course"]]
        if event["type"] == "EnrollmentCreated":
            row["students"] += 1
            row["revenue"] += event["fee"]
        elif event["type"] == "EnrollmentCancelled":
            row["students"] -= 1
            row["revenue"] -= event["fee"]

projection = CourseStatsProjection()
for e in events:
    projection.apply(e)

for course, stats in sorted(projection.view.items()):
    print(course, stats)

Output:

Plaintext
Java {'students': 1, 'revenue': 7000}
Python {'students': 1, 'revenue': 5000}

The dashboard query is now a simple key lookup — no joins, no aggregation at read time. Need a new view (top students, revenue by month)? Write a new projection and replay the events to build it.

Eventual consistency in the UI

The read model updates after the write commits, usually milliseconds to seconds later. A user who just enrolled might briefly not see the course on their dashboard. Ways to handle it:

  • Return the result of the command directly and show it optimistically in the UI.
  • Read your own writes — for the user who made the change, read from the write model for a short time.
  • Wait for the projection to reach the command's version before responding (version tokens).
  • Communicate — "Your enrollment is being processed…".

CQRS levels

Level What's separated Complexity
Code-level Separate command and query classes/handlers, same database Low — good practice anywhere
Separate read tables Read-optimised tables/materialised views in the same database Medium
Separate read stores Write DB + Elasticsearch/Redis/another DB fed by events High

You can adopt CQRS gradually; the first level is just clean code organisation.

CQRS and event sourcing

CQRS is often paired with event sourcing (next lesson): the write side stores events, and projections build every read model. But they're independent — you can use CQRS with an ordinary relational write model and the outbox pattern to publish changes.

When CQRS is worth it

  • Read and write workloads differ greatly in shape or scale.
  • Complex domains with rich business rules on the write side.
  • Many different views of the same data (dashboards, search, reports, mobile APIs).

When it isn't:

  • Simple CRUD screens where reads and writes look the same.
  • Small teams that can't afford the extra moving parts.

Common mistakes

  • Applying full CQRS everywhere — use it for the parts of the system that need it.
  • Non-idempotent projections — replaying or redelivered events double-count. Track the last processed event position per projection.
  • Writing to read models directly from commands, bypassing events and creating drift.
  • Ignoring rebuild time — plan how long replaying all events takes as data grows.

Key takeaways

  • CQRS separates the write model (rules) from read models (fast, shaped views).
  • Projections build read models from events and can be rebuilt by replay.
  • Expect eventual consistency and design the UI for it.
  • Start with code-level separation; add separate stores only when justified.
Finished this lesson?
Tick it off to track your Event-Driven Architecture progress.
Next lesson