Articles · Event Sourcing ·027

Time-Travel & Live Aggregation: Reading the Past from the Stream

Runnable sample on GitHub

Sample: samples/027-time-travel-and-live-aggregation · Prerequisites: 005 — Event Sourcing Basics, 006 — Concurrency & Snapshots

Overview

Event sourcing stores the sequence of events that produced an aggregate’s state, and the write side rebuilds the current state by replaying that sequence (article 005). But once the whole history is the source of truth, “current” is not the only state you can derive — you can fold the stream up to any point and recover what the aggregate looked like then. That is the promise event sourcing makes almost as an afterthought, and IEventSourcedReader<TAggregate, TId> is the read-side service that delivers it:

  • Time-travel (“as-of”) reads — rebuild the aggregate as of a past version (LoadAtVersionAsync) or a past timestamp (LoadAtTimeAsync). “What was this account’s balance on March 3rd?” stops being a database-archaeology project and becomes one method call.
  • Live aggregation — rebuild current state on demand (LoadAsync) by folding the whole stream, with no persisted read model. When you need the current value of one aggregate occasionally and don’t want to stand up a projection for it, you just fold the events.

The reader is read-only and snapshot-free by design. It complements the write-side IEventSourcedRepository (article 005) and the asynchronous projection pipeline (article 007) rather than replacing either.

Why this exists

The write-side IEventSourcedRepository.GetByIdAsync already replays a stream — but it does so to load the aggregate for writing: it tracks the result on the unit of work, it can consult a snapshot to skip ahead to the head, and it always means “the latest state.” None of those are what a query wants. A query side needs three things the repository deliberately does not offer:

  • A pure read that does not enrol the aggregate in the next transaction’s change tracking.
  • An arbitrary point in history, not just the head — and a snapshot of the head is actively wrong for a historical read.
  • A way to get current state without committing to a projection for aggregates queried rarely.

You could hand-roll this by calling IEventStore.GetEventsAsync, filtering, deserializing, and folding into a fresh aggregate yourself — which is exactly the fiddly, easy-to-get-subtly-wrong code (deserialize with the right registry, order by version, create the aggregate via its non-public constructor, replay without re-raising events) that frameworks exist to absorb. Relay ships the correct version as a single open-generic service.

When to use this

  • Temporal / “as-of” queries. Auditors, disputes, and “show me the state at the time of the incident” — reconstruct the aggregate as of a version or timestamp and read it like any other object.
  • Point-in-time debugging. Reproduce a bug by loading the aggregate exactly as it stood when the bad command landed, instead of guessing from the current state.
  • Occasional current-state reads with no projection. A rarely-queried aggregate whose current value you need on a detail page or an admin screen — fold the stream on demand rather than build, host, and rebuild a projection that earns its keep only a handful of times a day.
  • Verifying a projection. Fold the stream live and compare against the projected value to catch projection drift in a test or a reconciliation job.

When not to use this

  • High-volume current-state queries. Live aggregation reads and folds the entire stream on every call. For hot read paths, or any “list/filter across many aggregates” query, build a projection (article 007) — that is precisely the problem projections solve, and the event store is not a query database.
  • Cross-aggregate queries. The reader rebuilds one aggregate by id. “All accounts over $1000” or “accounts opened last week” are projection queries; the reader cannot answer them and never will.
  • When you already maintain a read model with history. If a projection (or a temporal table) already serves the as-of data you need, reading it is cheaper than re-folding the stream.
  • As a write path. The reader does not track the aggregate; mutating what it returns and expecting it to persist will silently do nothing. Use IEventSourcedRepository to write (article 005).

Concepts

IEventSourcedReader<TAggregate, TId>. A read-only, point-in-time rehydration service. Three methods, each returning null when the aggregate had no (qualifying) events:

  • LoadAsync(id) — fold the whole stream → current state (live aggregation).
  • LoadAtVersionAsync(id, version) — fold events with Version <= version → state as of that version.
  • LoadAtTimeAsync(id, asOf) — fold events with Timestamp <= asOf → state as it stood at that instant.

