Home Database CQRS with Databases: Separate Read/Write Models Explained

CQRS with Databases: Separate Read/Write Models Explained

In Plain English 🔥
Imagine a busy library. One desk handles all book returns and new arrivals — it carefully updates every record, checks for duplicates, and logs everything precisely. A completely separate desk handles 'what books do you have?' questions — it uses a big, pre-sorted poster on the wall for instant answers instead of digging through the filing cabinet. CQRS does exactly this for your database: one path writes data carefully and correctly, a completely separate path reads it fast. They don't share the same table, the same model, or even the same database engine. That's the whole idea.
⚡ Quick Answer
Imagine a busy library. One desk handles all book returns and new arrivals — it carefully updates every record, checks for duplicates, and logs everything precisely. A completely separate desk handles 'what books do you have?' questions — it uses a big, pre-sorted poster on the wall for instant answers instead of digging through the filing cabinet. CQRS does exactly this for your database: one path writes data carefully and correctly, a completely separate path reads it fast. They don't share the same table, the same model, or even the same database engine. That's the whole idea.

Every system eventually hits the same wall: reads and writes want completely different things. Your write path needs strict consistency, complex validation, and transactional safety. Your read path needs denormalized, pre-joined data that returns in milliseconds without locking a single row. Shoving both responsibilities into one data model is like making your head librarian personally answer every 'where is the fiction section?' question between processing every book return. Something always suffers. In high-traffic production systems, it's usually reads — or worse, reads start killing writes through lock contention on shared tables.

Command Query Responsibility Segregation (CQRS) solves this by treating reads and writes as fundamentally different concerns at the database level, not just the application layer. It gives each side its own optimized data model, its own storage engine if needed, and its own scaling strategy. This isn't just an architectural buzzword — it's a direct response to the CAP theorem tensions, OLTP vs OLAP impedance mismatch, and the practical reality that read-to-write ratios in most production systems are anywhere from 10:1 to 1000:1.

By the end of this article you'll understand exactly how to design the write-side (command) and read-side (query) database models, how to build the synchronization layer between them without data loss, how to handle the eventual consistency window safely, and where CQRS genuinely helps versus where it adds complexity you don't need. You'll leave with runnable code, real schema designs, and the mental model to defend these decisions in a production architecture review.

The Write Side: Designing a Command Model That Protects Invariants

The command side of your database has one job: accept a change, validate it completely, and persist it durably. That means your write model is normalized, constraint-heavy, and optimized for correctness — not speed. You're not trying to return data fast here. You're trying to make sure that when you say 'this order is confirmed', it actually is, with no race conditions and no partial writes.

In practice, the write model typically lives in a relational database (PostgreSQL is a popular choice) with proper foreign keys, unique constraints, check constraints, and row-level locking where needed. The schema reflects your domain's invariants, not your UI's data needs. An 'Order' table doesn't have a denormalized customer name column — it has a foreign key to a Customers table because that constraint matters at write time.

The critical design decision is what you persist. Many teams using CQRS go one step further and adopt Event Sourcing on the write side — instead of storing the current state of an order, you store the sequence of events that produced it (OrderPlaced, PaymentReceived, OrderShipped). This makes the command model an append-only event log, which has profound implications: you get a full audit trail for free, replaying events rebuilds any read model, and you eliminate update contention entirely. Even if you don't use Event Sourcing, your write model should emit domain events after each successful command — these events are the bridge to your read side.

write_side_schema.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
-- ============================================================
-- WRITE SIDE (Command Model) — PostgreSQL
-- Normalized, constraint-heavy, optimized for correctness.
-- This schema is NEVER queried by the UI directly.
-- ============================================================

-- Core customer record — source of truth for identity
CREATE TABLE customers (
    customer_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    email           TEXT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- Enforce unique emails at the DB level, not just the app layer
    CONSTRAINT uq_customers_email UNIQUE (email)
);

