Saga Pattern di NestJS: Transaksi Terdistribusi Tanpa 2PC

Foto oleh RLJ Photography NYC on flickr
Saga pattern mengoordinasikan sebuah transaksi bisnis yang melintasi banyak layanan dengan memecahnya menjadi rangkaian transaksi lokal, masing-masing di-commit di layanannya sendiri. Jika langkah berikutnya gagal, saga menjalankan aksi kompensasi untuk membatalkan efek dari langkah-langkah yang sudah berhasil, alih-alih mengandalkan satu commit atomik lintas layanan.
Two-phase commit mengharuskan setiap peserta menahan lock sementara koordinator mengumpulkan suara, dan jika koordinator gagal di tengah proses voting, lock tersebut bisa memblokir tanpa batas waktu. Ini juga memaksa setiap layanan yang terlibat harus bisa dijangkau secara sinkron pada saat yang sama, yang bertentangan dengan cara kerja microservices yang di-deploy secara independen.
Modul CQRS NestJS menyediakan decorator Saga yang mendengarkan stream observable dari event pada event bus, menyaringnya dengan operator ofType, lalu memetakan event yang cocok menjadi command yang otomatis dikirim oleh command bus. Setiap command handler menjalankan satu transaksi lokal dan memancarkan event sukses atau gagal, yang direspons oleh kelas saga yang sama untuk langkah berikutnya atau aksi kompensasi.
Orkestrasi menggunakan satu komponen pusat yang menyimpan urutan langkah dan mengirim command ke setiap layanan secara berurutan, sehingga ada satu tempat untuk memeriksa state saga. Koreografi tidak memiliki koordinator pusat; setiap layanan bereaksi terhadap event dari layanan lain secara independen, yang cocok untuk dua atau tiga peserta tetapi makin sulit ditelusuri seiring bertambahnya langkah.
Bisa, dan ini harus direncanakan sejak awal. Aksi kompensasi adalah transaksi lokal itu sendiri yang bisa gagal karena masalah jaringan atau konflik aturan bisnis, sehingga perlu penanganan retry dan idempotensi yang sama seperti langkah maju, ditambah jalur dead-letter dan monitoring agar operator bisa melakukan intervensi jika retry otomatis sudah habis.

