Outbox Pattern#
The transactional outbox pattern reliably publishes events from a service whose source of truth is a database. The event is written into an outbox table in the same local transaction that updates business state, then a relay process forwards it to the message broker.
flowchart LR
APP[Order Service]
DB[(Orders DB)]
OUTBOX[(outbox table)]
RELAY[Relay / CDC]
BROKER[Kafka / SNS]
C1[Consumer A]
C2[Consumer B]
APP -- tx: orders + outbox --> DB
DB --- OUTBOX
OUTBOX --> RELAY --> BROKER
BROKER --> C1
BROKER --> C2
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
class APP,C1,C2 service;
class DB,OUTBOX datastore;
class BROKER queue;
class RELAY compute;
The naive alternative is to commit the database row, then publish to the broker. If the broker call fails (network blip, broker outage, process crash between the two writes), the database and the rest of the world disagree forever. Wrapping the broker call inside the database transaction does not help either: the broker is not part of the database, and a 2PC across them is rarely available.
The outbox pattern dodges the dual-write problem by making event publishing a local database write. Whatever the relay does later, the event has already been recorded atomically with the business change. The relay is typically a CDC stream from the database log (Debezium reading Postgres WAL or MySQL binlog) or a simple poller that scans the outbox table for unsent rows. Together with an idempotent consumer (the inbox table on the receiver side), the system achieves effectively-once delivery.
Problem statement (interviewer prompt)
Design event publishing for a service that owns a database and must guarantee that every committed state change produces exactly one downstream event, even when the message broker, network, or service crashes mid-flow.
The dual-write problem#
A service typically wants to do two things in one logical step: persist business state and notify the world. The two stores are not joined by a single transaction, so any ordering breaks under failure.
flowchart LR
A[Service]
DB[(Database)]
MQ[(Broker)]
A -- 1 write row --> DB
A -- 2 publish event --> MQ
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
class A service;
class DB datastore;
class MQ queue;
Three failure modes haunt this design:
| Order | Failure point | Result |
|---|---|---|
| DB then broker | Crash before broker | DB committed, no event ever sent |
| Broker then DB | DB rollback after publish | Event seen by consumers, no DB row |
| Both | Process killed between | Either of the above |
XA/2PC across database and broker can theoretically solve this but is slow, fragile, and unsupported by most cloud-native brokers.
The outbox table#
Make the broker write into the database. The service writes both the business row and an outbox row in the same local transaction. A separate relay picks up the outbox row later and sends it to the broker. The database is now the only source of truth.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_id TEXT NOT NULL,
type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
sent_at TIMESTAMPTZ
);
CREATE INDEX outbox_unsent ON outbox (id) WHERE sent_at IS NULL;
def place_order(order: Order):
with db.transaction():
db.insert("orders", order.to_row())
db.insert("outbox", {
"aggregate_id": order.id,
"type": "OrderPlaced",
"payload": order.to_event(),
})
# broker publish happens later, in the relay
Relay options#
Two relay styles dominate, with different tradeoffs.
Polling relay#
A worker selects unsent outbox rows in small batches, publishes to the broker, and marks them sent.
def poll_loop():
while True:
rows = db.fetch(
"SELECT * FROM outbox WHERE sent_at IS NULL "
"ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED"
)
for r in rows:
broker.publish(r.type, r.payload, key=r.aggregate_id)
db.execute("UPDATE outbox SET sent_at=now() WHERE id=$1", r.id)
if not rows: time.sleep(0.5)
Pros: simple, no special infra. Cons: latency floor (poll interval), DB hot rows under load.
CDC relay#
A change data capture pipeline (Debezium reading Postgres WAL, MySQL binlog, MongoDB oplog) streams every insert into the outbox table to the broker without any application polling.
flowchart LR
DB[(Postgres WAL)]
DEBEZIUM[Debezium connector]
K[Kafka topic]
C[Consumer]
DB --> DEBEZIUM --> K --> C
classDef datastore fill:#fee2e2,stroke:#991b1b,stroke-width:1px,color:#0f172a;
classDef compute fill:#d1fae5,stroke:#065f46,stroke-width:1px,color:#0f172a;
classDef queue fill:#ede9fe,stroke:#5b21b6,stroke-width:1px,color:#0f172a;
classDef service fill:#fef3c7,stroke:#92400e,stroke-width:1px,color:#0f172a;
class DB datastore;
class DEBEZIUM compute;
class K queue;
class C service;
Pros: very low latency, no DB load from polling, preserves DB commit order. Cons: extra infra (Kafka Connect, Debezium), schema evolution discipline, exactly the right REPLICA IDENTITY and replication slots.
Inbox pattern and idempotent consumers#
The outbox guarantees at-least-once delivery: retries during broker failures can duplicate events. The consumer must therefore be idempotent. The standard tool is the inbox table: the consumer stores the event id before processing and refuses duplicates.
def handle(event):
with db.transaction():
try:
db.insert("inbox", {"event_id": event.id})
except UniqueViolation:
return # already processed
apply_business_change(event)
Together, outbox + inbox + at-least-once broker give effectively-once end-to-end semantics, which is what most "exactly-once" claims actually mean.
Ordering and partitioning#
Use the aggregate id (order id, user id) as the broker partition key. CDC preserves DB commit order per row, so consumers see events for one aggregate in the order they were committed. Cross-aggregate ordering is not guaranteed and rarely required.
Tradeoffs and gotchas#
| Concern | Mitigation |
|---|---|
| Outbox table growth | Periodic archive job, partition by date |
Hot index on sent_at IS NULL |
Partial index, or CDC to avoid polling |
| Replay during disaster recovery | Reset CDC offset, idempotent consumers absorb dupes |
| Schema migrations on outbox | Treat payload as evolving JSON, add a schema_version column |
| PII in payloads | Encrypt at rest, redact on archive |
| Long-running transactions | Outbox row is locked until commit, keep txns short |
Production gotchas#
- Always use a transactional outbox, never an in-memory queue between commit and publish.
- Make consumers idempotent even if you trust your broker, retries on the relay can dupe.
- Monitor outbox lag (oldest unsent row age) like database replication lag.
- Reserve a separate replication slot for Debezium and watch slot disk usage on Postgres.
- Test recovery: kill the relay, accumulate 10k rows, restart, verify all flush in order.
- Pair with the saga pattern: every saga participant uses an outbox so its step is observable to the orchestrator.
Quick reference#
Core idea#
- Write business row and outbox row in one local transaction
- A relay forwards outbox rows to the broker later
- No dual-write, DB is the only source of truth
Why#
- Solves the dual-write problem (DB committed but event lost or vice versa)
- Replaces XA / 2PC across DB and broker
- Foundation for reliable event-driven microservices
Relay options#
- Polling: SELECT WHERE sent_at IS NULL, simple but adds latency
- CDC: Debezium reads WAL/binlog/oplog, low latency, more infra
- Use SKIP LOCKED on Postgres for safe parallel pollers
Inbox pattern#
- Receiver stores event_id before processing
- Unique constraint rejects duplicates
- Together with outbox gives effectively-once
Ordering#
- Use aggregate_id as broker partition key
- CDC preserves per-row commit order
- Cross-aggregate ordering is not guaranteed
Failure modes#
- Outbox table grows unbounded, archive by date
- Hot partial index under load
- Replication slot disk fills if relay is offline
- Long open transaction blocks outbox row
- Replay storms after DR, idempotent consumers absorb
Operations#
- Monitor oldest unsent row age, page on lag
- Reserve a Debezium-only replication slot
- Encrypt PII in payload, redact in archive
- Add
schema_versionfor payload evolution - Test relay crash + restart drains in order
Pairs well with#
- Saga pattern (reliable step events)
- Event sourcing (events ARE the outbox)
- Idempotent consumers (inbox)
Refs#
- Chris Richardson: "Microservices Patterns" (2018)
- Debezium docs: "Outbox event router" (2021)
- Gunnar Morling: "Reliable microservices data exchange with the outbox pattern" (2019)
FAQ#
What is the transactional outbox pattern?#
The service writes both the business row and an event row into an outbox table in one DB transaction, then a relay reads the outbox and publishes the event to the broker.
What problem does it solve?#
It solves the dual-write problem. If you commit the DB row and then publish to a broker, a crash between the two leaves the system permanently inconsistent.
How does CDC fit in?#
A change data capture stream like Debezium tails the DB log and forwards new outbox rows to Kafka. It avoids polling and provides ordered, near-real-time delivery.
What is the inbox pattern?#
The consumer stores processed event IDs in an inbox table. Reprocessing the same event is skipped, giving effectively-once delivery on top of at-least-once brokers.
Outbox vs event sourcing?#
Outbox publishes events alongside a regular state-of-the-world database. Event sourcing makes the events themselves the source of truth and rebuilds state from them.
Related Topics#
- Saga Pattern: outbox is the standard publisher for saga events.
- Change Data Capture: the relay mechanism behind CDC outbox.
- Idempotency and Retries: the receiver side of the contract.
- Pub-Sub Pattern: the broker that carries outbox events.