-- Orders table — normalized, references customers by FK
CREATE TABLE orders (
    order_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id     UUID NOT NULL REFERENCES customers(customer_id),
    status          TEXT NOT NULL DEFAULT 'pending',
    total_cents     INTEGER NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- Prevent nonsense states at the DB level
    CONSTRAINT chk_orders_status CHECK (status IN ('pending','confirmed','shipped','cancelled')),
    CONSTRAINT chk_orders_total  CHECK (total_cents >= 0)
);

-- Order line items — fully normalized, no denormalized product names here
CREATE TABLE order_items (
    item_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id        UUID NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
    product_id      UUID NOT NULL,
    quantity        INTEGER NOT NULL,
    unit_price_cents INTEGER NOT NULL,
    CONSTRAINT chk_order_items_qty   CHECK (quantity > 0),
    CONSTRAINT chk_order_items_price CHECK (unit_price_cents > 0)
);

-- ============================================================
-- DOMAIN EVENTS TABLE — the outbox that feeds the read side
-- Every successful command inserts a row here atomically
-- in the same transaction as the state change.
-- ============================================================
CREATE TABLE domain_events (
    event_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type  TEXT NOT NULL,          -- e.g. 'Order'
    aggregate_id    UUID NOT NULL,          -- e.g. the order_id
    event_type      TEXT NOT NULL,          -- e.g. 'OrderConfirmed'
    payload         JSONB NOT NULL,         -- full event data
    occurred_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    published       BOOLEAN NOT NULL DEFAULT FALSE  -- outbox pattern flag
);

-- Index for the outbox poller — only scans unpublished events
CREATE INDEX idx_domain_events_unpublished
    ON domain_events (occurred_at)
    WHERE published = FALSE;

-- ============================================================
-- Example: confirming an order (run as a single transaction)
-- Both the state change AND the event are written atomically.
-- If either fails, both roll back — no lost events.
-- ============================================================
BEGIN;

    -- 1. Update the write-side state
    UPDATE orders
    SET    status     = 'confirmed',
           updated_at = now()
    WHERE  order_id   = 'a1b2c3d4-0000-0000-0000-000000000001'
    AND    status     = 'pending';   -- optimistic guard

    -- 2. Write the domain event in the SAME transaction (Outbox Pattern)
    INSERT INTO domain_events (aggregate_type, aggregate_id, event_type, payload)
    VALUES (
        'Order',
        'a1b2c3d4-0000-0000-0000-000000000001',
        'OrderConfirmed',
        '{"order_id": "a1b2c3d4-0000-0000-0000-000000000001", "confirmed_at": "2024-01-15T10:30:00Z"}'
    );

COMMIT;
-- Both rows land or neither does. The read side cannot miss this event.
▶ Output
UPDATE 1
INSERT 0 1
COMMIT
⚠️
Watch Out: The Dual-Write TrapNever update the write-side state AND publish to a message broker (Kafka, RabbitMQ) in two separate operations. If the app crashes between them, your read model diverges silently. The Outbox Pattern shown above — writing the event to a DB table in the same transaction, then having a separate poller publish it — is the production-safe solution. Debezium with PostgreSQL logical replication is the zero-polling alternative.

The Read Side: Denormalized Query Models Built for Your UI

The read side exists for one reason: return exactly the data a consumer needs in a single, cheap query. No joins. No aggregations at read time. No shared locks with the write side. The read model is a pre-computed view of your data, shaped specifically around how it will be consumed.

This is where CQRS pays its maintenance cost back with interest. Instead of every UI component issuing a 5-table join query, each feature has its own denormalized projection stored in a read-optimized store. An order summary page gets a flat 'order_summaries' view with customer name, item count, and total pre-joined. A customer history page gets a 'customer_order_history' projection sorted and paginated. Each projection is rebuilt by consuming the domain events emitted from the write side.

You're free to use different storage engines per projection. Order summaries might live in PostgreSQL with a covering index. A full-text product search projection might live in Elasticsearch. A real-time analytics projection might live in Redis sorted sets. This is called polyglot persistence — using the right tool for each specific read pattern instead of forcing one database to be everything to everyone.