It hydrates a fresh aggregate via AggregateRoot.LoadFromHistory (the same replay path the repository uses), and the result is not tracked on the unit of work — these are pure reads.

As-of vs live. Both are the same fold over a prefix of the stream; they differ only in where the prefix stops. Live aggregation is LoadAtVersionAsync at the head — the whole stream. A time-travel read stops earlier. There is no separate machinery for “history”: history is just a shorter replay.

Version vs timestamp. A version cut (LoadAtVersionAsync) is exact and monotonic — version n always means “after the (n+1)th event,” with no ambiguity. A timestamp cut (LoadAtTimeAsync) is for “what did it look like at this wall-clock instant,” and it filters on each event’s recorded Timestamp (the OccurredAt the event store persisted). Two events in the same transaction can carry timestamps microseconds apart, so a timestamp boundary that lands between events is what makes the cut unambiguous; prefer a version cut when you have an exact version in hand.

Snapshots are ignored — on purpose. A snapshot (article 006) captures the aggregate’s head state to bound replay cost. For a time-travel read that is exactly the wrong starting point: you want the state before the head, so the reader always replays from version 0. Replaying from zero is always correct, and acceptable for read-side queries; the snapshot table must still be mapped (the write-side repository needs it), but the reader never reads it.

Cost vs persisted projections. Live aggregation trades storage and a pipeline for read CPU: no read model to build, host, keep current, or rebuild — but every read folds the whole stream. A projection inverts that trade: cheap reads, at the cost of the projection machinery and eventual consistency. The reader is the right tool for occasional reads of a single aggregate; a projection is the right tool for frequent reads or queries across aggregates.

Architecture

flowchart TD
    subgraph Store["relay_events (append-only)"]
        E0["v0 AccountOpened<br/>t0"]
        E1["v1 MoneyDeposited 100<br/>t1"]
        E2["v2 MoneyWithdrawn 30<br/>t2"]
        E3["v3 MoneyDeposited 10<br/>t3"]
    end

    R["IEventSourcedReader&lt;BankAccount, Guid&gt;"]

    Store -->|GetEventsAsync id, 0| R

    R -->|"LoadAtVersionAsync(id, 1)<br/>fold v0..v1"| AV["balance = 100<br/>(as of v1)"]
    R -->|"LoadAtTimeAsync(id, t between t1,t2)<br/>fold Timestamp ≤ asOf"| AT["balance = 100<br/>(as of that instant)"]
    R -->|"LoadAsync(id)<br/>fold v0..head"| LV["balance = 80<br/>(current, no read model)"]

    note["Snapshots ignored: always replays from v0<br/>Result not tracked on the unit of work — pure read"]
    R -.-> note

The write side stores events; the reader derives any state — past or present — by folding a prefix of that one stream. That is the same separation that makes event sourcing pair with CQRS: the read model is derived, and here it is derived on demand instead of maintained ahead of time.

Building it step by step

The full sample is in samples/027-time-travel-and-live-aggregation. The aggregate, events, and commands are the article-005 BankAccount unchanged — the reader is added purely on the read side.

1. Register the reader

AddRelayEventSourcedRepositoryEfCore() already registers the reader transitively, but a query-only service that never writes can register just the reader:

services.AddRelayEventStoreEfCore<LedgerDbContext>();   // the reader needs the event store + serializer
services.AddRelayEventSourcedReaderEfCore();            // IEventSourcedReader<,> (open generic, scoped)

The sample’s fixture registers the full write stack and calls AddRelayEventSourcedReaderEfCore() explicitly, so the read-side wiring is visible in one place.

2. Time-travel by version

var reader = scope.ServiceProvider.GetRequiredService<IEventSourcedReader<BankAccount, Guid>>();

// After Open(v0), Deposit 100 (v1), Withdraw 30 (v2), Deposit 10 (v3):
var atV1 = await reader.LoadAtVersionAsync(id, 1, ct);
atV1!.Balance.Should().Be(100m);   // before the withdrawal — what it was then, not what it is now
atV1.Version.Should().Be(1);

