CQRS (Command Query Responsibility Segregation) artinya memisahkan model untuk menulis dari model untuk membaca. Event sourcing artinya yang disimpan bukan keadaan terakhir, tapi daftar semua kejadian, dan keadaan dihitung ulang dari daftar itu. Bayangin buku kas arisan. Setiap setoran dan penarikan ditulis satu baris, dan baris lama gak pernah dihapus. Papan tulis di dinding menampilkan total per anggota, disalin dari buku. Kalau papannya salah tulis, papannya dihapus dan disalin ulang dari buku. Buku kas = event store, papan = read model, menyalin ulang = rebuild.
🧪 Tebakan cepat: event store Konser Nusantara berisi 5 event (tabel di bagian "Event sourcing: simpan kejadiannya"), dan sisa tiket sebenarnya 9.997. Projector halaman konser punya bug: dia gak menangani event
PesananDibatalkan. Berapa sisa tiket yang ditampilkan halaman konser? Tebak dulu, jawabannya ada di bagian "Rebuild projection".
CQRS: model tulis dan model baca
Di TiketNada, menulis dan membaca punya kebutuhan yang beda:
- Command (tulis) mengubah sesuatu dan bisa ditolak. "Pesan 5 tiket" ditolak karena maksimal 4 tiket per pesanan. Sisi tulis butuh aturan bisnis dan data yang konsisten.
- Query (baca) cuma membaca, tanpa efek samping. Halaman konser butuh satu angka, "sisa tiket", dibaca ribuan kali per detik saat war tiket. Halaman "Tiket saya" butuh jumlah tiket per pembeli.
Dengan CQRS, sisi tulis menyimpan perubahan dengan modelnya sendiri, lalu sisi baca menyiapkan read model: tabel atau cache yang bentuknya persis seperti yang dibutuhkan layar, sudah dihitung, tanpa JOIN. Read model untuk halaman konser cukup satu baris: { konser: 42, sisaTiket: 9997 }.
Ini beda dengan read replica (lesson Database Scaling). Replica adalah salinan database yang sama dengan skema yang sama. Di CQRS, read model boleh punya bentuk yang sama sekali berbeda, bahkan di database lain: Redis untuk sisa tiket, Elasticsearch untuk pencarian konser.
Event sourcing: simpan kejadiannya
Database biasa menyimpan keadaan terakhir: sisa_tiket = 9997. Dari mana angka itu datang, hilang. Dengan event sourcing, yang disimpan adalah urutan event di event store, sebuah log yang cuma bisa ditambah (append-only). Ini stream event Konser Nusantara:
| v | Event | Isi |
|---|---|---|
| 1 | KonserDibuka |
kuota 10.000 |
| 2 | TiketDipesan |
pesanan 1001, Budi, 2 tiket |
| 3 | TiketDipesan |
pesanan 1002, Siti, 4 tiket |
| 4 | PesananDibatalkan |
pesanan 1002, Siti, 4 tiket |
| 5 | TiketDipesan |
pesanan 1003, Rina, 1 tiket |
v adalah versi stream, nomor urut event-nya. Keadaan sekarang didapat dengan replay: mulai dari kosong, terapkan event satu per satu. 10.000 − 2 − 4 + 4 − 1 = 9.997. Gratis kamu dapat dua hal: audit trail lengkap (Siti pernah memesan lalu membatalkan, dan itu gak hilang), dan time travel: keadaan di versi mana pun tinggal replay sampai versi itu. Setelah v3, sisa tiketnya 9.994.
Command yang ditolak gak pernah jadi event. Event adalah fakta yang sudah terjadi, jadi gak bisa ditolak dan gak pernah diubah atau dihapus. Pembatalan bukan menghapus v3, tapi menambah v4.
Replay jadi read model
Fungsi yang mengubah event jadi read model disebut projector, dan hasilnya disebut projection:
// TiketNada: event store Konser Nusantara (model contoh)
const events = [
{ v: 1, type: "KonserDibuka", kuota: 10000 },
{ v: 2, type: "TiketDipesan", pesanan: 1001, pembeli: "Budi", jumlah: 2 },
{ v: 3, type: "TiketDipesan", pesanan: 1002, pembeli: "Siti", jumlah: 4 },
{ v: 4, type: "PesananDibatalkan", pesanan: 1002, pembeli: "Siti", jumlah: 4 },
{ v: 5, type: "TiketDipesan", pesanan: 1003, pembeli: "Rina", jumlah: 1 },
];
// Projector: terapkan satu event ke read model
function proyeksikan(model, e) {
if (e.type === "KonserDibuka") return { sisaTiket: e.kuota, tiketPerPembeli: {} };
const per = { ...model.tiketPerPembeli };
if (e.type === "TiketDipesan") {
per[e.pembeli] = (per[e.pembeli] ?? 0) + e.jumlah;
return { sisaTiket: model.sisaTiket - e.jumlah, tiketPerPembeli: per };
}
if (e.type === "PesananDibatalkan") {
per[e.pembeli] -= e.jumlah;
return { sisaTiket: model.sisaTiket + e.jumlah, tiketPerPembeli: per };
}
return model; // event yang gak dikenal dilewati
}
// Rebuild: replay semua event dari awal
const readModel = events.reduce(proyeksikan, null);
console.log(`rebuild dari v1 sampai v${events.length}`);
console.log("sisa tiket:", readModel.sisaTiket);
console.log("tiket per pembeli:", JSON.stringify(readModel.tiketPerPembeli));
// Time travel: keadaan setelah v3
const dulu = events.slice(0, 3).reduce(proyeksikan, null);
console.log("setelah v3, sisa tiket:", dulu.sisaTiket);
Output-nya:
rebuild dari v1 sampai v5
sisa tiket: 9997
tiket per pembeli: {"Budi":2,"Siti":0,"Rina":1}
setelah v3, sisa tiket: 9994
proyeksikan(model, e)menerima read model sekarang dan satu event, lalu mengembalikan read model baru. Gak ada yang diubah di tempat.events.reduce(proyeksikan, null)adalah replay: mulai darinull, terapkan v1 sampai v5 berurutan.events.slice(0, 3)hanya mengambil v1 sampai v3. Itu time travel.
Di sistem nyata, projector juga jalan terus: tiap event baru langsung diterapkan ke read model. Seperti di lesson Event-Driven Architecture, read model itu eventually consistent: tertinggal sebentar dari event store.
Rebuild projection
Misalkan projector halaman konser ditulis tanpa cabang PesananDibatalkan. Saat v4 datang, projector melewatinya. Halaman konser menampilkan 10.000 − 2 − 4 − 1 = 9.993, dan "Tiket saya" milik Siti masih 4. Itu jawaban tebakannya: 9.993, padahal sebenarnya 9.997.
Di database biasa, data yang salah begini harus ditambal manual. Dengan event sourcing, event store-nya benar, cuma salinannya yang salah. Jadi: perbaiki kode projector, kosongkan read model, lalu replay semua event dari v1. Itu rebuild projection. Cara yang sama dipakai untuk membuat read model baru yang belum pernah ada, misalnya "penjualan per jam", dari semua event lama.
Snapshot: replay yang gak kepanjangan
Untuk memvalidasi command, sisi tulis harus tahu keadaan stream sekarang, artinya replay. Lima event itu murah. Konser dengan 30.000 pesanan dan pembatalan berarti 30.000 event untuk setiap command baru. Snapshot adalah keadaan yang disimpan di versi tertentu. Dengan snapshot tiap 100 event, command cukup membaca snapshot terakhir plus paling banyak 99 event setelahnya. Snapshot cuma cache: kalau hilang, bisa dibuat ulang dari event.
🤔 Kapan dipakai
CQRS + event sourcing menambah banyak bagian: event store, projector, rebuild, snapshot, dan read model yang telat. Itu sepadan kalau domainnya punya aturan bisnis yang kompleks, butuh audit trail (keuangan, tiket, asuransi), dan pola baca dan tulisnya sangat berbeda. Untuk aplikasi CRUD sederhana, cukup satu tabel dan satu model. CQRS juga bisa dipakai tanpa event sourcing: tabel tulis biasa, ditambah read model yang diperbarui setelah setiap tulisan.
📌 Event yang berubah bentuk
Event lama gak boleh diedit. Kalau bentuknya berubah, misalnya TiketDipesan versi 2 menambah field kategori, projector harus tetap bisa membaca versi 1: anggap kategorinya "Reguler", atau ubah event lama ke bentuk baru saat dibaca (upcasting).
⚠️ Jebakan yang sering ketemu
- Projector yang lupa satu jenis event. Read model diam-diam salah. Untung bisa di-rebuild, asal kamu tahu.
- Mengedit atau menghapus event. Merusak audit trail dan semua projection. Koreksi selalu berupa event baru.
- Lupa read model itu telat. Setelah command sukses, layar bisa sesaat menampilkan data lama.
- Replay penuh di setiap command. Lambat begitu stream panjang. Pakai snapshot.
- CQRS + event sourcing untuk CRUD biasa. Kompleksitas berlipat tanpa manfaat.
TL;DR: CQRS: memisahkan model tulis (command, bisa ditolak, menjaga aturan bisnis) dari model baca (query, read model yang sudah dihitung dan bentuknya sesuai layar), supaya masing-masing dioptimalkan sendiri. Event sourcing: state gak disimpan sebagai nilai terakhir, tapi sebagai urutan event di event store yang append-only, dan state dibangun ulang dengan replay (Konser Nusantara: 10.000 − 2 − 4 + 4 − 1 = 9.997). Projector mengubah event jadi read model; kalau projector salah (lupa PesananDibatalkan, 9.993), perbaiki lalu rebuild dari v1. Bonusnya audit trail dan time travel. Snapshot menyimpan state di versi tertentu supaya replay gak kepanjangan. Pakai kalau domainnya kompleks, butuh audit trail, dan baca-tulisnya sangat berbeda; jangan untuk CRUD sederhana.