Chapter sebelumnya menutup dengan janji: alur lintas service diselesaikan bukan dengan mengommit dua database sekaligus, melainkan dengan membatalkan efek langkah-langkah yang sudah menempel. Pola itu bernama saga — transaksi panjang yang dipecah menjadi rangkaian transaksi lokal, tiap langkah membawa pasangan aksi kompensasi yang mengurangi efeknya bila langkah berikutnya gagal. Idenya berusia lebih tua dari microservices — lahir dari penelitian database 1987 — dan menemukan medan paling luas di arsitektur terdistribusi modern. Chapter ini membangun saga lengkap — orchestrator, tumpukan kompensasi, tiga skenario kegagalan — dalam satu program yang bisa dijalankan.

Anatomi Saga #

Satu saga untuk “proses pesanan” memuat empat langkah maju, dan tiap langkah membawa kompensasinya sendiri:

Langkah majuServiceKompensasi bila langkah berikutnya gagal
catat pesanansvc-pesanantandai pesanan DIBATALKAN
kurangi stoksvc-inventorikembalikan stok
tagih kartusvc-pembayarankembalikan dana (refund)
kirim konfirmasisvc-notifikasibatalkan konfirmasi

Dua sifat membedakan kolom kompensasi dari rollback database. Pertama, kompensasi adalah operasi bisnis baru yang menempel di depan (refund dicatat sebagai transaksi tersendiri, bukan penghapusan transaksi penagihan) — sejarah tetap terpelihara. Kedua, kompensasi terjadi terbalik urutannya: bila langkah keempat gagal, kita mengurangi dulu efek langkah ketiga, lalu kedua, lalu pertama — seperti membongkar tumpukan piring dari yang paling atas.

Orchestrator dengan Kompensasi Terbalik #

Program berikut memodelkan satu objek Dunia sebagai keadaan seluruh service (stok inventori dan status pesanan), lalu menyusun saga sebagai daftar langkah. Tiga skenario dijalankan: mulus, gagal di tengah, dan gagal di langkah terakhir:

java SagaPesanan.java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

/** Saga dengan orchestrator: langkah maju, kompensasi berjalan terbalik saat gagal. */
public class SagaPesanan {

    /** Sinyal bahwa satu langkah saga gagal dan kompensasi harus berjalan. */
    static class LangkahGagal extends RuntimeException {
        LangkahGagal(String pesan) {
            super(pesan);
        }
    }

    /** Satu langkah saga: aksi maju beserta aksi kompensasinya. */
    record Langkah(String nama, Runnable maju, Runnable kompensasi) {}

    /** Keadaan seluruh "service" dalam satu dunia simulasi. */
    static class Dunia {
        int stok = 3;
        String pesanan = "BARU";
        final List<String> jejak = new ArrayList<>();

        void catat(String kejadian) {
            jejak.add(kejadian);
        }
    }

    public static void main(String[] args) {
        System.out.println("== Skenario 1: seluruh langkah sukses ==");
        jalankan(0);

        System.out.println();
        System.out.println("== Skenario 2: kartu ditolak di langkah ke-3 ==");
        jalankan(3);

        System.out.println();
        System.out.println("== Skenario 3: email konfirmasi gagal di langkah terakhir ==");
        jalankan(4);
    }

    /** Menjalankan saga; langkah bernomor `gagalDi` melempar LangkahGagal (0 = tanpa kegagalan). */
    static void jalankan(int gagalDi) {
        Dunia dunia = new Dunia();

        List<Langkah> saga = List.of(
                new Langkah("catat pesanan",
                        () -> {
                            dunia.pesanan = "TERCATAT";
                            dunia.catat("maju        : pesanan TERCATAT");
                        },
                        () -> {
                            dunia.pesanan = "DIBATALKAN";
                            dunia.catat("kompensasi  : pesanan DIBATALKAN");
                        }),
                new Langkah("kurangi stok",
                        () -> dunia.catat("maju        : stok jadi " + --dunia.stok),
                        () -> dunia.catat("kompensasi  : stok kembali " + ++dunia.stok)),
                new Langkah("tagih kartu",
                        () -> {
                            if (gagalDi == 3) {
                                throw new LangkahGagal("kartu ditolak");
                            }
                            dunia.catat("maju        : kartu ditagih");
                        },
                        () -> dunia.catat("kompensasi  : dana dikembalikan")),
                new Langkah("kirim konfirmasi",
                        () -> {
                            if (gagalDi == 4) {
                                throw new LangkahGagal("layanan email tidak tersedia");
                            }
                            dunia.catat("maju        : konfirmasi terkirim");
                        },
                        () -> dunia.catat("kompensasi  : konfirmasi dibatalkan")));

        Deque<Runnable> kompensasiTertunda = new ArrayDeque<>();
        String hasil = "SELESAI";
        try {
            for (Langkah l : saga) {
                l.maju().run();                    // transaksi lokal: kommit sendiri
                kompensasiTertunda.push(l.kompensasi()); // daftar tunggu kompensasi
            }
        } catch (LangkahGagal e) {
            hasil = "GAGAL (" + e.getMessage() + ")";
            while (!kompensasiTertunda.isEmpty()) {
                kompensasiTertunda.pop().run();   // urutan terbalik: LIFO
            }
        }

        for (String j : dunia.jejak) {
            System.out.println("  " + j);
        }
        System.out.println("  hasil: " + hasil + ", pesanan=" + dunia.pesanan
                + ", stok=" + dunia.stok);
    }
}

