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
ordermemanggil ketiganya satu per satu, Budi menunggu 580 ms. Kalauordercukup 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]();
}
subscribemendaftarkan consumer.ordergak memanggil siapa pun secara langsung, dia cumapublish.publishmemberi tiap subscriber salinan event. Kalauemailmati, event masukantreanEmail, gak hilang.jadwaldannantiadalah jam virtual: tiap subscriber selesai di ms yang berbeda, dan loopwhilemenjalankannya dari yang paling awal. Output-nya persis tabel di atas, ditambah baris refresh di ms 1.000.sudahadalah daftar event id yang sudah diproses tiap consumer. Dipakai kalauidempotentbernilaitrue(bagian "Event bisa datang dua kali").
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
- Debugging lebih sulit. Satu pembayaran memicu kerja di tiga service, di waktu yang berbeda. Gak ada satu stack trace yang memperlihatkan semuanya. Bawa correlation ID (misalnya
evt-1001) di setiap log, dan pakai distributed tracing (OpenTelemetry, Jaeger) untuk merangkai alurnya. - Eventual consistency. Data di tiap service belum tentu langsung terbaru.
- Duplikat. Setiap consumer harus aman menerima event yang sama dua kali.
- Urutan. Event yang berbeda bisa diproses gak berurutan. Kalau urutan penting, misalnya
order.paidlaluorder.cancelleduntuk pesanan yang sama, kirim keduanya dengan key yang sama (di Kafka: partition keyorderId) supaya urut.
🤔 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
- Consumer yang gak idempotent. At-least-once artinya duplikat pasti datang suatu saat. Poin dobel, e-tiket dobel.
- Menganggap semua langsung terbarui. Setelah publish, halaman lain bisa menampilkan data lama beberapa ratus milidetik.
- Event yang isinya perintah.
kirimEmailKeBudibukan event. Event menceritakan apa yang terjadi (order.paid), subscriber yang memutuskan reaksinya. - Gak ada correlation ID. Saat e-tiket gak sampai, kamu gak bisa melacak event mana yang macet di mana.
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).