Mengimplementasikan Transactional Outbox Pattern pada Go E-Wallet

·Abdul Wahid Kahar

Saat mengembangkan project go-ewallet, saya memisahkan beberapa service. Salah satunya adalah notification-service yang bertugas mengirim notifikasi ketika ada transaksi seperti transfer dan top up.

Awalnya alurnya cukup sederhana.

Transfer berhasil │ ▼ Commit transaksi database │ ▼ Publish event ke Redis Stream │ ▼ notification-service membaca event

Secara fungsional semuanya berjalan dengan baik. Namun setelah saya berpikir lebih jauh, ternyata ada satu masalah yang cukup penting.


Masalah yang saya temukan

Pada implementasi awal, event dipublish ke Redis setelah transaksi database berhasil di-commit.

Kurang lebih seperti berikut.

tx.Commit() publishEvent()

Pendekatan ini memang benar untuk menghindari event terkirim ketika transaksi database gagal.

Tetapi saya mulai bertanya:

Bagaimana jika database berhasil commit, tetapi Redis sedang down?

Misalnya urutannya seperti ini.

Database Commit ✅ Redis XADD ❌

Artinya:

  • saldo pengguna sudah berubah

  • transfer sudah tercatat

  • ledger sudah tersimpan

Tetapi event tidak pernah masuk ke Redis Stream.

Karena notification-service hanya membaca dari Redis Stream, maka notifikasi transaksi tersebut tidak pernah terkirim.

Event hilang.


Kenapa ini bisa terjadi?

Masalah ini dikenal sebagai Dual Write Problem.

Dalam satu proses, aplikasi harus menulis ke dua sistem yang berbeda.

  1. PostgreSQL

  2. Redis Stream

Kedua sistem tersebut tidak berada dalam satu transaksi yang sama.

Sehingga selalu ada kemungkinan seperti ini.

PostgreSQL berhasil Redis gagal

Atau sebaliknya.

Redis berhasil PostgreSQL rollback

Tidak ada cara sederhana untuk membuat kedua operasi tersebut benar-benar atomik.


Referensi yang saya gunakan

Setelah membaca beberapa artikel dan dokumentasi mengenai event-driven architecture, saya menemukan pola yang hampir selalu direkomendasikan, yaitu Transactional Outbox Pattern.

Beberapa referensi yang saya gunakan:

  • Chris Richardson – Microservices Patterns (Transactional Outbox)

  • Martin Fowler – Transactional Outbox

  • Dokumentasi Event-driven Architecture dari Microsoft

  • Berbagai implementasi komunitas Go dan diskusi mengenai Outbox Pattern

Dari beberapa referensi tersebut, konsep yang dijelaskan hampir sama: jangan langsung publish event setelah commit, tetapi simpan event terlebih dahulu di database.


Keputusan yang saya ambil

Saya menambahkan tabel baru bernama outbox_events.

Ketika transaksi berlangsung, bukan hanya data transaksi yang disimpan, tetapi juga event yang nantinya akan dipublish.

BEGIN TRANSACTION INSERT transfers UPDATE wallets INSERT ledger_entries INSERT outbox_events status = PENDING COMMIT

Dengan cara ini, transaksi bisnis dan event berada dalam satu transaksi database.

Jika commit gagal, semuanya ikut rollback.

Jika commit berhasil, event dipastikan sudah tersimpan.


Publisher dipindahkan ke Background Worker

Setelah transaksi selesai, ada worker yang berjalan di background.

Alurnya menjadi seperti berikut.

Outbox Worker ↓ Cari event dengan status PENDING ↓ Publish ke Redis Stream ↓ Jika berhasil ↓ Status menjadi PUBLISHED

Kalau Redis sedang down, worker tidak menghapus event tersebut.

Status tetap PENDING dan akan dicoba lagi beberapa saat kemudian.

Saya menggunakan retry dengan exponential backoff.

Retry ke-1 ↓ 1 detik ↓ 2 detik ↓ 4 detik ↓ 8 detik ↓ 16 detik ↓ ...

Worker akan terus mencoba sampai publish berhasil.


Pengujian

Saya mencoba mensimulasikan Redis mati ketika terjadi transaksi.

Hasilnya seperti ini.

Transfer berhasil ↓ Data transaksi tersimpan ↓ Outbox status = PENDING ↓ Redis masih mati ↓ Retry ↓ Retry ↓ Retry ↓ Redis dinyalakan kembali ↓ Publish berhasil ↓ Status menjadi PUBLISHED

Pada implementasi lama, event tersebut akan hilang.

Pada implementasi sekarang, event tetap tersimpan sampai berhasil dipublish.


Hal yang perlu diperhatikan

Outbox Pattern bukan berarti event hanya akan dikirim satu kali.

Masih ada kemungkinan publisher berhasil mengirim event ke Redis, tetapi gagal mengubah status menjadi PUBLISHED.

Akibatnya event bisa dipublish ulang.

Karena itu consumer tetap harus bersifat idempotent, sehingga event yang sama tidak diproses dua kali.


Hasil implementasi

Perubahan yang saya dapatkan setelah menggunakan Transactional Outbox Pattern:

  • transaksi database dan penyimpanan event menjadi atomik

  • event tidak hilang ketika Redis sedang tidak tersedia

  • publisher dapat melakukan retry otomatis

  • notification-service tidak perlu diubah karena tetap hanya mengonsumsi Redis Stream

  • arsitektur menjadi lebih tahan terhadap kegagalan dibanding implementasi sebelumnya


Penutup

Awalnya saya mengira cukup melakukan XADD setelah COMMIT. Setelah memahami masalah dual write, saya menyadari bahwa pendekatan tersebut masih memiliki celah yang bisa menyebabkan event hilang.

Dengan menambahkan Transactional Outbox Pattern, event menjadi bagian dari transaksi database terlebih dahulu, kemudian dipublish secara asynchronous oleh background worker.

Untuk project ini, menurut saya pendekatan tersebut memberikan trade-off yang lebih baik antara kompleksitas implementasi dan keandalan sistem. Saya juga jadi lebih memahami mengapa pola ini sering muncul pada sistem berbasis microservices dan event-driven architecture.