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
IEventSourcedRepositoryto 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 withVersion <= version→ state as of that version.LoadAtTimeAsync(id, asOf)— fold events withTimestamp <= 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<BankAccount, Guid>"]
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.
LoadAtTimeAsyncfilters on each event’sOccurredAt. 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.GetByIdAsyncalso 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 | yes — LoadAtVersionAsync / LoadAtTimeAsync |
| need current state of a rarely-queried aggregate, no projection | yes — LoadAsync (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.