The read model is also entirely disposable. Because it's derived from events, you can delete any projection and rebuild it by replaying the event log from the beginning. This is a superpower — it means schema migrations on the read side are never scary. Drop the old projection, deploy the new schema, replay events, cut over. No risky ALTER TABLE on a live system.

read_side_projections.sql · SQL
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
-- ============================================================
-- READ SIDE (Query Models) — PostgreSQL read replica
-- Denormalized. No foreign keys enforced. No joins needed at query time.
-- These tables are populated by the event consumer, NOT by the app.
-- ============================================================

-- Projection 1: Order Summary List
-- Powers the "My Orders" page — returns everything needed in ONE query
CREATE TABLE order_summary_projection (
    order_id            UUID PRIMARY KEY,
    customer_id         UUID NOT NULL,
    customer_email      TEXT NOT NULL,    -- denormalized from Customers
    customer_name       TEXT NOT NULL,    -- denormalized from Customers
    status              TEXT NOT NULL,
    item_count          INTEGER NOT NULL, -- pre-aggregated
    total_cents         INTEGER NOT NULL,
    first_item_name     TEXT,            -- denormalized first item for display
    created_at          TIMESTAMPTZ NOT NULL,
    last_updated_at     TIMESTAMPTZ NOT NULL
);

-- Covering index: all columns needed for the list query are in the index
-- This means Postgres can answer the query without touching the heap at all
CREATE INDEX idx_order_summary_customer_created
    ON order_summary_projection (customer_id, created_at DESC)
    INCLUDE (status, item_count, total_cents, first_item_name);

-- Projection 2: Customer Lifetime Value
-- Powers analytics — pre-aggregated so no GROUP BY at query time
CREATE TABLE customer_ltv_projection (
    customer_id         UUID PRIMARY KEY,
    customer_email      TEXT NOT NULL,
    total_orders        INTEGER NOT NULL DEFAULT 0,
    total_spent_cents   BIGINT  NOT NULL DEFAULT 0,
    last_order_at       TIMESTAMPTZ,
    first_order_at      TIMESTAMPTZ
);

-- ============================================================
-- Event consumer: applies domain events to keep projections current
-- This runs in a background worker that polls the domain_events outbox
-- (or listens to Kafka/RabbitMQ if you use a broker)
-- ============================================================

-- Handler for 'OrderConfirmed' event
-- Called by your event consumer with the raw event payload
CREATE OR REPLACE FUNCTION apply_order_confirmed_event(
    p_order_id      UUID,
    p_customer_id   UUID,
    p_confirmed_at  TIMESTAMPTZ
) RETURNS VOID AS $$
BEGIN
    -- Update the order summary projection status
    UPDATE order_summary_projection
    SET    status          = 'confirmed',
           last_updated_at = p_confirmed_at
    WHERE  order_id        = p_order_id;

    -- No need to touch order_items or customers — they're already denormalized
END;
$$ LANGUAGE plpgsql;

-- Handler for 'OrderPlaced' event — builds the initial projection row
CREATE OR REPLACE FUNCTION apply_order_placed_event(
    p_order_id          UUID,
    p_customer_id       UUID,
    p_customer_email    TEXT,
    p_customer_name     TEXT,
    p_item_count        INTEGER,
    p_total_cents       INTEGER,
    p_first_item_name   TEXT,
    p_placed_at         TIMESTAMPTZ
) RETURNS VOID AS $$
BEGIN
    -- INSERT ... ON CONFLICT makes this handler idempotent:
    -- replaying the same event twice won't create duplicate rows
    INSERT INTO order_summary_projection (
        order_id, customer_id, customer_email, customer_name,
        status, item_count, total_cents, first_item_name,
        created_at, last_updated_at
    ) VALUES (
        p_order_id, p_customer_id, p_customer_email, p_customer_name,
        'pending', p_item_count, p_total_cents, p_first_item_name,
        p_placed_at, p_placed_at
    )
    ON CONFLICT (order_id) DO NOTHING;  -- idempotent: safe to replay

    -- Also update or insert the customer LTV projection
    INSERT INTO customer_ltv_projection (
        customer_id, customer_email,
        total_orders, total_spent_cents,
        last_order_at, first_order_at
    ) VALUES (
        p_customer_id, p_customer_email,
        1, p_total_cents,
        p_placed_at, p_placed_at
    )
    ON CONFLICT (customer_id) DO UPDATE SET
        total_orders      = customer_ltv_projection.total_orders + 1,
        total_spent_cents = customer_ltv_projection.total_spent_cents + EXCLUDED.total_spent_cents,
        last_order_at     = GREATEST(customer_ltv_projection.last_order_at, EXCLUDED.last_order_at);
