Bi-Temporal Events
Dwa czasy w jednym zdarzeniu: kiedy coś się stało, a kiedy się o tym dowiedzieliśmy. Wizja Martina Fowlera kontra praktyka „the Arkency way”.
W tym wpisie
Wyobraź sobie, że 10 stycznia klient zmienia adres dostawy, ale informacja dociera do systemu dopiero 14 stycznia — bo przyszła mailem, ktoś ją przepisał, a potem był weekend. Jaką datę zapiszesz? Jeśli tylko jedną, to w którymś raporcie skłamiesz.
Właśnie o tym są zdarzenia bitemporalne: każde zdarzenie ma dwa czasy.
valid_at— kiedy fakt zaszedł w świecie rzeczywistym,recorded_at— kiedy system się o nim dowiedział.
Wizja Fowlera
Martin Fowler opisuje bitemporalność jako sposób na odpowiedź na dwa rodzaje pytań: „jak wyglądał świat 12 stycznia?” oraz „co wiedzieliśmy o świecie 12 stycznia, patrząc z 15 stycznia?”. To drugie pytanie brzmi akademicko, dopóki nie przyjdzie audytor, faktura korygująca albo pytanie od prawnika.
W wersji „pełnej” bitemporalność obejmuje też przedziały ważności (valid_from, valid_to) i pozwala na korekty historii bez jej kasowania. Piękne, kompletne — i rzadko potrzebne w całości.
Praktyka: „the Arkency way”
Arkency (twórcy Rails Event Store) pokazali podejście pragmatyczne: dodaj valid_at do zdarzenia i zostaw timestamp (czas zapisu) jako drugi wymiar. Event store i tak zapisuje, kiedy zdarzenie trafiło do strumienia — więc drugi czas masz za darmo. Trzeba tylko:
- dodać
valid_atdo metadanych zdarzenia, - przy odczycie strumienia móc sortować po
valid_atzamiast po kolejności zapisu, - w projekcjach świadomie wybierać, po którym czasie budujesz widok.
type EventMeta = {
validAt: Date; // kiedy stało się naprawdę
recordedAt: Date; // kiedy zapisaliśmy (nadaje store)
correlationId?: string;
};
type AddressChanged = {
type: 'AddressChanged';
data: { customerId: string; address: Address };
meta: EventMeta;
};
Domyślnie validAt === recordedAt. Różnią się tylko wtedy, gdy zdarzenie zapisujemy „wstecz” — a właśnie te przypadki są najciekawsze.
Projekcje po dwóch czasach
Dwie projekcje z tego samego strumienia:
// widok "operacyjny": co jest prawdą teraz
const current = events
.sort(byValidAt)
.reduce(applyAddress, emptyCustomer);
// widok "audytowy": co system wiedział 12 stycznia
const asKnownOn = (date: Date) => events
.filter(e => e.meta.recordedAt <= date)
.sort(byValidAt)
.reduce(applyAddress, emptyCustomer);
Ta druga funkcja to cała wartość bitemporalności w pięciu linijkach. Bez niej odpowiedź na pytanie „dlaczego wysłaliśmy paczkę na stary adres?” wymaga archeologii w logach.
Kiedy to wdrażać
- Tak, gdy dane przychodzą z opóźnieniem: integracje, dokumenty papierowe, korekty finansowe, dane medyczne.
- Tak, gdy ktoś kiedyś zapyta „co wiedzieliśmy wtedy”.
- Nie, gdy zdarzenia zawsze powstają w momencie faktu (kliknięcia w UI). Wtedy
validAtbędzie zawsze równerecordedAt, a Ty będziesz utrzymywać pole, którego nikt nie czyta.
Pułapki
- Sortowanie po
validAtłamie założenie, że strumień jest w kolejności zapisu — agregaty, które liczą coś narastająco, muszą to wiedzieć. - Strefy czasowe: zapisuj oba czasy w UTC, a strefę użytkownika trzymaj osobno.
- Nie „poprawiaj” starych zdarzeń. Dodaj nowe z wcześniejszym
validAt. Historia zapisu ma zostać nietknięta — to jej cała wartość.
Fowler daje mapę całego terytorium. Arkency pokazuje, którą ścieżką iść w pierwszym sprincie. Zacznij od dwóch pól, resztę dodasz, gdy naprawdę będzie potrzebna.