Output yang Dihasilkan:

text
== Skenario 1: seluruh langkah sukses ==
  maju        : pesanan TERCATAT
  maju        : stok jadi 2
  maju        : kartu ditagih
  maju        : konfirmasi terkirim
  hasil: SELESAI, pesanan=TERCATAT, stok=2

== Skenario 2: kartu ditolak di langkah ke-3 ==
  maju        : pesanan TERCATAT
  maju        : stok jadi 2
  kompensasi  : stok kembali 3
  kompensasi  : pesanan DIBATALKAN
  hasil: GAGAL (kartu ditolak), pesanan=DIBATALKAN, stok=3

== Skenario 3: email konfirmasi gagal di langkah terakhir ==
  maju        : pesanan TERCATAT
  maju        : stok jadi 2
  maju        : kartu ditagih
  kompensasi  : dana dikembalikan
  kompensasi  : stok kembali 3
  kompensasi  : pesanan DIBATALKAN
  hasil: GAGAL (layanan email tidak tersedia), pesanan=DIBATALKAN, stok=3

Mari kita bedah mekanisme intinya:

  1. record Langkah(nama, maju, kompensasi): satu langkah saga adalah pasangan dua Runnable — aksi maju (transaksi lokal yang mengommit sendiri) dan aksi kompensasi. Pengemasan pasangan ini membuat daftar saga deklaratif: urutan, aksi, dan penanggulangannya terbaca dalam satu tabel kode.
  2. Deque<Runnable> kompensasiTertunda dan push(...): setiap kali satu langkah maju berhasil, kompensasinya didorong ke tumpukan. Struktur LIFO ArrayDeque otomatis mengurutkan pembongkaran secara terbalik — perhatikan skenario 3: dana dikembalikan dulu, baru stok kembali, baru pesanan DIBATALKAN.
  3. throw new LangkahGagal(...): kegagalan langkah bukan return biasa, melainkan exception yang melompat keluar dari loop maju dan masuk ke catch — satu titik kendali untuk seluruh jalur kegagalan, dari langkah mana pun.
  4. Loop while di blok catch: mengeksekusi seluruh kompensasi tertunda sampai tumpukan kosong. Hasil skenario 2 membuktikan kebersihan keadaan: stok=3 kembali ke nilai semula dan pesanan=DIBATALKAN.
  5. Kompensasi langkah yang gagal tidak ikut dieksekusi: kompensasi didorong setelah maju().run() berhasil, sehingga langkah yang melempar exception tidak pernah mendaftarkan kompensasinya — pada skenario 2 tidak ada baris “dana dikembalikan” karena penagihan memang tidak pernah terjadi.

Orchestration dan Choreography #

Ada dua cara mengoordinasikan saga. Orchestration: satu objek Orchestrator (di sini: method jalankan) memegang daftar langkah, memanggil tiap service, dan memutuskan kapan kompensasi berjalan — alur terbaca di satu tempat, tetapi orchestrator mengenal seluruh peserta. Choreography: tanpa pemimpin — service saling bereaksi lewat event (svc-pesanan mempublikasikan PesananDibuat, svc-inventori menjawab dengan StokBerhasil, svc-pembayaran mendengarkan yang itu, dan seterusnya).

AspekOrchestrationChoreography
Tempat alur terlihatsatu kelas orchestratortersebar di handler event tiap service
Kegagalankompensasi dipicu eksplisitvia event kompensasi berantai
Risiko utamaorchestrator jadi pusat kendalialur sulit dipetakan begitu panjang
Cocok untukalur panjang, banyak cabangalur pendek, sedikit langkah

Kebanyakan tim memilih orchestration untuk alur bisnis inti (pesanan, pembayaran, pengiriman) karena kejelasannya saat debugging, lalu memakai choreography untuk reaksi sampingan yang ringan.

