Semakin besar aplikasi, semakin banyak komponen yang ingin tahu ketika sesuatu terjadi: pesanan ditempatkan, pembayaran diterima, stok habis. Jika semua pihak saling memanggil secara langsung, kita mendapat jaring panggilan yang mengunci semua komponen satu sama lain. Event-driven architecture menawarkan model komunikasi yang berbeda: komponen cukup mengumumkan fakta yang sudah terjadi, dan pihak yang berkepentingan mendengarkan sendiri. Pada chapter ini kita akan membangun event bus dalam proses yang bisa diuji, membedakan jenis-jenis event, menghadapi kenyataan eventual consistency, lalu menjamin event tidak hilang dengan pola outbox di atas SQLite.
Event adalah Fakta yang Sudah Terjadi #
Event diberi nama dengan kata kerja bentuk lampau: OrderPlaced, bukan PlaceOrder. Penamaan ini bukan selera — PlaceOrder adalah command (permintaan untuk melakukan sesuatu, yang bisa ditolak), sedangkan OrderPlaced adalah pernyataan bahwa sesuatu sudah terjadi dan tidak bisa dibatalkan. Tabel berikut memetakan tiga jenis pesan yang sering tertukar:
| Aspek | Command | Query | Event |
|---|---|---|---|
| Contoh nama | PlaceOrder | GetOrder | OrderPlaced |
| Tujuan | Meminta perubahan state | Meminta data | Memberi tahu fakta |
| Jumlah penerima | Satu target | Satu target | Siapa pun yang berlangganan |
| Bisa ditolak? | Ya (validasi gagal) | Ya (tidak berhak) | Tidak — sudah terjadi |
| Ada respons? | Ya | Ya | Tidak |
Konsekuensi pentingnya: produsen event tidak menunggu apa pun dari penerima. Modul Ordering mengumumkan OrderPlaced lalu melanjutkan hidupnya; apakah tim email, tim inventori, atau tim analitik ikut mendengarkan bukan urusannya. Pada skala objek tunggal, pola mendengarkan ini adalah observer pattern
yang sudah kita kenal; pada skala arsitektur, pola yang sama menjadi tulang punggung event-driven architecture.
Event Bus Dalam Proses #
Mari kita bangun tulang punggungnya: sebuah dispatcher sederhana yang menyimpan daftar listener per nama event, lalu memanggil semuanya ketika event di-dispatch. Seluruh mekanisme hidup dalam satu proses PHP — cukup untuk arsitektur modular seperti pada chapter Modular Monolith , sebelum kita berbicara message broker eksternal:
<?php
interface DomainEvent
{
}
final class OrderPlaced implements DomainEvent
{
public function __construct(
public readonly string $orderId,
public readonly int $totalCents,
) {}
}
final class EventBus
{
/** @var array<string, list<callable>> */
private array $listeners = [];
public function subscribe(string $eventName, callable $listener): void
{
$this->listeners[$eventName][] = $listener;
}
public function dispatch(object $event): int
{
$listeners = $this->listeners[$event::class] ?? [];
foreach ($listeners as $listener) {
$listener($event);
}
return count($listeners);
}
}
$bus = new EventBus();
$bus->subscribe(OrderPlaced::class, function (OrderPlaced $event): void {
echo "[Email] Konfirmasi pesanan {$event->orderId} dikirim ke pelanggan\n";
});
$bus->subscribe(OrderPlaced::class, function (OrderPlaced $event): void {
$rupiah = number_format($event->totalCents / 100, 0, ',', '.');
echo "[Inventori] Cadangkan stok senilai Rp{$rupiah}\n";
});
$dipanggil = $bus->dispatch(new OrderPlaced('ORD-2041', 25000000));
echo "Listener terpanggil: $dipanggil\n";Output yang Dihasilkan:
[Email] Konfirmasi pesanan ORD-2041 dikirim ke pelanggan
[Inventori] Cadangkan stok senilai Rp250.000
Listener terpanggil: 2Mari kita bedah kode di atas:
DomainEvent: interface penanda tanpa method — tipe ini memberi nama konsep “ini adalah event” agar type hint dan container bisa menyaringnya.OrderPlaced: event pembawa data (orderId,totalCents) yang dibekukan lewatreadonly. Event adalah catatan sejarah; isinya tidak boleh berubah setelah dibuat.$this->listeners[$event::class]: nama class event menjadi kunci pendaftaran listener. Operator::classpada objek menghasilkan FQCN, misalnyaOrderPlaced.dispatch(): memanggil setiap listener lalu mengembalikan jumlahnya. Nilai balik sederhana ini membuat pengujian bisa memastikan bahwa event benar-benar sampai ke pendengarnya.
Perhatikan bahwa Ordering — pemanggil dispatch() — tidak mengenal satu listener pun. Menambah reaksi baru (misalnya modul loyalitas menambah poin) cukup dengan subscribe() di tempat lain, tanpa menyentuh kode produsen.
Event Notifikasi vs Event Pembawa State #
Ada dua gaya membawa data dalam event. Event notifikasi hanya membawa identitas — orderId — dan penerima yang butuh data lengkap harus bertanya balik ke produsen. Event event-carried state transfer membawa seluruh data yang diperlukan penerima, sehingga penerima bisa menyimpan salinannya sendiri dan tidak perlu bertanya lagi:
<?php
// Notifikasi: hanya identitas, penerima harus query balik.
final class OrderPlacedNotification
{
public function __construct(public readonly string $orderId) {}
}
// Carried state: data lengkap ikut dikirim.
final class OrderPlacedWithState
{
/**
* @param list<array{sku: string, qty: int}> $items
*/
public function __construct(
public readonly string $orderId,
public readonly array $items,
public readonly int $totalCents,
) {}
}Notifikasi menjaga event tetap ramping dan mencegah produsen membocorkan terlalu banyak struktur internal, tetapi setiap penerima menambah satu panggilan balik ke produsen. Carried state membebaskan penerima dari ketergantungan runtime pada produsen — bahkan bisa menyimpan salinan lokal — dengan bayaran event yang gemuk dan kontrak data yang lebih ketat. Pilihan antara keduanya adalah keputusan per-event, bukan aturan global.
Kenyataan Eventual Consistency #
Ketika listener berjalan setelah event diumumkan, ada jeda — sekecil apa pun — antara momen “pesanan ditempatkan” dan momen “invoice tercipta”. Dalam event-driven architecture, konsistensi data antar komponen bersifat eventual: sistem akan konsisten setelah semua event selesai diproses, bukan pada saat transaksi utama berakhir. Antara keduanya, pembaca bisa melihat pesanan yang sudah pasti tapi belum punya invoice.
Pola Outbox: Menjamin Event Tidak Hilang #
Dalam aplikasi nyata, event biasanya dipicu oleh perubahan database. Di sinilah lubang tersembunyi: jika kode melakukan INSERT ke tabel pesanan lalu mengirim event ke broker sebagai dua langkah terpisah, kegagalan di antara keduanya (proses mati, jaringan putus) membuat pesanan tersimpan tanpa event — listener tidak akan pernah tahu. Pola outbox menutup lubang ini dengan menulis event ke tabel khusus di dalam transaksi yang sama dengan data bisnis, lalu proses relay terpisah membaca tabel tersebut dan mengirim event yang belum terkirim:
Diagram di atas menunjukkan alur lengkapnya: handler menulis tabel orders dan tabel outbox dalam satu transaksi, relay membaca baris outbox yang belum terkirim dan meneruskannya ke bus, lalu listener-listener merespons. Mari kita buktikan idenya dengan SQLite di memori:
<?php
final class OrderPlaced
{
public function __construct(
public readonly string $orderId,
public readonly string $customerEmail,
) {}
}
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE orders (id TEXT PRIMARY KEY, total_cents INTEGER)');
$pdo->exec('CREATE TABLE outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_name TEXT NOT NULL,
payload TEXT NOT NULL,
published INTEGER NOT NULL DEFAULT 0
)');
function simpanPesanan(PDO $pdo, string $orderId, int $totalCents, string $email): void
{
$pdo->beginTransaction();
$order = $pdo->prepare('INSERT INTO orders (id, total_cents) VALUES (?, ?)');
$order->execute([$orderId, $totalCents]);
$event = new OrderPlaced($orderId, $email);
$outbox = $pdo->prepare('INSERT INTO outbox (event_name, payload) VALUES (?, ?)');
$outbox->execute([
$event::class,
json_encode(['orderId' => $event->orderId, 'email' => $event->customerEmail]),
]);
$pdo->commit();
}
simpanPesanan($pdo, 'ORD-2042', 15000000, '[email protected]');
// Relay: proses terpisah yang membaca outbox dan "mengirim" event.
$rows = $pdo->query(
'SELECT id, event_name, payload FROM outbox WHERE published = 0'
)->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
echo "Kirim {$row['event_name']}: {$row['payload']}\n";
$pdo->prepare('UPDATE outbox SET published = 1 WHERE id = ?')->execute([$row['id']]);
}
$sisa = $pdo->query(
'SELECT COUNT(*) FROM outbox WHERE published = 0'
)->fetchColumn();
echo "Event belum terkirim: $sisa\n";Output yang Dihasilkan:
Kirim OrderPlaced: {"orderId":"ORD-2042","email":"[email protected]"}
Event belum terkirim: 0Ada tiga hal penting pada kode di atas. Pertama, beginTransaction() hingga commit() mengikat penulisan tabel orders dan tabel outbox menjadi satu kesatuan atomik — keduanya berhasil, atau keduanya batal. Kedua, relay membaca baris dengan published = 0 lalu menandainya setelah dikirim; bila proses relay mati di tengah jalan, baris yang belum sempat ditandai akan diambil lagi pada putaran berikutnya (paling aman, penandaan dilakukan idempoten). Ketiga, payload berupa JSON dari data event — format yang sama sekali tidak peduli apakah penerimanya modul dalam proses atau service jauh di ujung message broker.
Kapan Event Justru Menyulitkan #
Event bukan obat untuk segalanya. Alur yang terdiri dari lima event berantai jauh lebih sulit di-debug daripada lima panggilan fungsi berurutan: untuk menjawab “kenapa invoice tidak tercipta?”, kita harus melacak event mana yang terkirim, diterima siapa, dan diproses atau tidak. Untuk logika yang selalu sederhana, selalu satu arah, dan jarang berubah — misalnya menghitung total belanja — panggilan langsung tetaplah pilihan terbaik. Gunakan event ketika jumlah pihak yang berkepentingan dengan suatu fakta benar-benar tumbuh atau berubah tanpa bisa diprediksi; pada saat itu, kopling longgar membayar kembali biaya penelusurannya.
Ringkasan #
Event-driven architecture mengganti jaring panggilan langsung dengan fakta yang diumumkan: event dinamai dengan bentuk lampau, event bus dalam proses mempertemukan produsen dan listener tanpa saling mengenal, dan pola outbox menjamin fakta tidak hilang di celah antara database dan broker. Imbangannya adalah eventual consistency dan alur yang lebih sulit dilacak — harga yang layak hanya jika kebutuhan desentralisasi memang nyata. Semua fakta ini — OrderPlaced, PaymentReceived — sebenarnya menyimpan petunjuk besar tentang bahasa bisnis aplikasi kita, dan memahami bahasa itulah inti dari Domain-Driven Design pada chapter berikutnya.