Service yang memperbarui database dan memublikasikan event ke message broker melewati dua batas commit yang independen. Jika commit database berhasil lalu publish ke broker gagal, state yang durable sudah ada tanpa pesan pasangannya. Membalik urutannya hanya membalik bentuk kegagalan: consumer dapat melihat pesan untuk perubahan state yang pada akhirnya tidak pernah commit.

Transactional outbox mempersempit celah ini dengan menempatkan write aplikasi dan record pesan yang durable dalam transaksi database lokal yang sama. Proses publikasi dipindahkan ke relay terpisah. Pola ini tidak membuat database dan broker menjadi satu sistem atomik; batasnya diubah sehingga intensi pesan menjadi bagian dari commit database.

Dual write tidak memiliki titik commit bersama

Bayangkan service order yang menandai sebuah order sebagai sudah dibayar dan mengirim OrderPaid. Implementasi langsung memiliki dua side effect yang durable:

UPDATE orders SET status = 'paid' WHERE id = 42;
PUBLISH OrderPaid(order_id=42);

Tidak ada urutan kedua operasi tersebut yang menghapus interval kegagalan. Database-first dapat meninggalkan order yang sudah commit tanpa event. Broker-first dapat mengekspos event sebelum transaksi database commit, lalu rollback berikutnya membuat consumer bertindak atas transisi state yang tidak bertahan.

Retry tidak menciptakan atomicity. Mengulang operasi database dapat mengulangi efek aplikasi kecuali write aman untuk diulang. Mengulang publikasi dapat menghasilkan pesan duplikat kecuali broker dan pemrosesan downstream menyediakan jaminan yang lebih kuat. Persoalan utamanya bukan kebijakan retry, melainkan tidak adanya satu keputusan commit yang mencakup kedua resource.

Protokol distributed transaction dapat menyediakan commit terkoordinasi ketika setiap sistem peserta dan model deployment mendukung protokol yang dibutuhkan. Transactional outbox menangani model operasi yang berbeda: database tetap menjadi otoritas untuk transisi state lokal, sedangkan broker menerima proyeksi yang datang kemudian dari record pesan yang sudah commit di database.

Intensi pesan menjadi state database

Tabel outbox ditulis dalam transaksi yang sama dengan data domain. Schema minimal dapat memuat identifier, tipe pesan, payload, dan state publikasi:

CREATE TABLE outbox (
    id uuid PRIMARY KEY,
    message_type text NOT NULL,
    payload jsonb NOT NULL,
    created_at timestamptz NOT NULL,
    published_at timestamptz
);

Transaksi aplikasi kini memiliki satu batas commit lokal:

BEGIN;

UPDATE orders
SET status = 'paid'
WHERE id = 42;

INSERT INTO outbox (id, message_type, payload, created_at)
VALUES (:id, 'OrderPaid', :payload, CURRENT_TIMESTAMP);

COMMIT;

Jika transaksi rollback, perubahan order maupun row outbox tidak bertahan. Jika commit berhasil, keduanya bertahan. Inilah properti atomik yang menjadi dasar pola tersebut.

Row outbox bukan pesan broker itu sendiri. Row tersebut adalah bukti durable bahwa publikasi harus dilakukan. Relay dapat tertunda, restart, atau terputus sementara dari broker tanpa menghapus intensi itu.

Pemisahan ini juga membuat transaction isolation relevan. Relay hanya boleh memproses row outbox yang sudah commit. Aturan visibilitas database normal menyediakan batas tersebut ketika relay membaca melalui query transaksi biasa atau change stream dengan semantik commit yang terdokumentasi.

Relay menambahkan sisi at-least-once

Relay membaca row pending, memublikasikannya, lalu mencatat penyelesaian. Tiga tindakan itu kembali melewati batas database-broker:

read pending row
publish message
mark row as published

Anggap broker menerima pesan lalu relay crash sebelum published_at tersimpan. Setelah restart, row outbox yang sama masih terlihat pending dan dapat dipublikasikan lagi. Menandai row lebih dulu bukan inversi yang aman: crash setelah penandaan tetapi sebelum publikasi akan menghilangkan pesan.

Karena itu, transactional outbox dasar biasanya menghasilkan perilaku publikasi at-least-once pada batas relay. Duplicate delivery bukan kasus langka yang berada di pinggir desain; kondisi tersebut langsung mengikuti lokasi interval crash.

Identifier outbox yang stabil memberi consumer deduplication key yang berguna. Consumer dapat mencatat ID pesan yang sudah diproses bersama perubahan state miliknya ketika keduanya dapat masuk dalam satu transaksi lokal. Hal itu tidak otomatis membuat setiap side effect downstream menjadi idempotent. Email provider, payment API, atau sistem eksternal lain memiliki batas commit sendiri dan memerlukan kontrak pengendalian duplikat sendiri.

Fitur broker dapat mengubah sebagian perilaku ini, tetapi jaminannya harus dibaca pada interface yang benar-benar dipakai. Producer identity, transaction, atau deduplication window di sisi broker tidak otomatis meluas ke database service atau ke side effect consumer yang arbitrer.

Ordering bergantung pada kontrak relay

Outbox mempertahankan transaksi database yang membuat setiap intensi pesan, tetapi tidak dengan sendirinya menetapkan urutan publikasi global. Beberapa transaksi aplikasi dapat commit secara konkuren, beberapa worker relay dapat mengambil row secara konkuren, dan broker dapat mempartisi pesan ke stream yang independen.

Jika consumer memerlukan urutan per aggregate, desain membutuhkan ordering key atau sequence yang eksplisit. Sebagai contoh, event untuk satu order dapat membawa versi aggregate yang meningkat secara monoton. Consumer kemudian dapat menolak, menahan, atau menangani versi yang tidak mengikuti kontrak yang diharapkan.

