CQRS untuk SaaS layak dibangun ketika kebutuhan baca tidak lagi cocok dengan model tulis: misalnya query dashboard dengan banyak agregasi memperlambat pembuatan order, atau satu data order harus ditampilkan dalam bentuk berbeda untuk pelanggan, tim operasional, dan laporan internal. Solusinya bukan selalu memecah seluruh sistem menjadi arsitektur event-driven. Sering kali, read model terpisah yang kecil dan terukur sudah cukup.

Keputusan utamanya adalah apakah kompleksitas tambahan—sinkronisasi data, konsistensi eventual, monitoring, dan prosedur pemulihan—lebih kecil daripada biaya mempertahankan query langsung pada database transaksi. Artikel ini membantu menilai titik tersebut secara praktis.

Masalah yang biasanya memicu CQRS

Pada monolit CRUD, tabel dan model yang sama sering dipakai untuk menerima perubahan dan melayani semua query. Pendekatan ini efisien pada tahap awal. Namun, masalah muncul saat pola akses baca dan tulis mulai bertentangan.

Contoh: order dan dashboard tenant

Misalkan aplikasi SaaS menerima order. Transaksi utama perlu menjamin stok, harga, status pembayaran, dan isolasi tenant tetap benar. Sementara itu, dashboard manajer memerlukan total penjualan per hari, jumlah order per status, produk terlaris, tren per kanal, dan daftar order terbaru.

Jika dashboard menghitung semua informasi tersebut langsung dari tabel orders, order_items, dan payments, query dapat melibatkan join, filter tenant, agregasi, pengurutan, serta rentang waktu besar. Ketika query semacam ini bersaing dengan transaksi INSERT dan UPDATE, dampaknya dapat berupa:

  • latensi pembuatan atau perubahan order meningkat;
  • database utama mengalami CPU, I/O, atau koneksi tinggi pada jam akses dashboard;
  • indeks ditambahkan khusus untuk dashboard tetapi memperberat operasi tulis;
  • logika query berkembang menjadi sulit diuji dan sulit dioptimalkan;
  • setiap tampilan baru menambah join atau agregasi pada jalur data operasional.

CQRS memisahkan tanggung jawab ini: write model menjaga aturan bisnis dan konsistensi transaksi, sedangkan read model dibentuk untuk query tertentu. Pemisahan ini tidak mengharuskan dua layanan, dua database, atau event sourcing sejak hari pertama.

Membandingkan tiga pilihan arsitektur

1. Monolit CRUD dengan query langsung

API perintah dan API baca menggunakan database serta skema transaksi yang sama. Dashboard dapat memakai view SQL, indeks tambahan, cache, atau replica baca bila tersedia.

Pilih ini jika:

  • query baca masih sederhana dan dapat dioptimalkan dengan indeks atau pagination;
  • data harus terlihat segera setelah transaksi berhasil;
  • jumlah variasi tampilan sedikit;
  • tim belum siap mengoperasikan worker, antrean, dead-letter handling, dan monitoring lag;
  • masalah kinerja belum terbukti melalui metrik atau profiling.

Jangan mengadopsi CQRS hanya karena ada dashboard. Banyak dashboard dapat dilayani oleh query langsung yang baik, materialized view terjadwal, cache dengan invalidasi jelas, atau read replica. Jika beban utamanya adalah satu query buruk, perbaiki query tersebut terlebih dahulu.

2. CQRS ringan: read model terpisah dari perubahan aplikasi

Pada CQRS ringan, aplikasi menulis ke model transaksi lalu memperbarui tabel proyeksi atau indeks baca khusus. Pemisahan dapat tetap berada dalam satu codebase dan bahkan satu database, tetapi tabel read model tidak dipakai untuk menegakkan aturan bisnis.

Contoh tabel proyeksi dashboard:

tenant_daily_sales
- tenant_id
- sales_date
- paid_order_count
- gross_amount
- refunded_amount
- updated_at

order_dashboard_rows
- tenant_id
- order_id
- customer_name
- status
- paid_at
- total_amount
- item_count
- updated_at

Model ini cocok bila proyeksi dapat diperbarui dari perubahan aplikasi yang jelas, misalnya setelah order dibayar atau dibatalkan. Anda memperoleh query baca yang sederhana tanpa harus langsung mengelola stream event sebagai sumber kebenaran utama.

Keunggulan: implementasi lebih sederhana, migrasi lebih rendah risiko, dan query dashboard dapat dirancang khusus untuk kebutuhan UI.

