~/software-design/cqrs-pattern-explained.md

CQRS Pattern, Explained Simply: Split Your Writes From Your Reads

4 min readNovember 03, 2026by Gorav Singal#cqrs#system-design#distributed-systems
// TL;DR

CQRS simply means you stop using one model for both writing and reading. The write side (Commands) handles changes — validation, domain rules, invariants — and stays clean and normalised. The read side (Queries) is a separate model, or many, shaped exactly for the screens that need them (denormalised, fast, no 7-table JOINs). The two are kept in sync by EVENTS, usually asynchronously, which is the main catch — the read side is EVENTUALLY CONSISTENT, so a user can save something and not see it for a moment. Use CQRS when reads and writes have genuinely different scale or shape (read-heavy products, complex domains, dashboards + search over the same data, or alongside Event Sourcing). Do NOT use it for a simple CRUD app — one model with a few indexes is simpler and you will thank yourself later. Design around the consistency gap (read-your-own-writes where it matters) and it becomes a feature, not a bug.

CQRS Pattern, Explained Simply: Split Your Writes From Your Reads

Most of us start our career with one model for everything. The same Order class, the same table, the same object — we write to it, and we read from it. For 90% of applications this is perfectly fine, and honestly you should not change it. But once a system becomes big and read-heavy, this single model starts to feel tight, like a shirt that has shrunk after washing. CQRS is the pattern that loosens it. Let us go through it simply.

CQRS stands for Command Query Responsibility Segregation, which is a very heavy name for a fairly simple idea: use one model to change data, and a seperate model to read it. That’s it. Commands change state; queries return state; and the two no longer have to share the same shape.

The core idea

CQRS architecture: a client sends commands down the write path and queries down the read path. The command side goes from Commands like createOrder and cancelOrder, to a write model holding domain rules, validation and invariants, to a normalised write store that is the source of truth. The query side goes from Queries like getOrderSummary and search, to read models shaped per screen, to denormalised query-optimised read stores. The two sides are kept in sync by events through an async projection. The core idea: reads and writes have different needs, so each gets its own model, synced by events, which makes the read side eventually consistent.

Think about it — writes and reads actually want very different things:

  • A write cares about correctness. Is this order valid? Does the user have enough balance? Are all the business rules and invariants satisfied? For this you want a clean, normalised model with the domain logic in one place.
  • A read cares about speed and shape. Give me exactly what this screen needs, fast, without joining seven tables every time. For this you want a denormalised model, shaped per view, maybe even sitting in a totally different store (Elasticsearch for search, Redis for a hot summary, and so on).

In the traditional single-model world, you are forcing both of these needs onto one poor model, and it does neither job well. CQRS simply says: let them go their own way. The write side handles commands and stays pure. The read side builds whatever views are convenient. When a command changes something, it emits an event, and the read side updates its models from that event. One stream of changes going in, many tailored views coming out.

The catch nobody tells you first: eventual consistency

Now here is the part which people conveniently skip in interviews, and then it bites them in production.

CQRS trade-offs: what you gain is scaling reads and writes separately, read models shaped per screen with no more 7-table JOINs, and a clean write model with pure domain rules. What you pay is two models to keep in sync, eventual consistency where reads lag writes by a few milliseconds to seconds, more moving parts, and projection logic to own — and it is overkill for a simple CRUD app. The consistency gap on a timeline: at T0 the write commits, at T0 plus delta the read model is updated, and a read in between sees stale data. When to reach for it versus not, and how to handle the gap with read-your-own-writes where it matters.

Beacuse the read side updates asynchronously from events, there is a small window — a few milliseconds, sometimes a few seconds — where the write has happened but the read model has not caught up yet. So a user saves their profile, the page reloads, and the old name is still showing. Technically everything is working correctly; it’s just eventually consistent.

This is not a bug to be fixed, it is a property to be designed around. Some practical ways:

  • Read-your-own-writes: for the user’s own action, show the result optimistically from the command’s response, or read from the write side just for that one case.
  • Make the rest tolerant: most reads (someone else’s dashboard, a search result) genuinely do not need to be millisecond-fresh. A small lag is totally okay there.
  • Tell the user: a tiny “updating…” hint is far better than a confusing stale screen.

If your product truly cannot tolerate any staleness anywhere, then CQRS is probably not your friend, and that is fine.

A small code sketch

The shape in code is just two paths that do not share a model:

# WRITE side — a command, with rules
def handle(cmd: PlaceOrder):
    order = Order.create(cmd.items, cmd.user_id)
    order.validate()                 # domain rules live here
    write_db.save(order)
    events.publish(OrderPlaced(order.id, order.user_id, order.total))

