Panggilan fungsi mengikat dua komponen secara kaku: pemanggil harus mengenal yang dipanggil, dan setiap reaksi baru menuntut perubahan di pemanggil. Event-driven design melepas ikatan itu dengan pola berbeda: komponen cukup mengumumkan kejadian — “pesanan dibuat” — tanpa peduli siapa yang menanggapi. Email, audit, dan inventaris masing-masing berlangganan dan bereaksi sendiri. Pada chapter ini kita membangun event bus in-memory dengan subscribe dan publish, melihat seberapa lepas ketergaitannya, lalu membahas kesepakatan yang harus dijaga ketika event dipakai secara serius: idempotensi, urutan, dan persistensi.
Anatomi: Event, Penerbit, Pelanggan, dan Bus #
Event hanyalah struct yang menjadi kontrak data bersama: PesananDibuat berisi nomor, pembeli, dan total. Tiga peran lain mengelilinginya. Penerbit menerbitkan event ke topik tertentu, pelanggan mendaftarkan handler-nya ke topik yang sama, dan bus menjadi perantara yang menyimpan daftar langganan per topik. Diagram berikut memperlihatkan arah hubungan yang terbentuk:
Perhatikan tidak ada panah dari penerbit ke pelanggan. Layanan pesanan tidak mengimpor modul email maupun modul audit; satu-satunya yang dikenalnya adalah bus. Menambah reaksi baru — misalnya modul loyalitas poin — tidak menyentuh satu baris kode layanan pesanan.
Demo: Bus In-Memory #
Berkas event-demo.go mengimplementasikan bus dengan map topik ke daftar handler, dilindungi sync.Mutex karena goroutine mana pun boleh menerbitkan. Pemanggilan handler dilakukan sinkron sehingga urutan output tetap deterministik:
package main
import (
"fmt"
"sync"
)
// ===== Event: kontrak data yang disepakati penerbit dan pelanggan =====
type PesananDibuat struct {
Nomor string
Pembeli string
Total int
}
type Pelanggan func(e PesananDibuat)
// ===== Bus in-memory: daftar langganan per topik =====
type Bus struct {
mu sync.Mutex
langganan map[string][]Pelanggan
}
func BusBaru() *Bus {
return &Bus{langganan: map[string][]Pelanggan{}}
}
func (b *Bus) Berlangganan(topik string, fn Pelanggan) {
b.mu.Lock()
defer b.mu.Unlock()
b.langganan[topik] = append(b.langganan[topik], fn)
}
func (b *Bus) Terbitkan(topik string, e PesananDibuat) {
b.mu.Lock()
// salin agar pelanggan baru tidak bisa mengubah iterasi ini.
fns := append([]Pelanggan(nil), b.langganan[topik]...)
b.mu.Unlock()
for _, fn := range fns {
fn(e) // pemanggilan sinkron: urutan tetap deterministik
}
}
// ===== Penerbit: layanan pesanan tidak tahu siapa pelanggannya =====
func terbitkanPesanan(b *Bus, nomor, pembeli string, total int) {
b.Terbitkan("pesanan.dibuat", PesananDibuat{
Nomor: nomor,
Pembeli: pembeli,
Total: total,
})
}
func main() {
bus := BusBaru()
// modul email berlangganan topik pesanan.dibuat.
bus.Berlangganan("pesanan.dibuat", func(e PesananDibuat) {
fmt.Printf("[email] %s: pesanan Anda %s sebesar Rp%d sedang diproses\n", e.Pembeli, e.Nomor, e.Total)
})
// modul audit berlangganan topik yang sama.
bus.Berlangganan("pesanan.dibuat", func(e PesananDibuat) {
fmt.Printf("[audit] mencatat pesanan %s di jejak audit\n", e.Nomor)
})
terbitkanPesanan(bus, "PSN-101", "[email protected]", 250000)
terbitkanPesanan(bus, "PSN-102", "[email protected]", 99000)
}Jalankan program dengan perintah berikut dari direktori chapter:
go run event-demo.goOutput yang Dihasilkan:
[email] [email protected]: pesanan Anda PSN-101 sebesar Rp250000 sedang diproses
[audit] mencatat pesanan PSN-101 di jejak audit
[email] [email protected]: pesanan Anda PSN-102 sebesar Rp99000 sedang diproses
[audit] mencatat pesanan PSN-102 di jejak auditMari kita telaah bagian-bagian penting program di atas:
- Baris 10-14 (
type PesananDibuat struct ...): event berbentuk struct dengan nama kejadian lampau (“dibuat”, bukan “buat”). Nama lampau menegaskan fakta yang sudah terjadi, bukan perintah yang bisa ditolak. - Baris 22 (
langganan map[string][]Pelanggan): satu topik bisa punya banyak pelanggan, disimpan dalam urutan pendaftaran.Pelangganadalah function type sehingga handler cukup closure satu baris. - Baris 35-43 (
func (b *Bus) Terbitkan ...): daftar pelanggan disalin dulu di bawah kunci, lalu handler dipanggil di luar kunci. Salinan mencegah handler yang mendaftar saat iterasi berlangsung mengubah slice yang sedang dipakai. - Baris 47-53 (
func terbitkanPesanan ...): satu-satunya kode penerbit. Fungsi ini hanya mengenal bus dan topik"pesanan.dibuat"— bukan email, bukan audit. - Baris 59-66 (
bus.Berlangganan ...): dua modul bereaksi pada topik yang sama tanpa saling mengenal. Menambah pelanggan ketiga tidak mengubah fungsiterbitkanPesanansama sekali.
Panggilan Langsung atau Event #
| Aspek | Panggilan langsung | Event |
|---|---|---|
| Ketergaitan | Penerbit mengenal pemroses | Penerbit tidak tahu siapa memproses |
| Menambah reaksi | Ubah kode penerbit | Tambah pelanggan baru |
| Alur yang terbaca | Jelas: A memanggil B | Tidak langsung: perlu dokumentasi topik |
| Kegagalan pemroses | Error balik ke pemanggil | Ditangani pelanggan sendiri |
| Hasil yang dibutuhkan | Bisa dikembalikan seketika | Tidak tersedia bagi penerbit |
Tabel terakhir adalah kunci keputusan: bila penerbit membutuhkan hasil — misalnya pesanan tidak boleh dibuat bila stok kurang — gunakan panggilan langsung lewat interface seperti pada chapter Modular Monolith . Event cocok untuk reaksi yang tidak memutuskan jalur utama: kirim email, catat audit, perbarui laporan.
Kesepakatan yang Harus Dijaga #
Event di produksi membawa tiga kesepakatan. Pertama, pelanggan harus idempoten: event yang sama bisa terkirim dua kali (retry, restart), sehingga memprosesnya dua kali tidak boleh mengubah hasil — “kirim email sekali” dicapai dengan menandai nomor pesanan yang sudah diproses. Kedua, urutan: bus in-memory kita memelihara urutan sinkron, tetapi broker nyata sering mengirim paralel; event jangan diandaikan tiba berurutan kecuali broker menjaminnya. Ketiga, persistensi: event yang hanya hidup di memori hilang saat proses mati. Pola outbox menjawabnya — event ditulis di tabel database dalam transaksi yang sama dengan data bisnisnya, lalu proses terpisah membaca tabel itu dan menerbitkan ke broker, sehingga event tidak pernah hilang meski aplikasi crash di tengah jalan.
Dengan seluruh peranti arsitektur kini tersedia — lapisan, DI, domain, modul, event — pertanyaan terakhir bersifat keputusan: arsitektur mana yang tepat untuk proyek Anda? Chapter penutup section ini membantu menjawabnya secara proporsional.
Referensi #
- Package sync
—
Mutexyang menjaga daftar langganan bus. - Errors are values — penanganan error di dalam handler event.
- Package context — pembatalan dan timeout saat event diproses asinkron.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
event-demo.go
Unduh berkaspackage main
import (
"fmt"
"sync"
)
// ===== Event: kontrak data yang disepakati penerbit dan pelanggan =====
type PesananDibuat struct {
Nomor string
Pembeli string
Total int
}
type Pelanggan func(e PesananDibuat)
// ===== Bus in-memory: daftar langganan per topik =====
type Bus struct {
mu sync.Mutex
langganan map[string][]Pelanggan
}
func BusBaru() *Bus {
return &Bus{langganan: map[string][]Pelanggan{}}
}
func (b *Bus) Berlangganan(topik string, fn Pelanggan) {
b.mu.Lock()
defer b.mu.Unlock()
b.langganan[topik] = append(b.langganan[topik], fn)
}
func (b *Bus) Terbitkan(topik string, e PesananDibuat) {
b.mu.Lock()
// salin agar pelanggan baru tidak bisa mengubah iterasi ini.
fns := append([]Pelanggan(nil), b.langganan[topik]...)
b.mu.Unlock()
for _, fn := range fns {
fn(e) // pemanggilan sinkron: urutan tetap deterministik
}
}
// ===== Penerbit: layanan pesanan tidak tahu siapa pelanggannya =====
func terbitkanPesanan(b *Bus, nomor, pembeli string, total int) {
b.Terbitkan("pesanan.dibuat", PesananDibuat{
Nomor: nomor,
Pembeli: pembeli,
Total: total,
})
}
func main() {
bus := BusBaru()
// modul email berlangganan topik pesanan.dibuat.
bus.Berlangganan("pesanan.dibuat", func(e PesananDibuat) {
fmt.Printf("[email] %s: pesanan Anda %s sebesar Rp%d sedang diproses\n", e.Pembeli, e.Nomor, e.Total)
})
// modul audit berlangganan topik yang sama.
bus.Berlangganan("pesanan.dibuat", func(e PesananDibuat) {
fmt.Printf("[audit] mencatat pesanan %s di jejak audit\n", e.Nomor)
})
terbitkanPesanan(bus, "PSN-101", "[email protected]", 250000)
terbitkanPesanan(bus, "PSN-102", "[email protected]", 99000)
}