END;
$$ LANGUAGE plpgsql;

-- ============================================================
-- The actual query — no joins, no aggregations, blazing fast
-- ============================================================
SELECT
    order_id,
    status,
    item_count,
    total_cents,
    first_item_name,
    created_at
FROM  order_summary_projection
WHERE customer_id = 'cust-uuid-here'
ORDER BY created_at DESC
LIMIT 20;
▶ Output
order_id | status | item_count | total_cents | first_item_name | created_at
-------------------------------------+-----------+------------+-------------+----------------------+-------------------------
a1b2c3d4-0000-0000-0000-000000000001 | confirmed | 3 | 4599 | Mechanical Keyboard | 2024-01-15 10:28:00+00
b2c3d4e5-0000-0000-0000-000000000002 | pending | 1 | 1299 | USB-C Hub | 2024-01-14 08:15:00+00
(2 rows)

Time: 0.842 ms <-- single index scan, no joins
⚠️
Pro Tip: Make Every Event Handler IdempotentYour event consumer WILL process the same event twice eventually — network retries, consumer restarts, at-least-once delivery guarantees. Every handler must produce the same result whether it runs once or ten times. The ON CONFLICT DO NOTHING / DO UPDATE pattern shown above is your best friend. Store a processed_event_ids table as a second safeguard for events that don't map cleanly to upsert semantics.

Synchronization, Eventual Consistency, and the Replication Lag Problem

Here's the uncomfortable truth that many CQRS tutorials gloss over: after a command succeeds, there is a window — however brief — where your read model is stale. A user places an order, the write side commits, then they immediately refresh their order list and see nothing. This isn't a bug — it's eventual consistency by design. But if you don't handle it explicitly, your users will think your system is broken.

The synchronization pipeline has several moving parts, each with its own failure mode. The Outbox poller (or Debezium CDC process) reads unpublished events, publishes them to a broker, and marks them published. The consumer reads from the broker and applies events to projections. Each step introduces latency. Under normal conditions this is 50-500ms. Under load, broker backpressure, or a consumer restart, it can be seconds or minutes.

Production strategies for managing this lag window are critical. The simplest is 'read-your-writes consistency': after a successful command, the API response includes the current state of the relevant data (pulled from the write side, just this once) or a version number the client can poll against. The client-side strategy is to optimistically update the UI immediately on command success and reconcile when the read model catches up — exactly what every modern frontend framework does with optimistic updates.

For the synchronization infrastructure itself, you have two main patterns: the polling Outbox (simple, works with any DB) and Change Data Capture with Debezium (zero-polling, sub-100ms lag, but adds operational complexity). In both cases, the event consumer must track its position (a cursor or Kafka offset) durably so it can resume after a crash without reprocessing from the beginning or missing events.

outbox_poller_and_event_consumer.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
# ============================================================
# Outbox Poller + Event Consumer — Python with psycopg2
# Runs as a separate background service alongside your main app.
# Polls the domain_events outbox, publishes to a channel,
# and applies events to read-side projections.
# ============================================================

import psycopg2
import psycopg2.extras
import json
import time
import logging
from datetime import datetime, timezone
from typing import Dict, Callable, Any

logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger(__name__)

