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:
- Tambah
version: 2ke event baru - Handler apply harus handle v1 dan v2
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
}
}