CQRS and materialized views
CQRS and materialized views explained for interviews. Feeding read models by CDC or events, idempotent projectors, rebuilds, lag and read-your-writes.
- Read first
- Queues and streamsConsistency models
CQRS (command query responsibility segregation) splits a system’s data into two sides. Commands change a write model, shaped to enforce the rules. Queries read read models, each shaped for one screen or one question. The read models are built from the write model’s changes, and kept in step by a stream of events. A read model that is computed and stored ahead of the query is a materialized view.
The hard part is the stream between the two sides. Views run behind. Events arrive twice, or out of order. Views drift. And users save something, and then do not see it.
The problem it solves
Section titled “The problem it solves”An order service runs on one Postgres primary. It takes 300 writes a second: orders placed,
paid, shipped, cancelled. The schema is normalized for the write side: orders, order_items,
payments and shipments, with constraints that stop an order being shipped before it is paid.
That shape is right for writes. It is wrong for nearly every read:
| Read | Rate | What it needs | Cost on the write schema |
|---|---|---|---|
| “My orders” page | 20,000/s | One customer’s orders, newest first, with item names and status | A 4-table join, 20 to 40 ms, on the primary or a replica |
| Seller dashboard | 50/s | Revenue per day per status for the last 90 days | An aggregate over about 2 million rows: 2 to 5 s per query |
| Product search | 3,000/s | Free text, typo tolerance, facets by brand and price | Not possible with B-tree indexes; LIKE '%term%' scans the table |
| Support console | 20/s | Every event for one order, with who did what | Several tables stitched together by timestamp |
Each read can be helped with an index. But every index slows every write, and the shapes conflict. The dashboard wants rows grouped by seller and day. The “my orders” page wants them by customer. And search wants an inverted index the database does not have. Read replicas copy the same schema. So they add capacity, but no new shapes.
CQRS gives each read its own store, in the shape that read wants, answered in one lookup. It feeds them all from the write side’s changes. The write model stays the strongly consistent source of truth. Every read model is eventually consistent, behind by the pipeline’s lag. The name comes from Bertrand Meyer’s command-query separation, applied to whole systems by Greg Young around 2010.
How it works
Section titled “How it works”Step 1: Denormalize inside the same transaction
Section titled “Step 1: Denormalize inside the same transaction”The cheapest read model is a column or table updated in the same transaction as the write. An
order_count and last_order_at on the customer row, or an order_summaries table with one
row per order holding exactly what the “my orders” page shows. The write path updates it in the
same BEGIN ... COMMIT, by application code or a trigger.
It has no lag. It costs write latency, write amplification, and coupling. The order service owns every read shape. And the summary shares the CPU and disks of the writes it was meant to relieve. A counter that every order updates (sales per seller per day) also becomes a hot row that serializes writes. The payment system’s ledger runs into this with its balance rows.
Step 2: Materialized views the database maintains
Section titled “Step 2: Materialized views the database maintains”Most databases can store a query’s result, and refresh it. In Postgres,
CREATE MATERIALIZED VIEW seller_daily AS SELECT ... runs the query once, and stores the rows.
REFRESH MATERIALIZED VIEW CONCURRENTLY seller_daily recomputes it without blocking readers. (It
needs a unique index on the view.) A cron job refreshes it every 5 minutes. And the dashboard reads a
few hundred rows, instead of aggregating 2 million.
Postgres recomputes the whole query on every refresh. So the cost grows with the data, not with the changes. A refresh that took 4 seconds at launch takes 4 minutes two years later. And staleness is the refresh interval plus the refresh time.
Go deeperViews the database maintains incrementally1 min
Some engines apply each change to the view, instead of recomputing it. Examples are Oracle’s fast refresh with materialized view logs, SQL Server’s indexed views, and ClickHouse’s materialized views (which run on each inserted block). So do streaming databases, such as Materialize and RisingWave. Incremental maintenance limits which queries are allowed: joins and aggregates, with restrictions. And it still runs on the database it reads from. So it removes the refresh cost, but not the shared resources.
Step 3: A separate read store, fed by changes
Section titled “Step 3: A separate read store, fed by changes”Sometimes a read needs a different engine (a search index, Redis, a columnar store), or more capacity than the source can give. Then the view moves out. Every committed change flows through a log. One projector per view consumes the log, and writes the view.
- Color key
- Service
- Database
The service must not write the read store itself as a second write after the commit. That is a dual write, and a crash or a race between two updates leaves the view wrong forever, as the databases page explains. The log is fed in one of two ways, both covered in detail elsewhere:
- Change data capture (CDC). A connector such as Debezium reads the database’s own replication log: the Postgres WAL through a logical replication slot, or the MySQL binlog. It publishes every committed row change. Nothing in the service changes, and nothing committed is missed.
- Domain events through an outbox. The service writes an event row (“OrderPaid, order 9, v2, amount 48”) in the same transaction as the change. A relay or CDC publishes it. The transactional outbox gives at-least-once publishing. Distributed transactions shows it with the inbox on the receiving side.
A CDC row change couples every view to the write schema. A domain event is a contract the team versions. Many systems run CDC on the outbox table only. That gives CDC’s reliability, plus an event’s contract.
Step 4: The projector: ordered, idempotent, checkpointed
Section titled “Step 4: The projector: ordered, idempotent, checkpointed”A projector is a consumer, so the usual delivery rules hold: at least once, in order within a partition. Three duties are specific to a view.
Ordered per key. The log is partitioned by order id, so one consumer reads order 9’s events in order. Order across orders is not needed (what per-key order costs).
Idempotent by a version per key. Each event carries the entity’s version: a counter the write model increments, or the commit LSN. Each view row stores the version it holds. An event that is not newer is skipped.
The usual idempotent consumer is an inbox of event ids. It stops repeats. But it accepts an older event it has never seen. That’s what a snapshot load overlapping the log delivers, or a second feed path. The version check rejects both, with no table of seen ids.
Checkpointed with the view. The projector saves the offset it has applied, in the same transaction as the view rows. So the view and its position never disagree, and a restart seeks there. A broker-side commit also works, since the version check absorbs the replay after a crash. But rebuilds and reconciliation need the view’s own position.
The event carries the order’s whole new status, so a gap in versions is harmless. A view that adds deltas (“one more PAID, one fewer PLACED”) is right only if every transition is applied exactly once. The leaderboard and the ad click aggregator both write absolute totals to their views for exactly this reason.
Step 5: Rebuild a view from the log
Section titled “Step 5: Rebuild a view from the log”A view is a function of the log. A projector bug, a new field or a new shape is fixed by replaying from the start: a read model is disposable. A safe rebuild never edits the live view:
- Color key
- Service
- Database
Three things decide whether a rebuild is possible and how long it takes:
- The log must still hold the history. Kafka keeps 7 days by default. A compacted topic keeps the latest event per key forever. That’s enough for state-carrying events. It’s not enough for a view that needs every transition, like counts per day, or an audit trail. For those, archive raw events to object storage. Or start from a source snapshot, plus the log from its position, as the search reindex does.
- Throughput is per partition. Take 20,000 events a second per partition, a typical batched rate. (The range is wide, see At scale.) With 48 partitions, 2 billion events take about 35 minutes. On one partition, more than a day.
- It must not starve live traffic. Throttle it, and load the new store with replicas and refreshes off.
The snippet runs that sequence in miniature. A v1 projector without the version check let a late copy of o1’s v2 overwrite v3. Projector v2 replays into a shadow copy, chases live traffic to the head, is compared with v1, and is swapped in by moving one reference.
import java.util.*;
// Fixing a projection the safe way. The live view was built by a v1 projector that applied every// event as it came, so a late, older copy of an event sent an order back in time. The fix is a v2// projector with a version check per key, replayed from offset 0 into a shadow copy while v1 keeps// serving reads, then compared and swapped in with one atomic switch.public class Projector { // State-carrying: the event holds the order's whole new status, not a delta, so applying the // newest version is enough and a duplicate or an older copy can simply be skipped. record Event(long offset, String orderId, long version, String status) {}
record Row(long version, String status) {}
// One copy of the read model. In production a table, a Redis keyspace or a search index; // everything one handle() call changes here is one transaction in that store. static class ViewStore { final Map<String, Row> rows = new TreeMap<>(); final Map<String, Integer> countByStatus = new TreeMap<>(); long checkpoint = -1; // last offset applied, saved with the rows: resume at checkpoint + 1
@Override public String toString() { StringJoiner out = new StringJoiner(", "); rows.forEach((k, r) -> out.add(k + " " + r.status() + " v" + r.version())); return out + " | counts " + countByStatus; } }
static class OrderProjector { final ViewStore store; final boolean checkVersions; int applied, rejected;
OrderProjector(ViewStore store, boolean checkVersions) { this.store = store; this.checkVersions = checkVersions; }
void handle(Event e) { Row cur = store.rows.get(e.orderId()); // The version check: skip anything not newer than what the view holds for this key. // It rejects a relay's duplicate (same version) and a late copy (older version) alike. if (checkVersions && cur != null && e.version() <= cur.version()) { rejected++; } else { // Counts follow the row's state (leave the old status, enter the new one), never a // blind +1, so a skipped event moves nothing and a rebuild lands on the same totals. if (cur != null) store.countByStatus.merge(cur.status(), -1, Projector::sum); store.countByStatus.merge(e.status(), 1, Projector::sum); store.rows.put(e.orderId(), new Row(e.version(), e.status())); applied++; } store.checkpoint = e.offset(); // same transaction as the row }
void catchUp(List<Event> log) { for (long o = store.checkpoint + 1; o < log.size(); o++) handle(log.get((int) o)); } }
// The name readers resolve: a search alias, a view name, a config key. Moving it is one write, // so no reader ever sees a half-built copy, and moving it back is the rollback. static class ReadAlias { volatile ViewStore current;
ReadAlias(ViewStore store) { current = store; } }
// A count that reaches zero is removed, so two copies with the same orders compare equal. static Integer sum(Integer a, Integer b) { int s = a + b; return s == 0 ? null : s; }
public static void main(String[] args) { List<Event> log = new ArrayList<>(List.of( new Event(0, "o1", 1, "PLACED"), new Event(1, "o2", 1, "PLACED"), new Event(2, "o1", 2, "PAID"), new Event(3, "o1", 2, "PAID"), // the outbox relay published it twice new Event(4, "o2", 2, "CANCELLED"), new Event(5, "o1", 3, "SHIPPED"), new Event(6, "o1", 2, "PAID"))); // a stale copy of v2, arriving late
ViewStore live = new ViewStore(); OrderProjector v1 = new OrderProjector(live, false); v1.catchUp(log); ReadAlias reads = new ReadAlias(live); System.out.println("live, built by v1: " + reads.current);
// v2 replays into a new copy. v1 keeps serving and applying new events, so the copy chases // a moving head; it is swapped in only once it has caught up. ViewStore shadow = new ViewStore(); OrderProjector v2 = new OrderProjector(shadow, true); v2.catchUp(log); log.add(new Event(7, "o3", 1, "PLACED")); // live traffic during the rebuild v1.catchUp(log); while (shadow.checkpoint < log.size() - 1) v2.catchUp(log); System.out.println("v2 applied " + v2.applied + ", rejected " + v2.rejected + " as duplicate or stale");
// Compare at the same checkpoint before switching. Every difference must be one the fix // explains; anything else means v2 has a bug of its own. for (String k : shadow.rows.keySet()) { Row a = live.rows.get(k), b = shadow.rows.get(k); if (!b.equals(a)) System.out.println("differs at " + k + ": live " + a + ", rebuilt " + b); } reads.current = shadow; // the swap; keep the old copy a day for rollback System.out.println("reads now see: " + reads.current); }}from dataclasses import dataclass, field
# Fixing a projection the safe way. The live view was built by a v1 projector that applied every# event as it came, so a late, older copy of an event sent an order back in time. The fix is a v2# projector with a version check per key, replayed from offset 0 into a shadow copy while v1 keeps# serving reads, then compared and swapped in with one atomic switch.
# State-carrying: the event holds the order's whole new status, not a delta, so applying the# newest version is enough and a duplicate or an older copy can simply be skipped.@dataclass(frozen=True)class Event: offset: int order_id: str version: int status: str
@dataclass(frozen=True)class Row: version: int status: str
# One copy of the read model. In production a table, a Redis keyspace or a search index;# everything one handle() call changes here is one transaction in that store.@dataclassclass ViewStore: rows: dict[str, Row] = field(default_factory=dict) count_by_status: dict[str, int] = field(default_factory=dict) checkpoint: int = -1 # last offset applied, saved with the rows: resume at checkpoint + 1
def add_count(self, status: str, delta: int) -> None: # A count that reaches zero is removed, so two copies with the same orders compare equal. n = self.count_by_status.get(status, 0) + delta if n == 0: self.count_by_status.pop(status, None) else: self.count_by_status[status] = n
def __str__(self) -> str: rows = ", ".join(f"{k} {r.status} v{r.version}" for k, r in sorted(self.rows.items())) return f"{rows} | counts {dict(sorted(self.count_by_status.items()))}"
class OrderProjector: def __init__(self, store: ViewStore, check_versions: bool) -> None: self.store = store self.check_versions = check_versions self.applied = 0 self.rejected = 0
def handle(self, e: Event) -> None: s = self.store cur = s.rows.get(e.order_id) # The version check: skip anything not newer than what the view holds for this key. # It rejects a relay's duplicate (same version) and a late copy (older version) alike. if self.check_versions and cur is not None and e.version <= cur.version: self.rejected += 1 else: # Counts follow the row's state (leave the old status, enter the new one), never a # blind +1, so a skipped event moves nothing and a rebuild lands on the same totals. if cur is not None: s.add_count(cur.status, -1) s.add_count(e.status, 1) s.rows[e.order_id] = Row(e.version, e.status) self.applied += 1 s.checkpoint = e.offset # same transaction as the row
def catch_up(self, log: list[Event]) -> None: for o in range(self.store.checkpoint + 1, len(log)): self.handle(log[o])
# The name readers resolve: a search alias, a view name, a config key. Moving it is one write,# so no reader ever sees a half-built copy, and moving it back is the rollback.@dataclassclass ReadAlias: current: ViewStore
def main() -> None: log = [ Event(0, "o1", 1, "PLACED"), Event(1, "o2", 1, "PLACED"), Event(2, "o1", 2, "PAID"), Event(3, "o1", 2, "PAID"), # the outbox relay published it twice Event(4, "o2", 2, "CANCELLED"), Event(5, "o1", 3, "SHIPPED"), Event(6, "o1", 2, "PAID"), # a stale copy of v2, arriving late ]
live = ViewStore() v1 = OrderProjector(live, check_versions=False) v1.catch_up(log) reads = ReadAlias(live) print(f"live, built by v1: {reads.current}")
# v2 replays into a new copy. v1 keeps serving and applying new events, so the copy chases # a moving head; it is swapped in only once it has caught up. shadow = ViewStore() v2 = OrderProjector(shadow, check_versions=True) v2.catch_up(log) log.append(Event(7, "o3", 1, "PLACED")) # live traffic during the rebuild v1.catch_up(log) while shadow.checkpoint < len(log) - 1: v2.catch_up(log) print(f"v2 applied {v2.applied}, rejected {v2.rejected} as duplicate or stale")
# Compare at the same checkpoint before switching. Every difference must be one the fix # explains; anything else means v2 has a bug of its own. for k in sorted(shadow.rows): a, b = live.rows.get(k), shadow.rows[k] if a != b: print(f"differs at {k}: live {a}, rebuilt {b}") reads.current = shadow # the swap; keep the old copy a day for rollback print(f"reads now see: {reads.current}")
if __name__ == "__main__": main()using System;using System.Collections.Generic;using System.Linq;
// Fixing a projection the safe way. The live view was built by a v1 projector that applied every// event as it came, so a late, older copy of an event sent an order back in time. The fix is a v2// projector with a version check per key, replayed from offset 0 into a shadow copy while v1 keeps// serving reads, then compared and swapped in with one atomic switch.
// State-carrying: the event holds the order's whole new status, not a delta, so applying the// newest version is enough and a duplicate or an older copy can simply be skipped.public sealed record OrderEvent(long Offset, string OrderId, long Version, string Status);
public sealed record Row(long Version, string Status);
// One copy of the read model. In production a table, a Redis keyspace or a search index;// everything one Handle call changes here is one transaction in that store.public sealed class ViewStore{ public SortedDictionary<string, Row> Rows { get; } = new(); public SortedDictionary<string, int> CountByStatus { get; } = new(); public long Checkpoint { get; set; } = -1; // last offset applied, saved with the rows
// A count that reaches zero is removed, so two copies with the same orders compare equal. public void AddCount(string status, int delta) { int n = (CountByStatus.TryGetValue(status, out var c) ? c : 0) + delta; if (n == 0) CountByStatus.Remove(status); else CountByStatus[status] = n; }
public override string ToString() => string.Join(", ", Rows.Select(kv => $"{kv.Key} {kv.Value.Status} v{kv.Value.Version}")) + " | counts " + string.Join(", ", CountByStatus.Select(kv => $"{kv.Key}={kv.Value}"));}
public sealed class OrderProjector{ private readonly ViewStore _store; private readonly bool _checkVersions; public int Applied { get; private set; } public int Rejected { get; private set; }
public OrderProjector(ViewStore store, bool checkVersions) { _store = store; _checkVersions = checkVersions; }
public void Handle(OrderEvent e) { bool known = _store.Rows.TryGetValue(e.OrderId, out var cur); // The version check: skip anything not newer than what the view holds for this key. // It rejects a relay's duplicate (same version) and a late copy (older version) alike. if (_checkVersions && known && e.Version <= cur!.Version) { Rejected++; } else { // Counts follow the row's state (leave the old status, enter the new one), never a // blind +1, so a skipped event moves nothing and a rebuild lands on the same totals. if (known) _store.AddCount(cur!.Status, -1); _store.AddCount(e.Status, 1); _store.Rows[e.OrderId] = new Row(e.Version, e.Status); Applied++; } _store.Checkpoint = e.Offset; // same transaction as the row }
public void CatchUp(IReadOnlyList<OrderEvent> log) { for (long o = _store.Checkpoint + 1; o < log.Count; o++) Handle(log[(int)o]); }}
// The name readers resolve: a search alias, a view name, a config key. Moving it is one write,// so no reader ever sees a half-built copy, and moving it back is the rollback.public sealed class ReadAlias{ private volatile ViewStore _current; public ReadAlias(ViewStore store) => _current = store; public ViewStore Current => _current; public void SwapTo(ViewStore store) => _current = store;}
public static class Program{ public static void Main() { var log = new List<OrderEvent> { new(0, "o1", 1, "PLACED"), new(1, "o2", 1, "PLACED"), new(2, "o1", 2, "PAID"), new(3, "o1", 2, "PAID"), // the outbox relay published it twice new(4, "o2", 2, "CANCELLED"), new(5, "o1", 3, "SHIPPED"), new(6, "o1", 2, "PAID"), // a stale copy of v2, arriving late };
var live = new ViewStore(); var v1 = new OrderProjector(live, checkVersions: false); v1.CatchUp(log); var reads = new ReadAlias(live); Console.WriteLine($"live, built by v1: {reads.Current}");
// v2 replays into a new copy. v1 keeps serving and applying new events, so the copy // chases a moving head; it is swapped in only once it has caught up. var shadow = new ViewStore(); var v2 = new OrderProjector(shadow, checkVersions: true); v2.CatchUp(log); log.Add(new(7, "o3", 1, "PLACED")); // live traffic during the rebuild v1.CatchUp(log); while (shadow.Checkpoint < log.Count - 1) v2.CatchUp(log); Console.WriteLine($"v2 applied {v2.Applied}, rejected {v2.Rejected} as duplicate or stale");
// Compare at the same checkpoint before switching. Every difference must be one the fix // explains; anything else means v2 has a bug of its own. foreach (var (k, b) in shadow.Rows) { live.Rows.TryGetValue(k, out var a); if (a != b) Console.WriteLine($"differs at {k}: live {a}, rebuilt {b}"); } reads.SwapTo(shadow); // the swap; keep the old copy a day for rollback Console.WriteLine($"reads now see: {reads.Current}"); }}package main
import ( "fmt" "maps" "slices" "strings" "sync/atomic")
// Fixing a projection the safe way. The live view was built by a v1 projector that applied every// event as it came, so a late, older copy of an event sent an order back in time. The fix is a v2// projector with a version check per key, replayed from offset 0 into a shadow copy while v1 keeps// serving reads, then compared and swapped in with one atomic switch.
// Event is state-carrying: it holds the order's whole new status, not a delta, so applying the// newest version is enough and a duplicate or an older copy can simply be skipped.type Event struct { Offset int64 OrderID string Version int64 Status string}
type Row struct { Version int64 Status string}
// ViewStore is one copy of the read model. In production a table, a Redis keyspace or a search// index; everything one Handle call changes here is one transaction in that store.type ViewStore struct { Rows map[string]Row CountByStatus map[string]int Checkpoint int64 // last offset applied, saved with the rows: resume at Checkpoint + 1}
func NewViewStore() *ViewStore { return &ViewStore{Rows: map[string]Row{}, CountByStatus: map[string]int{}, Checkpoint: -1}}
// addCount removes a count that reaches zero, so two copies with the same orders compare equal.func (s *ViewStore) addCount(status string, delta int) { if n := s.CountByStatus[status] + delta; n == 0 { delete(s.CountByStatus, status) } else { s.CountByStatus[status] = n }}
func (s *ViewStore) String() string { var rows []string for _, k := range slices.Sorted(maps.Keys(s.Rows)) { rows = append(rows, fmt.Sprintf("%s %s v%d", k, s.Rows[k].Status, s.Rows[k].Version)) } return strings.Join(rows, ", ") + fmt.Sprintf(" | counts %v", s.CountByStatus)}
type OrderProjector struct { store *ViewStore checkVersions bool applied, rejected int}
func (p *OrderProjector) Handle(e Event) { s := p.store cur, ok := s.Rows[e.OrderID] // The version check: skip anything not newer than what the view holds for this key. // It rejects a relay's duplicate (same version) and a late copy (older version) alike. if p.checkVersions && ok && e.Version <= cur.Version { p.rejected++ } else { // Counts follow the row's state (leave the old status, enter the new one), never a // blind +1, so a skipped event moves nothing and a rebuild lands on the same totals. if ok { s.addCount(cur.Status, -1) } s.addCount(e.Status, 1) s.Rows[e.OrderID] = Row{e.Version, e.Status} p.applied++ } s.Checkpoint = e.Offset // same transaction as the row}
func (p *OrderProjector) CatchUp(log []Event) { for o := p.store.Checkpoint + 1; o < int64(len(log)); o++ { p.Handle(log[o]) }}
func main() { log := []Event{ {0, "o1", 1, "PLACED"}, {1, "o2", 1, "PLACED"}, {2, "o1", 2, "PAID"}, {3, "o1", 2, "PAID"}, // the outbox relay published it twice {4, "o2", 2, "CANCELLED"}, {5, "o1", 3, "SHIPPED"}, {6, "o1", 2, "PAID"}, // a stale copy of v2, arriving late }
live := NewViewStore() v1 := &OrderProjector{store: live} v1.CatchUp(log) // The name readers resolve: a search alias, a view name, a config key. Moving it is one // write, so no reader ever sees a half-built copy, and moving it back is the rollback. var reads atomic.Pointer[ViewStore] reads.Store(live) fmt.Println("live, built by v1:", reads.Load())
// v2 replays into a new copy. v1 keeps serving and applying new events, so the copy chases // a moving head; it is swapped in only once it has caught up. shadow := NewViewStore() v2 := &OrderProjector{store: shadow, checkVersions: true} v2.CatchUp(log) log = append(log, Event{7, "o3", 1, "PLACED"}) // live traffic during the rebuild v1.CatchUp(log) for shadow.Checkpoint < int64(len(log))-1 { v2.CatchUp(log) } fmt.Printf("v2 applied %d, rejected %d as duplicate or stale\n", v2.applied, v2.rejected)
// Compare at the same checkpoint before switching. Every difference must be one the fix // explains; anything else means v2 has a bug of its own. for _, k := range slices.Sorted(maps.Keys(shadow.Rows)) { if a, b := live.Rows[k], shadow.Rows[k]; a != b { fmt.Printf("differs at %s: live %v, rebuilt %v\n", k, a, b) } } reads.Store(shadow) // the swap; keep the old copy a day for rollback fmt.Println("reads now see:", reads.Load())}// Fixing a projection the safe way. The live view was built by a v1 projector that applied every// event as it came, so a late, older copy of an event sent an order back in time. The fix is a v2// projector with a version check per key, replayed from offset 0 into a shadow copy while v1 keeps// serving reads, then compared and swapped in with one atomic switch.
// State-carrying: the event holds the order's whole new status, not a delta, so applying the// newest version is enough and a duplicate or an older copy can simply be skipped.interface OrderEvent { offset: number; orderId: string; version: number; status: string;}
interface Row { version: number; status: string;}
// One copy of the read model. In production a table, a Redis keyspace or a search index;// everything one handle() call changes here is one transaction in that store.class ViewStore { rows = new Map<string, Row>(); countByStatus = new Map<string, number>(); checkpoint = -1; // last offset applied, saved with the rows: resume at checkpoint + 1
// A count that reaches zero is removed, so two copies with the same orders compare equal. addCount(status: string, delta: number): void { const n = (this.countByStatus.get(status) ?? 0) + delta; if (n === 0) this.countByStatus.delete(status); else this.countByStatus.set(status, n); }
toString(): string { const rows = [...this.rows] .sort(([a], [b]) => a.localeCompare(b)) .map(([k, r]) => `${k} ${r.status} v${r.version}`); const counts = [...this.countByStatus].sort(([a], [b]) => a.localeCompare(b)); return `${rows.join(', ')} | counts ${JSON.stringify(Object.fromEntries(counts))}`; }}
class OrderProjector { applied = 0; rejected = 0; private readonly store: ViewStore; private readonly checkVersions: boolean;
constructor(store: ViewStore, checkVersions: boolean) { this.store = store; this.checkVersions = checkVersions; }
handle(e: OrderEvent): void { const s = this.store; const cur = s.rows.get(e.orderId); // The version check: skip anything not newer than what the view holds for this key. // It rejects a relay's duplicate (same version) and a late copy (older version) alike. if (this.checkVersions && cur !== undefined && e.version <= cur.version) { this.rejected++; } else { // Counts follow the row's state (leave the old status, enter the new one), never a // blind +1, so a skipped event moves nothing and a rebuild lands on the same totals. if (cur !== undefined) s.addCount(cur.status, -1); s.addCount(e.status, 1); s.rows.set(e.orderId, { version: e.version, status: e.status }); this.applied++; } s.checkpoint = e.offset; // same transaction as the row }
catchUp(log: OrderEvent[]): void { for (let o = this.store.checkpoint + 1; o < log.length; o++) this.handle(log[o]); }}
// The name readers resolve: a search alias, a view name, a config key. Moving it is one write,// so no reader ever sees a half-built copy, and moving it back is the rollback.const reads: { current: ViewStore } = { current: new ViewStore() };
const ev = (offset: number, orderId: string, version: number, status: string): OrderEvent => ({ offset, orderId, version, status,});
const log: OrderEvent[] = [ ev(0, 'o1', 1, 'PLACED'), ev(1, 'o2', 1, 'PLACED'), ev(2, 'o1', 2, 'PAID'), ev(3, 'o1', 2, 'PAID'), // the outbox relay published it twice ev(4, 'o2', 2, 'CANCELLED'), ev(5, 'o1', 3, 'SHIPPED'), ev(6, 'o1', 2, 'PAID'), // a stale copy of v2, arriving late];
const live = new ViewStore();const v1 = new OrderProjector(live, false);v1.catchUp(log);reads.current = live;console.log(`live, built by v1: ${reads.current}`);
// v2 replays into a new copy. v1 keeps serving and applying new events, so the copy chases// a moving head; it is swapped in only once it has caught up.const shadow = new ViewStore();const v2 = new OrderProjector(shadow, true);v2.catchUp(log);log.push(ev(7, 'o3', 1, 'PLACED')); // live traffic during the rebuildv1.catchUp(log);while (shadow.checkpoint < log.length - 1) v2.catchUp(log);console.log(`v2 applied ${v2.applied}, rejected ${v2.rejected} as duplicate or stale`);
// Compare at the same checkpoint before switching. Every difference must be one the fix// explains; anything else means v2 has a bug of its own.for (const k of [...shadow.rows.keys()].sort()) { const a = live.rows.get(k); const b = shadow.rows.get(k)!; if (a?.version !== b.version || a?.status !== b.status) console.log(`differs at ${k}: live ${JSON.stringify(a)}, rebuilt ${JSON.stringify(b)}`);}reads.current = shadow; // the swap; keep the old copy a day for rollbackconsole.log(`reads now see: ${reads.current}`);
export {};Step 6: Read your own writes while the view lags
Section titled “Step 6: Read your own writes while the view lags”The user pays for order 9. The page reloads from the “my orders” view. And the view still says PLACED, because the projector is 800 ms behind. The one reader who always notices lag is the writer. The consistency models page covers session guarantees in general. With projections, there are four practical fixes:
- Version token. The command returns the version it committed (order 9, v2). The client sends it on its next read. The query API compares it with the version the view row holds. If the view has it, serve the view. If not, wait up to about 100 ms, then read that one entity from the write model.
- Read from the write model for the writer. For about 10 seconds after a write, the writer’s reads of their own resources go to the primary. It’s simple. But it loads the primary when writers are many.
- Optimistic UI. The client shows what it wrote at once and lets the view catch up. No server cost, but only the device that wrote sees it.
- Update the view in the command’s transaction. Step 1 again, for the one view that must never lag (a cart, a balance). Every other view stays asynchronous.
- Color key
- Service
The token cannot be a Kafka offset. The command returns before the relay or CDC publishes the event, so no offset exists yet. A version per entity needs no coordination, since the view already stores it per row. Sometimes a token must cover all of a session’s writes. Then it carries the commit LSN instead, and the projector records the source LSN it has applied, per partition.
Play with the whole loop. The write model and the log are on top. Projector A keeps “my orders”, by upserting each order’s state. Projector B keeps counts and revenue by status, by applying each transition, the way a naive counting projection does. Each view is checked against a correct replay of the log, up to its own checkpoint. So the demo can tell behind (lag) from wrong (divergence).
Write model orders table, source of truth
Event log newest first, A and B mark where each reads next
A: My orders upsert the order's state, grouped by customer
B: Sales by status move counts and revenue per transition
Three orders exist and both views are caught up. Slow down a projector, then place a few orders.
Things to try:
- Set A to Slow, place three orders, then Read as the writer. The view lacks the new order. Switch to “Token, else primary”, and read again. The view holds no row (or an older version) for the token, so the read goes to the write model.
- Turn on steady traffic with A on Slow. 1.4 writes a second against 0.7 applied: the lag grows without limit. A consumer slower than its input never catches up.
- Redeliver the last event, then a late, older version. Both are rejected by version. Turn the check off and repeat. A shrugs off the duplicate, because an upsert of the same state is harmless. But it goes back in time on the stale event. And B double-counts both.
- Inject OrderRefunded. A stores it. B has no code for the type, retries forever, and its lag climbs. Park it in the dead-letter queue: B catches up with wrong totals. Deploy v2 and rebuild B: the shadow copy replays from offset 0 and is swapped in correct.
Step 7: Event sourcing, at a glance
Section titled “Step 7: Event sourcing, at a glance”CQRS keeps a write model in tables, and derives views from its changes. Event sourcing goes one
step further: the events are the write model. An order is not a row with a status. It is the list
OrderPlaced, ItemAdded, OrderPaid, and its current state is a fold over that list.
A command loads the entity’s events, or a snapshot plus the events after it. It checks the rules against the folded state. Then it appends new events, with an expected version. So two concurrent commands on one entity conflict, instead of overwriting each other.
It gives a complete history, audit by construction, and any new view by replay. In the stock exchange the journal of sequenced inputs is the state, and the order books and the hot standby are derived by applying it. The payment system’s ledger is the same idea for money: append-only lines, with balances derived and rebuilt from them.
What it costs: every query needs a projection from day one. Events are forever, so every mistake in one is read by every future version of the code. Deleting personal data from an append-only log needs crypto-shredding (encrypt each person’s fields with their own key, then delete the key). And a CRUD screen (“edit the address”) becomes an event, a handler and a projection.
Use it where the history is the product: ledgers, trading, audit-heavy workflows, and collaborative editing. (The collaborative editor keeps an operation log.) Keep a state table plus an outbox everywhere else.
Go deeperEvolving event schemas1 min
Events are replayed for years by code written later, and during a rollout new events meet old code, so every change must work both ways.
- Add, never repurpose. New fields are optional with defaults.
amountin dollars becoming cents is silent corruption on replay. - Version the event, and let an upcaster turn v1 into v2 on read, so projector logic sees only the newest shape.
- Decide what an unknown type does. A convenience view skips and logs. An exact view (money, inventory) stops and alerts, because skipping makes it silently wrong.
- Check compatibility at publish time. A schema registry (Avro or Protobuf) rejects a producer schema that breaks the chosen rule: backward (the default), forward, or full.
Options and tradeoffs
Section titled “Options and tradeoffs”Where the read model lives
Section titled “Where the read model lives”| Option | Lag | Read shapes available | Costs | Wins when |
|---|---|---|---|---|
| Indexes and read replicas | Replica lag, ms | The write schema only | Index writes, replica hardware | The queries fit the schema; this is not CQRS and often enough |
| Summary table in the same transaction | None | Anything relational | Write latency, hot rows, coupling | One read must be exact right after the write |
| Materialized view in the same database | Refresh interval, minutes | Anything SQL expresses | Full recompute per refresh on Postgres | Dashboards that tolerate minutes; small to medium data |
| Separate read store fed by a log | 100 ms to seconds | Any engine: search, KV, columnar | A pipeline, idempotent projectors, rebuild tooling | Reads need another engine or far more capacity than the source |
How the read model hears about changes
Section titled “How the read model hears about changes”| Feed | Reliability | Coupling | Extra moving parts | Wins when |
|---|---|---|---|---|
| Dual write from the service | Loses or reorders updates on crashes and races | Low | None | Never for a view that must converge |
| CDC on the business tables | Every commit, in commit order | High: views see table and column names | Connector, replication slot, Kafka | Many consumers of a legacy schema you cannot change |
| Outbox, relayed by CDC or polling | Every commit, at least once | Low: a published event contract | An outbox table, a relay | The default for services you own |
| Event sourcing | The events are the truth | Lowest between views, highest in the code | An event store, snapshots, upcasters | History is the product: ledgers, trading, audit |
What an event carries
Section titled “What an event carries”| Event shape | Example | Duplicate or late copy | Rebuild from a compacted topic | Wins when |
|---|---|---|---|---|
| Notification (id only) | OrderChanged 9 |
Harmless: the consumer fetches current state | Yes | Many consumers want different fields; source can take the reads |
| State-carrying (full new state) | Order 9 v3: SHIPPED, total 48, ... |
Harmless with a version check | Yes | Views mirror entities: search docs, “my orders” |
| Delta (what changed) | Order 9: PLACED to PAID, +48 revenue |
Double-counts without dedup or a version check | No: needs every event | Aggregates and audit trails |
Notification events push load back onto the source: 10 consumers fetching order 9 is 10 reads per write, when the source is busiest. Most designs use state-carrying events for entity views and derive aggregates from them in the projector, writing absolute values.
Go deeperGiving the writer their own write, compared1 min
| Option | Who is covered | Cost | Wins when |
|---|---|---|---|
| Optimistic UI | The writing device | None on the server | Always, as the first layer |
| Version token, fallback to primary | The writer, any device that carries the token | A version comparison per read, rare primary reads | Lag is usually small, writers are many |
| Primary for N seconds after a write | The writer’s session | Primary load proportional to active writers | Few writers, simple stack |
| View updated in the write transaction | Everyone, at once | Write latency, the view tied to the write database | One view that must never lag (a cart, a balance) |
Go deeperWhen CQRS is overkill1 min
Skip a separate read side when any of these hold:
- The read shapes fit the write schema, with an index or two.
- One database (with replicas) handles the read load. A Postgres primary with a few replicas serves tens of thousands of indexed reads a second.
- Every read must see the latest write. (An inventory check before a booking reads the source, whatever else exists.)
- The team has no one to run a CDC pipeline and a broker.
- The domain is CRUD: forms that edit records, where “commands” and “queries” touch the same shape anyway.
Applying CQRS to a whole system because one screen is slow multiplies the moving parts for every screen. Apply it to the reads that need it.
How it fails
Section titled “How it fails”Lag spikes. A write burst, a slow view store, a rebalance or a deploy, and the projector falls behind. Reads stay fast but get older. Measure lag in seconds (the age of the oldest unapplied event): 10,000 messages behind is 1 second at one rate and 10 minutes at another.
Poison events. An event the projector cannot apply (an unknown type, a failed validation) is retried forever. And every event behind it on that partition waits. Retry a few times, then park it in a dead-letter topic, and alert. Parking leaves the view wrong for that key, until the fix is deployed and the event replayed. So a view that must never skip (money) stops and pages instead.
Projection divergence. A projector bug, a skipped event, a manual fix in the view store, or a dual write someone added. Lag fixes itself. Divergence never does. Detect it with reconciliation. On a schedule, compare the view with the source at the same position: counts and checksums per key range, or recomputed sample keys. Fix it with a rebuild, or a targeted replay. The ad click aggregator reconciles every day against the raw log, before invoicing.
Double application. A delta projection (count = count + 1) meets a duplicate. It might come
from a relay, a rebalance, or a rebuild run into the live view. Nothing signals the wrong count.
Prevent it by design (see Step 4).
Ordering across partitions. Per-key order holds within one partition. A view that joins two streams has no such guarantee:
- Color key
- Service
- Queue or stream
Fixes, in order of preference:
- Tolerate either order: a placeholder customer filled in later, or a join at read time.
- Key both streams by the view’s key, so they share a partition.
- Buffer until the dependency arrives, with a timeout.
Adding partitions to a topic causes the same problem inside one stream. A key’s new events land on a new partition, while its old ones are still unread.
A stalled CDC connector fills the source’s disk. A Postgres replication slot keeps every WAL
segment its consumer has not confirmed. Debezium down for a day means a day of WAL on the primary,
until the disk fills and writes stop. Set max_slot_wal_keep_size (Postgres 13 and later). Then
Postgres invalidates a slot that falls too far behind instead. Alert on retained WAL. And
re-snapshot the views after a slot is lost.
| Failure | Blast radius | Detect | Fix |
|---|---|---|---|
| Lag spike | Stale reads for every view on the slow projector | Age of the oldest unapplied event, per partition | Batching, more partitions, read-your-writes fallback |
| Poison event | Every key on that partition stops updating | Lag on one partition, repeated errors on one offset | Retry, then dead-letter and alert; replay after the fix |
| Divergence | Wrong answers that look right, indefinitely | Reconciliation against the source at the same position | Rebuild, or targeted replay of the differing keys |
| Double application | Counts and sums inflated | Reconciliation; totals above the source | Version per key, absolute values, processed-event table |
| Cross-partition order | Joins with missing or placeholder data | Placeholder rows that never fill, join misses | Tolerant projector, co-partitioning, bounded buffering |
| Stalled CDC slot | The source database’s disk, then its writes | Retained WAL per slot, connector lag | max_slot_wal_keep_size, alerting, re-snapshot |
| Rebuild into the live view | Readers see half-built data, deltas applied twice | Sudden drops and jumps in the view | Shadow copy and an atomic swap, never in place |
At scale
Section titled “At scale”Numbers to remember. These are typical ranges from common deployments, not guarantees: hardware, batch size, payload and the view store decide where a system lands. Measure your own.
| What | Typical range |
|---|---|
| CDC lag, commit to Kafka, steady state | About 100 ms to 1 s; minutes during a snapshot or after a connector restart |
| Polling outbox relay | Half the poll interval on average: 50 to 500 ms at 100 ms to 1 s polls |
| Kafka produce to consume, same region | A few ms to tens of ms |
| Elasticsearch visibility after a write | The refresh interval, 1 s by default |
| Projector, one row write per event | 500 to 2,000 events a second per partition (0.5 to 2 ms per round trip) |
| Projector, 500 events per batch transaction | 10,000 to 50,000 events a second per partition |
| Rebuild, 2 billion events, 48 partitions at 20,000/s | About 35 minutes |
| Postgres full refresh of a materialized view | Grows with the table: seconds at millions of rows, minutes at billions |
| Kafka default retention | 7 days; compacted topics keep the latest event per key indefinitely |
The end-to-end lag a user sees is the sum of four delays: CDC or relay delay, broker latency, the projector’s batch delay, and the view store’s own visibility delay (an Elasticsearch refresh). A healthy pipeline is typically 0.5 to 2 seconds from commit to visible.
At 10x (3,000 writes a second), the projector’s per-event round trip is the first limit. Batch: read 500 events, keep only the newest version per key, write them in one transaction with the checkpoint. Add partitions early, because adding them later moves keys. Rebuild time becomes a planning number: keep it under how long you can live with a broken view.
At 100x (30,000 writes a second, dozens of views owned by different teams), the stream becomes a platform. Schemas are checked by a registry. Each view has an owner, a lag SLO and a reconciliation job. Rebuilds come from an archive in object storage. Some views become stream-processing jobs (Flink, Kafka Streams) with local state. Each region runs its own projectors, with its own lag.
What to monitor:
- Lag per view and per partition, in seconds: the age of the oldest unapplied event. Alert on the SLO (for example, p99 under 5 seconds), not on message counts.
- Dead-letter rate and the oldest parked event per view. A parked event is a known-wrong key.
- Reconciliation differences per view, which should be zero. Trend them.
- Version-check rejections per view. A sudden rise means something is replaying into the view.
- Read-your-writes fallbacks to the primary per second: a cheap proxy for user-visible lag.
- For CDC: retained WAL per replication slot, and connector state.
In the interview
Section titled “In the interview”Interviewer asksThe search index and the order database disagree for some orders. How did it happen, and how do you fix it?
First, lag or divergence? Compare them at the same position, for those keys. Behind heals itself. Wrong at its own checkpoint means one of a few things. A dual write. A projector without a version check, which let a stale event win. A dead-lettered event, never replayed. Or a manual edit. For the data: a targeted replay of those keys, or a rebuild into a new index and an alias swap. For the system: versioned writes (Elasticsearch external versioning with the row’s LSN), the index fed only from the log, and a nightly reconciliation of counts and checksums per key range.
Interviewer asksA user pays for an order and the order page still says unpaid. The view is fed by CDC. What do you do?
The view is behind by the pipeline’s lag, usually under a second. I would not make the view synchronous. The pay command returns a token: the order id, and the version it committed. The page sends it. The query API compares it with the version the view holds for that order. It serves the view if it has caught up. Otherwise it waits briefly, then reads that order from the primary. The client also shows PAID optimistically. Other users get no promise, and need none.
Interviewer asksWe need a new field in the 'my orders' view, for orders going back three years. How do you backfill it?
Projector v2, with its own consumer group, writes a new copy of the view. If a compacted topic holds three years of state-carrying events, replay it from offset 0. If not, load a source snapshot, note its log position, and replay from there. The version check makes the overlap harmless. Throttle it, to protect live lag. Once v2 reaches the head, compare it with v1. Switch reads in one step, and keep v1 a day for rollback.
Interviewer asksShould we use event sourcing for this e-commerce system?
For most of it, no. Catalog, carts and order status are well served by tables, an outbox, and projections where reads need another shape. Event sourcing makes every read a projection, every event permanent, and privacy deletes a project. I would use it where history is the product: the payment ledger, append-only with derived balances, is already close to it. Elsewhere the outbox already gives us the stream and replayable views.
Seen in
Section titled “Seen in”- News feed: each timeline is a precomputed view of post IDs, fed by fan-out.
- Typeahead: the top suggestions per prefix are a view of the query log, built offline and swapped in whole.
- Ad click aggregator: rollups are projections of the click log, written as absolute totals, reconciled daily and rebuilt by replay after a bug.
- Leaderboard: Redis sorted
sets fed by an outbox, applied with
ZADD GTof absolute totals so duplicates cannot lower a score. - Payment system: an outbox for payment events, and seller balances derived from append-only ledger lines and rebuilt nightly.
- Ticketmaster and hotel reservation: search fed by CDC, and a per-user (or per-guest) orders table fed from the outbox once the write side shards.
- Proximity service: an in-memory geo index, loaded from a snapshot and kept fresh by state-carrying CDC events keyed by business.
- Job scheduler: run history flows through an outbox and Debezium, into a Cassandra view. Live state stays in the run store.
- Distributed cache: a binlog-driven invalidator, the same log-driven freshness applied to cache keys.
- Stock exchange: the journal is the state, and the books and the standby are derived by applying it.
- Pastebin: a feed of new pastes built as a read model from events, not a query across shards.
The shape. Reads need shapes the write schema cannot serve cheaply: search, aggregates, timelines. A write model for invariants, one read model per query shape, fed by changes.
The ladder. Indexes and replicas. A summary table in the same transaction. A materialized view. Then a separate store fed by a log, when a read needs another engine.
The feed. Never a dual write. CDC from the WAL or binlog, or domain events through an outbox (often relayed by CDC). State-carrying events make idempotency a version comparison.
The projector. Ordered per key by partitioning, idempotent by a version per key, and checkpointed, ideally in the same transaction as the view. Absolute values, never blind increments.
Rebuilds. A view is a function of the log. Replay into a shadow copy, compare, and swap atomically. Retention or an archive decides whether you can. Partitions decide how fast.
Lag. Typically 0.5 to 2 seconds end to end. Give the writer read-your-writes with a version token and a primary fallback, plus optimistic UI. Measure lag in seconds per partition.
Failure. Poison events stall a partition: retry, dead-letter, alert, replay. Divergence never heals: reconcile. Cross-partition joins must tolerate any order. A stalled CDC slot fills the disk.
Event sourcing. Events as the source of truth: full history and replay, at the cost of permanent schemas and hard deletes. For ledgers and trading, rarely for CRUD.
Further reading
Section titled “Further reading”- On this site: message queues, for the outbox, ordering and delivery semantics. Distributed transactions, for the inbox and sagas. Consistency models, for session guarantees. Search, for keeping an index in sync. And databases, for polyglot persistence.
- CQRS, Martin Fowler: the pattern, and a clear warning about where it adds more complexity than it removes.
- Event Sourcing, Martin Fowler: events as the record of state, with rebuilds, temporal queries and their costs.
- Debezium connector for PostgreSQL: how CDC reads the WAL through a replication slot, snapshots, and what happens when the connector stops.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.