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
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.
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.