Home October 6, 2025 5 min read Architecture By Arunkumar Ganesan

CQRS in Production: One Write Path, Many Read Models

CQRS is useful when one part of the system must decide truth with strong consistency, while several other parts need fast, searchable, denormalized, and eventually consistent views of that truth.

One path decides The command side validates business rules and commits the authoritative change.
Reads can lag Read models are rebuilt from committed facts and expose freshness honestly.
Different stores are fine PostgreSQL, DynamoDB, OpenSearch, Redis, and analytics stores can each serve the path they fit.

I use CQRS when one path must protect the business decision and other paths need fast, flexible reads. The shallow version is writes here and reads there; the useful version is one write truth with deliberate projections.

Imagine an order platform. The checkout command has to protect inventory, payment authorization, duplicate submissions, refund rules, and audit history. A support screen has a different need: show customer orders, shipment status, payment state, notes, refunds, and risk flags in one fast page. A dashboard has another need: count stuck orders by region and payment provider. Trying to make one model do all of that usually makes the write path slower and the read path awkward.

CQRS earns its cost when the write model must stay strict, while the read side needs several shapes that would damage the write model if they lived there.

The production shape

Command API PlaceOrder Write model Validate rules Commit aggregate Write DB orders outbox_events Relay CDC or poller same transaction Projection workers Order status DynamoDB or Redis Support search OpenSearch Agent timeline Document store Analytics Warehouse or OLAP

Strong writes stay boring

When I talk about "Strong writes stay boring", I am checking whether CQRS in Production makes ownership, failure handling, or rollback clearer.

The write side should not fan out to search, dashboards, caches, and reporting databases during checkout. It should validate the command, commit the authoritative state, and record the fact that something changed. If two users race for the last item, the write side decides the winner with a transaction, conditional write, serializable isolation, or aggregate version check.

@Transactional
OrderReceipt placeOrder(PlaceOrder command) {
    if (idempotencyStore.seen(command.commandId())) {
        return receiptStore.load(command.commandId());
    }

    Order order = orders.loadForUpdate(command.orderId());
    order.place(command.customerId(), command.items(), command.expectedVersion());

    orders.save(order);
    outbox.insert(new OutboxEvent(
        UUID.randomUUID(),
        order.id(),
        order.version(),
        "OrderPlaced",
        json.writeValueAsString(order.toEventPayload())
    ));

    receiptStore.save(command.commandId(), order.id(), order.version());
    return new OrderReceipt(order.id(), order.version());
}

The command handler makes one production decision: the order state and the outbox event commit together. If the process dies after the transaction, the relay can still publish the event later because the event is already stored beside the write.

Reads can be purpose built

My recommendation in "Reads can be purpose built" is to write the operational cost beside the architecture.

The read side is allowed to lag, but it should not lie. The order confirmation can come from the command response. The order history page may catch up a moment later. The support index may lag by a second. The analytics dashboard may have a longer window. Each read path should expose freshness with a source version, last event id, or projection timestamp.

Customer order status Key lookup by order id. DynamoDB, PostgreSQL read table, or Redis can work.
Support search Search by email, phone number, refund state, shipment carrier, or payment status. OpenSearch fits better than the write schema.
Agent timeline Denormalized document view with order, payment, shipment, notes, and dispute events already stitched together.
Operations dashboard Aggregates by region, provider, minute, and status. A warehouse or OLAP database fits the query shape.

The replication contract

What I learnt around "The replication contract" is that a clean diagram is not enough if the failure path is vague.

The replication path is where CQRS either becomes reliable or becomes theater. Do not publish events from memory after writing the database. A crash in the gap loses the change. Use a transactional outbox, change data capture, database streams, or an event store. Then make every projector idempotent because delivery can happen more than once.

@Transactional
void project(OrderPlaced event) {
    int firstTime = jdbc.update("""
        insert into processed_projection_events(projection, event_id)
        values ('order_summary', ?)
        on conflict do nothing
        """, event.eventId());

    if (firstTime == 0) {
        return;
    }

    jdbc.update("""
        insert into order_summary(order_id, customer_id, status, total, source_version)
        values (?, ?, 'PLACED', ?, ?)
        on conflict (order_id) do update
          set status = excluded.status,
              total = excluded.total,
              source_version = excluded.source_version
        where order_summary.source_version < excluded.source_version
        """, event.orderId(), event.customerId(), event.total(), event.version());
}

The projector records the event id and updates the read model in one transaction. Duplicate delivery returns immediately, and older aggregate versions cannot overwrite newer read state.

A useful production projection tracks event id, aggregate id, aggregate version, processed time, retry count, dead letter failures, and current lag. Without those numbers, eventual consistency becomes guesswork.

Different databases are not the point, but they help

I use "Different databases are not the point, but they help" to test whether the pattern helps on a bad production day, not only in a design review.

CQRS does not require separate databases. Martin Fowler notes that the models may share a database or may communicate through separate databases. In production, separate stores become attractive when the query shape is genuinely different from the write shape.

Path Useful database choice Reason
Write model PostgreSQL, SQL Server, DynamoDB single table, or event store Strong command validation, conditional writes, aggregate versioning, and audit trail.
Low latency read DynamoDB, Redis, or a denormalized relational table Fast lookup by id or customer without joins during the request.
Search read OpenSearch or Elasticsearch Text search, filters, partial matches, and support workflows.
Analytical read ClickHouse, Pinot, Druid, BigQuery, Redshift, Snowflake Aggregations over time, region, product, provider, and status.

Where this pattern pays rent

The way I apply "Where this pattern pays rent" is to make the tradeoff explicit before the implementation spreads.

Use CQRS for a bounded context, not the whole company. It fits checkout, payments, booking, claims, fraud review, inventory reservation, and workflow systems where the write rules are strict and the read needs are wide. It is usually too much for a simple admin screen where CRUD is honest and enough.

The trade is clean when stated plainly: one place decides the truth; many places make that truth useful. The write side should be strict, small, transactional, and observable. The read side can be duplicated, denormalized, searchable, cached, rebuilt, and tuned for the people and systems that read it.

What I learnt is that CQRS succeeds only when the write path is boring, strict, and observable, and the read paths are allowed to lag with clear expectations.

#CQRS #Architecture #EventDrivenArchitecture #TransactionalOutbox #DistributedSystems #SoftwareEngineering