CQRS (Command Query Responsibility Segregation) — Design Patterns

CQRS memisahkan operasi write (Command) dari read (Query) ke dalam model berbeda. Tujuannya: skalabilitas dan optimasi terpisah. Masalah yang dipecahkan…

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 })
}

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)
  }
}

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:

Tiga tingkat CQRS

  1. Pisah kodenya saja. Satu database, tapi kode yang mengubah (execute) terpisah dari kode yang membaca (query).
  2. Pisah modelnya, database sama. Tabel tulis yang rapi plus tabel baca seperti lacakView yang diperbarui dari event.
  3. 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:

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

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().

Yang akan kamu pelajari