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:

go
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:

bash
go run event-demo.go

Output yang Dihasilkan:

text
[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 audit

Mari kita telaah bagian-bagian penting program di atas:

  1. 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.
  2. Baris 22 (langganan map[string][]Pelanggan): satu topik bisa punya banyak pelanggan, disimpan dalam urutan pendaftaran. Pelanggan adalah function type sehingga handler cukup closure satu baris.
  3. 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.
  4. Baris 47-53 (func terbitkanPesanan ...): satu-satunya kode penerbit. Fungsi ini hanya mengenal bus dan topik "pesanan.dibuat" — bukan email, bukan audit.
  5. Baris 59-66 (bus.Berlangganan ...): dua modul bereaksi pada topik yang sama tanpa saling mengenal. Menambah pelanggan ketiga tidak mengubah fungsi terbitkanPesanan sama sekali.

Panggilan Langsung atau Event #

AspekPanggilan langsungEvent
KetergaitanPenerbit mengenal pemrosesPenerbit tidak tahu siapa memproses
Menambah reaksiUbah kode penerbitTambah pelanggan baru
Alur yang terbacaJelas: A memanggil BTidak langsung: perlu dokumentasi topik
Kegagalan pemrosesError balik ke pemanggilDitangani pelanggan sendiri
Hasil yang dibutuhkanBisa dikembalikan seketikaTidak 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

event-demo.go

Unduh berkas
go
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)
}