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 menyimpan urutan kejadian (event) yang mengubah data, bukan cuma keadaan terakhirnya. Keadaan sekarang dihitung dengan memutar ulang (replay) semua event dari awal. Bayangin buku tabungan. Bank mencetak tiap transaksi sebagai satu baris: setor, tarik, biaya admin. Saldo di baris terakhir adalah hasil semua baris di atasnya. Kalau bank salah catat, baris lama gak dihapus: mereka menambah baris koreksi, jadi kesalahan dan perbaikannya sama-sama kelihatan.

🧪 Tebakan cepat: log KCL-0001 berisi Dijemput, TibaDiGudang Bandung, TibaDiGudang Jakarta, DibawaKurir, Diterima oleh Pak Joko (kurir salah pindai di rumah lain), lalu PenerimaanDibatalkan. Apa status paketnya sekarang, dan berapa event yang ada di log? Tebak dulu, jawabannya ada di bagian "Salah pindai: koreksi dengan event baru".

Masalahnya: tabel yang cuma ingat keadaan terakhir

Dengan CRUD biasa, tabel kiriman punya kolom status, dan tiap pemindaian menimpanya: transit jadi diantar, lalu jadi terkirim. Keadaan lama hilang. Saat pelanggan bertanya "kenapa saya dapat SMS paket sudah diterima, padahal belum?", tabel cuma tahu status sekarang. Gak ada jejak siapa memindai apa, kapan.

Event: fakta yang sudah terjadi

const log = [
  { jam: "08.10", type: "Dijemput" },
  { jam: "11.30", type: "TibaDiGudang", kota: "Bandung" },
  { jam: "19.45", type: "TibaDiGudang", kota: "Jakarta" },
  { jam: "07.20", type: "DibawaKurir" },
]

Status sekarang = replay semua event

function apply(state, e) {
  switch (e.type) {
    case "Dijemput": return { ...state, status: "dijemput" }
    case "TibaDiGudang": return { ...state, status: "transit", kota: e.kota }
    case "DibawaKurir": return { ...state, status: "diantar" }
    case "Diterima": return { ...state, status: "terkirim", penerima: e.oleh }
    case "PenerimaanDibatalkan": return { ...state, status: "diantar", penerima: null }
    default: return state
  }
}
const AWAL = { resi: "KCL-0001", status: null, kota: null, penerima: null }
const rebuild = (events) => events.reduce(apply, AWAL)

console.log(rebuild(log).status)              // diantar
console.log(rebuild(log.slice(0, 3)).status)  // transit
event status sesudahnya kota
awal null null
#1 Dijemput dijemput null
#2 TibaDiGudang Bandung transit Bandung
#3 TibaDiGudang Jakarta transit Jakarta
#4 DibawaKurir diantar Jakarta

Proyeksi: banyak tampilan dari log yang sama

Dari event yang sama, kamu bisa membangun tampilan lain. Riwayat di halaman lacak adalah salah satunya:

const LABEL = {
  Dijemput: () => "dijemput kurir",
  TibaDiGudang: (e) => "tiba di gudang " + e.kota,
  DibawaKurir: () => "dibawa kurir untuk diantar",
  Diterima: (e) => "diterima " + e.oleh,
  PenerimaanDibatalkan: (e) => "penerimaan dibatalkan: " + e.alasan,
}
const riwayat = (events) => events.map((e) => e.jam + " " + LABEL[e.type](e))
// ["08.10 dijemput kurir", "11.30 tiba di gudang Bandung", ...]

Tampilan yang dibangun dari event disebut proyeksi, persis read model di lesson CQRS. Laporan baru, misalnya "berapa lama paket menginap di tiap gudang", cukup proyeksi baru yang dijalankan di atas event lama. Datanya sudah ada sejak hari pertama.

Salah pindai: koreksi dengan event baru

Kurir salah pindai: KCL-0001 tercatat Diterima oleh Pak Joko, dan SMS "sudah diterima" langsung terkirim ke pelanggan. Cara memperbaikinya adalah menambah event, bukan menghapus:

function append(e) {
  log.push(e)
  if (e.type === "Diterima") console.log("SMS terkirim: KCL-0001 sudah diterima " + e.oleh)
  if (e.type === "PenerimaanDibatalkan") console.log("SMS terkirim: koreksi, KCL-0001 belum diterima (" + e.alasan + ")")
}

append({ jam: "10.05", type: "Diterima", oleh: "Pak Joko" })
append({ jam: "10.40", type: "PenerimaanDibatalkan", alasan: "salah pindai" })
console.log(rebuild(log).status, log.length) // diantar 6

Statusnya kembali diantar, dan log berisi 6 event: salah pindainya dan koreksinya sama-sama tercatat. Itu jawaban tebakannya. Event yang membatalkan efek event lain seperti ini disebut compensating event.

hapus event Diterima tambah PenerimaanDibatalkan
status sekarang diantar diantar
isi log 4 event, seolah gak pernah terjadi apa-apa 6 event, salah dan koreksinya tercatat
SMS "sudah diterima" yang sudah terkirim gak bisa dijelaskan oleh log dijelaskan event #5, dan diikuti SMS koreksi
proyeksi lain yang sudah membaca event #5 gak tahu event-nya hilang ikut menerima koreksinya

Snapshot dan versi event

Snapshot. Paket punya sedikit event, tapi saldo COD seorang kurir bisa punya ratusan ribu setelah beberapa tahun. Replay semuanya tiap kali saldo dibaca itu lambat. Simpan snapshot (keadaan hasil replay) tiap, misalnya, 1.000 event. Saat memuat, ambil snapshot terakhir lalu replay event sesudahnya saja, paling banyak 1.000.

Versi event. Event lama gak boleh diubah, tapi bentuk event bisa berubah. Misalnya TibaDiGudang versi baru membawa gudang: { kota, kode }. Kalau apply cuma mengenal versi baru (e.gudang.kota), replay event lama melempar TypeError: Cannot read properties of undefined (reading 'kota'). apply harus mengenal kedua versi: e.v === 2 ? e.gudang.kota : e.kota. Ini sering diremehkan, karena event lama tetap ada selama bertahun-tahun.

Latihan: createAccount

Latihan nanti memakai rekening sederhana, seperti saldo COD kurir. createAccount(events) mengembalikan object dengan empat method, dan saldonya selalu hasil replay:

rebuild(events) di starter cukup memanggil createAccount(events). Console-nya jadi 0, 120 (100 + 50 - 30), 3, Insufficient funds, invalid deposit, 120 dari rebuild seluruh log, lalu 150 dari rebuild dua event pertama.

⚠️ Jebakan yang sering ketemu

TL;DR: Event Sourcing menyimpan urutan event yang sudah terjadi (append-only, nama lampau, gak pernah diubah), bukan cuma keadaan terakhir. Status sekarang adalah hasil replay: events.reduce(apply, AWAL), dan log.slice(0, n) memberi keadaan di titik waktu mana pun. Dari log yang sama bisa dibangun banyak proyeksi, seperti riwayat halaman lacak. Kesalahan diperbaiki dengan event koreksi (PenerimaanDibatalkan), bukan dengan menghapus: status KCL-0001 kembali diantar dan log berisi 6 event. apply harus tanpa efek samping dan mengenal semua versi event; snapshot mempercepat replay log yang panjang. Di latihan: validasi sebelum push, saldo selalu hasil replay, jadi rebuild seluruh log memberi 120 dan dua event pertama memberi 150.

Yang akan kamu pelajari