Event-Driven Architecture — System Design

Event-Driven Architecture (EDA) adalah pola arsitektur di mana komponen-komponen sistem berkomunikasi melalui event — notifikasi bahwa sesuatu telah terjadi…

Event-driven architecture adalah cara menyusun sistem di mana service saling memberi kabar lewat event: catatan bahwa sesuatu sudah terjadi. Bayangin pengumuman di grup WhatsApp RT: "Air PAM mati besok jam 8." Pak RT gak menelepon tiap rumah satu-satu dan gak menunggu jawaban. Tiap rumah bereaksi sendiri: ada yang mengisi ember, ada yang beli galon. Warga baru tinggal masuk grup, Pak RT gak perlu mengubah apa-apa. Yang HP-nya mati membaca belakangan. Itu event-driven: umumkan faktanya, biarkan yang berkepentingan bereaksi.

🧪 Tebakan cepat: setelah Budi membayar pesanan #1001, TiketNada harus memperbarui sisa tiket di halaman konser (40 ms), mengirim e-tiket (500 ms), dan menambah poin reward (30 ms). Kalau service order memanggil ketiganya satu per satu, Budi menunggu 580 ms. Kalau order cukup mengumumkan satu event (5 ms), berapa ms Budi menunggu? Tebak dulu, jawabannya ada di bagian "Gaya event: publish sekali".

Command dan event

Ada dua cara satu service menyuruh atau mengabari service lain:

Command Event
Bentuk perintah: "KirimETiket #1001" fakta, bentuk lampau: order.paid (atau OrderPlaced)
Ditujukan ke satu penerima tertentu siapa pun yang berlangganan, bisa 0, 1, atau 10
Pengirim menunggu jawaban, perintahnya bisa ditolak gak menunggu, gak tahu siapa yang mendengar
Kalau penerima mati pengirim ikut gagal event menunggu di broker

Istilahnya: producer (atau publisher) adalah yang menerbitkan event, consumer (atau subscriber) adalah yang berlangganan dan bereaksi, dan broker adalah perantara yang menyimpan dan menyalurkan event, misalnya Kafka, RabbitMQ, atau Redis Streams. Karena producer gak tahu siapa consumer-nya, keduanya loosely coupled: bisa diubah, di-deploy, dan mati sendiri-sendiri.

Gaya command: panggil satu per satu

Budi membayar pesanan #1001: 2 tiket Konser Nusantara, Rp1.500.000. Dengan command, order memanggil konser, email, lalu poin berurutan dan menunggu masing-masing (angka model contoh):

ms Yang terjadi
10 order menyimpan pembayaran #1001
50 konser selesai: sisa tiket 9.998
550 email selesai: e-tiket #1001 dikirim
580 poin selesai: poin Budi 150 (1 poin per Rp10.000)
580 Budi menerima 200: pembayaran berhasil

Sekarang email mati. order menunggu sampai timeout 2.000 ms, lalu menyerah: di ms 2.050 Budi menerima 503 Service Unavailable. Padahal kartunya sudah terpotong, sisa tiket sudah berkurang, dan poinnya belum masuk. Satu service yang mati menjatuhkan checkout, dan datanya setengah jadi.

Gaya event: publish sekali

Dengan event, order menyimpan pembayaran, menerbitkan order.paid ke broker (5 ms), lalu langsung menjawab Budi. Tiap subscriber mendapat salinan event-nya sendiri dan bekerja sendiri-sendiri. Lag antrean di depan konser di model ini 250 ms:

ms Yang terjadi
10 order menyimpan pembayaran #1001
15 order publish order.paid (evt-1001)
15 Budi menerima 200: pembayaran berhasil
45 poin selesai: poin Budi 150
305 konser selesai: sisa tiket 9.998
515 email selesai: e-tiket #1001 dikirim

Jawaban tebakannya: 15 ms, bukan 580. Dan kalau email mati, Budi tetap menerima 200 di ms 15. Event-nya menunggu di antrean email di broker, dan e-tiket dikirim begitu email hidup lagi. Mau menambah fitur baru, misalnya analitik penjualan? Tambah subscriber baru. Kode order gak berubah satu baris pun.

Bedanya dengan lesson Message Queue: di queue, satu pesan diambil satu worker. Di publish/subscribe, setiap subscriber mendapat salinannya sendiri, jadi konser, email, dan poin sama-sama menerima evt-1001.