# ── Database connection settings ──────────────────────────────
WRITE_DB_DSN = "postgresql://app_user:secret@write-db:5432/orders_write"
READ_DB_DSN  = "postgresql://app_user:secret@read-db:5432/orders_read"

# ── How many events to process in one polling batch ──────────
BATCH_SIZE = 100

# ── Pause between polls when no events are pending (seconds) ─
IDLE_SLEEP_SECONDS = 0.5


def fetch_unpublished_events(write_conn, batch_size: int) -> list:
    """Pull a batch of unpublished events from the outbox, ordered by time.
    We use SELECT ... FOR UPDATE SKIP LOCKED so multiple poller instances
    never process the same event — safe for horizontal scaling."""
    with write_conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cursor:
        cursor.execute("""
            SELECT event_id, aggregate_type, aggregate_id,
                   event_type, payload, occurred_at
            FROM   domain_events
            WHERE  published = FALSE
            ORDER  BY occurred_at ASC
            LIMIT  %s
            FOR UPDATE SKIP LOCKED
        """, (batch_size,))
        return cursor.fetchall()


def mark_events_published(write_conn, event_ids: list) -> None:
    """Mark the batch as published only AFTER the read side has committed.
    If the read-side apply fails, these stay unpublished and will be retried."""
    with write_conn.cursor() as cursor:
        cursor.execute("""
            UPDATE domain_events
            SET    published = TRUE
            WHERE  event_id  = ANY(%s)
        """, (event_ids,))
    write_conn.commit()  # commit the mark-published update


def apply_order_placed(read_conn, payload: Dict[str, Any]) -> None:
    """Update the order_summary_projection and customer_ltv_projection.
    Uses ON CONFLICT to be idempotent — safe to replay."""
    with read_conn.cursor() as cursor:
        cursor.execute("""
            SELECT apply_order_placed_event(
                %s::uuid, %s::uuid, %s, %s, %s, %s, %s, %s::timestamptz
            )
        """, (
            payload['order_id'],
            payload['customer_id'],
            payload['customer_email'],
            payload['customer_name'],
            payload['item_count'],
            payload['total_cents'],
            payload.get('first_item_name'),
            payload['placed_at']
        ))
    read_conn.commit()
    logger.info(f"Applied OrderPlaced for order {payload['order_id']}")


def apply_order_confirmed(read_conn, payload: Dict[str, Any]) -> None:
    """Update status in the order summary projection."""
    with read_conn.cursor() as cursor:
        cursor.execute("""
            SELECT apply_order_confirmed_event(%s::uuid, %s::uuid, %s::timestamptz)
        """, (
            payload['order_id'],
            payload['customer_id'],
            payload['confirmed_at']
        ))
    read_conn.commit()
    logger.info(f"Applied OrderConfirmed for order {payload['order_id']}")


# ── Registry mapping event_type strings to handler functions ─
# Adding a new event type is one line here + one handler function.
EVENT_HANDLERS: Dict[str, Callable] = {
    'OrderPlaced':     apply_order_placed,
    'OrderConfirmed':  apply_order_confirmed,
    # 'OrderShipped':  apply_order_shipped,   # add as needed
    # 'OrderCancelled': apply_order_cancelled,
}


