Pada seluruh arsitektur sebelumnya, service saling memanggil: svc-order tahu ia membutuhkan svc-pembayaran dan menunggunya. Event-driven architecture membalik hubungan itu: service tidak lagi memanggil siapa pun, melainkan mengumumkan fakta — “pesanan-1001 telah dibuat” — lalu kembali bekerja; service lain yang tertarik mendengarkan dan bereaksi menurut caranya sendiri. Hasilnya kopling paling longgar yang bisa dibangun: penerbit tidak mengenal pelanggannya, pelanggan bisa ditambahkan tanpa menyentuh kode penerbit. Chapter ini membangun mesinnya — event log, pub/sub, replay, dan proyeksi — dalam satu program in-process.
Kejadian sebagai Fakta #
Dua kata yang sering dipakai bertukar padahal beda jenis. Event adalah fakta masa lampau: PesananDibuat, StokMenipis, PembayaranBerhasil — tidak bisa dibatalkan atau diubah, hanya bisa diikuti event baru. Command adalah permintaan masa depan: BuatPesanan, TagihKartu — ditujukan kepada satu pihak, dan boleh ditolak. Bedanya dirangkum tabel berikut:
| Aspek | Event | Command |
|---|---|---|
| Makna | fakta yang sudah terjadi | permintaan yang ingin dijalankan |
| Arah | disiarkan ke siapa pun yang tertarik | ditujukan kepada satu penerima |
| Boleh ditolak? | tidak relevan — sudah terjadi | ya, oleh penerima |
| Jejak jangka panjang | bisa disimpan dan diputar ulang | ditolak tidak meninggalkan jejak |
| Penamaan khas | PesananDibuat | BuatPesanan |
Sifat kedua yang menempel pada event: immutable dan berurutan. Log kejadian bersifat append-only — baris baru ditambahkan di ekor, baris lama tidak pernah diubah — sehingga log yang sama selalu menghasilkan urutan yang sama.
Event Log dan Pub Sub #
Program berikut menggabungkan tiga mekanisme: log append-only sebagai penyimpanan fakta, langganan per tipe sebagai penyaluran (pub/sub), dan replay bagi pelanggan yang bergabung terlambat:
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Event-driven mini: event log append-only, pub/sub, replay, dan proyeksi. */
public class LogKejadian {
/** Satu event (kejadian): fakta yang sudah terjadi, tidak dapat diubah. */
record Kejadian(String tipe, String keterangan, long nilai) {}
/** Bus kejadian: log append-only + daftar pelanggan per tipe. */
static class Bus {
private final List<Kejadian> log = new ArrayList<>();
private final Map<String, List<String>> pelangganPerTipe = new LinkedHashMap<>();
void langganan(String tipe, String namaPelanggan) {
pelangganPerTipe.computeIfAbsent(tipe, k -> new ArrayList<>())
.add(namaPelanggan);
}
/** Publikasi: tambahkan ke log (append-only) lalu kirim ke pelanggan tipe itu. */
void publikasi(Kejadian k) {
log.add(k);
for (String p : pelangganPerTipe.getOrDefault(k.tipe(), List.of())) {
System.out.println(" -> " + p + " menerima [" + k.tipe() + "] "
+ k.keterangan() + " (Rp" + k.nilai() + ")");
}
}
/** Replay: kirim ulang seluruh event lama bagi pelanggan yang baru bergabung. */
void putarUlang(String tipe, String namaPelanggan) {
for (Kejadian k : log) {
if (k.tipe().equals(tipe)) {
System.out.println(" -> " + namaPelanggan + " menerima (replay) ["
+ k.tipe() + "] " + k.keterangan() + " (Rp" + k.nilai() + ")");
}
}
}
List<Kejadian> log() {
return log;
}
}
public static void main(String[] args) {
Bus bus = new Bus();
bus.langganan("PesananDibuat", "svc-notifikasi");
bus.langganan("PesananDibuat", "svc-rekomendasi");
bus.langganan("StokMenipis", "svc-pembelian");
System.out.println("== Publikasi: penerbit tidak mengenal pelanggan ==");
bus.publikasi(new Kejadian("PesananDibuat", "pesanan-1001 oleh Budi", 450_000));
bus.publikasi(new Kejadian("StokMenipis", "produk A1 sisa 2", 0));
bus.publikasi(new Kejadian("PesananDibuat", "pesanan-1002 oleh Siti", 175_000));
System.out.println();
System.out.println("== Pelanggan baru mengejar dengan replay dari log ==");
bus.langganan("PesananDibuat", "svc-audit");
bus.putarUlang("PesananDibuat", "svc-audit");
System.out.println();
System.out.println("== Proyeksi: total pendapatan dihitung dari log ==");
long total = 0;
for (Kejadian k : bus.log()) {
if (k.tipe().equals("PesananDibuat")) {
total += k.nilai();
}
}
System.out.println(" fold seluruh log -> total pendapatan = Rp" + total);
System.out.println(" log utuh: " + bus.log().size()
+ " kejadian append-only, urutan penerbitan terjaga");
}
}Output yang Dihasilkan:
== Publikasi: penerbit tidak mengenal pelanggan ==
-> svc-notifikasi menerima [PesananDibuat] pesanan-1001 oleh Budi (Rp450000)
-> svc-rekomendasi menerima [PesananDibuat] pesanan-1001 oleh Budi (Rp450000)
-> svc-pembelian menerima [StokMenipis] produk A1 sisa 2 (Rp0)
-> svc-notifikasi menerima [PesananDibuat] pesanan-1002 oleh Siti (Rp175000)
-> svc-rekomendasi menerima [PesananDibuat] pesanan-1002 oleh Siti (Rp175000)
== Pelanggan baru mengejar dengan replay dari log ==
-> svc-audit menerima (replay) [PesananDibuat] pesanan-1001 oleh Budi (Rp450000)
-> svc-audit menerima (replay) [PesananDibuat] pesanan-1002 oleh Siti (Rp175000)
== Proyeksi: total pendapatan dihitung dari log ==
fold seluruh log -> total pendapatan = Rp625000
log utuh: 3 kejadian append-only, urutan penerbitan terjagaMari kita bedah mekanismenya:
record Kejadian(tipe, keterangan, nilai): satu event adalah nilai yang tidak berubah — cocok denganrecordyang field-nya final; bentuknya mengikuti makna faktanya (nilaiRp0padaStokMenipis).publikasi(...): dua langkah —log.add(k)menempelkan fakta ke ekor log, lalu loop mengirimkannya ke seluruh pelanggan tipe itu dalam urutan pendaftaran —publikasibekerja tanpa mengetahui siapa saja yang ada.putarUlang(tipe, pelanggan): fitur yang membedakan log dari sekadar notifikasi. Pelanggan barusvc-auditbergabung terlambat tetapi tetap menerima event lama — cukup memindai log dari awal; di broker nyata, mekanisme ini berbentuk offset per pelanggan.- Proyeksi total pendapatan: loop terakhir melipat (fold) seluruh log dan menjumlahkan
nilaieventPesananDibuat— total Rp625.000 dihitung bukan dari tabel, melainkan dari sejarah kejadian itu sendiri.
Transactional Outbox #
Pertanyaan yang ditunda pada chapter Saga Pattern menemukan jawabannya di sini: bagaimana memastikan “database terkommit” dan “event terkirim” tidak terpisah? Masalahnya disebut dual write: service menulis ke database lokal lalu mengirim event ke broker — dua penulisan ke dua sistem; kegagalan di antara keduanya meninggalkan database terkommit tanpa event, atau event tanpa komit.
Solusi standar adalah transactional outbox: event tidak dikirim langsung, melainkan ditulis ke tabel outbox dalam transaksi yang sama dengan data bisnisnya — satu komit lokal menjamin keduanya menempel bersama. Proses relai terpisah (polling publisher atau transaction-log tailing) membaca tabel outbox dan meneruskannya ke broker. Pada program kita, publikasi(k) memainkan peran tulis-ke-outbox.
Dari Log ke Event Sourcing #
Bila proyeksi total pendapatan diperluas menjadi seluruh keadaan aplikasi — bukan hanya satu angka — arsitektur itu punya nama: event sourcing. Keadaan tidak lagi disimpan sebagai tabel yang ditimpa-timpa, melainkan dihitung ulang dengan melipat log kejadian dari awal. Tabel yang kita kenal berubah peran menjadi proyeksi atau read model: potongan keadaan yang dibangun untuk satu kebutuhan baca dan boleh dibangun ulang kapan pun dari log.
Pola pendampingnya, CQRS (Command Query Responsibility Segregation), memisahkan model tulis (validasi command → event) dari model baca (proyeksi event). Konsistensi kedua sisi menjadi eventual, tetapi tiap sisi bisa diskalakan sendiri — berharga saat beban baca jauh melebihi beban tulis.
Log, langganan, dan replay in-process adalah bayangan perangkat produksi yang menjalankannya pada skala jutaan pesan per detik. Chapter berikutnya memperkenalkan message brokers — Kafka, RabbitMQ, dan konsep partition, offset, dead letter di baliknya.
Referensi #
- microservices.io: Pola Domain Event
- microservices.io: Transactional Outbox
- Martin Fowler: Event Sourcing
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
LogKejadian.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** Event-driven mini: event log append-only, pub/sub, replay, dan proyeksi. */
public class LogKejadian {
/** Satu event (kejadian): fakta yang sudah terjadi, tidak dapat diubah. */
record Kejadian(String tipe, String keterangan, long nilai) {}
/** Bus kejadian: log append-only + daftar pelanggan per tipe. */
static class Bus {
private final List<Kejadian> log = new ArrayList<>();
private final Map<String, List<String>> pelangganPerTipe = new LinkedHashMap<>();
void langganan(String tipe, String namaPelanggan) {
pelangganPerTipe.computeIfAbsent(tipe, k -> new ArrayList<>())
.add(namaPelanggan);
}
/** Publikasi: tambahkan ke log (append-only) lalu kirim ke pelanggan tipe itu. */
void publikasi(Kejadian k) {
log.add(k);
for (String p : pelangganPerTipe.getOrDefault(k.tipe(), List.of())) {
System.out.println(" -> " + p + " menerima [" + k.tipe() + "] "
+ k.keterangan() + " (Rp" + k.nilai() + ")");
}
}
/** Replay: kirim ulang seluruh event lama bagi pelanggan yang baru bergabung. */
void putarUlang(String tipe, String namaPelanggan) {
for (Kejadian k : log) {
if (k.tipe().equals(tipe)) {
System.out.println(" -> " + namaPelanggan + " menerima (replay) ["
+ k.tipe() + "] " + k.keterangan() + " (Rp" + k.nilai() + ")");
}
}
}
List<Kejadian> log() {
return log;
}
}
public static void main(String[] args) {
Bus bus = new Bus();
bus.langganan("PesananDibuat", "svc-notifikasi");
bus.langganan("PesananDibuat", "svc-rekomendasi");
bus.langganan("StokMenipis", "svc-pembelian");
System.out.println("== Publikasi: penerbit tidak mengenal pelanggan ==");
bus.publikasi(new Kejadian("PesananDibuat", "pesanan-1001 oleh Budi", 450_000));
bus.publikasi(new Kejadian("StokMenipis", "produk A1 sisa 2", 0));
bus.publikasi(new Kejadian("PesananDibuat", "pesanan-1002 oleh Siti", 175_000));
System.out.println();
System.out.println("== Pelanggan baru mengejar dengan replay dari log ==");
bus.langganan("PesananDibuat", "svc-audit");
bus.putarUlang("PesananDibuat", "svc-audit");
System.out.println();
System.out.println("== Proyeksi: total pendapatan dihitung dari log ==");
long total = 0;
for (Kejadian k : bus.log()) {
if (k.tipe().equals("PesananDibuat")) {
total += k.nilai();
}
}
System.out.println(" fold seluruh log -> total pendapatan = Rp" + total);
System.out.println(" log utuh: " + bus.log().size()
+ " kejadian append-only, urutan penerbitan terjaga");
}
}