Transactional Outbox Pattern
The transactional outbox pattern solves the dual-write problem when a service must update its database and publish a domain event atomically. Instead of writing to the broker directly, the service appends an event row to an outbox table inside the same database transaction. A relay process tails the outbox and forwards events to the broker with at-least-once delivery.
1. Schema the outbox table
Create a dedicated outbox table colocated with the aggregate. Include an event id for idempotency, the aggregate id for ordering, the payload as JSON, and a published_at timestamp the relay updates after a successful broker ack.
CREATE TABLE outbox ( id UUID PRIMARY KEY, aggregate_id UUID NOT NULL, topic TEXT NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), published_at TIMESTAMPTZ ); CREATE INDEX outbox_unpub ON outbox (created_at) WHERE published_at IS NULL;
2. Write state and event in one transaction
Wrap the aggregate mutation and the outbox insert inside a single SQL transaction. If the broker is unreachable, no event is ever lost because it lives in the same ACID boundary as the business state. Crucially, the application never calls the broker on the request path.
3. Relay with at-least-once semantics
A separate worker polls or tails the outbox (Postgres logical replication works well), publishes each row to the broker, and marks it published. Consumers must be idempotent because the relay may retry. Pair this with a unique event id so downstream services can dedupe with a processed-set.