The Outbox Pattern: Solving the Dual-Write Problem
Every microservice eventually hits the dual-write trap. The outbox pattern is the right fix — but it moves complexity from your application code into your infrastructure.
Imagine you're building an order service. When a user places an order, you need to:
- Write the order to your database
- Publish an
OrderPlacedevent so the inventory service can reserve stock
Two operations. Two separate systems. One guarantee you can't easily make: that both succeed or neither does.
This is the dual-write problem, and it's the reason the outbox pattern exists.
The trap every team falls into
The naive implementation looks fine until it isn't:
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
if err := s.repo.Save(ctx, order); err != nil {
return err
}
return s.eventBus.Publish(ctx, OrderPlaced{OrderID: order.ID}) // publishes to Kafka
}This code has two failure windows. The DB write can succeed and the publish can fail — leaving a ghost order that never triggers downstream processing. Or the service crashes between the two lines. You'll ship it, it'll work in dev, and then at 2am on a Tuesday a network blip will eat an event and a customer's package won't ship.
Wrapping this in error checks doesn't help. The event is either not sent, or it's sent but the rollback fails silently. There's no distributed transaction that cleanly spans a relational database and a message broker without significant protocol overhead (2PC, anyone?).
The outbox idea
The fix is conceptually simple: make the event part of the database transaction.
Instead of publishing to the broker directly, write the event to an outbox table in the same database — inside the same transaction as your business entity.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
published_at TIMESTAMPTZ -- NULL = unpublished
);Now your service code becomes:
func (s *OrderService) PlaceOrder(ctx context.Context, order Order) error {
return s.db.WithTx(ctx, func(tx *sql.Tx) error {
if err := s.repo.SaveTx(ctx, tx, order); err != nil {
return err
}
return s.outbox.InsertTx(ctx, tx, OutboxEvent{
AggregateType: "Order",
AggregateID: order.ID.String(),
EventType: "OrderPlaced",
Payload: mustMarshal(OrderPlaced{OrderID: order.ID}),
})
})
}If the transaction commits, both the order row and the outbox row exist. If it rolls back, neither does. The database's ACID guarantees are doing the work — no broker involved at commit time.
The relay: who actually publishes the event?
A separate process — the relay — reads unpublished rows and pushes them to the broker. You have two approaches.
Polling is the right starting point. A scheduler queries for rows where published_at IS NULL, publishes each one, then marks it done.
func (r *Relay) Run(ctx context.Context) {
ticker := time.NewTicker(time.Second)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
events, _ := r.outbox.FindUnpublished(ctx, 100)
for _, e := range events {
r.broker.Publish(ctx, e)
r.outbox.MarkPublished(ctx, e.ID)
}
}
}
}Works on any database. Easy to understand, easy to test. The tradeoff: you're burning DB queries in a loop, and there's inherent latency between commit and publish (typically 0.5–5 seconds).
CDC (Change Data Capture) is the approach for high-volume systems. Tools like Debezium tail the database's write-ahead log (WAL), detect inserts into the outbox table, and stream them to Kafka in near real-time — with strong ordering and no polling overhead.
| Polling | CDC (Debezium) | |
|---|---|---|
| Latency | 0.5–5s | Near real-time |
| Throughput | Good | High |
| Setup | A cron job | Debezium + Kafka Connect |
| Ordering | Per-query | WAL-based, strong |
| Ops burden | Low | Higher |
Start with polling. Graduate to CDC when latency or throughput actually demand it.
What you're actually signing up for
The outbox pattern doesn't eliminate complexity — it relocates it from your application code into your infrastructure. Before you adopt it, know the costs:
At-least-once delivery, not exactly-once. If the relay crashes after publishing but before marking the row as done, the event is sent again on restart. Every consumer must be idempotent — use the event's id to detect and skip duplicates.
Table growth. The outbox table accumulates rows. Published rows need cleanup: delete them after a retention window, or partition the table by date and drop old partitions. Neglect this and you'll eventually be doing emergency maintenance on a 50-million-row table.
Poison messages. A malformed event payload fails on every retry, blocking the relay from processing subsequent events. You need a dead-letter strategy: detect events that have failed N times, park them in a DLQ, alert on them, and continue.
A new process to run. The relay is infrastructure now. It needs deployment, health checks, alerting, and crash recovery. In a Kubernetes cluster, that's a Deployment, a PodDisruptionBudget, and log monitoring. Not enormous, but not free either.
When to reach for it — and when not to
Reach for it when:
- A DB transaction must reliably trigger a downstream event
- You're already running a message broker
- Event loss causes real business problems — payments, inventory, notifications, billing
Consider alternatives when:
- You don't have async messaging yet and are tempted to add broker + relay + outbox table preemptively
- You can tolerate eventual consistency without strict ordering — a simple retry queue or idempotent HTTP callback may be enough
- You're using Event Sourcing — your event log already is the outbox; you don't need a separate table
The pattern is the right tool for a specific problem. If you've been bitten by silently dropped events in production, the outbox is worth every bit of operational cost. If you're adding it speculatively to a service that fires two events a day, you're building complexity ahead of the need.
The one thing to hold onto
The outbox pattern gives you a precise guarantee: an event is published if and only if the database transaction commits. Nothing more, nothing less. It's at-least-once delivery. Consumers still need to be idempotent. The relay still needs to run. The table still needs cleanup.
But when you need that guarantee — and in distributed systems, you will — the outbox pattern is the cleanest way to get it without pulling in a distributed transaction protocol.