Saga Pattern — System Design

Saga Pattern Di microservices, satu bisnis proses (misal: buat order) bisa melibatkan banyak service. Saga mengkoordinasi transaksi terdistribusi tanpa…

Saga adalah cara menjalankan satu proses bisnis yang menyentuh beberapa service tanpa satu transaksi besar: proses itu dipecah jadi urutan transaksi lokal, satu per service, dan tiap langkah punya compensating action (kompensasi) yang membatalkannya. Kalau satu langkah gagal, langkah-langkah yang sudah sukses dibatalkan satu per satu dengan urutan terbalik. Bayangin kamu merencanakan liburan: pesan hotel, lalu tiket pesawat, lalu sewa mobil. Tiga perusahaan, tiga sistem, gak ada satu tombol "batalkan semuanya". Kalau sewa mobil ditolak, kamu sendiri yang membatalkan tiket pesawat, lalu hotel. Tiap pesanan punya cara batalnya sendiri, dan pembatalannya juga sebuah transaksi baru (refund), bukan menghapus sejarah.

🧪 Tebakan cepat: checkout pesanan #1001 sudah menahan kursi A-12 dan A-13, lalu memotong kartu Budi Rp1.500.000, tapi service tiket menolak menerbitkan e-tiket. Mana yang harus dibatalkan duluan: kursinya atau pembayarannya? Tebak dulu, jawabannya ada di bagian "Gagal di langkah 3: kompensasi urutan terbalik".

Checkout TiketNada: tiga service, tiga database

Di lesson Distributed Transactions kita lihat kenapa two-phase commit gak cocok untuk TiketNada: semua peserta terkunci menunggu satu coordinator. Jadi checkout dijalankan sebagai saga. Tiap service punya database sendiri, dan tiap langkah langsung commit di database-nya:

Langkah Service Aksi (transaksi lokal) Kompensasi
1 konser TahanKursi: A-12, A-13 jadi DITAHAN LepasKursi
2 bayar PotongKartu: Rp1.500.000 Refund Rp1.500.000
3 tiket TerbitkanTiket: 2 e-tiket, NIK pemegang dicek BatalkanTiket

Kompensasi bukan "undo" ajaib. Kartu yang sudah terpotong gak bisa dibuat seolah gak pernah terpotong; yang bisa dilakukan adalah transaksi baru yang mengembalikan uangnya. Itu sebabnya disebut compensating transaction.

Gagal di langkah 3: kompensasi urutan terbalik

Di model contoh ini, satu jalan di jaringan butuh 10 ms, konser bekerja 20 ms, bayar 120 ms (memotong kartu) dan 100 ms (refund), tiket 60 ms. Service order jadi pengatur, dan NIK pemegang tiket kedua ternyata gak valid:

ms Pesan Akibatnya
0 order -> konser: TahanKursi
40 konser -> order: KursiDitahan kursi tersedia 10.000 jadi 9.998
180 bayar -> order: KartuTerpotong kartu Budi -Rp1.500.000
260 tiket -> order: ditolak, 422 NIK gak valid e-tiket gak terbit
380 bayar -> order: RefundSelesai kartu Budi kembali Rp0
420 konser -> order: KursiDilepas kursi kembali 10.000

Jawaban tebakannya: pembayarannya dulu, baru kursinya. Langkah yang terakhir sukses dibatalkan pertama. Kenapa? Karena tiap keadaan di tengah jalan jadi keadaan yang memang pernah ada saat saga maju: "kursi ditahan, kartu terpotong", lalu "kursi ditahan, kartu utuh", lalu "kosong". Kalau kursi dilepas duluan, ada saat kartu Budi terpotong padahal kursinya sudah bisa dibeli orang lain.

Aturannya berlaku untuk urutan apa pun: kalau sebuah saga menjalankan charge, lalu reserve stok, lalu shipping, dan shipping gagal, maka stok di-release dulu, baru pembayaran di-refund. Langkah yang gagal sendiri gak perlu dikompensasi: transaksi lokalnya gak pernah commit.

Kode orchestrator

// Saga checkout #1001 (orkestrasi): tiket menolak di langkah 3
const db = { kursi: 10000, kartuBudi: 0, eTiket: 0 };
const langkah = [
  { svc: "konser", aksi: "TahanKursi", undo: "LepasKursi",
    jalan: () => (db.kursi -= 2), batal: () => (db.kursi += 2) },
  { svc: "bayar", aksi: "PotongKartu", undo: "Refund",
    jalan: () => (db.kartuBudi -= 1500000), batal: () => (db.kartuBudi += 1500000) },
  { svc: "tiket", aksi: "TerbitkanTiket", undo: "BatalkanTiket",
    jalan: () => { throw new Error("422 NIK pemegang tiket gak valid"); }, batal: () => (db.eTiket -= 2) },
];

function sagaCheckout() {
  const selesai = []; // langkah yang sudah commit, urut
  for (const l of langkah) {
    try {
      l.jalan();
      console.log(l.svc + ": " + l.aksi + " sukses");
      selesai.push(l);
    } catch (err) {
      console.log(l.svc + ": " + l.aksi + " ditolak, " + err.message);
      for (const s of selesai.reverse()) {
        s.batal(); // kompensasi, urutan terbalik
        console.log(s.svc + ": " + s.undo);
      }
      return "BATAL";
    }
  }
  return "LUNAS";
}

