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 maju | Service | Kompensasi bila langkah berikutnya gagal |
|---|---|---|
| catat pesanan | svc-pesanan | tandai pesanan DIBATALKAN |
| kurangi stok | svc-inventori | kembalikan stok |
| tagih kartu | svc-pembayaran | kembalikan dana (refund) |
| kirim konfirmasi | svc-notifikasi | batalkan 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:
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:
== 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=3Mari kita bedah mekanisme intinya:
record Langkah(nama, maju, kompensasi): satu langkah saga adalah pasangan duaRunnable— 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.Deque<Runnable> kompensasiTertundadanpush(...): setiap kali satu langkah maju berhasil, kompensasinya didorong ke tumpukan. Struktur LIFOArrayDequeotomatis mengurutkan pembongkaran secara terbalik — perhatikan skenario 3:dana dikembalikandulu, barustok kembali, barupesanan DIBATALKAN.throw new LangkahGagal(...): kegagalan langkah bukanreturnbiasa, melainkan exception yang melompat keluar dari loop maju dan masuk kecatch— satu titik kendali untuk seluruh jalur kegagalan, dari langkah mana pun.- Loop
whiledi blokcatch: mengeksekusi seluruh kompensasi tertunda sampai tumpukan kosong. Hasil skenario 2 membuktikan kebersihan keadaan:stok=3kembali ke nilai semula danpesanan=DIBATALKAN. - 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).
| Aspek | Orchestration | Choreography |
|---|---|---|
| Tempat alur terlihat | satu kelas orchestrator | tersebar di handler event tiap service |
| Kegagalan | kompensasi dipicu eksplisit | via event kompensasi berantai |
| Risiko utama | orchestrator jadi pusat kendali | alur sulit dipetakan begitu panjang |
| Cocok untuk | alur panjang, banyak cabang | alur 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 berkasimport 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);
}
}