Keterbatasan: bila pembaruan write model dan read model dilakukan sebagai dua operasi terpisah tanpa mekanisme andal, kegagalan di tengah jalan dapat menciptakan data proyeksi tertinggal atau tidak konsisten.

3. CQRS berbasis event

Pada pendekatan ini, perubahan bisnis menghasilkan event seperti OrderPaid, OrderCancelled, atau RefundIssued. Satu atau lebih projector mengonsumsi event tersebut untuk membangun read model. Event dapat disimpan pada tabel outbox, broker pesan, atau log event yang tahan lama.

Pendekatan ini layak ketika beberapa konsumen membutuhkan data yang sama: dashboard, notifikasi, sinkronisasi analitik, pencarian, audit, atau integrasi eksternal. Namun, CQRS berbasis event bukan sekadar mengganti pemanggilan fungsi dengan antrean. Ia menuntut kontrak event, idempotensi, versioning, replay, observability, dan ownership operasional yang jelas.

Catatan: CQRS tidak identik dengan event sourcing. Event sourcing menjadikan event sebagai sumber kebenaran utama untuk state domain. Anda dapat memakai CQRS dengan event integrasi atau transactional outbox tanpa membangun event sourcing.

Matriks keputusan: apakah read model terpisah layak?

FaktorQuery langsung / CRUDCQRS ringanCQRS berbasis event
Kompleksitas query bacaRendah, sedikit variasiAgregasi dan denormalisasi mulai dominanBanyak proyeksi atau konsumen berbeda
Konsistensi yang dibutuhkan UIHarus langsung konsistenStale singkat dapat diterima untuk sebagian layarEventual consistency diterima dan dikomunikasikan
Konsumen perubahan dataSatu aplikasi utamaBeberapa endpoint dalam aplikasi yang samaDashboard, integrasi, pencarian, analitik, notifikasi
OperasionalSatu database dan monitoring biasaJob atau worker proyeksi terbatasAntrean/broker, retry, DLQ, lag, replay, kontrak event
Kecepatan implementasiPaling cepatMenengahPaling lambat, disiplin desain lebih tinggi
Kebutuhan histori perubahanTerbatas pada audit biasaDapat ditambah secara selektifHistori event dan replay bernilai tinggi

Gunakan matriks ini sebagai alat diskusi, bukan aturan absolut. Sebagai contoh, satu dashboard berat belum otomatis memerlukan broker pesan. Sebaliknya, satu domain order yang mengirim data ke banyak produk internal mungkin memang memerlukan aliran event yang andal.

Metrik pemicu adopsi yang perlu dikumpulkan

Keputusan sebaiknya didasarkan pada bukti produksi atau hasil load test yang realistis. Pantau metrik per tenant dan per endpoint agar tenant besar tidak menyamarkan masalah tenant lain.

  • Latensi transaksi: bandingkan p95/p99 endpoint tulis sebelum dan selama jam dashboard aktif.
  • Waktu dan frekuensi query: identifikasi query agregasi paling mahal, jumlah baris yang dipindai, serta pola join dan sort.
  • Kontensi database: amati penggunaan CPU, I/O, koneksi, lock wait, dan antrean koneksi pada database transaksi.
  • Rasio baca terhadap tulis: terutama endpoint yang berulang membaca agregasi yang sama.
  • Variasi representasi: hitung berapa banyak tampilan yang memerlukan bentuk data berbeda dari model domain.
  • Toleransi keterlambatan data: bedakan layar operasional yang perlu mutakhir dari laporan yang boleh tertinggal beberapa detik atau menit.
  • Biaya perubahan: ukur seberapa sering perubahan dashboard memaksa perubahan indeks, query domain, atau skema transaksi.

Pemicu yang kuat bukan sekadar “database terasa lambat”, melainkan pola berulang: query baca teridentifikasi mengganggu SLO transaksi, optimasi konvensional tidak lagi cukup, dan layar target dapat menerima data yang sedikit tertinggal.

Rancangan praktis untuk alur order dan dashboard

Untuk banyak SaaS, pola paling aman adalah menjaga transaksi order tetap sinkron, lalu menerbitkan perubahan untuk proyeksi secara andal. Jangan membuat keberhasilan pembayaran bergantung pada keberhasilan dashboard diperbarui.

Gunakan transactional outbox

Tulis perubahan domain dan record outbox dalam transaksi database yang sama. Worker kemudian mengirim atau memproses record outbox tersebut. Dengan demikian, aplikasi tidak mengalami kasus “order sukses tersimpan tetapi event gagal diterbitkan” akibat crash setelah commit.

BEGIN;

