Event-based architecture adalah cara menyusun aplikasi supaya bagian-bagiannya saling memberi kabar lewat event: catatan bahwa sesuatu sudah terjadi, dikirim ke siapa pun yang mendengarkan, tanpa pengirimnya tahu siapa saja pendengarnya. Bayangin tiga cara bicara di Laundry Bu Rini. Bu Rini menyuruh "Mas, setrika nomor 12!": itu perintah, ditujukan ke satu orang, dan bisa ditolak. Mas Eko berseru "Nomor 12 sudah disetrika!": itu event, kabar tentang hal yang sudah terjadi, dan siapa pun yang dengar boleh bereaksi (Bu Rini memperbarui papan, kasir mengabari Pak Darto). Papan di meja depan yang menulis status semua order saat ini: itu state, hasil dari semua kabar tadi.
🧪 Tebakan cepat: HP Pak Darto menerima event
order:status"Siap diambil" untuk order #12 dua kali, karena server mengirim ulang. Handler-nya cuma mengganti status lalu menampilkan notifikasi. Apa yang dilihat Pak Darto? Jawabannya ada di bagian "Kabar bisa hilang, bisa datang dua kali".
Event, perintah, dan state
| Di laundry | Di kode | |
|---|---|---|
| Perintah | "Ubah #12 jadi Disetrika" | tablet: socket.emit('order:set-status', { id: 12, status: 'Disetrika' }, ack). Server memeriksa, boleh menolak. |
| Event | "#12 sudah Disetrika" | server ke semua layar: order:status dengan { id: 12, status: 'Disetrika', ... } |
| State | papan berisi 4 order | GET /papan: #12 Dicuci, #13 Disetrika, #14 Siap diambil, #15 Antre |
Alurnya selalu sama: perintah masuk, server memeriksa dan menyimpan, baru event dikirim. Layar yang baru dibuka mulai dari state (GET /papan), lalu menerapkan event satu per satu ke state miliknya sendiri.
Emit dan listen
Di server realtime (Node.js), pola ini sudah ada bawaan: EventEmitter.
import { EventEmitter } from 'node:events'
const bus = new EventEmitter()
// tiga pendengar, tiga urusan, gak saling kenal
bus.on('order:status', (e) => io.to('papan').emit('order:status', e)) // papan dan tablet
bus.on('order:status', (e) => io.to(`order:${e.id}`).emit('order:status', e)) // HP pelanggan order itu
bus.on('order:status', (e) => console.log('log', e.id, e.status))
// di handler perintah dari tablet, setelah status tersimpan:
bus.emit('order:status', { id: 12, status: 'Disetrika', seq: 3, at: '2026-10-11T09:12:40+07:00', by: 'eko' })
bus.on(nama, fn)mendaftarkan pendengar.bus.emit(nama, data)memanggil semua pendengar nama itu, berurutan, saat itu juga (sinkron).io.to('papan')danio.to('order:12')mengirim ke room: kelompok koneksi di server (detailnya di pelajaran berikutnya).- Pengirim gak tahu siapa yang mendengar. Mau menambah kabar WhatsApp? Tambah satu
bus.on, handler tablet gak disentuh. Inilah loose coupling: bagian-bagian berkomunikasi lewat event yang di-emit dan di-listen, bukan saling memanggil langsung. - Di Socket.IO bentuknya sama. Pada
socket.on("message", callback),"message"adalah nama event yang didengarkan: callback jalan setiap kali sisi lain memanggilemit("message", data). - Dicoba di Node 22: kalau pendengar kedua melempar error, pendengar ketiga gak dipanggil, dan error-nya naik ke pemanggil
emit.
Merancang nama dan isi event
Nama: benda:kejadian, huruf kecil, konsisten di seluruh aplikasi: order:status, chat:message, presence:online. Isinya:
{ "id": 12, "status": "Siap diambil", "seq": 4, "at": "2026-10-11T09:15:02+07:00", "by": "eko" }
id: order yang mana.status: nilai barunya, bukan "maju satu langkah". Nilai akhir aman diterapkan dua kali.seq: nomor urut perubahan untuk order ini, dinaikkan server (Antre 1, Dicuci 2, Disetrika 3, Siap diambil 4).at: jam dari server, bukan dari tablet.by: siapa yang mengubah.
Jangan kirim seluruh papan di tiap event, dan jangan kirim data yang gak boleh dilihat semua layar (nomor HP pelanggan, misalnya).
Mengubah isi event tanpa merusak layar lama
Tablet Mas Eko bisa menjalankan JavaScript versi lama berhari-hari (tab gak di-refresh, aplikasi PWA di cache). Jadi:
- Menambah field aman. Layar lama mengabaikan field yang gak dikenalnya.
- Mengganti nama atau arti field merusak. Ubah
statusjadistate, dan layar lama menampilkanundefined. - Kalau terpaksa berubah: pakai nama event baru (
order:status.v2) dan kirim dua-duanya sampai semua layar lama sudah diperbarui.
Kabar bisa hilang, bisa datang dua kali
- At-most-once (paling banyak sekali): dikirim sekali, gak dicek. Ini perilaku bawaan Socket.IO dari server ke client. Dicoba dengan socket.io 4.8.4: "Siap diambil" yang dikirim saat HP Pak Darto putus gak pernah sampai. (Dengan opsi
connectionStateRecovery, event itu menyusul setelah tersambung lagi, asal putusnya kurang dari 2 menit.) - At-least-once (paling sedikit sekali): dikirim ulang sampai ada tanda terima. Gak hilang, tapi bisa dobel: kalau yang hilang tanda terimanya, kiriman pertama sebenarnya sudah sampai. Dicoba: client dengan
retries: 3yang tanda terima pertamanya gak dijawab membuat server menerima event yang sama dua kali. - Exactly-once (pas sekali) gak bisa dijamin lewat jaringan. Yang bisa: at-least-once ditambah handler yang idempoten, yaitu handler yang hasilnya sama walaupun event-nya diterapkan dua kali.
| Kabar masuk ke HP Pak Darto | Handler polos | Handler idempoten |
|---|---|---|
| seq 3, Disetrika | Disetrika | Disetrika |
| seq 4, Siap diambil | Siap diambil, notifikasi ke-1 | Siap diambil, notifikasi ke-1 |
| seq 4, Siap diambil (dobel) | notifikasi ke-2 | diabaikan |
Itu jawaban tebakannya: dengan handler polos, notifikasi "Cucian #12 siap diambil" muncul dua kali. Statusnya sendiri benar, karena isinya nilai akhir. Yang dobel adalah efek sampingnya. Handler idempoten mengingat seq terakhir tiap order:
const terakhir = new Map() // id order -> seq terakhir yang sudah diterapkan
socket.on('order:status', (e) => {
if (e.seq <= (terakhir.get(e.id) ?? 0)) return // dobel atau basi: abaikan
terakhir.set(e.id, e.seq)
papan[e.id] = e.status
if (e.status === 'Siap diambil') tampilkanNotif(`Cucian #${e.id} siap diambil`)
})
Urutan dijaga per order
Socket.IO menjaga urutan di dalam satu koneksi. Tapi kabar bisa datang dari dua jalan. Setelah tersambung lagi, HP Pak Darto meminta GET /pesanan/12 (isinya seq 3, Disetrika), dan sebelum jawabannya tiba, event seq 4 "Siap diambil" datang lewat socket:
| Datang | seq | Handler polos | Dengan cek seq |
|---|---|---|---|
| socket: Siap diambil | 4 | Siap diambil | Siap diambil |
| HTTP: Disetrika | 3 | Disetrika (mundur!) | diabaikan |
Urutan cuma perlu benar per order: kabar #13 boleh menyalip kabar #12. Karena itu seq dihitung per order, dan handler yang sama menangani dobel sekaligus basi.
⚠️ Jebakan yang sering ketemu
- Event berisi perubahan relatif ("maju satu langkah", "tambah 1"). Datang dua kali, maju dua langkah: #12 lompat dari Dicuci ke Siap diambil. Kirim nilai akhirnya.
- Menganggap emit pasti sampai. Event saat putus hilang. Setelah tersambung lagi, ambil state lewat HTTP, lalu lanjutkan event.
- Memakai jam tablet untuk urutan. Jam tiap perangkat bisa selisih menit. Pakai
seqatauatdari server. - Mengganti nama field. Layar lama rusak diam-diam. Tambah field baru, jangan ubah yang lama.
TL;DR: Event adalah kabar tentang hal yang sudah terjadi (order:status), perintah adalah permintaan yang boleh ditolak (order:set-status), state adalah hasil semuanya (GET /papan). Bagian-bagian aplikasi berkomunikasi lewat emit dan on, bukan saling memanggil langsung; nama di socket.on("message", ...) adalah nama event. Isi event: id, nilai akhir status, seq per order, at dan by dari server. Socket.IO bawaannya at-most-once (event saat putus hilang); kirim ulang membuatnya at-least-once (bisa dobel). Jadi buat handler idempoten: abaikan event yang seq-nya gak lebih besar dari yang terakhir diterapkan, dan menambah field lebih aman daripada mengganti.