📚 Topik — Event-Driven Architecture: Choreography vs Orchestration, dan Kapan Event Jadi Neraka Kategori: Distributed Systems · Level: advanced · Sesi: 05:00 Deep
🎯 Tujuan Belajar
Setelah sesi ini, keputusan yang jadi lebih baik:
- Bisa jawab "pakai event atau panggilan sinkron?" dengan alasan, bukan karena FOMO.
- Bisa pilih choreography vs orchestration berdasarkan shape workflow (jumlah langkah, stabilitas, siapa yang harus tahu aturan).
- Tahu bahwa "event-driven" bukan pola arsitektur tunggal — ada 4 hal berbeda yang orang campur jadi satu kata: event notification, event-carried state transfer, event sourcing, CQRS.
- Bisa menjelaskan kenapa sebagian besar sistem event-driven gagal bukan di broker, tapi di ownership, schema evolution, dan observability.
🧠 Penjelasan Inti
Kenapa ada. Pemanggilan sinkron antar-service menciptakan temporal coupling: kalau Order butuh Inventory, Pricing, Payment, Notification — dan Notification mati 30 detik — checkout mati. Event-driven memutus coupling waktu: publisher commit state-nya, lalu selesai; subscriber memproses kapan pun ia siap.
Tapi ini bukan gratis. Yang ditukar: coupling waktu diganti coupling pengetahuan (subscriber harus tahu bentuk event) dan coupling operasional (dua sistem sekarang berbagi broker, dan broker jadi titik kegagalan bersama).
Empat makna "event" yang sering dicampur:
| Jenis | Isi pesan | Tujuan | Efek samping |
|---|---|---|---|
| Event notification | ID + tipe, minim data | "Sesuatu terjadi, tanya kalau perlu" | Subscriber harus callback → coupling balik |
| Event-carried state transfer | Snapshot data lengkap | Subscriber tak perlu tanya | Payload besar, data basi (staleness) |
| Event sourcing | Fakta domain sebagai sumber kebenaran | Audit, replay, time travel | Query jadi masalah, butuh proyeksi |
| CQRS | Bukan event, tapi pemisahan read/write model | Read scale, read model berbeda | Konsistensi eventual, dua model untuk dirawat |
Kesalahan paling umum: orang bilang "kami pakai event sourcing" padahal cuma publish event notifikasi ke Kafka. Itu bukan event sourcing — event sourcing berarti state dihitung dari event, dan event adalah source of truth, bukan side effect.
⚙️ Cara Kerja Internal
1. Delivery semantics — kenyataan yang harus diterima
- At-most-once: fire and forget. Boleh hilang.
- At-least-once: default realistis semua broker (Kafka, RabbitMQ, SQS, NATS JetStream). Boleh duplikat.
- Exactly-once: hanya di dalam batas transaksi broker (Kafka EOS dengan idempotent producer + transaksi, atau dalam satu konsumen + offset commit atomik). Tidak berlaku lintas sistem — begitu keluar ke DB eksternal atau HTTP call, jaminan hilang.
Konsekuensi: consumer harus idempoten. Bukan saran, itu syarat.
2. Urutan (ordering)
Kafka jamin urutan per-partition, bukan per-topic. Artinya: - Key sama → partition sama → urut. - Key beda → bisa saling menyusul. - Rebalance/retry → urutan bisa rusak meski key sama (retry ke depan, atau batch retry).
Kalau domain butuh "event A harus sebelum event B untuk entity X", key = entity ID, dan jangan pernah parallel-process entity yang sama.
3. Dual-write problem — ini akar banyak bug
BEGIN TX
INSERT order ...
COMMIT TX
publish_event(order_created) ← kalau crash di sini: DB punya order, broker tidak
Atau kebalikannya: publish dulu, DB gagal → downstream sudah bertindak atas order yang tidak ada.
Solusi: Transactional Outbox. Tulis event ke tabel outbox di transaksi yang sama dengan perubahan state. Proses terpisah (relay / CDC / Debezium) membaca outbox dan publish. Atomik, karena satu DB.
BEGIN TX
INSERT order ...
INSERT outbox(event_type, payload, aggregate_id) ...
COMMIT
-- relay: SELECT ... FROM outbox WHERE published_at IS NULL ORDER BY id
-- publish → UPDATE outbox SET published_at = now()
Relay crash setelah publish sebelum update → duplikat. Karena itu: at-least-once + idempoten consumer, bukan exactly-once.
Alternatif: CDC langsung dari WAL (Debezium, PostgreSQL logical replication). Lebih mulus, tapi mengikat skema DB ke kontrak event — perubahan kolom internal bocor jadi perubahan kontrak.
4. Choreography vs Orchestration
Choreography — tiap service bereaksi pada event, tanpa pusat:
Order ──OrderPlaced──▶ Inventory ──StockReserved──▶ Payment ──PaymentTaken──▶ Shipping
│ │
└──StockRejected──▶ Order (compensate: cancel)
└──PaymentFailed──▶ Inventory (compensate: release)
Kelebihan: tidak ada single point of logic, service benar-benar otonom, tambah subscriber baru tanpa ubah apa pun. Kelemahan: alur bisnis tidak ada di satu tempat. Untuk tahu "apa yang terjadi kalau payment gagal", kamu harus membaca 5 repo. Debugging = menelusuri jejak di 5 log. Cyclic dependency muncul diam-diam (A dengar B, B dengar A).
Orchestration — satu service/state machine memimpin:
Saga Orchestrator (state machine, persisted):
CREATED → reserveStock() → stock_reserved
→ chargePayment() → payment_taken
→ arrangeShipping() → done
on failure → compensate in reverse: refund() → releaseStock() → cancelled
Kelebihan: alur eksplisit dan bisa dibaca satu file; timeout, retry, compensation terpusat; observability jauh lebih mudah. Kelemahan: orchestrator tahu terlalu banyak (god service kalau dibiarkan); risiko jadi "microservices dengan ESB di tengah".
Aturan praktis: - 2–4 langkah, stabil, tidak ada kompensasi rumit → choreography. - ≥5 langkah, ada percabangan, butuh timeout/SLA per langkah, ada kompensasi berlapis → orchestration. - Campur boleh: orchestrate alur utama, choreograph efek samping (email, analytics, search index).
5. Saga ≠ transaksi ACID
Saga memberi konsistensi eventual + kompensasi, bukan isolasi. Anomali yang tetap terjadi: - Dirty read: langkah 2 membaca state langkah 1 yang nanti dikompensasi. - Lost update: dua saga menyentuh agregat sama. - Fuzzy read: pembacaan sebagian.
Mitigasi: semantic lock (status PENDING/RESERVED yang memblokir operasi konflik), commutative operations, atau desain agregat agar satu saga saja yang boleh menyentuh.
6. Schema evolution
Event yang sudah dipublish itu kontrak publik permanen — konsumen lama mungkin masih ada 2 tahun lagi. Karena itu:
- Tambah field = aman (opsional, ada default).
- Rename/hapus field = breaking. Butuh versi baru atau upcasting.
- Pakai schema registry (Avro/Protobuf + compatibility mode BACKWARD/FULL), jangan JSON bebas tanpa kontrak.
- Event harus membawa metadata: event_id, occurred_at, schema_version, correlation_id, causation_id, producer.
🏗️ Diagram: di mana event-driven berhenti jadi arsitektur dan mulai jadi neraka
┌──────────── Broker ────────────┐
Service A ──────▶│ topic: order.events │
│ ├─▶ B (butuh) │
│ ├─▶ C (butuh) │
│ └─▶ ??? (siapa lagi?) │
└────────────────────────────────┘
Titik patah:
1. B publish event ke topic baru → C diam-diam berhenti dapat data. Tak ada error. Hanya dashboard kosong 3 minggu kemudian.
2. Schema berubah, C parse gagal, C retry selamanya → lag naik, disk penuh.
3. Dua event untuk entity sama diproses paralel → state akhir salah, tanpa exception.
4. Retry tanpa backoff → C menghajar D, D mati, C retry lebih keras. Retry storm.
Pseudocode: consumer idempoten + poison-pill handling
def handle(msg):
key = (msg.topic, msg.event_id) # idempotency key
if db.exists("processed", key): # cek + tulis dalam 1 TX dengan efek
return ack(msg)
with db.tx():
db.insert("processed", key)
apply(msg) # efek bisnis
ack(msg)
Gagal parse / data invalid → jangan retry. Kirim ke dead-letter queue dengan alasan, ack. Retry hanya untuk kegagalan transien (timeout, 5xx, lock).
⚖️ Trade-off & Kapan JANGAN Dipakai
| Aspek | Sinkron (REST/gRPC) | Event-driven |
|---|---|---|
| Konsistensi | Kuat, langsung | Eventual, ada jendela salah |
| Debugging | Stack trace satu request | Rekonstruksi lintas log/trace |
| Perubahan alur | Ubah caller | Ubah kontrak event (lebih mahal) |
| Skala tim | Coupling ketat, koordinasi tinggi | Otonomi, tapi kontrak harus dijaga |
| Biaya operasional | Rendah (tak ada broker) | Broker + schema registry + DLQ + observability |
| Failure mode | Timeout, cascade | Duplikat, out-of-order, lag, silent loss |
JANGAN pakai event-driven kalau:
- Butuh jawaban langsung. User klik "bayar" dan harus tahu berhasil/sekarang. Event untuk efek samping, bukan untuk jalur kritis user.
- Workflow-nya satu langkah. Satu service, satu DB — event cuma menambah hop dan latensi.
- Tim belum punya observability. Tanpa distributed tracing + korelasi ID + consumer lag monitoring, sistem event-driven adalah sistem yang gagal secara diam-diam. Ini alasan nomor satu kenapa "event jadi neraka".
- Domain belum dipahami. Event-driven membekukan model. Kalau bounded context masih goyang, kamu akan mempublish kontrak yang salah dan mengunci diri.
- Hanya 2–3 service, traffic rendah. Modular monolith dengan modul terpisah + satu DB memberi 80% manfaat dengan 10% biaya.
- Butuh transaksi ACID lintas entitas. Kalau "harus atomik" adalah syarat bisnis keras (misal ledger keuangan dengan constraint saldo), paksa ke satu agregat/satu DB, bukan saga.
Soal choreography khusus — jangan dipakai kalau: ada lebih dari ~4 langkah, ada kompensasi yang saling bergantung, atau kamu tidak bisa menggambar alurnya di satu halaman. Kalau tidak bisa digambar, tidak bisa dioperasikan.
🏭 Contoh Produksi & Failure Mode
1. Silent consumer break (paling sering, paling mahal).
Producer rename order.total → order.amount. Consumer lama baca null, lanjut jalan dengan nilai 0. Tidak ada error. Invoice salah selama 11 hari. Pelajaran: kontrak event = API publik. Butuh schema registry + compatibility check di CI, bukan niat baik.
2. Retry storm / thundering herd.
Consumer gagal → retry instan tanpa jitter → downstream yang sudah sekarat dihajar lebih keras. Di Kafka, retry.backoff.ms default kecil; konsumen naive langsung loop. Mitigasi: exponential backoff + jitter, circuit breaker per dependency, dan pause partition (bukan block thread) saat dependency down.
3. Rebalance storm.
max.poll.interval.ms terlampaui karena satu batch diproses 6 menit (default 5 menit) → consumer dianggap mati → rebalance → kerja diulang dari offset terakhir → duplikat masif + progres tak pernah selesai. Ini loop klasik: proses terlalu lama per poll. Fix: batch kecil, atau pause/resume manual, atau naikkan interval dengan sadar.
4. Out-of-order tanpa terdeteksi.
OrderCancelled diproses sebelum OrderPlaced (key berbeda, partition berbeda) → cancel diabaikan karena order belum ada → order tetap hidup. Mitigasi: key = aggregate ID, atau consumer menyimpan pending event untuk ID yang belum dikenal.
5. Poison pill menghentikan seluruh partition.
Satu pesan corrupt, consumer crash, restart, ambil pesan sama, crash lagi. Partition itu membeku. Seluruh tenant di belakangnya berhenti. Mitigasi: DLQ + retry counter per event_id, bukan hanya per offset.
6. Outbox tabel tumbuh tak terbatas.
Relay mati 3 hari, tak ada alert. Outbox jadi 40 GB, autovacuum kewalahan, DB melambat untuk semua orang. Mitigasi: alert pada count(outbox where published_at is null) dan pada umur pesan tertua — bukan hanya pada error relay.
7. Dua sumber kebenaran. Event dipublish, lalu service lain menyimpan salinan state dan mulai memakainya sebagai kebenaran. Sekarang ada dua jawaban untuk "status order ini apa". Ini bukan bug event-driven, ini bug ownership: harus jelas agregat mana yang memiliki state.
8. Event sourcing tanpa snapshot. Replay 4 juta event untuk satu agregat tiap request. Latensi naik linear seiring umur sistem. Butuh snapshot + arsip, dan strategi rebuild proyeksi yang eksplisit.
⚠️ Miskonsepsi Umum
- "Kafka = exactly-once." Hanya di dalam batas transaksi Kafka. Begitu efeknya ke DB eksternal atau HTTP, kembali at-least-once. Idempotensi tetap wajib.
- "Event-driven = microservices." Modular monolith bisa event-driven secara internal (in-process bus, outbox). Microservices bisa sepenuhnya sinkron. Dua sumbu berbeda.
- "Event = command."
PlaceOrderadalah command (satu penerima, bisa ditolak).OrderPlacedadalah event (fakta, banyak penerima, tidak bisa ditolak). Mencampur keduanya membuat consumer saling menolak dan saling menunggu. - "Choreography lebih loosely coupled, jadi lebih baik." Choreography mengubah coupling eksplisit (interface) jadi coupling implisit (pengetahuan tentang event siapa). Coupling implisit lebih sulit ditemukan, bukan lebih kecil.
- "Event sourcing cocok untuk semua agregat." Hampir selalu salah. Pakai untuk agregat yang butuh audit/replay tinggi (ledger, order lifecycle). CRUD biasa dengan event sourcing = biaya besar tanpa manfaat.
- "Broker menjamin pesan tidak hilang." Tergantung
acks, replikasi,min.insync.replicas, dan apakah producer menangani kegagalan publish. Default Kafka produceracks=alldi 4.x, tapi topic denganmin.insync.replicas=1+unclean.leader.electiontetap bisa kehilangan data. - "Kalau ada DLQ, aman." DLQ tanpa ada yang memonitor dan me-replay = tempat sampah. DLQ butuh proses operasional, bukan hanya topik.
✅ Cek Pemahaman
-
Tim kamu punya alur 7 langkah: reserve stock → charge card → create shipment → notify warehouse → update loyalty → send email → update analytics. Mana yang kamu orchestrate, mana yang kamu choreograph, dan mengapa? Kalau langkah 4 gagal permanen, apa urutan kompensasinya dan apa yang terjadi pada langkah 7 yang sudah jalan?
-
Kamu menambahkan field
discount_codeke eventOrderPlaced. Tiga consumer lama masih berjalan. Apa yang harus kamu pastikan sebelum deploy, dan bagaimana kamu mendeteksi kalau satu consumer ternyata parse gagal tanpa melempar error? -
Seorang engineer mengusulkan mengganti semua REST call internal dengan Kafka karena "lebih scalable". Sebutkan dua skenario konkret di sistem kamu di mana usulan itu akan memperburuk keadaan, dan metrik apa yang akan membuktikannya.
🔑 Poin Kunci
- Event-driven menukar coupling waktu dengan coupling pengetahuan + biaya operasional. Bukan kemenangan otomatis.
- At-least-once adalah realitas. Idempoten consumer adalah syarat masuk, bukan optimasi.
- Dual-write adalah bug yang menunggu. Outbox atau CDC, pilih satu.
- Choreography untuk alur pendek dan stabil; orchestration untuk alur panjang, bercabang, butuh kompensasi. Batas praktis sekitar 4 langkah.
- Saga = eventual consistency + kompensasi. Bukan ACID. Tidak ada isolasi.
- Event yang sudah dipublish adalah kontrak publik permanen. Schema registry + compatibility check di CI.
- Kegagalan paling mahal bersifat diam: consumer berhenti, urutan rusak, pesan menumpuk. Tanpa tracing, lag monitoring, dan alert pada umur pesan tertua, kamu buta.
- Event sourcing hanya untuk agregat yang memang butuh audit/replay. Bukan default.
➡️ Eksplorasi Lanjut
- Transactional Outbox + CDC — bandingkan relay polling vs Debezium logical replication: biaya, latensi, dan apa yang terjadi saat skema DB berubah. Kaitannya langsung ke topik database migration.
- Idempotency key design — di mana disimpan, berapa lama dipertahankan, dan bagaimana menangani key yang sama dengan payload berbeda (ini kasus yang jarang dibahas dan sering jadi bug).
- Backpressure & consumer lag — apa yang harus dilakukan saat lag naik terus: scale out (dan batas partition), pause, atau shed load. Terkait topik resilience.
- Kafka 4.x tanpa ZooKeeper — KRaft jadi default sejak 4.0 (rilis Maret 2025), klien butuh Java 11, broker butuh Java 17; rilis 4.2/4.3 sudah keluar sepanjang 2026. Relevan kalau kamu mengevaluasi upgrade atau cluster baru.
- Consistency model — lanjut ke read-your-writes, monotonic reads, dan bagaimana CQRS read model melanggar keduanya tanpa disadari.