← Blog

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”.

Kacper Walczak · 21 sierpnia 2024 · 2 min czytania

W tym wpisie
  1. Wizja Fowlera
  2. Praktyka: „the Arkency way”
  3. Projekcje po dwóch czasach
  4. Kiedy to wdrażać
  5. Pułapki

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:

  1. dodać valid_at do metadanych zdarzenia,
  2. przy odczycie strumienia móc sortować po valid_at zamiast po kolejności zapisu,
  3. 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 validAt będzie zawsze równe recordedAt, 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.