3. Time-travel by timestamp

await Bus.Execute<DepositCommand, BankAccount>(new DepositCommand(id, 100m), ct);
await Task.Delay(50);
var asOf = DateTimeOffset.UtcNow;   // a boundary that lands between events
await Task.Delay(50);
await Bus.Execute<WithdrawCommand, BankAccount>(new WithdrawCommand(id, 40m), ct);

var asOfState = await reader.LoadAtTimeAsync(id, asOf, ct);
asOfState!.Balance.Should().Be(100m);   // the withdrawal is after the cutoff, so it is not folded

4. Live aggregation — current state with no read model

// The only thing stored is the event stream; there is no balances table, no projection.
var account = await reader.LoadAsync(id, ct);
account!.Balance.Should().Be(200m);   // 250 deposited − 50 withdrawn, folded on demand
account.Version.Should().Be(2);       // and the result is NOT tracked for writing

Complete source code

File Contents
Accounts/BankAccount.cs The event-sourced aggregate and its events (article-005 shape)
Accounts/Commands.cs Commands + handlers (write side, no [SkipTransaction])
LedgerDbContext.cs ApplyRelayEventStore() + ApplyRelaySnapshots()
LedgerFixture.cs Testcontainers Postgres + DI incl. AddRelayEventSourcedReaderEfCore()
TimeTravelTests.cs as-of-version, as-of-timestamp, live aggregation, unknown-account

Running the example

dotnet test samples/027-time-travel-and-live-aggregation/Ledger.TimeTravel.Tests

This needs Docker — the fixture starts a real postgres:16 via Testcontainers, applies the schema with EnsureCreatedAsync(), runs commands through the full transactional pipeline, then reads past and present state back through the reader. That is deliberate: reconstructing state from a stored stream is a storage behaviour, and the only honest demonstration is against a real store. In production you replace EnsureCreatedAsync() with EF migrations (or the baseline SQL in libraries/nuvora-nexus-relay/docs/migrations/) and add the AddRelaySchema() readiness check.

Testing

The sample proves the two properties that make the reader trustworthy, against real PostgreSQL rather than a mock:

Time-travel reproduces past state. After four events, LoadAtVersionAsync(id, 1) rebuilds the balance as it was at version 1 (100), and LoadAtVersionAsync(id, 2) rebuilds it at version 2 (70) — not the current 80. The timestamp test captures a cutoff between events (bracketed by small delays so the cut is unambiguous) and shows LoadAtTimeAsync folds only the events at or before it.

Live aggregation returns current state with no read model. The test first asserts the only stored artefact is the event stream (IEventStore.GetEventsAsync returns versions 0, 1, 2 and nothing else), then shows LoadAsync folds that stream to the current balance. Mocking the store here would test the mock; the guarantees — correct ordering, correct deserialization, correct fold — only exist end-to-end against the real store.

Production considerations

  • Live aggregation reads the whole stream, every call. It is O(stream length) per read with no snapshot shortcut. Fine for short streams and occasional reads; for long streams or hot paths, build a projection (article 007). The reader is a scalpel, not a query engine.
  • Time-travel ignores snapshots — and that is correct. Do not “optimize” by feeding the reader a snapshot; a snapshot is the head, which is wrong for a historical read. Replay from zero is the price of correctness here, and it is the right price for read-side queries.
  • Timestamp reads are only as precise as the recorded timestamps. LoadAtTimeAsync filters on each event’s OccurredAt. Events committed in the same transaction can be microseconds apart, and clocks across processes can skew. For an exact cut, prefer a version; reserve timestamp cuts for genuine “as of this wall-clock instant” questions and pick a boundary that falls between events.
  • The reader sees only committed events. It loads through IEventStore.GetEventsAsync, so it reads what has committed — outside a write transaction it never sees a half-written stream.
  • Event versioning still applies. The reader deserializes historical events with the same registry and upcasting (article 024) as everything else; a poorly-versioned event is as much a liability on read as on the write side.

