Event Sourcing — Design Patterns

Event Sourcing: alih-alih menyimpan state saat ini, simpan urutan event yang menghasilkan state tersebut. State = fold(events). Perbandingan: CRUD…

Event Sourcing: alih-alih menyimpan state saat ini, simpan urutan event yang menghasilkan state tersebut. State = fold(events).

Perbandingan:

CRUD tradisional:
users table
┌────┬───────┬─────────┬──────────┐
│ id │ email │ balance │ updated  │
├────┼───────┼─────────┼──────────┤
│ 1  │ [email protected] │ 150     │ 10:30    │
└────┴───────┴─────────┴──────────┘
(Tahu balance SEKARANG. Tidak tahu bagaimana sampai 150.)

Event Sourcing:
events table
┌─────┬────────────┬──────────────┬───────┐
│ seq │ aggregate  │ type         │ data  │
├─────┼────────────┼──────────────┼───────┤
│ 1   │ user-1     │ Registered   │ [email protected] │
│ 2   │ user-1     │ Deposited    │ +100  │
│ 3   │ user-1     │ Withdrew     │ -20   │
│ 4   │ user-1     │ Deposited    │ +70   │
└─────┴────────────┴──────────────┴───────┘
(State = replay semua event. Bisa rebuild state di titik waktu mana pun.)

Aggregate & command flow:

class Account {
  constructor() {
    this.balance = 0;
    this.events = [];
  }

  static fromEvents(events) {
    const acc = new Account();
    events.forEach(e => acc.apply(e));
    return acc;
  }

  // Reduce event → state
  apply(event) {
    switch (event.type) {
      case "Deposited": this.balance += event.amount; break;
      case "Withdrew":  this.balance -= event.amount; break;
    }
  }

  // Command menghasilkan event
  deposit(amount) {
    if (amount <= 0) throw new Error("Invalid amount");
    const event = { type: "Deposited", amount, timestamp: Date.now() };
    this.apply(event);
    this.events.push(event);
    return event;
  }

  withdraw(amount) {
    if (amount > this.balance) throw new Error("Insufficient funds");
    const event = { type: "Withdrew", amount, timestamp: Date.now() };
    this.apply(event);
    this.events.push(event);
    return event;
  }
}

Persistence:

async function loadAccount(id) {
  const events = await eventStore.loadFor(id); // ambil semua event
  return Account.fromEvents(events);
}

async function saveAccount(account) {
  const newEvents = account.events.filter(e => !e.persisted);
  for (const e of newEvents) {
    await eventStore.append({ aggregateId: account.id, ...e });
  }
}

Snapshot (optimasi):

Replay 10 juta event lambat. Simpan snapshot tiap 1000 event:

async function loadAccount(id) {
  const snapshot = await snapshotStore.findLatest(id); // state di event #9000
  const eventsSince = await eventStore.loadFor(id, snapshot.seq); // event #9001+
  const acc = Account.fromState(snapshot.state);
  eventsSince.forEach(e => acc.apply(e));
  return acc;
}

Kelebihan:

Audit trail lengkap gratis — semua perubahan tercatat ✅ Time travel — rebuild state di timestamp mana pun ✅ Debugging powerful — replay event untuk reproduce bug ✅ Multiple projections — dari event yang sama, buat banyak view (pair dengan CQRS) ✅ Refactor analitik — jalankan analysis baru di event history lama

Kekurangan:

Query langsung sulit — "siapa user dengan balance > 1000?" butuh read model ❌ Schema migration complex — event lama tidak bisa di-update, harus handle versi ❌ Storage overhead — simpan semua event (mitigasi: snapshot, event retention policy) ❌ Learning curve — tim harus pikir dalam event, bukan CRUD

Kapan pakai:

✅ Financial/audit-critical (banking, accounting) ✅ Compliance mengharuskan "lihat state di tanggal X" ✅ Domain dengan workflow kompleks (ordering, logistics) ✅ Tim sudah familiar dengan event-driven

❌ Simple CRUD app ❌ Data yang wajar di-update in-place (cache, session)

Kombinasi tipikal:

Event Sourcing (write) + CQRS (split read) + Kafka/EventStore (persistence) + Projections (denormalized read tables).

Event versioning:

Schema event harus immutable. Jika butuh field baru:

apply(event) {
  if (event.type === "Deposited" && event.version === 1) {
    this.balance += event.amount;
  }
  if (event.type === "Deposited" && event.version === 2) {
    this.balance += event.amount;
    this.currency = event.currency; // field baru
  }
}