CQRS (Command Query Responsibility Segregation) memisahkan kode yang mengubah data dari kode yang membaca data, sampai ke model datanya. Perubahan masuk lewat command ke sisi tulis yang ketat; bacaan diambil lewat query dari sisi baca yang bentuknya sudah siap tampil. Bayangin papan jadwal kereta di stasiun. Petugas di ruang kendali mencatat perubahan jadwal di buku induk, dengan aturan dan tanda tangan. Papan di peron menampilkan versi yang gampang dibaca penumpang, dan diperbarui petugas lain beberapa saat kemudian. Penumpang yang melihat papan tepat setelah jadwal berubah masih melihat jadwal lama, sebentar.
🧪 Tebakan cepat: kurir memindai KCL-0001
diantar, dan command-nya berhasil. Di baris berikutnya, aplikasi kurir langsung membuka halaman lacak lewat query. Status apa yang tampil di halaman lacak? Tebak dulu, jawabannya ada di bagian "Bacaan basi tepat sesudah menulis".
Masalahnya: satu model untuk dua kebutuhan
Halaman lacak Kurir Kancil menampilkan status, progres, dan riwayat perjalanan sebuah paket. Halaman itu dibuka jauh lebih sering daripada status berubah: misalnya puluhan kali untuk tiap pemindaian kurir. Dengan satu model, tiap kali halaman dibuka, server menggabungkan tabel kiriman, tabel riwayat, dan tabel kurir lagi, padahal hasilnya sama dengan bukaan sebelumnya.
Sisi tulisnya butuh hal lain: validasi ketat. Status cuma boleh pindah sesuai aturan lesson State (transit ke diantar, bukan langsung ke terkirim). Satu model yang melayani keduanya jadi kompromi di dua arah. CQRS memisahkannya.
Sisi tulis: command yang memvalidasi
const shipments = {
"KCL-0001": { resi: "KCL-0001", status: "transit" },
}
const BOLEH = { dijemput: ["transit"], transit: ["diantar"], diantar: ["terkirim", "transit"], terkirim: [] }
const antrian = [] // event yang menunggu proyektor
let writeCount = 0
function execute(cmd) {
const s = shipments[cmd.resi]
if (!s) throw new Error("Resi tidak ditemukan: " + cmd.resi)
if (!BOLEH[s.status].includes(cmd.status)) throw new Error("ditolak " + s.status + " → " + cmd.status)
s.status = cmd.status
writeCount++
antrian.push({ type: "StatusDiubah", resi: cmd.resi, status: cmd.status })
}
- Command adalah permintaan untuk mengubah sesuatu, ditulis sebagai data:
{ type: "UbahStatus", resi: "KCL-0001", status: "diantar" }. Bentuknya sama dengan command sebagai data di lesson Command. executememeriksa dulu, baru menulis. Command yang ditolak melempar error sebelumshipments,writeCount, atauantrianberubah.- Sesudah menulis, sisi tulis menaruh event
StatusDiubahdi antrian: catatan bahwa sesuatu sudah terjadi. Dari event itu sisi baca nanti diperbarui.
Sisi baca: query dari tampilan yang sudah jadi
const PROGRES = { dijemput: 25, transit: 50, diantar: 75, terkirim: 100 }
const lacakView = {
"KCL-0001": { resi: "KCL-0001", status: "transit", progres: 50, riwayat: ["dijemput", "transit"] },
}
function query(q) {
if (q.type === "LacakResi") return lacakView[q.resi] // cuma membaca, gak mengubah apa pun
throw new Error("Unknown query: " + q.type)
}
function proyektor() {
while (antrian.length > 0) {
const e = antrian.shift()
const row = lacakView[e.resi]
row.status = e.status
row.progres = PROGRES[e.status]
row.riwayat.push(e.status)
}
}
lacakViewadalah read model: satu baris per resi berisi semua yang dibutuhkan halaman lacak. Bentuk seperti ini disebut denormalized: datanya sudah digabung dan dihitung, jadi query cukup mengambil satu baris tanpa join.- Query cuma membaca. Query yang ikut mengubah data (misalnya menambah hitungan kunjungan) merusak pemisahannya.
proyektormemperbarui read model dari event di antrian. Di aplikasi beneran, dia worker yang berjalan terpisah, misalnya Laravel Job yang membaca antrian. Hasil sepertilacakViewsering disebut projection.
Bacaan basi tepat sesudah menulis
execute({ type: "UbahStatus", resi: "KCL-0001", status: "diantar" })
console.log(query({ type: "LacakResi", resi: "KCL-0001" }).status) // transit
proyektor()
console.log(query({ type: "LacakResi", resi: "KCL-0001" }).status) // diantar
| langkah | sisi tulis | antrian | sisi baca (halaman lacak) |
|---|---|---|---|
| awal | transit | kosong | transit, 50% |
execute(... "diantar") |
diantar | StatusDiubah diantar |
transit, 50% |
query(...) |
diantar | StatusDiubah diantar |
transit, 50% |
proyektor() |
diantar | kosong | diantar, 75% |
query(...) |
diantar | kosong | diantar, 75% |
Halaman lacak menampilkan transit, padahal command diantar sudah berhasil. Itu jawaban tebakannya. Sisi baca baru menyusul setelah proyektor memproses event-nya. Keadaan ini disebut eventual consistency: kedua sisi akhirnya sama, tapi ada jeda. Tiga cara menanganinya:
- Tampilkan hasil command ke penulisnya. Aplikasi kurir sudah tahu statusnya
diantar, jadi langsung tampilkan itu tanpa menunggu query (optimistic UI). - Baca dari sisi tulis untuk penulisnya sendiri (read-your-writes): kurir yang baru memindai membaca dari
shipments, pelanggan membaca darilacakView. - Tunggu proyeksinya selesai sebelum command dianggap selesai. Gak ada bacaan basi, tapi command jadi lebih lambat.
Tiga tingkat CQRS
- Pisah kodenya saja. Satu database, tapi kode yang mengubah (
execute) terpisah dari kode yang membaca (query). - Pisah modelnya, database sama. Tabel tulis yang rapi plus tabel baca seperti
lacakViewyang diperbarui dari event. - Pisah database. Misalnya PostgreSQL untuk menulis, Elasticsearch atau Redis untuk membaca, disinkronkan lewat antrian event.
Mulai dari tingkat paling ringan yang menyelesaikan masalahmu. Tingkat 2 dan 3 membawa bacaan basi dan kode sinkronisasi yang harus dirawat.
Kapan CQRS berlebihan
Istilah CQRS dipopulerkan Greg Young sekitar 2010, dari prinsip Command Query Separation Bertrand Meyer untuk method: sebuah method mengubah state atau mengembalikan nilai, jangan keduanya. Martin Fowler, di artikelnya tentang CQRS, mengingatkan bahwa untuk kebanyakan sistem CQRS menambah kerumitan yang berisiko, dan cuma cocok untuk bagian tertentu. Untuk CRUD sederhana dengan bacaan dan tulisan yang seimbang dan tim kecil, satu model sudah cukup.
Latihan: createUserCQRS
Latihan nanti memakai versi kecil untuk data user, dengan sisi baca yang diperbarui langsung di dalam command (tingkat 2 tanpa antrian, jadi gak ada bacaan basi). createUserCQRS() mengembalikan tiga method:
execute(command): terima{ type: "RegisterUser", id, name }atau{ type: "UpdateName", id, name }. Kalauidataunamekosong,throwerror sebelum menulis apa pun. Kalau sah, simpan command-nya diwriteStoredan isireadModel[id] = { id, name }.query(q):{ type: "GetUser", id }mengembalikanreadModel[id];{ type: "GetAllNames" }mengembalikan array nama yang diurutkan naik dengan.sort().writeCount(): jumlah command yang berhasil,writeStore.length.
Starter mendaftarkan Budi dan Ani, lalu mengubah nama Budi jadi Budi S.. Console-nya jadi Budi S., Ani,Budi S., 3, caught invalid (dari command dengan id: ''), lalu 3 lagi, karena command yang ditolak gak dihitung.
⚠️ Jebakan yang sering ketemu
- Menulis dulu, memeriksa kemudian.
writeStore.push(command)sebelum validasi membuat command yang ditolak tetap tercatat, danwriteCount()jadi 4. - Menganggap sisi baca langsung sama. Query tepat sesudah command bisa membaca data basi. Tampilkan hasil command ke penulisnya, atau baca dari sisi tulis untuknya.
- Query yang ikut mengubah data. Pemisahannya hilang, dan read model gak bisa dibangun ulang dengan aman.
- CQRS untuk CRUD kecil. Dua model dan sinkronisasi tanpa manfaat yang sebanding.
- Di latihan: lupa
.sort(). Object menyimpan kunci sesuai urutan masuknya, jadi tanpa sort hasilnyaBudi S.,Ani.
TL;DR: CQRS memisahkan command (mengubah, lewat execute yang memvalidasi dulu baru menulis) dari query (membaca, dari read model yang sudah jadi seperti lacakView, tanpa mengubah apa pun). Sisi tulis menaruh event, dan proyektor memperbarui sisi baca dari event itu. Akibatnya eventual consistency: query tepat sesudah command diantar masih menampilkan transit sampai proyektor jalan. Tangani dengan menampilkan hasil command ke penulisnya, read-your-writes, atau menunggu proyeksi. Ada tiga tingkat (pisah kode, pisah model, pisah database); mulai dari yang paling ringan. Untuk CRUD sederhana dengan tim kecil, CQRS berlebihan. Di latihan: validasi sebelum menulis, GetAllNames diurutkan dengan .sort(), dan command yang ditolak gak menambah writeCount().