# The transactional outbox after the happy-path diagram

> Define production invariants for duplicate delivery, ordering, poison records, cleanup, deployment, and relay recovery.

Canonical URL: https://www.devobs.io/articles/transactional-outbox-failure-modes/
By: Lucas Vale
Published: 2025-07-05T07:31:09.044Z
Updated: 2026-09-05
Section: Architecture

A transactional outbox solves one narrow problem: business state and the intent to publish an event commit together. It does not make delivery exactly once, preserve every useful ordering automatically, or operate its own backlog. Production reliability comes from explicit invariants for the database writer, relay, broker, and consumer.

## Protect the atomic boundary

Insert the business change and outbox row in the same database transaction. The row should contain a stable event ID, aggregate type and ID, event type, occurred time, schema version, and payload or payload reference. If the transaction rolls back, neither appears. If it commits, both appear. [AWS Prescriptive Guidance](https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html) describes this atomic-write purpose and warns that consumers need idempotency because messages can be delivered more than once.

Do not publish to the broker from a transaction callback and call it an outbox. A process crash after commit but before callback completion loses the event; a crash after publish but before local acknowledgement can duplicate it. The relay must be able to rediscover committed, unpublished rows.

## Accept duplicates deliberately

Use the event ID as the consumer's idempotency key. A consumer can insert it into a processed-events table in the same transaction as its own state change, rejecting duplicates with a unique constraint. For side effects outside that transaction, such as email, carry the same key to a provider that supports idempotency or create a local send-intent state machine. Time-based in-memory deduplication is only a bounded optimization.

Ordering needs a named scope. Global order is costly and rarely necessary. Usually events for one aggregate must preserve sequence, so store an aggregate version and partition the broker by aggregate ID. Consumers should reject, park, or reconcile gaps rather than applying version 12 before version 11. Two independent aggregates may safely interleave.

## Choose polling or CDC with operations in mind

An application poller selects a bounded batch, claims rows with leases or locking, publishes, and marks them delivered. It is easy to reason about in one codebase, but polling frequency, lock behavior, and cleanup affect database load. A change-data-capture relay reads the database log. [Debezium's outbox event router documentation](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html) shows how an outbox table can be transformed and routed using fields such as aggregate ID and event type. CDC reduces application polling but adds connector offsets, log retention, and deployment compatibility to the runbook.

Neither approach removes the publish/ack uncertainty. If the relay publishes and crashes before recording progress, it will publish again. That is why downstream idempotency is part of the pattern rather than an optional improvement.

## Operate the backlog as data

Measure oldest unpublished age, row count, publish attempts, relay lag, per-event-type failures, and dead-letter volume. A poison record must not block its entire partition forever. After a bounded retry policy, quarantine it with the error and schema version, alert an owner, and provide a replay path that preserves the original event ID.

Partition or index the polling path and delete delivered rows in small batches after a retention window. Test whether cleanup competes with inserts. During schema deploys, use expand-and-contract: consumers accept old and new versions before producers emit the new one. Keep the relay tolerant of additional columns and unknown event types.

Review the design with this checklist: one transaction for state and intent; stable IDs; explicit ordering scope; consumer idempotency; bounded relay claims; poison isolation; backlog alerts; safe cleanup; replay tooling; and version-compatible deploys.

Before choosing CDC or polling, build a failure test. Kill the relay immediately before and after broker acknowledgement, restart it, and prove that the business event is neither lost nor applied twice. The expected duplicate is the most important line in the test.

## Source references

- <https://docs.aws.amazon.com/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html>
- <https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html>
