Cruxly › System design › Advanced Trade-offs
CQRS and Event Sourcing
two patterns that are often sold together and should be bought separately, each with a price that most systems should not pay
Open the interactive lessonCQRS gives reads and writes separate models; event sourcing makes the log of changes the source of truth. People say them in one breath, but they are independent, and the interesting question in a design review is usually not how to build them but whether to. This lesson takes each in depth (projections and how the UI copes with stale reads, aggregates, snapshots, replay, upcasting, deleting personal data), works an account example with a replay estimate, and ends on when neither is justified. It builds on Event-Driven Architecture: Choreography, Orchestration and Sagas (the outbox, inbox and schema rules are not repeated), the event-sourced workflow section of Workflow Orchestration Engine, Payment Ledger, Kafka as a log store and Replication and Consistency.
Two models for two jobs: commands and queries
CQRS says the model that decides whether a change is allowed need not be the model that answers questions, and it is a trade you make for a specific pain, not a default. Martin Fowler's page (14 July 2011) defines Command Query Responsibility Segregation as using a different model to update information than the one you use to read it, and credits Greg Young with first describing it to him. The same page is blunt: for most systems it adds risky complexity, it should be applied to a portion of a system (one bounded context), not the whole, and he is cautious about it. Read the pattern with that warning attached.
Why the models diverge. The Azure Architecture Center CQRS page (dated 2025-02-20) lists the mismatches that justify a split:
- Data mismatch. The shape you validate and store is not the shape a screen needs. An order write needs line items, prices and a state machine; the order-history list needs customer name, total, and the latest status, flattened.
- Lock contention. Heavy reads and writes on one set of rows interfere.
- Different scaling. Reads often outnumber writes by a large factor, and the read side can scale horizontally while the write side runs on few instances to keep consistency.
- Security. Only the appropriate domain entities or operations hold permission to write.
Commands are intentions, queries are projections. The Azure page says commands should express a business task ("Book hotel room"), not a low-level update ("set ReservationStatus to Reserved"), and queries return plain data transfer objects with no domain logic. A command can be rejected; a query cannot change anything. The write side usually organises its rules around an aggregate: a cluster of objects changed as one unit, always left consistent. The read side has no rules to enforce, so it can be denormalised for the screen.
| Write model (command side) | Read model (query side) | |
|---|---|---|
| Job | Decide: validate, enforce invariants | Answer: return data shaped for a screen |
| Shape | Aggregates, state machines, normalised | Flat, denormalised, one per query family |
| Consistency | Strong, inside the aggregate | Usually eventual with respect to the write side |
| Scales by | Partitioning aggregates; few writers per aggregate | Replicas and caches; as many copies as needed |
| Failure it fears | Rule violation, lost update | Stale or wrong data shown |
Where the price shows up. Two models means two schemas, two bodies of code, and a synchronisation path between them. Fowler's 2017 article ("What do you mean by 'Event-Driven'?") adds that colleagues are deeply wary of CQRS because it is often misused, and that the benefit of separate models has to be weighed against the added complexity. The Azure page says it may not be suitable when the domain or business rules are simple, or when plain CRUD screens and data access are enough.
What experts say in the room. "What query is hurting us that one model cannot serve?" If nobody can name one, the answer is no. The number is the read-to-write ratio and the number of distinct query shapes. What a senior checks: that the split is scoped to one bounded context, that a named pain justifies it, and that the team can say who owns the synchronisation path at 3 a.m.
CQRS without events: a simple read model
The cheapest form of CQRS has no event log, no broker and no second database: it is a different class, a different query, or a view over the same tables. Fowler's CQRS page offers a reporting database as the simpler alternative when queries are demanding, and the Azure page names "separate models in a single data store" as the foundational level of the pattern. Teams skip this rung and pay for a distributed system they did not need.
The ladder, cheapest first.
- Separate code, same tables. Commands go through the domain layer; queries are hand-written SQL returning DTOs that bypass the object-relational mapper. No lag. You get the clarity and the query performance, and nothing else.
- A database view or materialised view. The database maintains the join. Lag is zero for a plain view and a refresh interval for a materialised one.
- A read replica. Same schema, other machine. Lag is replication lag (Replication and Consistency). Good for load, no help with shape.
- A separate read table or store fed by the write path. You pick the shape. Now you need a sync mechanism: update both in one transaction (no lag, but the stores must be the same database), or publish a change and update afterwards (lag, plus the dual-write problem that Event-Driven Architecture: Choreography, Orchestration and Sagas solves with an outbox). The Azure page says exactly this: you usually cannot enlist a broker and a database in one transaction, so use the transactional outbox and make the read-model consumer idempotent.
- A different technology for reads (a search index, a document store, a cache). Same sync problem, plus a second operational surface. The Azure page lists a document database for reads and a relational one for writes as an example of why teams do this.
Worked example: order history. An orders write schema needs four tables (orders, order_lines, payments, shipments). The order-history screen needs one row per order with customer name, total, line count and latest shipment status. A query joining four tables at 200 requests per second is a read-side problem, not a write-side one. Step 4 gives a single order_summary table updated by the same transaction that changes the order: the write transaction becomes slightly heavier (one extra upsert), the screen query becomes a primary-key lookup, and there is still no lag. You have done CQRS, and no event ever left the database.
Rule of thumb. Move down the ladder only when the step above fails a measured requirement, and write down which number failed. Moving from step 4 (same transaction) to a separate store changes consistency from immediate to eventual, so it is a product decision as well as an engineering one.
What experts say in the room. "Can a view or a summary table in the same database fix this?" The number is p99 query latency on the current model against the target. What a senior checks: that the team tried the lower rungs, that the read table's rebuild script exists (a read model with no rebuild script is a second source of truth by accident), and that the sync step is idempotent.
Projections and eventual consistency for readers
Once the read model is fed asynchronously, the user can write something and not see it, and designing for that gap is the actual work of CQRS. The Azure CQRS page says when read and write stores are separate, read data may not show recent changes and users may act on stale data, which needs careful handling. microservices.io lists replication lag as a drawback of the pattern: views are eventually consistent.
A projection is a function from events (or changes) to a read shape. It has a checkpoint, the position in the input it has processed. Its correctness rules are the same as any at-least-once consumer: idempotent, ordered per key, and tolerant of gaps (Event-Driven Architecture: Choreography, Orchestration and Sagas gives the inbox and the version-gap rule; the Azure Event Sourcing page repeats that without idempotent handlers "projections drift from the event stream").
How the UI copes: five techniques, in rising order of effort.
- Optimistic UI. After the command is accepted, the client draws the expected result locally and reconciles when the real data arrives. Cheap; fails visibly if the command is later rejected asynchronously.
- Read your own writes with a version token. The command response carries the aggregate version (or event position) that the write produced, say
v=42. The next query sendsmin_version=42. The read side answers immediately if its checkpoint for that aggregate is at least 42, otherwise it waits briefly for the projection to catch up (a bounded wait, for example 500 ms) and then either returns what it has with a "may be stale" marker or falls back to reading the write side. This is read-your-writes (Replication and Consistency) applied to a projection, and it needs no global lock. - Route the writer's reads to the write side for a short window after a write, the same trick used with replicas.
- Design the workflow around the gap. After "place order", show an order page in a pending state fed by the command result, not a list that might not contain it yet. The Azure Event Sourcing page says to make sure customers understand that data is eventually consistent and to design for it; this is what that looks like in a screen.
- Acknowledge asynchronously. Return 202 with a status resource. Honest, and it matches the Azure page's suggestion of asynchronous command processing, at the price of a status-polling UI.
Worked arithmetic: how stale is stale? Suppose the projector's lag has a p50 of 40 ms and a p99 of 1.5 s (assumed, to be measured). A user who places an order and is redirected to "my orders" in 200 ms hits a stale list whenever the lag exceeds 200 ms. Even if only 3 per cent of redirects fall in that tail (an assumption), 3 per cent of 1,000,000 orders a day is 30,000 confused users and likely duplicate submissions, which is why the version token is worth building before launch.
Failure modes of projections.
- Poison event. One event the projector cannot process blocks the stream (bounded retry, then a dead-letter with an owner).
- Projection bug fixed in code but not in data. The bad rows stay until you rebuild.
- Lag nobody watches. Alert on the age of the oldest unprocessed change, not on the broker being up.
- Cross-aggregate reads. A projection joining two aggregates sees them at different versions. Decide which is allowed to be inconsistent.
What experts say in the room. "What does the user see between click and projection?" The numbers are projection lag at p50 and p99 and the timeout on the version wait. What a senior checks: projection lag as a first-class metric with an alert, the version token wired through the API, and a rebuild path that has been rehearsed.
Event sourcing: state as the sum of history
Event sourcing stores the facts that happened and derives the current state from them, so the log is the database and everything else is a cache of it. Fowler's 2005 page defines it as capturing all changes to application state as a sequence of events, stored in the order applied; the Azure Event Sourcing page (dated 2026-03-27) calls the append-only store the system of record. Fowler's 2017 article adds two corrections worth repeating: it does not have to be asynchronous, and only the components that truly need the log should read it; most processing can use a working copy.
Mechanics.
- Event. An immutable, past-tense fact with intent:
SeatsReserved(2), notRemainingSeatsChanged(42). The Azure page uses this very contrast: the first tells you what happened and supports new read models later; the second reduces the store to a change log with no business meaning. - Stream. Each entity (aggregate) has its own ordered stream. The Azure page names this as the reason event stores partition naturally by entity id.
- Rehydration. To handle a command, load the aggregate's stream, fold the events into current state, run the business rule, and produce new events.
- Append with optimistic concurrency. Append says "I read up to version 17; add after it." If the stream moved on, the store rejects the append and the handler reloads, re-decides and retries. The Azure page's example: two handlers each see five remaining seats and both would accept; the store rejects the second append.
- Projections. Read models are derived from the stream (section 3). The Azure page notes they are typically needed because reading and replaying events for a query is costly.
What you get.
- A complete audit trail that cannot silently disagree with the state, since the state is computed from it (microservices.io calls it a 100% reliable audit log).
- Temporal queries: replay up to a point in time and see the past (Fowler 2005, 2017).
- New read models built from history without touching the write side.
- Reliable publication of events whenever state changes, since the event is the change (microservices.io).
What you pay. Both the Azure page and microservices.io are explicit.
- A different programming model with a learning curve (microservices.io). The Azure page calls it a complex pattern with significant trade-offs that changes how you store data, handle concurrency, evolve schemas and query state, and says it is costly to migrate to or from and constrains later design choices.
- Querying is hard. The Azure page: there is no standard query mechanism; you can read a stream by entity id and replay it. microservices.io: queries need CQRS, hence eventual consistency. This is why event sourcing is nearly always paired with CQRS, and why CQRS can live without event sourcing but not the reverse.
- Mistakes are permanent. Events are immutable. A bug that emits wrong events leaves them in the store, and the repair is a compensating event or an upcaster, not an UPDATE.
- Replay and outside systems. Fowler 2005: replaying can resend messages to the outside world, so wrap external systems in gateways that are switched off during rebuilds, keep the domain logic unaware of replay, and record answers to external queries so replays stay consistent.
- Events are not a broker's topic. The Azure page warns not to confuse an event store with an event-stream message broker such as Kafka: brokers typically lack per-entity stream queries and optimistic concurrency, and work as the distribution layer to projections (Kafka as a log store).
Depth beyond the workflow lesson. Workflow Orchestration Engine uses event history to resume a workflow and needs determinism in replay. Here the history is the business record itself, which adds three problems that lesson does not have: it lives for years rather than the length of a workflow, it carries personal data, and its readers include projections and other teams whose needs you cannot predict.
What experts say in the room. "Is history a feature the business asks for, or a side effect we find elegant?" The number is the longest stream you expect (section 5). What a senior checks: that events carry intent, that appends use expected-version checks, that the store is a real event store or a table with a unique (stream id, version) constraint, and that nobody treats a Kafka topic as the system of record.
Snapshots, replay and rebuilding projections
Replay is what makes event sourcing powerful and what makes it expensive, and the two numbers to know are the length of your longest stream and the total event count. The first decides how slow a single command is; the second decides how long a rebuild takes.
Snapshots bound the cost of one aggregate. The Azure page: if streams are large, rehydration costs time and compute; take a snapshot every N events, a serialised copy of the entity state at a point in its stream, load the latest snapshot and replay only the events after it. Its note matters: snapshots are an optimisation, never the source of truth, and can be regenerated from the stream at any time. microservices.io and Fowler (2005) describe the same idea.
Worked arithmetic: snapshot interval. Assumptions (illustrative, to be measured on your own system): decoding and applying one event takes 20 microseconds once loaded, and loading costs 1 ms per 100 events from a partitioned table.
| Stream length | No snapshot | Snapshot every 500 events |
|---|---|---|
| 120 events (typical account) | about 2.4 ms apply + 1.2 ms load | not worth it |
| 50,000 events (a busy merchant account) | 1 s apply + 500 ms load | at most 499 events: about 10 ms apply + 5 ms load, plus one snapshot read |
| 5,000,000 events (a bad aggregate boundary) | 100 s apply: unusable | still 15 ms, but the aggregate is a hot, serial bottleneck |
The last row is the real lesson: a snapshot hides a design error. If one aggregate receives 5 million events, every command on it serialises through one stream, which is a modelling problem (the aggregate is too big, or it should have been a period, such as one stream per account per month).
Rebuilding a projection is a replay of everything. Worked arithmetic for the account example: 200 million accounts averaging 120 events each is 24 billion events. At 300 bytes per event that is 7.2 TB of log. A projector applying 100,000 events per second takes 24e9 / 1e5 = 240,000 s, about 2.8 days. Partition the replay by account id across 32 workers and each handles a disjoint set of streams: 240,000 / 32 = 7,500 s, about 2 hours, if the target store can absorb 3.2 million upserts per second in aggregate, which it usually cannot without being sized for the rebuild. So the realistic limit is the write capacity of the read store, not the log.
Rebuild strategies, in order of risk.
- Blue-green projection. Build a new projection table from event zero in the background while the old one serves; when the new checkpoint reaches the live position, swap. Costs double the read-store space for the duration. This is the safest method and the one to design first.
- Catch-up from a snapshot of the projection. Restore a backup of the read model and replay from its checkpoint. Faster, but only valid if the projection logic did not change.
- In-place rebuild. Truncate and replay. The read side is empty or wrong for hours. Acceptable only for internal tools.
Replay rules that prevent incidents.
- Run projections with side effects (emails, payments) off during replay; Fowler 2005 says to disable gateways to the outside world while rebuilding.
- Replay into a new consumer, not the live one, so new events are not delayed behind history. (Event-Driven Architecture: Choreography, Orchestration and Sagas covers inbox retention against replay horizons.)
- Keep projections deterministic: no wall-clock time or random numbers inside a projector; if you need a time, it is in the event.
- Cut the event log with a retention policy only after deciding whether the log is the source of truth. If it is, it is never truncated, only archived by age to cheaper storage with the stream-per-period design above.
What experts say in the room. "How long does a full rebuild take, and has anyone done one this quarter?" The numbers are the longest stream and the event count divided by the replay rate. What a senior checks: a snapshot interval chosen from the stream-length distribution, a stream-length alert (the sign of a bad aggregate), a blue-green rebuild runbook, and the read store's write capacity under rebuild.
Evolving event schemas and handling deletion requests
An event store is permanent, so every schema change and every deletion request must be solved without editing history in place. These two problems are what most teams underestimate. The Azure page lists schema evolution and personal-data law among the headline trade-offs, and Fowler (2017) says you must work out how to deal with changes in the schema of events over time.
Schema evolution: four tools, cheapest first. The Azure Event Sourcing page gives the set, and the Axon Framework 4.10 reference describes the fourth in detail.
- Tolerant deserialisation. Consumers ignore unknown fields and default missing ones. Handles additive, non-breaking changes with no transformation of stored events. This is the discipline from Event-Driven Architecture: Choreography, Orchestration and Sagas; keep it for every consumer.
- Event versioning. A version in the envelope or in the type name (
MoneyWithdrawn.v2), so handlers branch on it. - Upcasting. Functions that convert an old event to the current shape as it is read, chained so that application code only sees the latest version. Axon's reference: an upcaster takes one event at revision x and outputs zero or more events at revision x + 1; upcasters are written by hand (the page says the conversion cannot be fully automated); they run in a registered order, each one's output feeding the next; and upcasting happens on read, so stored events are untouched and "the complete event history remains intact". Each stored event records its revision and its type name.
- In-place migration. Rewrite the stored events. The Azure page calls it a last resort because it breaks immutability and undermines the audit trail.
Worked example. AddressChanged(address: string) becomes AddressChanged(line1, city, postcode). The upcaster for v1 to v2 splits the string using a parser. Events with unparseable strings go through with city = null and a flag; a v2 consumer must cope. The one-to-many case (one old event becomes two new ones) is why Axon's upcasters can output zero or more events. The cost is a chain that grows by one function for every breaking change over the life of the system. After five changes, reading a 2019 event runs through five transformations; test every historical version of every event, and keep a fixture file per version.
Wrong events stay. If a bug produced bad events, fixing the code does not fix the stored ones. The Azure page: you may need compensating events (ReservationCanceled after SeatsReserved) or upcasters that correct bad data during replay.
Deleting personal data. The Azure page names the conflict directly: the append-only, immutable store clashes with "right to be forgotten" laws, and deleting events outright breaks stream integrity. Two designs, both from that page:
- Keep personal data outside the log. Events carry an id referencing a profile record; deleting the profile satisfies the request, and the stream stays intact. Cheapest, and right when you can design for it from the start.
- Crypto-shredding. Encrypt personal fields in each event with a per-subject key, held in a key store. To erase a person, delete their key. The events remain, the structure is intact, and the ciphertext is unreadable. The page names the costs: encryption overhead on every read and write and a need for robust key management.
What crypto-shredding does not do on its own (engineering consequences, not claims from the page). Whether an unreadable ciphertext counts as erased is a legal judgement that differs by regulator and case; the Azure page presents shredding as a technique, not as a compliance guarantee.
- Projections, snapshots, caches, search indexes and analytic copies built before the deletion contain the plaintext. Each must be rebuilt or purged, so keep a register of every derived store that holds personal fields.
- Backups of the key store defeat the shredding unless they are also expired within your stated deletion window.
- Personal data in event types, stream names or free-text fields is not encrypted. Review event design, not just encryption.
- Where law requires you to keep certain records (financial records, for example), erasure and retention rules collide; settle with counsel which fields fall under which rule before the event design is fixed, and for GDPR (EU) or DPDP (India) requests define the process and deadline in the runbook.
:::key Verified November 2026 Azure Event Sourcing pattern page dated 2026-03-27 (read 2 November 2026): tolerant deserialisation, versioning, upcasting and in-place migration as the schema strategies; separating personal data and crypto-shredding as the deletion strategies. Axon Framework 4.10 reference (read 2 November 2026) for upcaster behaviour. Tool names and APIs differ in other versions. :::
What experts say in the room. "Which fields in this event are personal data, and who holds the key?" The number is the length of the upcaster chain and the deletion deadline. What a senior checks: a per-event version, a fixture for every historical version, a register of derived stores holding personal data, and a rehearsed erasure runbook.
Cross-service reads: API composition versus a local read model
When each service owns its data, a query that needs data from three of them can be answered by calling them at read time or by keeping a local copy fed by their events, and the choice is a trade between freshness and independence. microservices.io frames the problem: after database per service, a join across services is no longer straightforward; the CQRS answer is a view database, a read-only copy built for one query or family of queries and kept current by subscribing to the owners' domain events, and API composition is the named alternative. The Hard Parts book treats this topic as distributed data access.
| API composition | Local read model (CQRS view) | |
|---|---|---|
| How it answers | Calls each owner at read time and joins in memory | Reads one local store populated from events |
| Freshness | Always current | Lag equal to event propagation and projection |
| Availability | Query fails if any owner is down; latency is the slowest call | Works while owners are down |
| Cost per query | Fan-out: 3 calls, tail latency compounds | One local read |
| Cost to build | Low | Event contracts, projection, rebuild, ownership of the copy |
| Fails when | Large joins, sorts or filters across services (you cannot push the filter down) | Event lag, missed events, a schema change in an owner |
Worked arithmetic: tail latency. A query that calls three services, each with p99 latency of 100 ms and independent, has probability 1 - 0.99^3 = 2.97% that at least one call exceeds 100 ms. The page-level p99 is therefore worse than any single service's p99. A local read model removes that compounding, at the cost of staleness.
When to choose which.
- Composition when the query is a lookup of a few entities by id, freshness matters, and you can tolerate dependence on the owners (an order page: fetch order, then customer name).
- A local read model when the query filters or sorts on fields from several services (orders by customer city and product category), the owners' availability must not decide yours, or the call volume would overload the owners.
- Hybrid. Keep a thin local copy of the few fields you filter on (id, city, status) and compose the rest by id at display time.
What a local read model commits you to. You now own a copy of other teams' data: its freshness, its rebuild, its deletion (section 6) and its schema coupling. Treat the event contract with the owner as a public API and version it. And remember who is authoritative: the copy is for reading; any decision that needs the truth (can this card be charged?) goes to the owner. Decomposing a System into Services has the ownership boundaries; this section is the read side.
What experts say in the room. "Do we filter across services, and can we live with the owners' tail latency?" The numbers are the fan-out count and the combined tail probability. What a senior checks: a versioned event contract, a rebuild path for the copy, the staleness the screen can tolerate, and who handles deletion in the copy.
When both are overkill
The strongest answer in an interview is often "neither", with a reason and an upgrade path. Fowler's 2011 page says most systems fit a CRUD model better; the Azure CQRS page lists simple domains and CRUD-style access as unsuitable; the Azure Event Sourcing page says for most systems and most parts of a system traditional data management is sufficient, it is costly to migrate to or from, and it is unsuitable for MVPs, short-lived systems, data that is mostly static or reference, systems needing consistent real-time views, and teams without event-driven experience.
The four combinations.
| Combination | What it is | When it fits |
|---|---|---|
| Neither | One model, current-state tables, an audit table if needed | Most systems, most of the time |
| CQRS without event sourcing | Separate read model fed from the write database | Read shapes diverge, or read load needs its own scale |
| Event sourcing without CQRS | Log as truth, state rebuilt per request | Small streams, a handful of simple queries; rare, as any real query needs a projection |
| Both | Log as truth, projections for every read | History is the product and reads are varied: ledgers, regulated audit |
Cheaper ways to get what teams want from event sourcing.
- "I need an audit trail." An append-only audit table written in the same transaction as the state change, or database change data capture into an archive. You keep current-state tables and lose only the ability to rebuild state from the audit rows.
- "I need to publish events reliably." The outbox, with no event-sourced state at all.
- "I need to undo and see history." Temporal tables or a versioned row per change.
- "I need to rebuild the read side." CQRS with a rebuild script from the write tables.
Where event sourcing earns its cost. The Azure page gives the criteria: you want intent and reason in the data, you need to minimise conflicting updates, you want replay, rollback and an audit log, events are already natural in the domain, or you use CQRS and accept eventual consistency. The page's own examples of good fits are a payment ledger and an order-processing pipeline, while user profiles and application configuration are better kept as plain CRUD. That matches Payment Ledger, where the postings are the facts and balances are derived. Apply it per bounded context, not per company.
The failure modes people hit (each is anchored in the sources above):
- Event sourcing everywhere. The Azure page's advice to apply it selectively exists because the cost lands on every part that adopts it.
- State-change events, a broker used as the event store, unbounded streams, non-idempotent projections and replays that fire real side effects (each covered in sections 4 and 5).
- No rebuild rehearsal. The first rebuild happens during an incident and runs for days.
- Both patterns adopted for a CRUD domain because they looked like the modern way, when either one alone, or neither, would have done.
The interview answer. State the requirement that forces the pattern (an auditable ledger with regulatory replay; a read workload fifty times the write workload with five different shapes) or say it does not exist. Start at the lowest rung of section 2. Name the lag, the rebuild time, the upcaster policy and the erasure plan, because an answer without them is a diagram, not a design.
What experts say in the room. "What happens if we do the boring thing?" The number is the requirement that the boring thing fails. What a senior checks: the requirement is written down, the scope is one bounded context, and the team can already operate a message-driven system.
Further reading: Neal Ford, Mark Richards, Pramod Sadalage and Zhamak Dehghani, Software Architecture: The Hard Parts, chapter 10 Distributed Data Access and chapter 12 Transactional Sagas. Primary sources: Martin Fowler's pages on CQRS, Event Sourcing and event-driven systems, the Azure Architecture Center CQRS and Event Sourcing pattern pages, and microservices.io.
How to say it in the interview
"First I ask whether one model is enough, because Fowler's own page says CQRS adds risky complexity for most systems and the Azure pattern page says event sourcing is not needed for most parts of most systems. If reads and writes really diverge, I start with CQRS without events: a write model that enforces the rules and a read table or view fed from it, accepting a lag I can measure. The command returns a version, and the UI reads with that version so the user sees their own write. I reach for event sourcing only where history is the product, a ledger or an audit trail, per aggregate, with snapshots every few hundred events, upcasters for old schemas and per-person keys so I can delete personal data. And I would say the number: replaying 24 billion events at 100,000 a second is nearly three days, so a rebuild plan is part of the design."
Checklist
- Named the specific read/write mismatch that justifies a separate read model, scoped to one bounded context
- Tried the lower rungs first (separate query code, a view, a summary table in the same transaction) and recorded the measured number that failed
- Made the synchronisation path idempotent and fed it through an outbox or the same transaction, never a dual write
- Returned an aggregate version from every command and honoured it on reads (bounded wait, then a marked fallback)
- Alerted on projection lag (age of the oldest unprocessed change) at p50 and p99, not only on the broker being up
- Modelled events as past-tense business facts with intent, appended with an expected-version check, in a store with per-stream reads
- Computed the longest stream and chose a snapshot interval or a period-based stream split, with an alert on stream length
- Computed full-rebuild time from event count, replay rate and the read store's write capacity, and rehearsed a blue-green rebuild
- Versioned every event, wrote upcasters with a fixture per historical version, and disabled external side effects during replay
- Kept personal data out of events or encrypted it with per-subject keys, and listed every derived store and backup that must be purged on erasure