What is the Outbox pattern and what problem does it solve?
The Outbox pattern solves the dual-write problem: how to atomically (1) save business data to your database AND (2) publish an event about it to a message bus, when those are two different systems.
The naive (broken) approach
public async Task PlaceOrderAsync(PlaceOrderCommand cmd)
{
await _db.SaveAsync(order); // (1) database
await _bus.PublishAsync(new OrderPlaced(order)); // (2) message bus
}
This is broken in three failure modes:
| Failure | Result |
|---|---|
| Process crashes between (1) and (2) | Order in DB, no event ever published. Downstream services don't know. |
| Bus publish succeeds but ack lost, caller retries | Two events published. Customer charged twice. |
| Bus is down briefly | Either the call hangs (slow), throws (event lost), or worse, succeeds late |
There is no ordering of these two writes that gives reliability across all failure modes. You need atomicity that spans DB + bus, which is not natively available.
The solution
Replace "two writes" with "one write" by storing the event as a row in the same database:
public async Task PlaceOrderAsync(PlaceOrderCommand cmd)
{
db.Orders.Add(order);
db.OutboxEvents.Add(new OutboxEvent {
EventType = "order.placed",
Payload = JsonSerializer.Serialize(orderEvent)
});
await db.SaveChangesAsync(); // ONE transaction — both rows commit atomically
}
A separate relay/dispatcher background process polls the outbox table and publishes events to the bus, then marks them sent.
Why it works
- The order row and the outbox row commit in the SAME database transaction → atomic
- The relay can retry forever — at worst, it publishes the same event twice (consumers must be idempotent)
- A process crash anywhere is safe — the outbox row sits in the DB until the relay picks it up
What it guarantees
- At-least-once delivery of every business event
- Atomic with the business write — event and entity always agree
- Crash-safe at every point
- Replayable — reset
sent_atand the relay re-publishes
What it does NOT guarantee
- Exactly-once — consumers MUST be idempotent (use the inbox pattern on the receiving end)
- Sub-millisecond latency — there's a ~100-500ms gap between commit and publish with polling
- Global ordering — events within one aggregate are ordered; across aggregates, ordering is not guaranteed without extra design
When you need it
- ANY service that writes to a DB AND publishes events
- Order processing, payment, user signup, inventory updates
- Foundational pattern for the SAGA pattern (sagas emit events; outbox makes that reliable)
- Anywhere a "lost event" causes a customer-visible bug
When you can skip it
- Pure read services (no writes)
- Internal-only fire-and-forget telemetry where event loss is acceptable
- Workflows where downstream can pull-based discover changes (rare)
Interview-grade summary
"The outbox pattern replaces the dual-write problem (DB + bus) with a single transactional write (DB only). The outbox table is a queue that lives in the same DB as your business data. A background relay drains it to the message bus. Done right, no business event is ever lost, even with crashes, network partitions, or message-bus outages."