Common mistakes

  • Using live aggregation as the everyday read path. Folding the whole stream on every request scales with stream length and does not answer cross-aggregate queries. The first time a read path gets hot, or needs a filter/list, it wanted a projection (article 007), not the reader.
  • Expecting snapshots to speed up time-travel. They cannot — a snapshot is the head. The reader ignoring them is the feature, not a missed optimization.
  • Treating a timestamp cut as exact. Relying on sub-millisecond timestamp ordering (or cross-node clock agreement) to separate events. Use a version when you need an exact boundary.
  • Mutating what the reader returns and expecting it to save. The result is not tracked; writes go through IEventSourcedRepository. The reader is read-only by contract.
  • Querying the event store directly to do this by hand. Re-implementing fold/deserialize/order is error-prone (wrong order, double-applied events, wrong registry). Use the reader.

Tradeoffs

  • On-demand fold vs. maintained projection. The reader needs no read model — nothing to build, host, keep current, or rebuild — at the cost of folding the stream on every read and being limited to single-aggregate, by-id reads. A projection inverts this: cheap, queryable, cross-aggregate reads, paid for with the projection pipeline and eventual consistency. Use the reader for occasional single-aggregate reads; use projections for frequent or cross-aggregate ones.
  • Version cut vs. timestamp cut. A version cut is exact and unambiguous but requires knowing the version; a timestamp cut answers the natural “as of then” question but is only as precise as the recorded timestamps. They are complementary — version for precision, timestamp for wall-clock intent.
  • Correctness (replay from zero) vs. raw speed. Skipping snapshots guarantees historical correctness at the cost of always replaying the prefix. For read-side queries that is the right trade; when replay cost for current state actually hurts, that is the signal to project, not to snapshot-shortcut the reader.

Alternatives

  • Projections (article 007). Maintain a read model updated as events arrive. The right answer for frequent reads, cross-aggregate queries, and lists. Eventually consistent, and you own the rebuild machinery — but reads are cheap and queryable. Complementary to the reader, not a competitor.
  • The write-side repository (article 005). IEventSourcedRepository.GetByIdAsync also replays a stream, but for writing — it tracks the result and may use a snapshot. Use it to load-then-mutate, not to query history.
  • Temporal tables / system-versioning. The database keeps row history, giving “as of” queries over state. But it versions rows, not business events — you get “the balance was 100 at noon,” not the events that produced it — and it cannot feed projections or replay behaviour.
  • Hand-rolled fold over IEventStore. Always available, never advisable: you re-implement ordering, deserialization, and aggregate construction that the reader already gets right.

Lessons from production systems

  • The “as-of” question always arrives. Teams that adopted event sourcing for the audit trail are, a quarter later, asked to reconstruct a disputed state at a past instant. Those who knew the reader existed shipped it in an afternoon; those who didn’t wrote a bespoke fold and got the event ordering wrong.
  • Live aggregation is a trap if it becomes the read path. It is genuinely useful for the occasional single-aggregate read, and genuinely a performance incident waiting to happen the moment it backs a hot endpoint or a list view. The discipline is knowing which one you have — and reaching for a projection before the stream gets long.
  • Snapshots and time-travel are orthogonal. The recurring confusion is “can’t we use the snapshot to speed up history?” — no, because a snapshot is the present. Internalizing that the reader replays from zero by design saves a class of subtle “as-of read returned current state” bugs.

Should you use this?

If you… Then…
need “as of a past version/time” reads of one aggregate yesLoadAtVersionAsync / LoadAtTimeAsync
need current state of a rarely-queried aggregate, no projection yesLoadAsync (live aggregation)
are reproducing a bug at a point in history yes — load the aggregate as it stood then
have a hot read path or need lists/filters no — build a projection (article 007)
need cross-aggregate queries no — the reader is by-id, single-aggregate
want to write no — use IEventSourcedRepository (article 005)

Next steps

  • 007 — Projections: when on-demand folding is too slow or you need queryable, cross-aggregate read models, maintain them ahead of time with the projection pipeline.
  • 005 — Event Sourcing Basics: the write side this reader reads from — the append-only store, optimistic concurrency, and replay the reader reuses.