def run_outbox_poller():
    """Main loop: poll → apply → mark published. Runs forever."""
    write_conn = psycopg2.connect(WRITE_DB_DSN)
    read_conn  = psycopg2.connect(READ_DB_DSN)
    write_conn.autocommit = False  # we control transactions manually
    read_conn.autocommit  = False

    logger.info("Outbox poller started — watching for domain events...")

    while True:
        try:
            # Open a transaction on the write side to lock the batch
            events = fetch_unpublished_events(write_conn, BATCH_SIZE)

            if not events:
                write_conn.rollback()  # release the FOR UPDATE lock
                time.sleep(IDLE_SLEEP_SECONDS)
                continue

            logger.info(f"Processing batch of {len(events)} event(s)")
            processed_ids = []

            for event in events:
                event_type = event['event_type']
                payload    = event['payload']  # already a dict from JSONB

                handler = EVENT_HANDLERS.get(event_type)

                if handler is None:
                    # Unknown event type — log and skip (don't crash the poller)
                    logger.warning(f"No handler for event type '{event_type}' — skipping")
                    processed_ids.append(event['event_id'])
                    continue

                try:
                    # Apply the event to the read-side projection
                    handler(read_conn, payload)
                    processed_ids.append(event['event_id'])

                except Exception as apply_error:
                    # Read-side apply failed — rollback read side, stop batch.
                    # These events remain unpublished and will retry next poll.
                    read_conn.rollback()
                    write_conn.rollback()
                    logger.error(
                        f"Failed to apply event {event['event_id']} "
                        f"({event_type}): {apply_error}"
                    )
                    break  # stop this batch, retry after sleep

            if processed_ids:
                # Only mark as published AFTER read side committed successfully
                mark_events_published(write_conn, processed_ids)
                logger.info(f"Marked {len(processed_ids)} event(s) as published")

        except Exception as poll_error:
            logger.error(f"Poller error: {poll_error}")
            try:
                write_conn.rollback()
                read_conn.rollback()
            except Exception:
                pass
            time.sleep(2)  # back off before retrying


if __name__ == '__main__':
    run_outbox_poller()
▶ Output
2024-01-15 10:30:00,001 INFO Outbox poller started — watching for domain events...
2024-01-15 10:30:00,542 INFO Processing batch of 2 event(s)
2024-01-15 10:30:00,581 INFO Applied OrderPlaced for order a1b2c3d4-0000-0000-0000-000000000001
2024-01-15 10:30:00,612 INFO Applied OrderConfirmed for order a1b2c3d4-0000-0000-0000-000000000001
2024-01-15 10:30:00,643 INFO Marked 2 event(s) as published
2024-01-15 10:30:01,144 INFO Processing batch of 0 event(s)
-- (sleeps 0.5s, then polls again)
2024-01-15 10:30:01,645 INFO Processing batch of 1 event(s)
2024-01-15 10:30:01,678 INFO Applied OrderPlaced for order b2c3d4e5-0000-0000-0000-000000000002
2024-01-15 10:30:01,699 INFO Marked 1 event(s) as published
🔥
Interview Gold: Debezium vs. Outbox PollingInterviewers love this trade-off. The Outbox Poller is simple, DB-agnostic, and needs no extra infrastructure — but it adds write load from polling and has minimum latency equal to your sleep interval. Debezium with PostgreSQL logical replication reads the WAL directly, achieving sub-100ms lag with zero polling overhead — but it requires a replication slot, careful WAL retention settings, and another service to operate. For most teams, start with the Outbox Poller. Graduate to Debezium when you can measure that you need it.
AspectWrite Side (Command Model)Read Side (Query Model)
Primary goalCorrectness and consistencySpeed and consumer convenience
Schema shapeNormalized — 3NF or higherDenormalized — flat projections
ConstraintsForeign keys, check constraints, unique indexesMinimal or none — data integrity guaranteed by events
Typical DB enginePostgreSQL (OLTP, strong ACID)PostgreSQL read replica, Redis, Elasticsearch, or DynamoDB
Indexing strategyIndexes on write-path lookup keysCovering indexes for exact read patterns; search indexes
Who writes to itApplication (via commands)Event consumer / projection updater only
Who reads from itOnly for consistency checks in commandsAPI layer, dashboards, reporting tools
Schema migrationsRisky — ALTER TABLE on live write trafficSafe — drop projection, replay events, swap in new schema
Scaling strategyVertical + connection pooling (PgBouncer)Horizontal — multiple read replicas or sharded per projection
Consistency modelStrong (synchronous, ACID)Eventual (asynchronous, updated via events)
Data freshnessAlways currentDelayed by replication lag (typically 50ms–5s)
Rebuild possible?No — it is the source of truthYes — replay all events to rebuild any projection from scratch