Merancang Kompensasi yang Sehat #

Tidak semua langkah sama mudahnya dikompensasi. Menurunkan stok mudah; menagih kartu masih wajar (refund, meski menambah biaya); tetapi email yang sudah terkirim tidak bisa ditarik kembali dan pesawat yang sudah lepas landas tidak bisa dikompensasi sama sekali. Pedoman penyusunannya: letakkan langkah paling sulit dikompensasi paling akhir. Dalam saga kita, konfirmasi email memang di ujung; satu-satunya “kompensasi”-nya adalah tindakan susulan, bukan pembatalan.

Kompensasi juga wajib idempotent: pembongkaran tumpukan yang terputus harus bisa dilanjutkan tanpa efek ganda — bekal dari chapter Idempotency. Satu pertanyaan terakhir: dari mana event yang menggerakkan saga versi choreography berasal secara andal? Chapter berikutnya masuk ke event-driven architecture dan membangun event log plus pub/sub in-process untuk menjawabnya.

Referensi #

Berkas Sumber

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

SagaPesanan.java

Unduh berkas
java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;

/** Saga dengan orchestrator: langkah maju, kompensasi berjalan terbalik saat gagal. */
public class SagaPesanan {

    /** Sinyal bahwa satu langkah saga gagal dan kompensasi harus berjalan. */
    static class LangkahGagal extends RuntimeException {
        LangkahGagal(String pesan) {
            super(pesan);
        }
    }

    /** Satu langkah saga: aksi maju beserta aksi kompensasinya. */
    record Langkah(String nama, Runnable maju, Runnable kompensasi) {}

    /** Keadaan seluruh "service" dalam satu dunia simulasi. */
    static class Dunia {
        int stok = 3;
        String pesanan = "BARU";
        final List<String> jejak = new ArrayList<>();

        void catat(String kejadian) {
            jejak.add(kejadian);
        }
    }

    public static void main(String[] args) {
        System.out.println("== Skenario 1: seluruh langkah sukses ==");
        jalankan(0);

        System.out.println();
        System.out.println("== Skenario 2: kartu ditolak di langkah ke-3 ==");
        jalankan(3);

        System.out.println();
        System.out.println("== Skenario 3: email konfirmasi gagal di langkah terakhir ==");
        jalankan(4);
    }

    /** Menjalankan saga; langkah bernomor `gagalDi` melempar LangkahGagal (0 = tanpa kegagalan). */
    static void jalankan(int gagalDi) {
        Dunia dunia = new Dunia();

        List<Langkah> saga = List.of(
                new Langkah("catat pesanan",
                        () -> {
                            dunia.pesanan = "TERCATAT";
                            dunia.catat("maju        : pesanan TERCATAT");
                        },
                        () -> {
                            dunia.pesanan = "DIBATALKAN";
                            dunia.catat("kompensasi  : pesanan DIBATALKAN");
                        }),
                new Langkah("kurangi stok",
                        () -> dunia.catat("maju        : stok jadi " + --dunia.stok),
                        () -> dunia.catat("kompensasi  : stok kembali " + ++dunia.stok)),
                new Langkah("tagih kartu",
                        () -> {
                            if (gagalDi == 3) {
                                throw new LangkahGagal("kartu ditolak");
                            }
                            dunia.catat("maju        : kartu ditagih");
                        },
                        () -> dunia.catat("kompensasi  : dana dikembalikan")),
                new Langkah("kirim konfirmasi",
                        () -> {
                            if (gagalDi == 4) {
                                throw new LangkahGagal("layanan email tidak tersedia");
                            }
                            dunia.catat("maju        : konfirmasi terkirim");
                        },
                        () -> dunia.catat("kompensasi  : konfirmasi dibatalkan")));

        Deque<Runnable> kompensasiTertunda = new ArrayDeque<>();
        String hasil = "SELESAI";
        try {
            for (Langkah l : saga) {
                l.maju().run();                    // transaksi lokal: kommit sendiri
                kompensasiTertunda.push(l.kompensasi()); // daftar tunggu kompensasi
            }
        } catch (LangkahGagal e) {
            hasil = "GAGAL (" + e.getMessage() + ")";
            while (!kompensasiTertunda.isEmpty()) {
                kompensasiTertunda.pop().run();   // urutan terbalik: LIFO
            }
        }

        for (String j : dunia.jejak) {
            System.out.println("  " + j);
        }
        System.out.println("  hasil: " + hasil + ", pesanan=" + dunia.pesanan
                + ", stok=" + dunia.stok);
    }
}