Transactional Outbox Menutup Celah Commit antara Database dan Broker

Sebuah service sering perlu mengubah state di database sekaligus memublikasikan message dalam satu request. Kedua aksi itu dapat terlihat berdekatan di application code, tetapi masing-masing memiliki commit boundary sendiri. Jika database dan broker tidak berbagi transaction protocol, dua write biasa tidak dapat dibuat atomic hanya dengan mengatur urutannya.

Bayangkan order service menyimpan order yang diterima lalu mengirim OrderCreated. Publish setelah database commit menyisakan celah crash sebelum pemanggilan broker. Publish lebih dulu menghasilkan celah sebaliknya: consumer dapat menerima event untuk state yang akhirnya gagal commit.

Transactional outbox memindahkan niat publikasi ke transaction database yang sama dengan business mutation. Relay terpisah menangani pengiriman ke broker.

Dual write memiliki dua celah kegagalan

Urutan langsung terlihat ringkas:

BEGIN
INSERT order
COMMIT

publish OrderCreated

Process failure setelah COMMIT tetapi sebelum publish meninggalkan business state yang durable tanpa message pasangannya. Retry request awal belum tentu memperbaiki celah ini jika request deduplication menyatakan order sudah dibuat.

Membalik urutan tidak memperbaiki protokol:

publish OrderCreated

BEGIN
INSERT order
COMMIT

Broker kini dapat menerima message sebelum database transaction abort. Consumer dapat bertindak atas order yang tidak pernah menjadi durable.

Masalahnya bukan sekadar retry yang belum ditambahkan. Tidak ada satu atomic boundary yang mencakup dua hasil yang diinginkan.

Outbox row bergabung dengan business transaction

Dengan outbox, request transaction menulis domain state dan publication record yang durable:

BEGIN;

INSERT INTO orders(id, customer_id, status)
VALUES (:id, :customer_id, 'accepted');

INSERT INTO outbox(
    event_id,
    aggregate_id,
    event_type,
    payload,
    created_at
)
VALUES (
    :event_id,
    :id,
    'OrderCreated',
    :payload,
    CURRENT_TIMESTAMP
);

COMMIT;

Jika transaction commit, kedua row tersedia. Jika rollback, keduanya tidak ada. Broker sengaja tidak ikut dalam transaction ini.

Boundary tersebut mengubah recovery problem. Crash setelah commit tidak dapat menghapus niat publikasi karena outbox row sudah durable. Delivery dapat dilanjutkan dari stored state setelah process hidup kembali.

Relay mengubah niat durable menjadi broker delivery

Relay berulang kali memilih row yang belum dipublikasikan, mengirimkannya ke broker, lalu mencatat progress delivery. Implementasinya dapat berupa polling worker atau database change capture, selama publication record tetap menjadi sumber durable untuk pending work.

Bentuk polling sederhana:

select pending rows
        |
        v
publish to broker
        |
        v
mark rows delivered

Relay sebaiknya memproses batch terbatas dan tidak menahan database transaction selama broker call yang lambat, kecuali desain memang menerima coupling tersebut. Beberapa relay instance juga memerlukan claim protocol, misalnya row locking dengan skip-locked semantics, lease, atau mekanisme atomic ownership lain.

Mekanisme persisnya bergantung pada database. Invariant-nya adalah pending row tetap dapat dipulihkan dan relay concurrent tidak memperlakukan ownership sebagai race read-then-write tanpa proteksi.

Delivery biasanya bersifat at least once

Outbox menghilangkan celah lost message antara local commit dan publication intent. Outbox tidak membuat broker delivery menjadi exactly once.

Misalkan broker menerima event lalu relay crash sebelum menandai outbox row sebagai delivered:

publish event -> broker accepts
crash
mark delivered -> never runs

Setelah restart, row masih terlihat pending sehingga relay mengirimkannya lagi. Menghindari retry tersebut justru membuka kembali loss window saat hasil dari broker bersifat ambigu.

Consumer karena itu memerlukan penanganan yang duplicate-safe. event_id yang stabil dapat dipakai untuk consumer-side inbox table, unique constraint, conditional write, atau mutation yang memang idempotent. Deduplication boundary harus mencakup consumer side effect yang perlu dilindungi.

Publication state memerlukan schema yang disengaja

Outbox table biasanya menyimpan lebih dari payload. Field yang berguna secara operasional dapat mencakup event identifier yang stabil, event type, aggregate identifier, creation time, delivery state, attempt count, dan last error.

Payload sebaiknya mewakili contract yang diterima consumer, bukan serialization sembarang dari internal ORM object. Aturan schema evolution tetap berlaku karena queued row dapat bertahan melewati deployment dan dipublikasikan oleh code yang lebih baru.

Payload besar juga memengaruhi database storage, replication, scan, dan cleanup. Jika event hanya memerlukan identifier beserta beberapa fakta terpilih, envelope yang ringkas mengurangi tekanan pada transactional database.

Ordering bergantung pada scope yang diperlukan

Satu outbox table tidak otomatis menciptakan global event order yang diamati sama oleh semua consumer. Beberapa relay, broker partition, retry, dan consumer concurrency dapat memengaruhi urutan kedatangan.

Banyak sistem hanya memerlukan urutan per aggregate. aggregate_id bersama sequence yang monotonik dapat membuat kebutuhan itu eksplisit:

order-42 seq=18
order-42 seq=19
order-77 seq=6

Strategi relay dan broker partitioning harus mempertahankan scope yang benar-benar diperlukan aplikasi. Memaksakan satu total order untuk aggregate yang tidak berkaitan biasanya menambah koordinasi tanpa memperbaiki domain correctness.

Cleanup tidak boleh race dengan recovery

Delivered row akan terus bertambah jika sistem tidak menghapus atau mengarsipkannya. Cleanup hanya boleh menargetkan row yang aman dibuang menurut retention policy.

Menghapus row hanya karena publish attempt sudah dimulai tidak aman. Relay mungkin gagal sebelum broker menerima message. Sebaliknya, menyimpan semua delivered row selamanya membuat outbox menjadi operational table tanpa batas.

Desain yang praktis memisahkan delivery state dari retention policy dan memantau oldest pending age, pending count, publish latency, retry count, serta terminal failure. Signal ini memperlihatkan relay yang macet sebelum backlog outbox berubah menjadi masalah database.

Boundary ini bersifat lokal, bukan solusi ajaib

Transactional outbox memberi guarantee yang spesifik: ketika local business transaction commit, niat untuk publish ikut commit. Relay kemudian dapat mengulang delivery dari durable state.

Pattern ini tidak menjadikan database dan broker satu atomic system, tidak menghapus duplicate delivery, tidak menentukan consumer idempotency, dan tidak memberi ordering di luar scope yang benar-benar ditegakkan desain. Semua hal itu tetap menjadi bagian eksplisit dari messaging protocol.

Guarantee yang sempit tersebut justru berguna. Dual-write gap yang tidak dapat dipulihkan berubah menjadi durable work yang dapat di-retry, diamati, dan direkonsiliasi.