How do you keep read and write models in sync in CQRS?
Depends on whether you have one database or two.
Scenario 1 — Same database (no sync needed)
The most common, lowest-complexity form of CQRS. Read and write code paths are separated, but both query the same physical schema. No projection, no event bus, no eventual consistency.
// Write — adds an order
public class PlaceOrderHandler {
public async Task Handle(PlaceOrderCommand cmd) {
db.Orders.Add(order);
await db.SaveChangesAsync();
}
}
// Read — queries the same DB but with a different shape
public class GetOrderHandler {
public async Task<OrderDto> Handle(GetOrderQuery q) {
return await db.Orders
.Where(o => o.Id == q.Id)
.Select(o => new OrderDto(o.Id, o.Customer.Name, ...)) // projection
.FirstOrDefaultAsync();
}
}
Both see the same data immediately. No sync problem because there's nothing to sync.
Scenario 2 — Separate read DB (eventual consistency)
When read load justifies a denormalized read store (Elastic, Mongo, read replicas, separate Postgres with materialized views). The write DB and read DB are different.
The standard sync mechanism is domain events emitted by command handlers + a projector that updates the read model.
Step 1 — Emit events from command handlers (use the outbox pattern)
public class PlaceOrderHandler {
public async Task<Guid> Handle(PlaceOrderCommand cmd) {
var order = new Order(cmd);
db.Orders.Add(order);
// Outbox: same transaction → atomic
db.OutboxEvents.Add(new OutboxEvent {
Topic = "order.placed",
Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id, ...))
});
await db.SaveChangesAsync();
return order.Id;
}
}
Step 2 — A relay publishes outbox events to the bus
public class OutboxRelay : BackgroundService {
protected override async Task ExecuteAsync(CancellationToken stop) {
while (!stop.IsCancellationRequested) {
var batch = await db.OutboxEvents
.Where(e => e.SentAt == null)
.Take(100)
.ToListAsync();
foreach (var ev in batch) {
await bus.PublishAsync(ev.Topic, ev.Payload);
ev.SentAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync();
await Task.Delay(250, stop);
}
}
}
Step 3 — Projectors subscribe and update the read model
public class OrderProjector : BackgroundService {
protected override async Task ExecuteAsync(CancellationToken stop) {
await foreach (var ev in bus.SubscribeAsync<OrderPlaced>("orders-read", stop)) {
// Upsert into the read DB
await readDb.OrderListItems.UpsertAsync(new OrderListItem {
Id = ev.OrderId,
CustomerName = ev.CustomerName,
Total = ev.Total,
PlacedAt = ev.At
});
}
}
}
What this gives you
- ✅ Read DB optimized however you want (denormalized, Mongo, Elastic, etc.)
- ✅ Independent scaling
- ✅ Write side never blocked by slow read processing
- ✅ Audit trail by design (outbox events log every state change)
The trade-off — eventual consistency
There's a window (typically 100ms - 2s) where the write succeeded but the read model hasn't caught up. The user sees:
- POST /orders/place → 201 Created
- GET /orders → their order is missing
UX strategies to handle this:
| Strategy | Description |
|---|---|
| Optimistic UI | Show the new order in the UI immediately based on the POST response, before fetching the list |
| Read-your-writes routing | Route the next read by the same user to the write DB temporarily |
| Wait + retry | Client polls until the order appears (~5s window) |
| Push notification | Use SignalR / SSE to push "your order is now visible" |
Production essentials
- Outbox pattern for reliable event publishing — never trust an inline bus publish
- Inbox pattern on the projector for idempotent consumption (dedup duplicate events)
- Lag monitoring — alert when projection lag exceeds your SLO (5 seconds is typical)
- Replay capability — be able to rebuild the read model from scratch if it gets corrupted
- Schema versioning on events — old events must still be processable after schema changes
Anti-patterns
- Inline bus publish without outbox → events lost on crash between DB commit and bus publish
- Non-idempotent projectors → duplicate events cause double counters / duplicate rows
- Reading from the write DB by accident in query handlers → defeats the point of the separate read store
- Letting the projector touch business rules → projectors should be dumb data movers
Interview-grade summary
"Same-DB CQRS needs no sync — both halves see the same data. Separate-DB CQRS needs domain events emitted via the outbox pattern, a relay to publish to the bus, and idempotent projectors to update the read model. Eventual consistency window is ~100ms to 2s; handle it in UX via optimistic updates or read-your-writes routing."