DEV Community

Cover image for CQRS Explained: It's Just Two Models (Not a Whole Architecture)
Arnav Sharma
Arnav Sharma

Posted on

CQRS Explained: It's Just Two Models (Not a Whole Architecture)

CQRS explained: it's just two models (not a whole architecture)

Why does a pattern whose entire content is "use a different model for reads than for writes" end up meaning event sourcing, two databases, and a message bus in every conference talk? How did we get from "split your objects" to an architecture diagram with fifteen boxes?

I think we cargo-culted it. Someone saw a Greg Young talk where CQRS happened to sit next to event sourcing, and the two got welded together in collective memory. The actual pattern is almost disappointingly simple.

Let me un-weld them.


🔑 The minimal claim

Bertrand Meyer gave us Command-Query Separation in 1988. Method-level rule: a method either changes state (command) or returns data (query). Never both.

Greg Young extended that to the model level in 2010 and called it CQRS. His definition: "simply the creation of two objects where there was previously only one." You have a write model that handles commands and enforces business rules. You have a read model shaped for queries. That's it.

Not two databases. Not a message bus. Not eventual consistency. Young said this explicitly: "CQRS is not eventual consistency, it is not eventing, it is not messaging, it is not having separated models for reading and writing, nor is it using event sourcing."

So what is it? Separation at the model boundary. Your write side validates and persists. Your read side queries and returns. They don't share the same object or the same shape.

🧠 Four things CQRS is not

Let me kill these early because they're the reason people over-architect their first attempt.

It doesn't require event sourcing. Event sourcing is a persistence strategy where you store every state change as an event. CQRS works with a plain Postgres table. They pair well together, but neither requires the other. You can do CQRS without events. You can do event sourcing without separating read/write models.

It doesn't require a second database. Both models can live in the same database. Different tables, different views, even just different queries on the same table. A separate read store is one point on the spectrum, not the definition.

It doesn't require a message bus. You can call the write model, wait for it to finish, then query the read model synchronously. No Kafka, no RabbitMQ, no SQS. Messaging is an infrastructure choice you make when you need async decoupling, not a prerequisite for CQRS.

It's not a system-wide architecture. Fowler is blunt about this: "CQRS should only be used on specific portions of a system (a Bounded Context in DDD lingo) and not the system as a whole." Udi Dahan agrees. You apply it to the bounded context where read/write shapes have genuinely diverged. Your user settings page probably doesn't need it.


The implementation spectrum

Here's where it gets practical. CQRS exists on a spectrum from cheap to expensive.

Tier 1: same model, separate methods. You split your service into command handlers and query handlers. They might even hit the same database table. The separation is in your code organization, not your infrastructure. Cost: almost nothing.

// Command handler: validates, mutates, persists
async function makeCustomerPreferred(customerId: string, repo: CustomerRepo) {
  const customer = await repo.load(customerId);
  if (customer.orderCount < 10) {
    throw new Error("Customer doesn't qualify for preferred status");
  }
  customer.status = "preferred";
  customer.preferredSince = new Date();
  await repo.save(customer);
}

// Query handler: reads a flat view, zero domain logic
async function getPreferredCustomers(db: Pool) {
  return db.query<{ id: string; name: string; since: Date }>(
    "SELECT id, name, preferred_since FROM customers WHERE status = 'preferred'"
  );
}
Enter fullscreen mode Exit fullscreen mode

Tier 2: separate DTOs. The write side uses rich domain entities. The read side uses flat DTOs shaped for the screen. Same database, but the query handler doesn't hydrate your full domain model just to render a list.

Tier 3: separate read models, same database. You add a materialized view or a denormalized table that's optimized for reads. The write side updates the canonical tables; a trigger or background job refreshes the read view. Still one database. But now your read queries are fast without N+1 problems or complex joins.

Tier 4: separate read database, async sync. Dedicated read store (Elasticsearch for search, Redis for hot data, a read replica shaped differently from the primary). Kept in sync via domain events or CDC. This is where eventual consistency enters the picture. And this is where most of the complexity lives.

Most teams should stop at tier 2 or 3. Tier 4 earns its cost only when you have genuine read/write asymmetry (thousands of reads per write) or when your read shapes are so different from your write schema that joins become the bottleneck.


âš¡ The eventual-consistency cost

Once you cross into tier 4, a user can write something and immediately read stale data. The projection hasn't caught up yet. "I updated my profile but it still shows the old name." Sound familiar?

This is the read-your-own-writes problem, and you can't wish it away. But people have solved it repeatedly. Four approaches that actually work in production:

Route to primary after write. After a user mutates data, pin their reads to the write-side database for a few seconds (session flag or cookie). Everyone else reads from the eventual-consistent projection. Simple. Works.

Version tokens. The write returns a version number or logical timestamp. The client sends it on the next read. The routing layer picks a replica that's at or past that version. If none qualifies yet, it falls back to primary.

Optimistic UI merge. The client knows what it just wrote. It merges that pending state into whatever the read model returns until the projection catches up. React Query's optimisticUpdate is basically this.

Read from write model for "own" data. A user's own profile or cart reads directly from the write model. Other users' views of that data come from the projection. You're routing by ownership, not by time.

None of these are free. Each adds code paths and edge cases. But they're well-understood patterns, not research problems.


When to actually use it (and my default answer)

Fowler's warning is worth quoting: "you should be very cautious about using CQRS … the majority of cases I've run into have not been so good, with CQRS seen as a significant force for getting a software system into serious difficulties."

Strong words from someone who usually hedges.

My position: the default answer is don't. Start with a single model. If your read shapes are almost identical to your write shapes — which they are in most CRUD apps — separated models just double your code for no benefit.

CQRS earns its complexity when:

  • You have genuinely different read and write shapes (a dashboard aggregating data from multiple write models)
  • Read/write traffic is wildly asymmetric (analytics dashboards hit 10,000x more than the admin panel writing data)
  • Write-side invariants are complex enough that polluting the domain model with display concerns makes it worse
  • You're already in an event-driven system where projecting events into read models is natural

That last point is where CQRS pairs with the event-driven patterns I'll cover in a dedicated post on event-driven architecture. If your system already publishes domain events, building read projections from those events is a small incremental step. But adopting events and CQRS and a message bus all at once for a CRUD app? That's resume-driven development.

If you're working with API gateways that route between services, CQRS might make sense at the service boundary. And if you're already running Kafka with partitioned consumers, projecting events into a read store is straightforward. But those are preconditions, not reasons to adopt CQRS from scratch.


📌 Key takeaways

  • CQRS is two models. One for writes, one for reads. That's the whole pattern.
  • It doesn't require event sourcing, a second database, or a message bus. Those are infrastructure choices you might make, not prerequisites.
  • The spectrum runs from "separate methods on one class" to "fully async separate databases." Most apps should stay on the cheap end.
  • Eventual consistency is only a problem at tier 4. If you're there, use route-to-primary, version tokens, or optimistic UI to handle read-your-own-writes.
  • Default answer: don't use it. Wait until you feel the pain of divergent read/write shapes before you split the model.

More writing

I park all my writing at arnavsharma.dev if you want to read more.

Top comments (0)