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,TibaDiGudangBandung,TibaDiGudangJakarta,DibawaKurir,Diterimaoleh Pak Joko (kurir salah pindai di rumah lain), laluPenerimaanDibatalkan. 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" },
]
- Tiap event adalah fakta yang sudah terjadi, jadi namanya berbentuk lampau:
Dijemput,TibaDiGudang, bukanJemput. - Log-nya append-only: event cuma ditambah di akhir. Event yang sudah tercatat gak pernah diubah atau dihapus.
- Gak ada kolom
statusdi mana pun. Status adalah hasil hitungan dari log.
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 |
apply(state, e)menerima keadaan dan satu event, lalu mengembalikan keadaan baru ({ ...state, ... }), tanpa mengubah yang lama.events.reduce(apply, AWAL)memutar ulang semua event dari keadaan awal. Ini yang disebut fold atau replay.log.slice(0, 3)memutar ulang tiga event pertama saja: keadaan KCL-0001 pada jam 19.45 kemarin. Keadaan di titik waktu mana pun bisa dihitung ulang (time travel).applygak boleh punya efek samping, misalnya mengirim SMS. Replay terjadi berkali-kali, dan SMS-nya akan ikut terkirim berkali-kali. Efek samping dijalankan sekali, saat event ditambahkan.
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:
- Salin
eventsawal ke log ([...events]), laluapplytiap event:Depositedmenambah saldo,Withdrewmenguranginya. balance(): kembalikan saldo.deposit(amount): kalauamount <= 0,throwsebelum mencatat apa pun. Kalau sah, push{ type: "Deposited", amount }ke log laluapply.withdraw(amount): kalauamount > balance,throw new Error("Insufficient funds"). Kalau sah, push{ type: "Withdrew", amount }laluapply.getEvents(): kembalikan log.
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
- Mengedit atau menghapus event. Log gak bisa lagi menjelaskan efek yang sudah terjadi, seperti SMS yang sudah terkirim. Tambahkan event koreksi.
- Mencatat event sebelum validasi. Setoran -5 yang ditolak tetap masuk log, dan rebuild menghasilkan 95, padahal saldo di memori 100.
- Efek samping di dalam
apply. Tiap replay mengirim ulang semua SMS. Jalankan efek samping sekali, saat event ditambahkan. applyyang cuma mengenal versi event terbaru. Replay event lama melemparTypeError.- Event sourcing untuk data yang gak butuh riwayat. Tema tampilan atau cache cukup disimpan keadaan terakhirnya.
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.