CQRS with Databases: Separate Read/Write Models Explained
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 (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.
INSERT 0 1
COMMIT
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 (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;
-------------------------------------+-----------+------------+-------------+----------------------+-------------------------
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
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 + 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()
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
| Aspect | Write Side (Command Model) | Read Side (Query Model) |
|---|---|---|
| Primary goal | Correctness and consistency | Speed and consumer convenience |
| Schema shape | Normalized — 3NF or higher | Denormalized — flat projections |
| Constraints | Foreign keys, check constraints, unique indexes | Minimal or none — data integrity guaranteed by events |
| Typical DB engine | PostgreSQL (OLTP, strong ACID) | PostgreSQL read replica, Redis, Elasticsearch, or DynamoDB |
| Indexing strategy | Indexes on write-path lookup keys | Covering indexes for exact read patterns; search indexes |
| Who writes to it | Application (via commands) | Event consumer / projection updater only |
| Who reads from it | Only for consistency checks in commands | API layer, dashboards, reporting tools |
| Schema migrations | Risky — ALTER TABLE on live write traffic | Safe — drop projection, replay events, swap in new schema |
| Scaling strategy | Vertical + connection pooling (PgBouncer) | Horizontal — multiple read replicas or sharded per projection |
| Consistency model | Strong (synchronous, ACID) | Eventual (asynchronous, updated via events) |
| Data freshness | Always current | Delayed by replication lag (typically 50ms–5s) |
| Rebuild possible? | No — it is the source of truth | Yes — 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.
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.