// TiketNada: Budi membayar pesanan #1001, gaya event (model contoh)
const idempotent = false; // consumer mengingat event id yang sudah diproses
const lagKonser = 250; // ms antrean di depan consumer konser
let emailHidup = true;
let sisaTiket = 10000;
let poinBudi = 0;
let eTiket = 0;
const antreanEmail = [];
const jadwal = []; // jam virtual: [ms, fungsi]
const nanti = (ms, fn) => jadwal.push([ms, fn]);
const log = (ms, teks) => console.log(`[${ms} ms] ${teks}`);
const subscribers = [];

function subscribe(nama, ms, handler) {
  subscribers.push({ nama, ms, handler, sudah: new Set() });
}

function proses(s, event, t) {
  if (idempotent && s.sudah.has(event.id)) return log(t, `${s.nama}: ${event.id} sudah diproses, lewati`);
  s.sudah.add(event.id);
  log(t, `${s.nama} selesai: ${s.handler(event)}`);
}

function publish(event, t) {
  for (const s of subscribers) {
    if (s.nama === "email" && !emailHidup) {
      antreanEmail.push(event);
      log(t, `email mati: ${event.id} menunggu di antrean email`);
    } else {
      nanti(t + s.ms, () => proses(s, event, t + s.ms));
    }
  }
}

subscribe("konser", lagKonser + 40, (e) => `sisa tiket ${(sisaTiket -= e.jumlah)}`);
subscribe("email", 500, (e) => `e-tiket #${e.orderId} dikirim (${++eTiket})`);
subscribe("poin", 30, (e) => `poin Budi ${(poinBudi += e.total / 10000)}`);

const event = { type: "order.paid", id: "evt-1001", orderId: 1001, jumlah: 2, total: 1500000 };
log(10, "order menyimpan pembayaran #1001");
log(15, `order publish ${event.type} (${event.id})`);
publish(event, 15);
log(15, "Budi: 200 pembayaran berhasil");
nanti(1000, () => log(1000, `Budi refresh halaman konser: sisa ${sisaTiket}`));

while (jadwal.length) {
  jadwal.sort((a, b) => a[0] - b[0]); // yang paling awal dulu
  jadwal.shift()[1]();
}

Eventual consistency: halaman konser telat

Budi menerima "berhasil" di ms 15, tapi konser baru memperbarui sisa tiket di ms 305. Di antara keduanya, halaman konser masih menampilkan 10.000. Data di service yang berbeda sempat berbeda, lalu akhirnya sama: itu eventual consistency.

Saat war tiket, antrean di depan konser bisa menumpuk. Kalau lag-nya 1.000 ms, konser selesai di ms 1.055, dan Budi yang me-refresh di ms 1.000 masih melihat sisa 10.000. Jadi rancang UI-nya jujur: "Pembayaran berhasil, e-tiket sedang dikirim", bukan janji bahwa semua angka sudah berubah.

Event bisa datang dua kali

Broker biasanya menjamin at-least-once delivery: event sampai minimal sekali. Kalau consumer selesai tapi ack-nya hilang, broker mengirim ulang event yang sama. Consumer poin yang naif menambah 150 lagi: poin Budi jadi 300, sisa tiket turun jadi 9.996, dan e-tiket terkirim dua kali.

Obatnya consumer idempotent: tiap event punya id unik (evt-1001), dan consumer menyimpan id yang sudah diproses. Event yang sama datang lagi, dilewati. Di kode tadi, ubah idempotent jadi true. Polanya sama dengan Idempotency-Key di lesson Idempotency, dan sama dengan consumer e-tiket di lesson Message Queue.

Harga yang dibayar

🤔 Kapan command, kapan event

Pakai command kalau pengirim butuh jawabannya sekarang: cek kursi masih kosong sebelum membayar, memotong kartu. Pakai event kalau yang lain cukup bereaksi belakangan: e-tiket, poin, analitik, notifikasi. Aplikasi CRUD kecil dengan satu tim gak butuh broker sama sekali.

⚠️ Jebakan yang sering ketemu

TL;DR: Event-driven architecture: producer menerbitkan event (fakta dalam bentuk lampau, misalnya order.paid) ke broker tanpa tahu siapa consumer-nya (loose coupling), dan tiap subscriber bereaksi sendiri. Command ditujukan ke satu penerima dan ditunggu jawabannya. TiketNada: dengan command Budi menunggu 580 ms dan checkout gagal kalau email mati; dengan event Budi menunggu 15 ms, dan event untuk email menunggu di broker. Menambah fitur = menambah subscriber, order gak berubah. Harganya: eventual consistency (sisa tiket baru berubah di ms 305), debugging tersebar (pakai correlation ID dan tracing), dan duplikat dari at-least-once delivery, jadi consumer harus idempotent (simpan event id yang sudah diproses).

Yang akan kamu pelajari