# a projector updates the READ side from the event (async)
def on(event: OrderPlaced):
    read_db.upsert_order_summary(
        order_id=event.order_id,
        user=event.user_id,
        total=event.total,
        status="placed",
    )   # denormalised, exactly what the "my orders" screen needs

# READ side — a query, no domain logic at all
def get_my_orders(user_id):
    return read_db.order_summaries_for(user_id)   # one fast lookup

Notice the read query has zero business logic — it is a dumb, fast lookup into a model that was pre-shaped for it. That is really the whole benefit.

When to use it, and when to please not

I will be honest here, because CQRS is over-applied by people who read one blog and get excited (I was also guilty of this once).

Good fits:

  • Read-heavy systems where reads and writes scale very differently.
  • Complex domains where the write-side rules are rich, and you want to keep them clean.
  • When you already need multiple read shapes — a search index, a reporting view, a cache — over the same data.
  • Alongside Event Sourcing, where the two patterns fit together like hand in glove.

Please do not use it when:

  • You have a simple CRUD app. One model with a few good indexes is simpler, and it will serve you well for years.
  • Your team is small and the domain is still changing a lot. The extra moving parts will slow you down more than they help.

The real skill is not knowing CQRS — it is knowing when a plain boring model is actually the better engineering decision. Most of the time, it is.

Takeaways

  • CQRS = one model to write, a separate model (or many) to read. Commands change state; queries return it.
  • The write model stays clean and normalised; the read models are denormalised and shaped per screen. Events keep them in sync.
  • The sync is usually async, so reads are eventually consistent — design for the small gap (read-your-own-writes, tolerant reads), don’t be caught out by it.
  • Use it for read-heavy or complex-domain systems and alongside Event Sourcing — not for a simple CRUD app.

This is part of a small series on event-driven data patterns. Next, see Event Sourcing (its natural partner), Change Data Capture, the Outbox pattern, and for the bigger picture, the System Design Masterclass.

Share

Related Posts

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Here is a question I like to ask in interviews: your bank account shows a…

The Transactional Outbox Pattern: Never Lose an Event Again

The Transactional Outbox Pattern: Never Lose an Event Again

This is one of those patterns that looks like nothing until you get burnt once…

The Circuit Breaker Pattern: Fail Fast, So One Dead Service Doesn't Kill Them All

The Circuit Breaker Pattern: Fail Fast, So One Dead Service Doesn't Kill Them All

Like the electrical switch in your home that it is named after, a circuit…

Change Data Capture (CDC): Stream Every Database Change Without Touching Your App

Change Data Capture (CDC): Stream Every Database Change Without Touching Your App

Your orders live in Postgres. But you also need them in Elasticsearch for search…

Message Queue vs Stream — RabbitMQ/SQS vs Kafka, and When to Use Which

Message Queue vs Stream — RabbitMQ/SQS vs Kafka, and When to Use Which

“Should we use RabbitMQ or Kafka?” is one of the most common — and most muddled…

Idempotency in Distributed Systems — Why Your Retry Just Charged the Customer Twice

Idempotency in Distributed Systems — Why Your Retry Just Charged the Customer Twice

Here is a bug that has cost real companies real money: a customer taps “Pay ₹50…

Latest Posts

How to Stay Valuable as a Software Engineer in the AI Era: A Practical Playbook

How to Stay Valuable as a Software Engineer in the AI Era: A Practical Playbook

In the previous article I argued that AI is not killing software engineering…

How to Review AI-Generated Code: A Checklist for Catching What the Model Missed

How to Review AI-Generated Code: A Checklist for Catching What the Model Missed

A few months back I approved a PR that was almost entirely AI-generated. It…

How to Debug AI-Generated Code: Understand First, Fix Last

How to Debug AI-Generated Code: Understand First, Fix Last

In the last article I wrote about reviewing AI code before it goes in. This one…

The Future of Software Engineering: What AI Changes, What Stays, and Where the Jobs Go

The Future of Software Engineering: What AI Changes, What Stays, and Where the Jobs Go

Every few weeks somebody sends me a scary headline — “AI will replace all…

The Transactional Outbox Pattern: Never Lose an Event Again

The Transactional Outbox Pattern: Never Lose an Event Again

This is one of those patterns that looks like nothing until you get burnt once…

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Event Sourcing, Explained: Store What Happened, Not Just the Final State

Here is a question I like to ask in interviews: your bank account shows a…