UPDATE orders
SET status = 'paid', paid_at = CURRENT_TIMESTAMP
WHERE id = :order_id
  AND tenant_id = :tenant_id
  AND status = 'pending';

INSERT INTO outbox_events (
  event_id, event_type, aggregate_id, tenant_id, payload, occurred_at
) VALUES (
  :event_id, 'OrderPaid', :order_id, :tenant_id, :payload, CURRENT_TIMESTAMP
);

COMMIT;

Worker membaca event outbox, lalu projector memperbarui read model. Tabel outbox sebaiknya memiliki status atau checkpoint pemrosesan yang memungkinkan retry tanpa kehilangan event. Detail implementasi bergantung pada database dan broker yang digunakan, tetapi prinsipnya sama: perubahan domain dan niat untuk mempublikasikan perubahan harus atomik.

Buat projector idempoten

Pengiriman pesan umumnya bersifat at-least-once: event dapat diterima lebih dari sekali. Karena itu projector tidak boleh menaikkan total penjualan secara buta setiap kali menerima event.

Salah satu strategi adalah menyimpan event yang sudah diterapkan dan mengabaikan duplikasi dalam transaksi yang sama dengan pembaruan proyeksi:

BEGIN;

INSERT INTO processed_events (projector_name, event_id, processed_at)
VALUES ('sales-dashboard', :event_id, CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING;

-- Lanjutkan hanya jika INSERT di atas benar-benar menambah satu record.
UPDATE tenant_daily_sales
SET paid_order_count = paid_order_count + 1,
    gross_amount = gross_amount + :order_total,
    updated_at = CURRENT_TIMESTAMP
WHERE tenant_id = :tenant_id
  AND sales_date = :paid_date;

COMMIT;

Alternatif yang sering lebih aman untuk proyeksi tertentu adalah melakukan upsert berdasarkan order_id, lalu menghitung ulang agregat dari data proyeksi pada interval tertentu. Pilihan yang tepat bergantung pada apakah event membawa delta yang mudah dibalik dan bagaimana refund atau perubahan status ditangani.

Jaga isolasi tenant pada read model

Setiap key proyeksi, indeks, dan query wajib memasukkan tenant_id. Jangan hanya memfilter tenant di lapisan API setelah query dilakukan. Untuk data sensitif, terapkan juga kontrol akses di database atau service layer sesuai kemampuan platform, dan pastikan proses replay tidak pernah mencampur event antar-tenant.

Konsistensi eventual dan pengalaman pengguna

Read model asinkron berarti API tulis dapat mengembalikan sukses sebelum dashboard berubah. Ini adalah konsistensi eventual, bukan bug, selama batas dan perilakunya jelas.

  • Setelah order dibayar, halaman detail order dapat membaca write model atau respons command agar status langsung terlihat.
  • Dashboard harian dapat memakai read model dan menampilkan waktu pembaruan terakhir.
  • Jika pengguna perlu memastikan data baru masuk, sediakan refresh yang tetap membaca proyeksi, bukan memaksa dashboard menjalankan query berat ke database utama.
  • Jangan gunakan read model yang tertinggal untuk keputusan kritis, seperti validasi stok, otorisasi pembayaran, atau limit kredit.

Tentukan target keterlambatan yang dapat diterima untuk setiap proyeksi. Target tersebut harus dapat diukur melalui selisih antara waktu event dibuat dan waktu event berhasil diterapkan oleh projector.

Kegagalan projector, replay event, dan observability

Kegagalan yang harus diasumsikan

Projector dapat berhenti, payload event dapat tidak kompatibel setelah deployment, database read model dapat penuh, atau satu event rusak dapat menghambat partisi antrean. Sistem yang tidak menyiapkan kondisi ini akan menghasilkan dashboard salah secara diam-diam.

Minimal, siapkan:

  • retry dengan batas dan backoff untuk kegagalan sementara;
  • dead-letter queue atau tabel error untuk event yang gagal berulang kali;
  • alert untuk projector yang tidak berjalan, backlog meningkat, dan lag melewati target;
  • korelasi antara event_id, order_id, request log, dan trace;
  • dashboard operasional yang memperlihatkan event diproses, gagal, diduplikasi, dan usia event tertua yang belum diproses.

Replay bukan tombol yang boleh ditekan sembarangan

Replay berarti membangun ulang proyeksi dari event atau sumber data yang dapat dipercaya. Ini penting saat bug projector diperbaiki atau skema read model berubah. Namun replay dapat membebani database, mengirim efek samping ulang, dan bertabrakan dengan projector aktif bila tidak dirancang.

Praktik aman untuk replay:

  1. Beri nama dan versi pada proyeksi, misalnya sales_dashboard_v2.
  2. Bangun proyeksi baru secara terpisah dari sumber event atau snapshot yang konsisten.
  3. Validasi jumlah record, total agregat, dan sampel tenant terhadap sumber transaksi.
  4. Alihkan query aplikasi ke proyeksi baru setelah validasi.
  5. Hentikan atau arahkan projector lama secara terkontrol, lalu hapus proyeksi lama setelah masa observasi.

Projector sebaiknya tidak memiliki efek samping eksternal seperti mengirim email. Pisahkan consumer untuk proyeksi dari consumer notifikasi agar replay dashboard tidak mengirim notifikasi ulang.

Biaya nyata: tim, infrastruktur, dan maintainability

Read model terpisah bukan optimasi gratis. Selain penyimpanan tambahan, Anda menambah jalur deploy, skema data, proses backfill, dan kemungkinan kegagalan baru. Pada CQRS berbasis event, tim harus memahami urutan event, duplikasi, schema evolution, idempotensi, serta debugging lintas komponen.

Pertimbangkan pertanyaan berikut sebelum melanjutkan:

  • Siapa yang memiliki kontrak event dan menyetujui perubahan payload?
  • Apakah tim on-call mampu membedakan masalah write path, publisher, broker, dan projector?
  • Apakah ada prosedur tertulis untuk memperbaiki proyeksi yang tertinggal?
  • Apakah biaya database/proyeksi tambahan lebih rendah daripada scaling database transaksi?
  • Apakah tim memiliki test integrasi untuk event duplikat, event terlambat, event tidak berurutan, dan replay?

Untuk startup atau produk dengan domain belum stabil, kompleksitas ini sering lebih mahal daripada manfaatnya. Untuk SaaS yang sudah memiliki pola dashboard berat, integrasi banyak, dan kebutuhan representasi data yang bertumbuh, biaya tersebut dapat menjadi investasi yang masuk akal.

Strategi migrasi bertahap tanpa big bang

Jangan mengganti semua endpoint menjadi CQRS sekaligus. Mulailah dari satu query yang mahal, sering dipakai, dan toleran terhadap keterlambatan data.

  1. Pilih satu use case: misalnya kartu ringkasan penjualan harian, bukan seluruh dashboard.
  2. Ukur baseline: catat latensi endpoint, biaya query, dan dampaknya pada transaksi utama.
  3. Definisikan kontrak read model: tentukan field, kunci tenant, aturan pembaruan, dan batas stale data yang diterima.
  4. Bangun proyeksi secara paralel: isi dari data historis, lalu proses perubahan baru melalui outbox atau job.
  5. Shadow compare: untuk periode tertentu, bandingkan hasil read model dengan query lama pada tenant dan rentang waktu yang sama.
  6. Alihkan satu endpoint: gunakan feature flag agar rollback cepat bila ditemukan perbedaan.
  7. Tambahkan observability dan runbook: lakukan ini sebelum memperluas jumlah proyeksi.
  8. Evaluasi hasil: pastikan latensi write path membaik atau beban query berkurang sebelum membangun proyeksi berikutnya.

Pada tahap awal, CQRS ringan dengan tabel proyeksi dan worker internal sering merupakan pilihan terbaik. Pindah ke broker atau beberapa service hanya ketika kebutuhan delivery, skala konsumen, atau isolasi operasional benar-benar membenarkannya.

Kapan tetap memakai query langsung lebih tepat

Tetap gunakan query langsung ke model transaksi bila query dapat diselesaikan dengan indeks, pagination, pre-aggregation sederhana, cache, atau replica baca tanpa mengorbankan jalur write. Ini khususnya tepat jika aplikasi membutuhkan read-after-write yang ketat, volume data masih moderat, dan tim belum memiliki kapasitas mengoperasikan sistem asinkron.

Hindari read model terpisah jika satu-satunya alasan adalah ingin mengikuti pola arsitektur populer. CQRS menyelesaikan ketidaksesuaian antara kebutuhan baca dan tulis; ia tidak otomatis membuat sistem lebih sederhana, lebih cepat, atau lebih benar.

Ringkasnya: bangun read model terpisah saat ada bukti bahwa beban baca kompleks mengganggu transaksi utama atau representasi data berbeda terus bertambah, sementara keterlambatan data kecil dapat diterima. Mulai kecil, buat projector idempoten, ukur lag, siapkan replay, dan perlakukan operasi proyeksi sebagai bagian dari produk—bukan pekerjaan latar yang boleh gagal tanpa terlihat.