Siddhant Deval
Siddhant Deval
backend16 min read

CQRS: Separating Write Models from Read Projections

CQRS is not about code organization — it is about materializing read-optimized projections from an authoritative write model, accepting an eventual consistency window in exchange for independently scalable read and write throughputs. Learn aggregate commands, optimistic concurrency, projection lag, and when the CQRS tax is worth paying.

CQRS: Separating Write Models from Read Projections

Every boundary is a failure isolation decision — and one of the most consequential boundaries in a distributed system is the boundary between the model you write to and the model you read from. The most common mistake teams make with CQRS is treating it as a code organization pattern: they create a CommandService and a QueryService class, route reads to one and writes to the other, and call it CQRS. It isn't. True CQRS materializes read-optimized projections from an authoritative write model, accepts an explicit eventual consistency window between them, and scales each path independently. The added complexity is significant — and is only justified when read and write requirements genuinely diverge.

Architectural Note

Series positioning: This is Part 5 of the Distributed Architecture & System Design series. Having established inter-service communication and messaging brokers in Parts 1–4, it introduces Command-Query Responsibility Segregation (CQRS), separating write-side transactional aggregates from read projections before exploring event-sourced persistence in Part 6: Event Sourcing with Kafka.


1. Why Shared Models Break at Scale

A system with a single normalized database model shared by both reads and writes faces a fundamental tension:

TYPESCRIPT
// ❌ Shared model — optimized for neither reads nor writes
// Write side: needs strong consistency, invariant enforcement, row-level locking
// Read side: needs denormalized, pre-joined data for the dashboard query

// This single query powers a dashboard that joins 6 tables:
const dashboardData = await db.query(`
  SELECT
    u.name, u.email,
    COUNT(o.id)                           AS order_count,
    SUM(o.total_cents)                    AS lifetime_value_cents,
    MAX(o.created_at)                     AS last_order_at,
    json_agg(DISTINCT p.category)         AS categories_purchased,
    AVG(r.rating)                         AS avg_review_rating,
    COUNT(DISTINCT r.product_id)          AS products_reviewed
  FROM users u
  LEFT JOIN orders o ON o.user_id = u.id
  LEFT JOIN order_items oi ON oi.order_id = o.id
  LEFT JOIN products p ON p.id = oi.product_id
  LEFT JOIN reviews r ON r.user_id = u.id
  WHERE u.id = $1
  GROUP BY u.id
`, [userId])
// This query runs on every page load — same database, same connection pool as your write path
// High read traffic → contention on write tables → degraded write P99
Mental Model Check

In a high-read system, query load contends with write locks. In a high-write system, write load floods the read connection pool. A normalized schema can be optimized for one workload — not both simultaneously. CQRS solves this by materializing a denormalized read model optimized for the query shape, populated asynchronously from the write model.


2. The CQRS Model Separation

Monolithic unified database bottleneck with contending read and write queries (left) versus segregated CQRS architecture with dedicated write aggregate model and asynchronous read projection stores (right).
Monolithic unified database bottleneck with contending read and write queries (left) versus segregated CQRS architecture with dedicated write aggregate model…

2.1 The Write Side: Aggregates & Commands

The write model enforces all business invariants. Commands mutate aggregate state, and any mutation that violates an invariant is rejected before it reaches the database:

TYPESCRIPT
// ✅ Write side — Order aggregate enforces business invariants
class OrderAggregate {
  private status: 'pending' | 'confirmed' | 'cancelled' | 'shipped'
  private readonly orderId: string
  private readonly userId: string
  private readonly lineItems: OrderLineItem[]
  private version: number  // used for optimistic concurrency

  static create(cmd: PlaceOrderCommand): { aggregate: OrderAggregate; event: OrderPlacedEvent } {
    if (cmd.lineItems.length === 0) throw new DomainError('Order must contain at least one item')
    if (cmd.lineItems.some(item => item.quantity <= 0)) throw new DomainError('Quantity must be positive')
    if (cmd.totalCents <= 0) throw new DomainError('Order total must be positive')

    const aggregate = new OrderAggregate(cmd.orderId, cmd.userId, cmd.lineItems)
    const event: OrderPlacedEvent = {
      type: 'OrderPlaced',
      orderId: cmd.orderId,
      userId: cmd.userId,
      lineItems: cmd.lineItems,
      totalCents: cmd.totalCents,
      placedAt: new Date().toISOString(),
      version: 1,
    }
    return { aggregate, event }
  }

  cancel(cmd: CancelOrderCommand): OrderCancelledEvent {
    if (this.status === 'shipped') throw new DomainError('Cannot cancel a shipped order')
    if (this.status === 'cancelled') throw new DomainError('Order already cancelled')
    this.status = 'cancelled'
    return { type: 'OrderCancelled', orderId: this.orderId, reason: cmd.reason,
             cancelledAt: new Date().toISOString(), version: this.version + 1 }
  }
}

2.2 Optimistic Concurrency Control

The write model must prevent lost updates under concurrent modification without pessimistic write locks on the read model:

TYPESCRIPT
// ✅ Optimistic concurrency — expected_version check prevents concurrent write collisions
async function handleCancelOrder(cmd: CancelOrderCommand): Promise<void> {
  const { aggregate, currentVersion } = await orderRepository.load(cmd.orderId)
  const event = aggregate.cancel(cmd)

  await db.transaction(async (trx) => {
    // Fail if another command modified this aggregate since we loaded it
    const updated = await trx('orders')
      .where({ id: cmd.orderId, version: currentVersion }) // ← expected version
      .update({ status: 'cancelled', version: currentVersion + 1 })

    if (updated === 0) {
      throw new ConcurrencyError(`Order ${cmd.orderId} was modified concurrently — retry`)
    }

    await eventBus.publish('order.cancelled', event, { trx })
  })
}

