Transactional outbox
How does the outbox move a second write inside one system's boundary, and who pays for the rest?
The dual-write problem: update a database and publish a message, atomically. There are two systems, so by "atomicity over n items is atomicity over one item" you would need one atomic event covering both, and there is none. If the code does UPDATE then publish, a crash between them leaves a seat reserved and no message. If it publishes first, a crash leaves a message about a state change that never happened.
The fix is structural, not clever: move the second effect inside the first system's atomic boundary. Write the message as a row in an outbox table in the same local transaction as the state change.
BEGIN;
UPDATE seats SET status = 'sold', order_id = 981 WHERE seat_id = 'F-12';
INSERT INTO outbox (id, topic, payload)
VALUES ('evt-981-paid', 'order.paid', '{"order_id": 981}');
COMMIT; -- one atomic event covers both the change and the intent to publishA separate process, the relay, reads the outbox and publishes each row to the event-bus, and then marks or deletes it.
Be precise about what this accomplished. Atomicity across two systems (impossible) was decomposed into three parts:
- atomicity in one system (trivial);
- at-least-once delivery from the relay to the broker (achievable: keep trying until acknowledged);
- idempotent consumption (mandatory, and pushed onto someone else).
The problem was not solved. It was split into an easy part you keep and a hard part you export. That is "costs are conserved, never removed", with the receiving party clearly named.
The costs are concrete:
- Duplicates are guaranteed. A non-idempotent consumer is a latent bug, not a risk.
- Per-key ordering needs the relay to be partition-aware or single-threaded per key.
- The outbox table is a queue inside a database: rows are inserted and deleted at a high rate, so you get index bloat and vacuum pressure, which is a reliable way to recreate the MVCC problem of Multiversion and optimistic schemes. That is why mature implementations read the outbox through CDC (change data capture: reading the database's own log of changes) instead of polling the table.
The variant worth naming: skip the outbox and publish the WAL directly. The atomic event becomes the database commit itself, and the table disappears. The price: the published events now have the shape of your physical schema, which couples every consumer to your table layout. The outbox buys freedom in event design for the cost of a table; raw CDC saves the table for the cost of coupling. Decide deliberately, not by default.