Bi-Temporal Events
Two clocks in one event: when something happened, and when we found out. Martin Fowler's vision versus practice, "the Arkency way".
In this post
Imagine a customer changes their delivery address on 10 January, but the information reaches the system on 14 January — it came by email, someone retyped it, then there was a weekend. Which date do you store? If only one, some report will lie.
That is what bi-temporal events are about: every event carries two clocks.
valid_at— when the fact happened in the real world,recorded_at— when the system learned about it.
Fowler’s vision
Martin Fowler describes bi-temporality as a way to answer two kinds of questions: “what did the world look like on 12 January?” and “what did we know about the world on 12 January, as seen from 15 January?”. The second question sounds academic until an auditor, a credit note or a lawyer shows up.
The “full” version also covers validity ranges (valid_from, valid_to) and allows corrections of history without deleting it. Beautiful, complete — and rarely needed in full.
Practice: “the Arkency way”
Arkency (the authors of Rails Event Store) showed the pragmatic route: add valid_at to the event and keep timestamp (write time) as the second dimension. The event store already records when an event entered the stream — so the second clock comes for free. You only need to:
- add
valid_atto the event metadata, - allow reading a stream sorted by
valid_atinstead of write order, - decide, per projection, which clock builds the view.
type EventMeta = {
validAt: Date; // when it really happened
recordedAt: Date; // when we stored it (set by the store)
correlationId?: string;
};
type AddressChanged = {
type: 'AddressChanged';
data: { customerId: string; address: Address };
meta: EventMeta;
};
By default validAt === recordedAt. They differ only when an event is written “retroactively” — and those are exactly the interesting cases.
Projections over two clocks
Two projections from the same stream:
// "operational" view: what is true now
const current = events
.sort(byValidAt)
.reduce(applyAddress, emptyCustomer);
// "audit" view: what the system knew on 12 January
const asKnownOn = (date: Date) => events
.filter(e => e.meta.recordedAt <= date)
.sort(byValidAt)
.reduce(applyAddress, emptyCustomer);
That second function is the entire value of bi-temporality in five lines. Without it, answering “why did we ship to the old address?” requires archaeology in the logs.
When to adopt it
- Yes, when data arrives late: integrations, paper documents, financial corrections, medical records.
- Yes, when someone will one day ask “what did we know back then”.
- No, when events are always created at the moment of the fact (UI clicks). Then
validAtalways equalsrecordedAtand you maintain a field nobody reads.
Pitfalls
- Sorting by
validAtbreaks the assumption that a stream is in write order — aggregates that accumulate running totals need to know that. - Time zones: store both clocks in UTC, keep the user’s zone separately.
- Never “fix” old events. Append a new one with an earlier
validAt. The write history must stay untouched — that is its whole value.
Fowler gives you the map of the whole territory. Arkency shows which path to take in the first sprint. Start with two fields; add the rest when you really need it.