3. The Read Side: Projections

The read side is a projection — a materialized, denormalized view of data derived from the event stream. Its schema is optimized for the query shape, not for normalization.

3.1 Catch-Up Subscriptions

TYPESCRIPT
// ✅ Projection handler — subscribes to the event stream and maintains the read model
// This runs as a separate process from the command handlers

class OrderDashboardProjection {
  async handle(event: DomainEvent): Promise<void> {
    switch (event.type) {
      case 'OrderPlaced':
        await redis.hset(`user:${event.userId}:dashboard`, {
          order_count:         await redis.hincrby(`user:${event.userId}:dashboard`, 'order_count', 1),
          last_order_at:       event.placedAt,
          lifetime_value_cents: await redis.hincrbyfloat(`user:${event.userId}:dashboard`, 'lifetime_value_cents', event.totalCents),
        })
        break

      case 'OrderCancelled':
        await redis.hset(`user:${event.userId}:dashboard`, {
          order_count: await redis.hincrby(`user:${event.userId}:dashboard`, 'order_count', -1),
        })
        break
    }
  }
}

// ✅ Query handler — reads directly from the pre-built read model (O(1) Redis lookup)
async function getOrderDashboard(userId: string): Promise<OrderDashboard> {
  const data = await redis.hgetall(`user:${userId}:dashboard`)
  return {
    orderCount:          Number(data.order_count ?? 0),
    lifetimeValueCents:  Number(data.lifetime_value_cents ?? 0),
    lastOrderAt:         data.last_order_at ?? null,
  }
  // No JOIN, no aggregation, no database — pure cache read
}

3.2 Projection Rebuilds

Because the event log is the source of truth, any projection can be rebuilt at any time:

TYPESCRIPT
// ✅ Rebuild — replay all events from offset 0 to reconstruct a new or changed projection
async function rebuildOrderDashboardProjection(): Promise<void> {
  // Step 1: Clear the existing read model
  await redis.flushdb()

  // Step 2: Subscribe from the beginning and replay all events
  await kafkaConsumer.subscribe({ topic: 'order.events', fromBeginning: true })
  await kafkaConsumer.run({
    eachMessage: async ({ message }) => {
      const event = JSON.parse(message.value!.toString()) as DomainEvent
      await projection.handle(event)
      // No commits during rebuild — processing is synchronous and sequential
    }
  })
}
Crucial Requirement

Projection schemas are disposable. Because the full event log is the source of truth, adding a new field to a projection (e.g., categories_purchased) requires replaying the log once — not migrating rows in a production table with live traffic. This is one of the most powerful operational properties of CQRS + Event Sourcing.


4. The Eventual Consistency Window

The lag between a write committing and its read projection updating is not a bug — it is a mandatory, explicit property of any asynchronous CQRS system.

Asynchronous projection pipeline showing command execution on PostgreSQL write DB, Kafka event streaming, projection worker processing, and the eventual consistency window before Elasticsearch read model queryability.
Asynchronous projection pipeline showing command execution on PostgreSQL write DB, Kafka event streaming, projection worker processing, and the eventual cons…

4.1 UX Patterns for Eventual Consistency

Scenario Pattern
User places order, immediately views dashboard Return 202 + optimistic UI update on client; show "processing" state until next poll confirms
Admin queries order count after bulk import Add "last updated" timestamp to projection; display staleness explicitly
Financial report requires strong consistency Query write store directly for the specific time window — bypass the projection

5. When CQRS Is and Is Not Appropriate

5.1 Apply CQRS When

Signal Explanation
Read/write traffic ratio > 10:1 Read path needs independent scaling and different optimizations
Query shapes are incompatible with normalized schema Dashboard requires 6-table JOIN; each query serves a different denormalized shape
Event-driven audit trail required All state changes must be recorded as business events for compliance
Multiple read models needed Same data accessed via different projections (search, reporting, API)

5.2 Do NOT Apply CQRS When

Signal Explanation
Small team (< 5 engineers) Operational overhead exceeds benefit
Simple CRUD domain No complex invariants to enforce; no projection divergence
Strong consistency required everywhere Eventual consistency window is not acceptable for the business
Domain model is still evolving rapidly Premature event schema locks in wrong assumptions

Summary

Architectural Concern Production Rule
Write Model Invariants Enforces invariants and emits domain events; optimized exclusively for state transitions, never used for reporting queries.
Read Model Projections Denormalized projections optimized for query shape; eventually consistent with write model.
Eventual Consistency Window The lag between write commit and projection materialization is explicit; UX must account for processing states.
Disposable Projections Projection schemas are disposable; replay the event log from offset 0 to rebuild or migrate read stores.
Optimistic Concurrency expected_version checks on write prevent lost updates without requiring pessimistic locks.

What's Next

Now that we have separated write models from read projections, Part 6: Event Sourcing with Kafka takes CQRS to its architectural conclusion: persisting domain state as an append-only sequence of immutable events with snapshot optimizations and Avro schema evolution.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#CQRS#System Design#Event-Driven#Backend#Architecture
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.