← semua materi
15:00 Teknis · 13 Oct 2026 · 03:01

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

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

  1. 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.
  2. Non-atomic publish (lost event). Append sukses, publish gagal → read model permanen salah, tanpa error terlihat. Mitigasi: transactional outbox.
  3. Idempotensi diabaikan. Broker at-least-once mengirim ulang → read model double-count. Wajib dedup per event_id.
  4. Out-of-order delivery. Projection A terima event v3 sebelum v2 → state korup. Mitigasi: partisi per stream_id, atau buffer + urutkan per version.
  5. Event sebagai DTO internal. Kirim entity database sebagai payload → coupling ke schema DB → seluruh sistem rusak saat refactor. Event harus domain-meaningful, schema-stable.
  6. God event / fat payload. Satu event bawa semua → coupling tinggi. Pisah jadi event kecil.
  7. "Sedang butuh laporan yang tak ada." Read model tak punya kolom → harus ganti projection & replay. Kalau sering terjadi, ES bikin kamu lambat, bukan cepat.
  8. Versioning event lupa dari awal. Ubah payload tanpa upcast → replay event lama error. Ini hampir selalu menyakitkan di produksi.
  9. 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.
  10. 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


Eksplorasi Lanjut

  1. Transactional Outbox pattern — implementasi konkret (polling vs CDC vs log-tailing). Ini jembatan paling penting sebelum ES.
  2. Saga pattern — koordinasi transaksi lintas agregat di dunia eventual-consistent (choreography vs orchestration).
  3. Snapshotting & event compaction — bagaimana menjaga replay tetap wajar untuk stream panjang.
  4. Event schema evolution — upcasting vs versioning vs event transformation; peran Schema Registry (Avro/Protobuf).
  5. Bandingkan EventStoreDB, Axon, Marten, vs DIY di Postgres — kapan pakai produk, kapan bangun sendiri.
  6. CRDT & ES untuk collaborative/offline-first (lihat pendekatan lokal-first seperti Automerge).
  7. Observability untuk eventual consistency — cara mengukur projection lag & deteksi drift di produksi.
  8. Studi kasus nyata — ledger fintech, sistem booking, dan mengapa banyak tim akhirnya keluar dari ES dan kembali ke CRUD + audit.