Level 3 · 30 min
CQRS with Events
CQRS (Command Query Responsibility Segregation) separates the write model (commands that mutate state) from the read model (queries that return data). When combined with an event-driven architecture, domain events from the write side drive projections on the read side. This enables massive scale and flexibility at the cost of eventual consistency.
Command-Query Split
In traditional architectures, the same model serves reads and writes — optimizing for one limits the other. CQRS splits them: commands (CreateOrder, UpdatePaymentStatus) mutate state via the write model. Queries (GetOrderSummary, ListCustomerOrders) read from the read model. Each can use different storage — the write model might use a normalized relational DB for integrity; the read model uses denormalized tables, Elasticsearch, or Redis for fast queries. Command handlers validate, apply business rules, and emit domain events. Query handlers simply fetch from the read store. The command pipeline: HTTP request → Command object → Command validation (syntax, auth) → Command handler → domain aggregate → event(s) emitted → events persisted (event store or DB) → events published (Kafka/bus) → response to caller (202 Accepted, not 200 OK — the read model isn't updated yet). Critically: command handlers return a command ID or correlation ID, not the updated resource — the caller polls or subscribes for the result. This is intentional: the write model doesn't know what the read model looks like. Command idempotency key: include a client-generated idempotency key in every command. The handler checks a processed_commands table — duplicate keys return the cached result without reprocessing. Prevents double-submits from retrying clients.
Read Model Projections
Projections are event handlers that consume domain events and update the read model. When OrderPlaced fires, the OrderSummaryProjection creates a new row in the order_summaries table. When PaymentReceived fires, it updates the payment_status column. Projections are rebuilt from scratch by replaying all events — enabling schema evolution without migrations. Projection idempotency is mandatory: at-least-once event delivery means the same event can be delivered twice. Each projection handler must be idempotent: use `INSERT ... ON CONFLICT DO NOTHING` or `UPSERT` with the event's stream position as the idempotency key. Store the projection's checkpoint (`last_processed_position`) in the same DB transaction as the read model update — if the projection crashes after updating the read model but before saving the checkpoint, it replays from the last checkpoint and the idempotent upsert silently handles the re-apply. Projection rebuild strategy: (1) create a shadow table (e.g., `order_summaries_v2`), (2) replay all events into the shadow table via a one-shot job, (3) atomic table swap (`ALTER TABLE order_summaries RENAME TO order_summaries_old; ALTER TABLE order_summaries_v2 RENAME TO order_summaries;`), (4) drop old table. Zero downtime migration, no locks on the live table during rebuild. Key insight from Designing Data-Intensive Applications: "The traditional approach to database and schema design is based on the fallacy that data must be written in the same form as it will be queried. Debates about normalization and denormalization become largely irrelevant if you can translate data from a write-optimized event log to read-optimized application state: it is entirely reasonable to denormalize data in the read-optimized views, as the translation process gives you a mechanism for keeping it consistent with the event log." Kleppmann calls this CQRS approach "unbundling" the database — using a durable event log as the integration point between specialized write and read stores, each optimized for its own access patterns. The projection rebuild strategy (shadow table → replay → atomic swap) is the practical implementation of Kleppmann's insight that derived views are disposable: the event log is the source of truth, and any read model can be recreated from it.
Eventual Consistency and Integration
The write model and read models are eventually consistent — there's a measurable lag between a command being processed and the read model reflecting it. Under normal load: 50-200ms (Kafka consumer lag). Under peak load or projection worker restarts: minutes. A trading platform experienced a 2-hour projection lag during a market spike — users placing orders saw stale portfolio positions, leading to over-buying on perceived available balance. The lag was invisible in the UI. Fix: surface lag as a first-class UX concern. Strategies: (1) Optimistic updates — after a successful command, the UI immediately shows the expected new state (derived locally) without waiting for the read model. If the server-side result differs, reconcile on next poll. (2) Read-your-writes consistency — after a command, include the event position in the response. The query endpoint accepts `?minPosition=X` and waits (up to 500ms) for the projection to catch up to position X before returning. (3) Version polling — client polls with exponential backoff until the read model version matches the expected version from the command response. Production configs for projection workers: process events in micro-batches (50-100 at a time), commit checkpoint after each batch, tune consumer `fetch.min.bytes` and `fetch.max.wait.ms` for throughput. Monitor `projection_lag_seconds` as a Prometheus gauge — alert at 30s, page at 5 minutes.
Code example
// Command handler: idempotent write model
@CommandHandler
CommandResult handle(CreateOrderCommand cmd) {
// Idempotency: skip if already processed
if (commandLog.exists(cmd.getIdempotencyKey())) {
return commandLog.getResult(cmd.getIdempotencyKey());
}
Order order = Order.create(cmd); // validate + raise events
// Persist events with optimistic concurrency (expected version)
long newVersion = eventStore.append(
order.getId(), order.getUncommittedEvents(), order.getVersion() - 1);
commandLog.record(cmd.getIdempotencyKey(), newVersion);
// Events published via outbox — not here directly
return CommandResult.accepted(order.getId(), newVersion);
}
// Projection: idempotent upsert with checkpoint
@EventHandler
@Transactional
void on(OrderPlaced event, long position) {
// Upsert: safe to replay — ON CONFLICT DO UPDATE is idempotent
orderSummaryRepo.upsert(OrderSummaryView.from(event));
checkpointStore.save("order-summary-projection", position);
// Both writes in same transaction: atomically consistent checkpoint
}