console.log("pesanan #1001:", sagaCheckout());
console.log(db); // { kursi: 10000, kartuBudi: 0, eTiket: 0 }

Output-nya: konser: TahanKursi sukses, bayar: PotongKartu sukses, tiket: TerbitkanTiket ditolak, 422 NIK pemegang tiket gak valid, bayar: Refund, konser: LepasKursi, pesanan #1001: BATAL.

Orkestrasi vs koreografi

Ada dua cara mengatur siapa memanggil siapa:

Untuk kegagalan yang sama, orkestrasi butuh 10 pesan (5 command, 5 jawaban) dan selesai di ms 420. Koreografi cuma 6 event dan selesai di ms 380. Harganya: aturan "kalau TiketDitolak, refund" tinggal di bayar, aturan "kalau PembayaranDikembalikan, lepas kursi" tinggal di konser. Untuk tahu pesanan #1001 sedang di mana, kamu harus merangkai event dari empat service.

Pegangan praktisnya: koreografi untuk rantai pendek (2 sampai 3 langkah) yang ingin service-nya saling gak kenal; orkestrasi untuk saga lebih dari 3 langkah, atau saat tim sering kesulitan menelusuri di langkah mana sebuah pesanan gagal. Flow yang eksplisit lebih mudah di-debug.

Kompensasi harus idempoten

Ingat lesson Kenapa Distributed System Sulit: response bisa hilang di jalan. Misalnya bayar sudah me-refund Rp1.500.000 di ms 370, tapi jawabannya hilang. order menunggu 2.000 ms, timeout, lalu mengirim Refund lagi. Tanpa pengaman, bayar me-refund lagi: Budi menerima Rp3.000.000 untuk pembayaran Rp1.500.000. Di koreografi lebih parah: event TiketDitolak dikirim ulang broker, bayar publish PembayaranDikembalikan dua kali, dan konser melepas kursi dua kali. Kursi tersedia jadi 10.002, dua kursi hantu yang bisa dijual.

Obatnya sama dengan lesson Idempotency & Retry Safety: tiap kompensasi membawa Idempotency-Key, misalnya refund-1001. bayar menyimpan hasil per key; kiriman kedua dengan key yang sama cuma mendapat hasil yang tersimpan, uangnya gak keluar lagi. Kompensasi yang idempoten boleh dipanggil berkali-kali dengan hasil yang sama, dan itu wajib, karena retry pasti terjadi.

Kalau kompensasinya gagal terus (misalnya bank down), jangan menyerah diam-diam: retry dengan jeda, lalu pindahkan ke dead letter queue untuk dicek manusia (lesson Message Queue).

📌 Saga gak punya isolation

Transaksi database biasa menyembunyikan perubahan setengah jadi dari orang lain. Saga gak bisa: begitu langkah 1 commit, pembeli lain sudah melihat A-12 sebagai DITAHAN. Karena itu keadaan tengah dibuat eksplisit: kursi berstatus DITAHAN (bukan TERJUAL) dan pesanan berstatus MENUNGGU_BAYAR sampai saga selesai. Halaman lain membaca status itu dan gak menganggapnya final. Langkah yang paling mungkin ditolak sebaiknya ditaruh di depan, dan langkah yang gak bisa dibatalkan (mengirim email) ditaruh paling akhir.

🎯 Checklist saga

  1. State saga disimpan ke database tiap langkah, supaya crash bisa dilanjutkan.
  2. Correlation ID (di sini nomor saga #1001) di setiap command dan event, supaya satu saga bisa ditelusuri lintas service (lesson Observability in Distributed Systems).
  3. Kompensasi idempoten dan dites, bagian yang paling sering bug.
  4. Timeout di tiap langkah: jangan menunggu selamanya.
  5. Dead letter queue untuk kompensasi yang tetap gagal.

⚠️ Jebakan yang sering ketemu

TL;DR: Saga menjalankan proses lintas service sebagai urutan transaksi lokal, masing-masing dengan kompensasi. Checkout #1001: TahanKursi (konser), PotongKartu (bayar), TerbitkanTiket (tiket). Kalau tiket menolak, langkah yang sudah sukses dibatalkan dengan urutan terbalik: Refund dulu, LepasKursi kemudian; langkah yang gagal gak perlu dikompensasi. Orkestrasi: satu pengatur mengirim command, alurnya eksplisit dan mudah di-debug, cocok untuk saga lebih dari 3 langkah. Koreografi: service bereaksi pada event, lebih sedikit pesan dan lebih lepas, tapi alurnya tersebar, cocok untuk rantai pendek. Kompensasi wajib idempoten (Idempotency-Key seperti refund-1001), karena retry setelah timeout bisa me-refund dua kali. Simpan state saga ke database, pasang correlation ID dan timeout, dan kirim kompensasi yang tetap gagal ke dead letter queue.

Yang akan kamu pelajari