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:

AspekEventCommand
Maknafakta yang sudah terjadipermintaan yang ingin dijalankan
Arahdisiarkan ke siapa pun yang tertarikditujukan kepada satu penerima
Boleh ditolak?tidak relevan — sudah terjadiya, oleh penerima
Jejak jangka panjangbisa disimpan dan diputar ulangditolak tidak meninggalkan jejak
Penamaan khasPesananDibuatBuatPesanan

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:

java LogKejadian.java
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:

text
== 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 terjaga

Mari kita bedah mekanismenya:

  1. record Kejadian(tipe, keterangan, nilai): satu event adalah nilai yang tidak berubah — cocok dengan record yang field-nya final; bentuknya mengikuti makna faktanya (nilai Rp0 pada StokMenipis).
  2. publikasi(...): dua langkah — log.add(k) menempelkan fakta ke ekor log, lalu loop mengirimkannya ke seluruh pelanggan tipe itu dalam urutan pendaftaran — publikasi bekerja tanpa mengetahui siapa saja yang ada.
  3. putarUlang(tipe, pelanggan): fitur yang membedakan log dari sekadar notifikasi. Pelanggan baru svc-audit bergabung terlambat tetapi tetap menerima event lama — cukup memindai log dari awal; di broker nyata, mekanisme ini berbentuk offset per pelanggan.
  4. Proyeksi total pendapatan: loop terakhir melipat (fold) seluruh log dan menjumlahkan nilai event PesananDibuat — 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 #

Berkas Sumber

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

LogKejadian.java

Unduh berkas
java
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");
    }
}