Same payments in.
Different money out.
Every payment is saved, then announced to other systems, and both steps must succeed. Knock the broker offline: the naive design loses money for good, while the outbox holds every cent.
Naive, two writes
- in ledger
- $0
- lost
- $0
Transactional outbox, one write
- in ledger
- $0
- held in outbox
- $0
The broker is up: every event reaches the ledger, so both lanes stay in sync. The broker is down: the naive lane loses its events for good; the outbox holds and replays them.
How the outbox actually works read more
The naive app writes twice: it commits the payment to its database, then publishes an event to the broker. Nothing ties the two together, so if the broker is down after the commit, the event is gone with no record it was ever meant to send.
The outbox makes it one write: the event is inserted into an outbox table in the same database, in the same transaction as the payment, so both rows commit together or neither does.
BEGIN;
INSERT INTO payments (..., status='captured');
INSERT INTO outbox (event='payment.captured', status='pending');
COMMIT; -- one transaction A separate relay reads the pending rows and publishes them, marking each done on success. Broker down? The rows just wait, and go out when it is back. A durable queue in front of the broker would not help: that is still a second system, so it is still a dual write. The outbox works because it lives in your own database.
The cost: the relay republishes at least once, so consumers must be idempotent, and delivery is eventual, not instant.