🎯 Key Takeaways

  • The write model enforces invariants; the read model serves consumers — they have opposite optimization goals and should never share the same table structure or storage engine.
  • The Outbox Pattern — writing domain events in the same transaction as state changes — is the only reliable way to prevent the dual-write problem and guarantee your read model never silently diverges.
  • Every event handler in your consumer must be idempotent by design, not by hope. At-least-once delivery is a guarantee from every serious message broker — double processing is not a theoretical edge case.
  • Read projections are disposable by design. The ability to drop and rebuild any projection from the event log is CQRS's most underrated superpower — it turns read-side schema migrations from a production risk into a routine operation.

⚠ Common Mistakes to Avoid

  • Mistake 1: Querying the write-side database from the read path — Symptom: your read queries start causing lock contention on the write tables, SELECT COUNT(*) takes 4 seconds during order processing, and your ops team sees VACUUM struggling. Fix: enforce strict separation at the connection string level — your read API service should have credentials that only have SELECT on read-side tables and zero access to write-side tables. Use two separate database users enforced at the PostgreSQL role level, not just a naming convention.
  • Mistake 2: Building one giant 'universal' read projection that serves every query — Symptom: your projection table has 60 columns, half of which are NULL for most rows, and you're doing JSONB blobs to handle cases that don't fit. Queries still need 4-way joins to assemble a response. Fix: projections should be one-to-one with query patterns, not one-to-many. The order list page gets its own projection. The admin analytics dashboard gets its own. Build as many projections as you have distinct query shapes — storage is cheap, query performance isn't.
  • Mistake 3: Not handling idempotency in event consumers — Symptom: during a Kafka consumer group rebalance or a pod restart, events get processed twice. Your customer_ltv_projection shows double the actual spend. Totals are wrong. Fix: every projection update function must be idempotent. Use INSERT ... ON CONFLICT DO UPDATE for additive projections. For strictly additive counters, store the last processed event_id per projection and skip events you've already seen. Never use UPDATE ... SET total = total + 1 without an idempotency guard — that statement is not safe to replay.

Interview Questions on This Topic

  • QHow does CQRS handle the consistency problem when a user submits a command and immediately queries the read model — what patterns exist to bridge that gap, and what are their trade-offs?
  • QIf your read-side projection gets corrupted or falls behind by several thousand events, how do you safely rebuild it in production without downtime, and what does that process look like in a system using the Outbox Pattern?
  • QA colleague says 'we should use CQRS for our user authentication service to improve read performance'. How would you respond, and what criteria would you use to decide whether CQRS is genuinely appropriate for a given service?

Frequently Asked Questions

Do I need Event Sourcing to implement CQRS?

No — they're complementary but independent patterns. CQRS just means separating your read and write models. You can implement it with a traditional state-based write database (PostgreSQL with normal UPDATE statements) and use the Outbox Pattern to emit events that sync to read projections. Event Sourcing (storing events as the primary record instead of current state) pairs naturally with CQRS and adds an audit log and replayability, but it carries significant additional complexity and isn't required.

How much eventual consistency lag is acceptable in a CQRS system?

That entirely depends on your domain. For an e-commerce order list, 500ms is invisible to users. For a bank balance display after a transfer, even 2 seconds may be unacceptable. The right answer is: measure your actual lag under realistic load, then decide per-feature whether to accept it, use read-your-writes consistency (serve the write-side response once after a command), or use optimistic UI updates on the client. There's no universal number.

When should I NOT use CQRS?

CQRS adds real operational complexity — you're now maintaining two data models, a synchronization pipeline, idempotent consumers, and eventual consistency handling. Don't use it for simple CRUD services where reads and writes have the same shape, low-traffic internal tools, or any domain where the team isn't ready to operationalize the event pipeline. The clearest signals that CQRS is worth it: read-to-write ratio above 10:1, distinct read patterns per consumer (dashboards vs. lists vs. search), or a need for a full audit trail of state changes.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousApache HBase BasicsNext →Multi-version Concurrency Control
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged