Transactional Outbox
The only reliable way to both commit a row and publish an event.
The mechanism. You cannot atomically write to your database and publish to a broker — there is no shared transaction. So write the event into an outbox table inside the same database transaction as the state change. A separate process reads unpublished rows and sends them to the broker, marking them sent. The commit is atomic; the publish is at-least-once.
The bug it prevents. The naive version does db.commit() then broker.publish(). Crash between them and the order exists but no one was told — a silent, permanent inconsistency that no retry can detect. Swap the order and you get phantom events for orders that do not exist. There is no ordering of two non-transactional calls that is correct.
Reading the outbox. Polling is simple and fine at modest scale (SELECT ... WHERE published_at IS NULL ORDER BY id LIMIT n FOR UPDATE SKIP LOCKED). Change Data Capture is better at scale: Debezium tails the write-ahead log, so there is no polling load and ordering comes from the log itself. SKIP LOCKED is what makes concurrent pollers safe.
At-least-once is the contract. The relay can crash after publishing and before marking, so consumers will see duplicates. Every consumer needs to be idempotent — dedupe on the event id. If you also need ordering, partition by aggregate id and keep a single consumer per partition.
The inverse, too. The same reasoning applies on the consume side: the inbox pattern records processed event ids in the same transaction as the side effect, which is how a consumer becomes genuinely idempotent rather than hopefully so.
Takeaway. Dual writes are the single most common source of permanent data inconsistency in event-driven systems, and the outbox removes the whole class.