10:00 Technical Exploration — CQRS & Event Sourcing: Kapan Masuk Akal
Topik
CQRS (Command Query Responsibility Segregation) & Event Sourcing — arsitektur pemisahan model tulis/baca, plus penyimpanan state sebagai urutan event. Fokus: mengenali kapan pola ini membayar dirinya sendiri dan kapan ia sekadar menambah kompleksitas tanpa manfaat.
Difficulty: advanced (3/5). Catatan: bagian konseptual sudah mapan; bagian spesifik produk (versi, fitur) saya tandai mana yang terverifikasi.
Tujuan Belajar
Setelah sesi ini kamu bisa: 1. Membedakan CQRS dari Event Sourcing — banyak orang menganggap keduanya sama; keduanya ortogonal. 2. Menilai apakah suatu bounded context pantas pakai CQRS/ES berdasarkan read/write ratio, audit requirement, dan temporal query need. 3. Mengenali biaya nyata: eventual consistency, duplikasi event, versioning event, replay, dan operasional projection. 4. Merancang projection yang idempoten dan aman dari out-of-order delivery. 5. Menghindari jebakan "CQRS everywhere" yang membunuh tim kecil.
Arsitektur & Alur Teknis
1. CQRS tanpa Event Sourcing (varian paling ringan)
Satu database, dua model. Write model ternormalisasi (validasi ketat), read model denormalized (siap dibaca). Bisa di satu DB yang sama — bahkan satu PostgreSQL.
Client
│
├─ Command ──► Write Model ──► DB (normalized)
│ │
│ └─(sync/trigger/outbox)─► Read Model ──► DB (denormalized/cached)
│
└─ Query ────► Read Model (view/materialized view/cache)
Yang DIKETAHUI: CQRS paling minimal = PostgreSQL materialized view + REFRESH MATERIALIZED VIEW CONCURRENTLY. Ini CQRS valid, tanpa broker, tanpa event store. Banyak tim melewatkan ini dan langsung lompat ke Kafka.
2. Event Sourcing (ES)
State bukan yang disimpan — yang disimpan adalah fakta yang terjadi (event). State = hasil fold/replay event.
Event Stream (append-only, immutable)
[UserRegistered, EmailChanged, OrderPlaced, OrderCancelled]
current_state = events.reduce(apply, initial_state)
Rebuild: replay 100k event → state detik ini
Snapshot tiap 1000 event → replay dari snapshot terdekat, bukan dari nol
Event store = append-only log, biasanya di atas DB biasa (tabel events(stream_id, version, type, payload, metadata, ts) dengan unique constraint (stream_id, version) untuk optimistic concurrency).
3. CQRS + ES digabung (pola lengkap)
Command ─► Command Handler ─► validate against current state (load dari event stream)
│
└─► append event(s) [atomic, optimistic lock]
│
▼
Event Store (append-only)
│
▼ (publish)
Message Broker (Kafka/NATS)
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Projection A Projection B Saga/Process Mgr
(read DB) (search idx) (long-running WF)
Penting: publish ke broker harus atomic dengan append event → pakai Transactional Outbox. Kalau tidak, kamu append event sukses tapi publish gagal → projection divergen selamanya.
Contoh Kode / Diagram
Idempotent projection (wajib untuk ES)
# Projection handler — aman dari redelivery/duplikat
def handle(event, db):
# kunci idempotensi per (consumer, event_id)
already = db.execute(
"SELECT 1 FROM processed WHERE consumer=%s AND event_id=%s",
(CONSUMER_ID, event.id),
).fetchone()
if already:
return # sudah diproses, skip
with db.transaction(): # atomic: update state + catat event_id
apply_to_read_model(event, db)
db.execute(
"INSERT INTO processed (consumer, event_id, ts) VALUES (%s,%s,now())",
(CONSUMER_ID, event.id),
)
ponytail: tabel processed tumbuh tanpa batas → pindah ke partial index + TTL purge saat >10M baris.
Optimistic concurrency di event store
-- append hanya valid kalau version expected cocok
INSERT INTO events (stream_id, version, type, payload)
VALUES ($1, $2, $3, $4);
-- dengan UNIQUE(stream_id, version) → konflik = ConcurrentModification → retry
Versioning event (migrasi tanpa replay ulang)
# upcasting: event lama → bentuk baru, saat read
def upcast(raw):
if raw["type"] == "UserRegistered" and raw["version"] == 1:
raw["payload"].setdefault("source", "unknown") # field baru
raw["version"] = 2
return raw
Aturan: jangan pernah mutasi event lama di store. Selalu upcast saat baca / saat replay.
Trade-off & Pendekatan Alternatif
| Aspek | CRUD biasa | CQRS (tanpa ES) | CQRS + ES |
|---|---|---|---|
| Kompleksitas | Rendah | Sedang | Tinggi |
| Audit trail | Manual | Manual | Gratis (by design) |
| Temporal query ("state 3 bulan lalu?") | Sangat sulit | Sulit | Trivial (replay sampai timestamp) |
| Read scaling | Vertical + replica | Terpisah, mudah | Terpisah, mudah |
| Consistency | Kuat | Eventual | Eventual |
| Debugging | Lihat row | Lihat row | Replay event (bisa replicate bug) |
| Schema migration | ALTER TABLE | ALTER + rebuild view | Upcasting + rethink event |
| Cocok untuk | 95% kasus | Read-heavy + write-complex | Domain kaya audit/waktu |
Opsi alternatif yang sering lebih tepat: 1. CRUD + audit table — kalau butuh audit tapi tak butuh temporal query. Jauh lebih murah dari ES. 2. Postgres materialized view — kalau cuma masalah read performance. 3. Change Data Capture (Debezium) — kalau mau event turunan dari DB tanpa ubah model tulis jadi event-sourced. Ini sering jawabannya ketika orang mengira butuh ES. 4. Event Streaming tanpa ES — pakai Kafka untuk integrasi, tapi state tetap di DB relational. Best of both.
Kapan ES benar-benar masuk akal (kriteria praktis): - ✅ Domain di mana perubahan secara inheren penting (ledger keuangan, order lifecycle, compliance/audit, collaborative editing). - ✅ Butuh temporal query / time-travel debug. - ✅ Butuh rebuild projection baru dari sejarah (mis. fitur analitik baru atas data lama). - ❌ TIDAK: CRUD form biasa, user profile, settings, konten CMS.
Aturan emas: Event sourcing per bounded context, bukan per aplikasi. Satu service ES, sisanya CRUD. Jangan seluruh sistem.
Use Case Produksi
- Ledger perbankan / fintech (DIKETAHUI): event = mutasi, state = saldo. Audit regulator gratis, rekonsiliasi deterministik.
- E-commerce order lifecycle:
OrderPlaced → Paid → Shipped → Delivered— state machine alami, ES natural. - Collaborative editor (OT/CRDT + ES): operasi = event.
- Sistem inventory dengan projection terpisah untuk search/analytics.
- Bukan ES, tapi CQRS: dashboard analytics heavy-read dengan write jarang → write model normal, read model distabilkan ke warehouse.
Catatan industri: pola ini populer via Greg Young (2010, "CQRS Documents") dan buku Implementing Domain-Driven Design (Vaughn Vernon). Framework seperti Axon (Java), EventStoreDB, dan Marten (.NET) menyediakan infrastrukturnya. Saya tidak memverifikasi versi terkini framework tersebut di sesi ini — cek dokumentasi resmi sebelum commit ke satu produk.
Failure Mode Umum
- Projection drift / rebuild dari nol tidak pernah diuji. Orang bangun projection, jalan 2 tahun, lalu butuh projection baru → ternyata replay 200 juta event makan 3 hari. Mitigasi: snapshot berkala + uji replay sejak hari pertama.
- Non-atomic publish (lost event). Append sukses, publish gagal → read model permanen salah, tanpa error terlihat. Mitigasi: transactional outbox.
- Idempotensi diabaikan. Broker at-least-once mengirim ulang → read model double-count. Wajib dedup per
event_id. - Out-of-order delivery. Projection A terima event v3 sebelum v2 → state korup. Mitigasi: partisi per
stream_id, atau buffer + urutkan per version. - Event sebagai DTO internal. Kirim entity database sebagai payload → coupling ke schema DB → seluruh sistem rusak saat refactor. Event harus domain-meaningful, schema-stable.
- God event / fat payload. Satu event bawa semua → coupling tinggi. Pisah jadi event kecil.
- "Sedang butuh laporan yang tak ada." Read model tak punya kolom → harus ganti projection & replay. Kalau sering terjadi, ES bikin kamu lambat, bukan cepat.
- Versioning event lupa dari awal. Ubah payload tanpa upcast → replay event lama error. Ini hampir selalu menyakitkan di produksi.
- Over-engineering tim kecil. 5 developer, 1 domain CRUD, dipaksa ES. Kecepatan fitur jatuh 3x, tanpa manfaat. Ini failure mode paling umum dan paling mahal.
- Eventual consistency bocor ke UX. User simpan, refresh, data belum muncul → lapor bug. Butuh read-your-writes handling (mis. tunggu projection offset, atau query write model untuk kasus kritis).
Latihan Praktis Kecil
Target: rasakan sendiri CQRS minimal tanpa ES — 30 menit, satu PostgreSQL.
-- 1. Write model normal
CREATE TABLE orders (id serial PRIMARY KEY, status text, total numeric);
-- 2. Read model untuk dashboard
CREATE MATERIALIZED VIEW order_dashboard AS
SELECT status, count(*) AS n, sum(total) AS revenue
FROM orders GROUP BY status;
-- 3. "Projection refresh" (ini sudah CQRS)
REFRESH MATERIALIZED VIEW CONCURRENTLY order_dashboard;
Uji read/write: tulis 1000 order, ukur query dashboard langsung vs dari view. Pertanyaan reflektif: kalau view ini refresh tiap 5 detik, apakah konsistensi eventual itu terasa oleh user? Kalau tidak — kenapa kamu butuh Kafka?
Latihan lanjutan (opsional): tambah tabel events(stream_id, version, type, payload) dengan UNIQUE(stream_id, version), coba append 2x dengan version sama → rasakan optimistic lock bekerja.
Poin Kunci
- CQRS ≠ Event Sourcing. CQRS = pisahkan model baca/tulis (bisa murah). ES = simpan event, bukan state (mahal). Bisa sendiri-sendiri.
- CQRS termurah = materialized view. Jangan lompat ke broker sebelum ini tidak cukup.
- ES termahal = komitmen jangka panjang. Event adalah API permanen ke masa depan; kamu tak bisa
ALTER TABLEmasa lalu, hanyaupcast. - CQRS/ES per bounded context, jangan selebar aplikasi.
- Gunakan CDC (Debezium) sebelum ES kalau hanya perlu event turunan.
- Transactional Outbox + idempotent projection = syarat mutlak, bukan opsional, kalau pakai broker.
- Uji replay dari hari pertama. Kalau tidak, kamu menemukan masalahnya saat paling mahal.
- Kriteria keputusan: apakah sejarah perubahan itu sendiri adalah data bernilai? Ya → ES. Tidak → jangan.
- Kapan mereka membayar: consistency eventual OK, audit/temporal penting, domain kaya invariant. Kapan mereka merugikan: tim kecil, CRUD, butuh laporan baru tiap minggu, tidak ada waktu bangun infrastruktur operasional.
Eksplorasi Lanjut
- Transactional Outbox pattern — implementasi konkret (polling vs CDC vs log-tailing). Ini jembatan paling penting sebelum ES.
- Saga pattern — koordinasi transaksi lintas agregat di dunia eventual-consistent (choreography vs orchestration).
- Snapshotting & event compaction — bagaimana menjaga replay tetap wajar untuk stream panjang.
- Event schema evolution — upcasting vs versioning vs event transformation; peran Schema Registry (Avro/Protobuf).
- Bandingkan EventStoreDB, Axon, Marten, vs DIY di Postgres — kapan pakai produk, kapan bangun sendiri.
- CRDT & ES untuk collaborative/offline-first (lihat pendekatan lokal-first seperti Automerge).
- Observability untuk eventual consistency — cara mengukur projection lag & deteksi drift di produksi.
- Studi kasus nyata — ledger fintech, sistem booking, dan mengapa banyak tim akhirnya keluar dari ES dan kembali ke CRUD + audit.