Recipe

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.