Outbox vs Inbox pattern — what is the difference?
Outbox is about reliably emitting events from your service. Inbox is about reliably consuming events on the other side.
Together they give you effectively-once message delivery across service boundaries.
Outbox — producer side
The service writes an event into an outbox table inside the same DB transaction as the business write. A relay drains it to the bus.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
sent_at TIMESTAMPTZ
);
Guarantees: at-least-once delivery of every business event.
Inbox — consumer side
The service records every event it has ever processed, indexed by the event's original ID. Before processing, it checks the inbox — if the event was already seen, skip.
CREATE TABLE inbox_events (
event_id UUID PRIMARY KEY, -- the original event's id
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
Code:
public async Task HandleAsync(IncomingEvent ev, CancellationToken ct)
{
using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
// Try to claim this event id. PRIMARY KEY rejects duplicates.
db.InboxEvents.Add(new InboxEvent { EventId = ev.Id });
await db.SaveChangesAsync(ct);
}
catch (DbUpdateException) when (ex.IsUniqueConstraintViolation())
{
// Already processed — duplicate, safe to skip
return;
}
// Process — DB changes commit with the inbox row
await ProcessAsync(ev, ct);
// Mark as processed in the same transaction
var inboxRow = await db.InboxEvents.FindAsync(ev.Id);
inboxRow.ProcessedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
Guarantees: idempotent consumption — duplicates from at-least-once delivery are deduplicated.
Why both are needed
The Outbox alone gives at-least-once. Without Inbox on the consumer, retries from the producer cause:
- Order placed twice
- Inventory reserved twice
- Customer charged twice
- Confirmation email sent twice
The combined picture
┌─────────────────┐ ┌─────────────────┐
│ Order Service │ │ Inventory │
│ │ │ Service │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ outbox │ │ ┌────────────────────┐ │ │ inbox │ │
│ │ events │──┼──▶│ Kafka / RabbitMQ │───────┼─▶│ events │ │
│ └───────────┘ │ │ (at-least-once) │ │ └───────────┘ │
│ │ └────────────────────┘ │ │
└─────────────────┘ └─────────────────┘
producer message delivery consumer
(Outbox: at-least-once) (Inbox: dedup → exactly-once)
Side-by-side
| Outbox | Inbox | |
|---|---|---|
| Where it lives | Producer service | Consumer service |
| What it stores | Events to be published | Events already processed |
| Solves | Dual-write problem (DB + bus) | Duplicate-delivery problem (at-least-once → exactly-once) |
| Background process | Relay (drain to bus) | Janitor (delete old processed rows) |
| Lifecycle | Insert → Publish → Mark sent → Delete | Receive → Insert (or skip if exists) → Process → Mark processed → Delete |
When you can skip Inbox
- Consumer is idempotent BY OTHER MEANS (e.g. "UPSERT order WHERE id = ev.OrderId" — the second insert is a no-op)
- Loss of business invariant from duplicates is acceptable (analytics counters, click tracking)
- Strong domain-level idempotency keys exist (e.g. payment processors use IdempotencyKey headers)
When you MUST have Inbox
- Side effects with monetary impact: charges, refunds, fund transfers
- Side effects that cannot be naturally idempotent: emails, notifications, third-party API calls
- Stateful business operations: "decrement stock", "add loyalty points"
Inbox cleanup
The inbox grows forever without a janitor. Same pattern as outbox cleanup:
public class InboxJanitor : BackgroundService {
protected override async Task ExecuteAsync(CancellationToken stop) {
while (!stop.IsCancellationRequested) {
// Keep 30 days — covers any reasonable retry storm window
await db.Database.ExecuteSqlRawAsync(
"DELETE FROM inbox_events WHERE received_at < now() - interval '30 days'", stop);
await Task.Delay(TimeSpan.FromHours(6), stop);
}
}
}
Interview-grade summary
"Outbox guarantees the producer publishes every business event at-least-once. Inbox guarantees the consumer processes every event at-most-once. Together they give effectively-once message delivery — the strongest guarantee achievable in an asynchronous distributed system. Skip the inbox only when your consumer's side effect is naturally idempotent."