Projections, replay, and snapshots
Read models in event sourcing are built by replaying the event log through a projection. Replay enables rebuild-on-demand and temporal queries. Snapshots bound replay cost. Event versioning and upcasting keep old events readable as the schema evolves.
The billing team’s new event log was working. Every order event was being appended correctly. But a product manager asked: “Can you give me a dashboard showing all open orders, sorted by account manager, with overdue ones highlighted?” The team looked at the event log. It contained the full history of every order — but answering that query meant reading the complete history for every order, folding each one to get its current status, filtering the pending ones, joining with the account manager data, and sorting. On 200,000 orders, that query would take minutes. The event log was the source of truth, but it was not designed for query access patterns. The team needed a separate structure — a read model — shaped for the dashboard query. And they needed a mechanism to build and maintain it. The mechanism is projection: a process that reads the event log and transforms it into a denormalized read model. But projection raised a new question immediately: what happens when the event log format changes six months from now? The team had just added an approval_threshold field to OrderApproved events. What about the 40,000 OrderApproved events already in the log that were appended before the field existed?
Projections: read models derived from the event log
The event log is the source of truth for the write side. It is not designed for read access patterns. Answering “show all overdue orders for account manager Chen” by scanning and folding a 200,000-entry event log on every page load is structurally wrong.
A projection is a process that reads events from the log and builds a denormalized read model shaped for a specific query. It is the same concept as in CQRS (unit 07) — the key difference is that the source being projected is the event log rather than a normalized relational table.
Each projection subscribes to the event stream and handles each event type it cares about:
on OrderPlaced: insert row into OrderDashboard with status "pending"
on OrderApproved: update row, set status "approved", approved_by, approved_at
on InvoiceGenerated: update row, set status "invoiced", invoice_idThe result is an OrderDashboard table with one row per order, denormalized and shaped for the dashboard query. Queries hit the read model directly — no event folding at query time.
Rebuild by replay: the structural superpower
Because the event log is the source of truth and the read model is a derived artifact, the read model can be rebuilt from scratch at any time by replaying the full event log through the projection function. This is structurally different from a CRUD system where the read model and the write model are the same data — there is nothing to “replay.”
Rebuild-by-replay is valuable in multiple scenarios:
Schema change: The dashboard needs a new column days_to_first_payment. Add the column to the read model table, update the projection logic to compute it, then replay all events. Every historical row gets the correct value computed from the original event data. No write-side migration.
Bug fix: The projection had a bug — it was computing overdue status incorrectly, marking orders as overdue two days too early. Fix the projection logic, replay the event log, and the read model is now correct for all historical data.
New read model: The finance team needs a new view: all orders grouped by billing period. Create a new projection that builds a BillingPeriodSummary table. Replay the existing event log to build it from history. No waiting for future events to accumulate.
▸Why this works
Rebuild-by-replay is only possible because the event log is never modified. If events could be edited or deleted, a replay would not reproduce the original history. Immutability is not just a design preference in event sourcing — it is the structural prerequisite for replay to be meaningful.
Snapshots: bounding the replay cost
Loading an aggregate by folding its full event log becomes expensive as the log grows. An order with 4,000 events requires 4,000 applyEvent calls before a command can even begin. This is the fold-performance problem from lesson 1.
A snapshot is a cached intermediate fold result. At some sequence number N, the application persists the aggregate’s derived state as a snapshot alongside the event log:
{
aggregate_id: "order-48291",
snapshot_seq: 3800,
state: { status: "invoiced", total: 48200, line_items: [...], ... },
created_at: "2024-06-01T12:00"
}On the next load, instead of starting from event 1 and applying all 4,000 events, the application:
- Loads the latest snapshot (state at seq 3800)
- Loads only events after seq 3800 (events 3801–4000)
- Folds only those 200 events onto the snapshot state
The fold cost drops from 4,000 to 200. As new events are appended, a new snapshot is taken periodically.
The critical invariant: a snapshot is not the source of truth. The event log is. A snapshot can be discarded at any time — the aggregate state can always be reconstructed by folding the full event log from event 1. Snapshots exist purely for fold performance; they do not replace events.
Temporal queries: replaying a prefix of the log
Lesson 1 introduced the temporal query: to get the state of order #48291 on March 3rd at 2 PM, filter the event log to events before that timestamp and fold the prefix. This is a structural consequence of the immutable, append-only log.
Temporal queries have a performance implication: they cannot use a snapshot taken after the target timestamp (that snapshot includes events you want to exclude). They must fold from the beginning of the log to the target timestamp. For aggregates with very long histories, temporal queries over old timestamps are expensive. This is a known structural cost of event sourcing — it is manageable for occasional compliance queries, but should not be on the hot path of real-time queries.
Event versioning and upcasting
The event log is immutable — events already stored cannot be changed. But the application’s understanding of what an event means evolves. Six months after the system launched, OrderApproved gains a new field approval_threshold. The 40,000 existing OrderApproved events in the log predate this field. When the projection replays them, it will encounter events missing the new field.
The two structural responses:
Weak schema / tolerant reader: The projection handles OrderApproved with a tolerance policy: if approval_threshold is absent, treat it as null or a default value. The projection code handles both old and new versions of the event. This works for additive changes (new optional fields) and is the simplest approach.
Upcasting: On load (or at read time), old event versions are transformed to the current format before reaching the aggregate’s applyEvent function. An upcast function for v1 → v2 OrderApproved might set approval_threshold: null for all v1 events. The aggregate’s fold logic sees only the current format. The event log retains the original v1 bytes — upcasting is applied at read time, not written back to the log.
The billing team's event log has grown to 12 million events for the order aggregate. The team notices that rebuilding the OrderDashboard read model from scratch (for schema changes) is now taking 4 hours. A developer proposes: 'We should take a snapshot of the read model at regular intervals and use it as the starting point for future rebuilds, instead of replaying from event 1.' Is this a sound proposal?
The billing team wants to add a new field `freight_zone` to all OrderPlaced events going forward. They also want to backfill the field for existing OrderPlaced events in the log. A developer proposes: 'We can UPDATE the existing OrderPlaced event rows in the database to add the freight_zone field for historical events.' What is the structural problem with this proposal?
The team is designing an event sourced system for an order aggregate. They plan to take a snapshot every 100 events. A new team member asks: 'If we have a snapshot, can we delete the events before the snapshot to save storage?' What is the correct answer, and what does it depend on?
- 01Why can a read model in event sourcing be rebuilt from scratch at any time, and what makes this valuable?
- 02What is a snapshot in event sourcing and what invariant must it preserve?
- 03What is upcasting and why does it exist in event sourced systems?
The event log solves the audit and temporal-query problem structurally. But the product manager’s dashboard query — all open orders sorted by account manager — cannot be answered by scanning the event log at query time. The event log is optimized for write-side consistency and history preservation, not for arbitrary read queries.
Projections bridge this gap: a projection function replays events from the log and materializes a read model shaped for a specific query. Because the read model is derived from the immutable event log, it can be rebuilt at any time — schema changes, bug fixes, new query requirements are handled by updating the projection logic and replaying. No write-side migrations.
As the event log grows, fold performance degrades. Snapshots solve this: they cache the aggregate’s derived state at a sequence number, bounding the number of events that must be folded on load. Snapshots are pure performance optimization — the event log remains the source of truth, and snapshots can be discarded and rebuilt.
The event log is immutable, but the domain model is not static. Fields are added, renamed, restructured. Upcasting handles this: old events are transformed to the current schema at read time, leaving the stored bytes unchanged. History is preserved; the domain model can evolve.
The next lesson covers the traps: the operational and structural costs that event sourcing introduces in production — eventual consistency, GDPR tension, compensating events, and honest guidance on when not to event-source.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.