Foto oleh RLJ Photography NYC on flickr
Ringkasan Utama
Saga pattern mengganti satu transaksi database lintas layanan dengan rangkaian transaksi lokal, masing-masing dipasangkan dengan aksi kompensasi untuk saat langkah berikutnya gagal. Di NestJS, decorator Saga pada modul CQRS mengimplementasikan ini sebagai orchestrator yang bereaksi terhadap event, dan handler kompensasi yang idempoten adalah yang membuat pesan yang dikirim ulang tetap aman.
Begitu alur checkout menyentuh tiga layanan, layanan pesanan, layanan pembayaran, dan layanan inventaris, satu transaksi database tunggal berhenti menjadi pilihan. Setiap layanan memiliki database sendiri, sehingga tidak ada koneksi bersama yang bisa dibungkus dalam BEGIN dan COMMIT. Two-phase commit menjanjikan atomisitas lintas node, tetapi caranya adalah menahan lock lintas jaringan sementara setiap peserta menunggu koordinator memberi sinyal lanjut, dan jika koordinator itu tidak tersedia di tengah proses voting, seluruh peserta bisa terblokir tanpa batas waktu.
Saga pattern menyelesaikan masalah yang sama dengan trade-off berbeda. Alih-alih satu transaksi atomik, saga adalah rangkaian transaksi lokal, masing-masing langsung di-commit di layanannya sendiri, dengan aksi kompensasi yang didefinisikan untuk setiap langkah jika langkah berikutnya gagal. Artikel ini membahas implementasi saga di NestJS menggunakan modul CQRS resmi, membandingkan orkestrasi dan koreografi, serta aturan idempotensi yang menjaga kompensasi tetap aman ketika pesan dikirim ulang.
Two-phase commit (2PC) bekerja baik di dalam satu cluster database karena koordinator dan peserta berbagi koneksi cepat dan andal serta domain kegagalan yang sama. Ketika tersebar di layanan yang di-deploy secara independen dengan database masing-masing, tiga masalah muncul dengan cepat.
Jika sebuah alur kerja benar-benar butuh jaminan ACID yang ketat antara dua data, tanyakan dulu apakah kedua data itu seharusnya berada di layanan dan database yang sama. Memisahkannya ke layanan berbeda lalu merakit kembali transaksi terdistribusi biasanya menandakan batas layanan yang salah, bukan kekurangan pustaka 2PC.
Ada dua cara menyusun alur kontrol sebuah saga. Pada koreografi, setiap layanan mendengarkan event yang dipublikasikan layanan lain dan memutuskan sendiri langkah berikutnya, tanpa koordinator pusat. Pada orkestrasi, satu komponen orchestrator menyimpan urutan langkah beserta kompensasinya, mengirim perintah ke setiap layanan secara berurutan dan bereaksi terhadap hasilnya. Keduanya valid; pilihan yang tepat bergantung pada berapa banyak layanan yang terlibat dan seberapa besar kebutuhan tim akan visibilitas alur tersebut.
| Aspek | Koreografi | Orkestrasi |
|---|---|---|
| Lokasi logika saga | Tersebar di setiap layanan yang terlibat | Terpusat dalam satu kelas orchestrator |
| Debugging alur yang gagal | Perlu menelusuri event lintas layanan | Satu tempat untuk memeriksa langkah dan state saat ini |
| Paling cocok untuk | Dua atau tiga peserta, alur sederhana | Empat langkah atau lebih, atau langkah yang bercabang |
NestJS menyediakan modul CQRS dengan decorator Saga yang dibuat khusus untuk kasus ini. Metode saga menerima stream observable dari setiap event yang dikirim lewat event bus, menyaringnya dengan operator ofType, lalu memetakan event yang cocok menjadi command baru yang otomatis dikirim oleh command bus. Ini menjaga logika orkestrasi tetap deklaratif: bereaksi terhadap sebuah event, memancarkan command berikutnya, atau memancarkan command kompensasi ketika ada langkah sebelumnya yang melaporkan kegagalan.
// order-saga.ts
import { ICommand, ofType, Saga } from '@nestjs/cqrs'
import { Injectable } from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { OrderCreatedEvent } from './events/order-created.event'
import { ReserveInventoryCommand } from './commands/reserve-inventory.command'
import { InventoryReservationFailedEvent } from './events/inventory-reservation-failed.event'
import { CancelOrderCommand } from './commands/cancel-order.command'
@Injectable()
export class OrderSaga {
@Saga()
orderCreated = (events$: Observable<ICommand>): Observable<ICommand> => {
return events$.pipe(
ofType(OrderCreatedEvent),
map((event) => new ReserveInventoryCommand(event.orderId, event.items)),
)
}
// Compensating step: undo the order if a downstream step fails
@Saga()
inventoryReservationFailed = (events$: Observable<ICommand>): Observable<ICommand> => {
return events$.pipe(
ofType(InventoryReservationFailedEvent),
map((event) => new CancelOrderCommand(event.orderId, event.reason)),
)
}
}Setiap command handler dalam rangkaian ini menjalankan satu transaksi lokal dan mempublikasikan event sukses yang direspons saga berikutnya, atau event gagal yang memicu kompensasi yang sesuai. Orchestrator itu sendiri, kelas OrderSaga, tidak pernah berbicara langsung ke database layanan lain. Ia hanya mengenal bentuk event yang masuk dan command yang keluar, sehingga tetap mudah diuji tanpa harus menjalankan setiap layanan hilir.
Saga bukan rollback. Setelah layanan inventaris benar-benar mengurangi stok dan meng-commit perubahan itu, tidak ada pembatalan otomatis. Command kompensasi harus menjalankan transaksi lokal yang setara dan berlawanan, menambah kembali stok, dan kompensasi itu sendiri bisa gagal, jadi rencanakan mekanisme retry dan jalur dead-letter sejak awal.
Aksi kompensasi bukan sekadar kebalikan dari aksi maju; ia harus membalikkan efek bisnisnya dengan memperhitungkan apa pun yang terjadi di antaranya. Empat aturan berikut menjaga kompensasi tetap andal dalam praktik.
// cancel-order.handler.ts
@CommandHandler(CancelOrderCommand)
export class CancelOrderHandler implements ICommandHandler<CancelOrderCommand> {
constructor(
private readonly orders: OrderRepository,
private readonly ledger: ProcessedStepLedger, // dedupe table keyed by (sagaId, step)
) {}
async execute(command: CancelOrderCommand): Promise<void> {
const { orderId, reason } = command
// Idempotency check — a redelivered event must not double-compensate
const alreadyHandled = await this.ledger.wasProcessed(orderId, 'cancel-order')
if (alreadyHandled) return
const order = await this.orders.findById(orderId)
if (order.status === 'cancelled') return // already terminal, safe no-op
order.cancel(reason)
await this.orders.save(order)
await this.ledger.markProcessed(orderId, 'cancel-order')
}
}Message broker mayoritas lebih memilih pengiriman at-least-once dibanding exactly-once, karena pengiriman exactly-once lintas gangguan jaringan sebenarnya tidak bisa dicapai tanpa kerja sama dari penerima. Artinya, setiap command handler dalam saga, baik langkah maju maupun kompensasi, pada akhirnya akan dipanggil dua kali untuk event logis yang sama. Solusinya bukan deduplikasi di sisi broker; melainkan merancang setiap handler agar penerapannya dua kali menghasilkan state akhir yang sama seperti diterapkan satu kali.
Handler yang idempoten punya manfaat tambahan di luar keamanan: mereka membuat pengembangan lokal jadi sederhana, karena memutar ulang sebuah event dua kali saat debugging tidak pernah merusak data uji.
Saga menambah biaya operasional yang nyata: lebih banyak tipe event, tabel ledger untuk idempotensi, dan model mental yang mencakup banyak layanan alih-alih hidup dalam satu fungsi. Biaya itu terbayar ketika sebuah proses bisnis memang melintasi batas layanan dan butuh konsistensi eventual dengan jalur pemulihan yang jelas, seperti pemenuhan pesanan yang menyentuh pembayaran, inventaris, dan pengiriman. Saga menjadi alat yang salah ketika data sebenarnya milik satu layanan; perbaikan yang tepat biasanya menyatukan kepemilikan, bukan menambahkan saga di atas batas yang sudah salah.
Mulailah dengan orkestrasi bahkan untuk saga dua langkah. Biayanya hanya satu kelas kecil di awal dan terbayar sejak langkah ketiga ditambahkan, karena rantai event implisit pada koreografi lebih sulit diperluas dibanding ditulis ulang dari awal.