100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Software Architecture

The Outbox Pattern

The Outbox pattern guarantees a service's database write and its corresponding event publication happen atomically, by writing the event to an 'outbox' table in the same local transaction and relaying it separately.

Data & ConsistencyAdvanced8 min readJul 10, 2026
Analogies

The Dual-Write Problem

A common microservices need is: 'when I save this record to my database, also publish an event to a message broker like Kafka or RabbitMQ so other services can react.' The naive implementation writes to the database, then makes a separate call to publish the event. This is a dual write, and it is unsafe: if the process crashes or the network fails between the database commit and the broker publish, you either lose the event entirely (database committed, broker never got it) or, if you publish first, you might publish an event for a write that then fails to commit. There is no way to make a single ACID transaction span two completely different systems (a relational database and a message broker) without a distributed transaction coordinator, which, as with two-phase commit, most teams avoid for scalability and complexity reasons.

🏏

Cricket analogy: If a scorer updates the paper scorebook and then, in a completely separate step, radios the change to the broadcast van, a radio outage between those two actions leaves the scorebook and broadcast permanently out of sync, exactly like the dual-write problem.

How the Outbox Pattern Works

The Outbox pattern solves this by never actually doing a dual write. Instead of publishing to the broker directly, the service writes the business record and the event both into the same database, in the same local ACID transaction — the event goes into a dedicated 'outbox' table (columns like id, aggregate_id, event_type, payload, created_at, and a processed flag or none at all). Because it's the same database and the same transaction, this write is atomic: either both the business record and the outbox row are committed, or neither is. A separate process, called a message relay, then reads unpublished rows from the outbox table and publishes them to the broker, marking or deleting them once publish succeeds. This relay is commonly implemented either by polling the outbox table on an interval, or, more robustly, by using Change Data Capture (CDC) tools like Debezium that tail the database's write-ahead log and stream new outbox rows to Kafka automatically.

🏏

Cricket analogy: Rather than the scorer separately radioing every event, they write both the scorebook entry and a duplicate carbon-copy slip in one motion; a runner then physically carries the slips to the broadcast van on a schedule, guaranteeing nothing gets lost even if the radio itself fails.

sql
-- Outbox table lives in the same database and schema as the business tables
CREATE TABLE outbox (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type TEXT NOT NULL,      -- e.g. 'Order'
  aggregate_id UUID NOT NULL,        -- e.g. the order id
  event_type TEXT NOT NULL,          -- e.g. 'OrderPlaced'
  payload JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Both writes happen in ONE local transaction, so they are atomic
BEGIN;
  INSERT INTO orders (id, customer_id, total, status)
  VALUES ('a1b2', 'cust-99', 149.00, 'PLACED');

  INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload)
  VALUES ('Order', 'a1b2', 'OrderPlaced',
          '{"orderId":"a1b2","customerId":"cust-99","total":149.00}');
COMMIT;

-- A separate relay process (polling or CDC via Debezium) later reads
-- new outbox rows, publishes them to Kafka, then deletes or marks them.

Debezium is the most widely used CDC tool for implementing outbox relays in production. It tails PostgreSQL's or MySQL's write-ahead log directly (no polling overhead, near-zero added latency), detects new outbox rows, and streams them to Kafka, including a dedicated 'outbox event router' single message transform (SMT) that unwraps the payload into a clean Kafka message.

Guarantees, Limitations, and Cleanup

The outbox pattern guarantees at-least-once delivery of the event, not exactly-once — the relay might publish the same outbox row twice if it crashes after publishing but before marking the row processed, so every consumer of these events must be idempotent (commonly by deduplicating on the event's unique id). It does not guarantee ordering across different aggregates by default, though ordering within a single aggregate's events is typically preserved if you partition the Kafka topic by aggregate_id and the relay processes rows in creation order. Operationally, the outbox table needs housekeeping: rows must be periodically purged or archived once safely published (often after a retention window, e.g., 7 days, to allow for debugging and replay), otherwise it grows unbounded and slows down the very writes it's meant to protect.

🏏

Cricket analogy: The carbon-copy slip system guarantees the broadcast van eventually gets every scoring event (at-least-once), but if a runner accidentally delivers the same slip twice, the broadcast team must recognize the duplicate by its slip number rather than double-counting the run.

A frequent mistake is publishing directly from application code inside a try/catch 'just in case' as a supposed safety net alongside the outbox table, effectively reintroducing the dual-write problem the pattern was meant to eliminate. The relay reading from the outbox table must be the only path that publishes to the broker; application code should only ever write to the local database.

  • The dual-write problem occurs when a service writes to its database and separately publishes an event to a broker, risking inconsistency if a failure happens between the two steps.
  • The Outbox pattern writes the business record and the event into the same local database transaction, in a dedicated outbox table, making the combined write atomic.
  • A separate message relay process reads unpublished outbox rows and publishes them to the broker, either by polling or via Change Data Capture (e.g., Debezium tailing the write-ahead log).
  • The pattern guarantees at-least-once delivery, not exactly-once, so downstream consumers must be idempotent and deduplicate by event ID.
  • Ordering is typically preserved only within a single aggregate's events, usually by partitioning the broker topic by aggregate ID.
  • The outbox table requires periodic cleanup or archival of already-published rows to avoid unbounded growth that slows down writes.
  • Application code must never publish to the broker directly alongside writing to the outbox table, or the dual-write problem returns.

Practice what you learned

Was this page helpful?

Topics covered

#SoftwareArchitecture#MicroservicesStudyNotes#SoftwareEngineering#TheOutboxPattern#Outbox#Pattern#Dual#Write#StudyNotes#SkillVeris