Timestamp biasanya bukan pengganti yang baik untuk kontrak sequencing. Nilai clock dapat sama, clock aplikasi dapat berbeda, dan waktu pembuatan row tidak selalu sama dengan urutan observasi broker. Sequence value yang dihasilkan database dapat menetapkan urutan, tetapi urutan tersebut tetap harus bertahan melewati concurrency relay dan aturan partitioning broker jika consumer diharapkan melihatnya.

Global ordering sering lebih kuat daripada kebutuhan aplikasi. Menyatakan kebutuhan yang lebih sempit, seperti urutan dalam satu account atau aggregate, memungkinkan concurrency pada relay tanpa berpura-pura bahwa transaksi yang tidak saling terkait membentuk satu total sequence yang bermakna.

Claim row adalah persoalan concurrency

Relay berbasis polling umumnya menjalankan lebih dari satu worker. Tanpa koordinasi, dua worker dapat memilih row pending yang sama dan keduanya memublikasikannya. Row locking dapat mengurangi claim konkuren di dalam database:

SELECT id, message_type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at, id
FOR UPDATE SKIP LOCKED
LIMIT 100;

Syntax dan perilaku lock yang tepat bersifat database-specific. SKIP LOCKED, misalnya, merupakan fitur implementasi yang semantiknya perlu diperiksa untuk database dan isolation level yang dipilih.

Menahan transaksi database tetap terbuka selama publikasi jaringan juga dapat menjadi masalah. Lock hidup lebih lama dan latency broker ikut menentukan pemakaian resource database. Desain sering mengambil row secara singkat, melakukan publish di luar transaksi claim tersebut, lalu menyimpan state yang cukup untuk melakukan retry terhadap claim yang ditinggalkan. Cara ini mengurangi durasi lock tetapi menambahkan semantik lease, timeout, atau recovery.

Tidak satu pun skema claim tersebut menghapus interval crash antara publish dan pencatatan hasil. Skema itu mengendalikan kerja relay yang konkuren; skema itu tidak mengubah acknowledgement broker dan update database menjadi satu commit.

Change-data capture memindahkan batas relay

Polling bukan satu-satunya cara mengamati row outbox. Sistem change-data capture dapat membaca record log database yang sudah commit dan mengubah insert menjadi pesan broker. Cara ini dapat menghindari pemindaian tabel berulang dan dapat mewarisi properti ordering dari log database dalam batas sistem capture yang digunakan.

Invariant intinya tetap sama: transaksi aplikasi menulis state domain dan intensi pesan secara bersamaan. Change-data capture mengubah transport dari state outbox yang sudah commit menuju broker. Recovery checkpoint, retensi log, batas transaksi, perilaku schema evolution, dan semantik duplikat milik sistem capture menjadi bagian dari kontrak delivery.

Pemakaian change-data capture tidak berarti aplikasi harus menulis hanya tabel domain lalu menyimpulkan semua event setelahnya. Itu merupakan desain yang berbeda. Row outbox eksplisit dapat membawa tipe event, identifier stabil, versi payload, dan data yang dipilih pada saat transaksi, sehingga intensi pesan menjadi bagian first-class dari state yang commit.

Cleanup adalah bagian dari model storage

Row yang sudah dipublikasikan terus bertambah kecuali sistem menghapus atau mengarsipkannya. Cleanup tidak boleh berpacu dengan state publikasi. Proses retensi dapat menghapus row yang sudah ditandai published secara durable dan lebih tua dari recovery horizon yang dipilih, sesuai kebutuhan retry dan audit relay yang sebenarnya.

Karena itu, outbox bukan sekadar queue sementara yang diimplementasikan dalam SQL. Ia ikut memengaruhi pertumbuhan database, index, perilaku vacuum atau compaction, volume backup, dan operational recovery. Laju pesan yang tinggi dapat membuat tabel outbox tanpa batas menjadi workload storage yang material.

Partitioning berdasarkan waktu, archiving, atau retensi terbatas dapat menjaga workload tersebut tetap terkendali, tetapi setiap pilihan mengubah opsi recovery. Menghapus row yang sudah published menghilangkan bukti lokal bahwa pesan pernah dimaksudkan untuk dikirim dan kemudian ditandai selesai. Sistem yang membutuhkan audit trail lebih panjang perlu mempertahankan bukti tersebut di tempat yang sesuai, bukan memperlakukan cleanup sebagai detail implementasi yang tidak terlihat.

Jaminan berhenti pada intensi yang durable

Properti terkuat transactional outbox bersifat sempit dan berguna: perubahan state lokal tidak dapat commit tanpa intensi pesan pasangannya ketika kedua write berada dalam transaksi database sukses yang sama. Pola ini tidak menjamin exactly-once effect melintasi broker dan setiap consumer.

Publikasi tetap membutuhkan perilaku retry. Consumer tetap membutuhkan kebijakan duplikat. Ordering tetap membutuhkan scope yang eksplisit. Side effect eksternal tetap melewati batas commit masing-masing. Semua itu bukan cacat yang disembunyikan pola ini; semuanya adalah batas yang tersisa setelah dual write awal diperkecil menjadi satu transaksi lokal ditambah relay yang dapat dipulihkan.

Perbedaan tersebut menjaga desain tetap presisi. Commit database merekam apa yang berubah sekaligus kewajiban untuk melakukan publish. Semua proses setelah commit itu adalah pekerjaan delivery dengan semantik kegagalan dan pengulangan yang harus dinyatakan secara terpisah.