Outbox & Inbox Patterns
Key Points
- Outbox solves: "did the DB save AND the message send happen atomically?". Write the message to an outbox table in the same DB transaction; a relay process publishes it.
- Inbox solves: "did I process this message exactly-once?". Record processed message IDs in an inbox table; skip duplicates.
- Together: at-least-once delivery + idempotent processing = effective exactly-once.
- Implementations: MassTransit (
UseEntityFrameworkOutbox), Wolverine (built-in), NServiceBus, hand-rolled. - Cost: extra DB writes; relay process; idle latency. Worth it for any business event that must not be lost.
Concepts (deep dive)
The problem
// ❌ "I save to DB, then publish to broker"
_db.Orders.Add(order);
await _db.SaveChangesAsync(); // ✅ committed
await _broker.PublishAsync(new OrderPlaced(...)); // ❌ broker down → lost!
// ❌ "I publish first, then save"
await _broker.PublishAsync(new OrderPlaced(...));
_db.Orders.Add(order);
await _db.SaveChangesAsync(); // ❌ DB fails → ghost event!
You need atomicity across DB + broker. Distributed transactions (XA) are dead.
The outbox
┌─────────────────────────────────┐
│ DB transaction │
│ ├─ INSERT INTO orders (...) │
│ └─ INSERT INTO outbox (msg) │
│ COMMIT │ ← single atomic op
└─────────────────────────────────┘
┌──────────────────┐ ┌────────┐ ┌─────────┐
│ Outbox relay │───▶│ Broker │───▶│Consumer │
│ (background poll │ └────────┘ └─────────┘
│ or CDC) │
└──────────────────┘
The outbox is a table in the same DB. Inserting business + outbox row in one transaction = atomic. The relay reads the outbox, publishes to the broker, marks rows sent.
Schema
CREATE TABLE Outbox (
Id UUID PRIMARY KEY,
OccurredAt TIMESTAMP NOT NULL,
Type VARCHAR(200) NOT NULL,
Payload JSON NOT NULL,
SentAt TIMESTAMP NULL,
INDEX idx_unsent (SentAt)
);
Hand-rolled outbox
public class OrderPlacement(AppDb db)
{
public async Task PlaceAsync(PlaceOrder cmd, CancellationToken ct)
{
var order = Order.Place(cmd);
db.Orders.Add(order);
db.Outbox.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
OccurredAt = DateTimeOffset.UtcNow,
Type = nameof(OrderPlaced),
Payload = JsonSerializer.Serialize(new OrderPlaced(order.Id))
});
await db.SaveChangesAsync(ct);
}
}
public class OutboxRelay(AppDb db, IBroker broker) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var unsent = await db.Outbox
.Where(o => o.SentAt == null)
.OrderBy(o => o.OccurredAt)
.Take(100)
.ToListAsync(ct);
foreach (var msg in unsent)
{
await broker.PublishAsync(msg.Type, msg.Payload, ct);
msg.SentAt = DateTimeOffset.UtcNow;
}
await db.SaveChangesAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
}
}
MassTransit + EF outbox
builder.Services.AddDbContext<AppDb>();
builder.Services.AddMassTransit(x =>
{
x.AddEntityFrameworkOutbox<AppDb>(o =>
{
o.UsePostgres();
o.UseBusOutbox();
o.QueryDelay = TimeSpan.FromSeconds(1);
});
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.ConfigureEndpoints(ctx);
});
});
// Now Publish() inside a SaveChanges scope auto-uses outbox:
public class OrderPlacement(AppDb db, IPublishEndpoint pub)
{
public async Task PlaceAsync(PlaceOrder cmd)
{
db.Orders.Add(new Order(...));
await pub.Publish(new OrderPlaced(...)); // queued in outbox until SaveChanges
await db.SaveChangesAsync();
}
}
CDC-based outbox (advanced)
Instead of polling, use Change Data Capture: - Postgres logical replication → Debezium → Kafka. - SQL Server CDC → Debezium / SQL Server Agent.
Outbox table changes flow to broker via the WAL/binlog. Lower latency; no polling overhead. More operational complexity.
Inbox pattern
The consumer side: ensure the same message isn't processed twice.
public class OrderPlacedHandler(AppDb db)
{
public async Task Handle(ConsumeContext<OrderPlaced> ctx)
{
var msgId = ctx.MessageId.Value;
if (await db.Inbox.AnyAsync(i => i.Id == msgId)) return; // already processed
// do work + insert into Inbox in one tx
db.SomeTable.Add(...);
db.Inbox.Add(new InboxMessage { Id = msgId, ProcessedAt = DateTimeOffset.UtcNow });
await db.SaveChangesAsync();
}
}
MassTransit's EF outbox covers this too via AddEntityFrameworkOutbox.
TTL on inbox
Don't grow forever:
Match TTL to broker's max retention.
Latency
- Polling: latency = poll interval (1–5s typical).
- CDC: low latency (~ms).
- Listen/Notify (Postgres): ~ms.
For most apps, polling is fine. For tight latencies, CDC.
Multiple brokers / fan-out
Outbox can route to many brokers. Or have multiple relays — one per broker.
Failure modes
| Failure | Outbox | Inbox |
|---|---|---|
| App crash post-DB-commit, pre-broker | Relay retries | N/A |
| Broker down | Relay retries until up | N/A |
| Message delivered twice | N/A | Inbox dedup |
| Consumer crash mid-handle | Inbox tx rolls back; redelivered; processed |
Outbox vs no outbox
No outbox + retry: works for some events, but you can lose. Use for low-importance events.
Outbox: business events that must never be lost.
Cost
- DB writes: 2x (business + outbox).
- Relay process: extra deployable.
- Storage: outbox grows; needs cleanup.
- Latency: poll interval.
For most apps, well worth it.
Aspire integration
Aspire (.NET cloud-native) exposes outbox patterns through MassTransit / Wolverine integrations. Local-to-prod consistency is straightforward.
Code: correct vs wrong
❌ Wrong: publish without atomicity
_db.Orders.Add(order);
await _db.SaveChangesAsync();
await _broker.PublishAsync(...); // can fail; ghost order in DB
✅ Correct: outbox
_db.Orders.Add(order);
_db.Outbox.Add(new OutboxMessage { ... });
await _db.SaveChangesAsync(); // atomic
// relay publishes async
❌ Wrong: consumer without dedup
public async Task Handle(OrderPlaced m)
{
_db.Inventory.Decrement(m.OrderId, m.Quantity); // duplicate decrement on redelivery
}
✅ Correct: inbox dedup
public async Task Handle(OrderPlaced m, ConsumeContext ctx)
{
if (await IsProcessed(ctx.MessageId)) return;
_db.Inventory.Decrement(m.OrderId, m.Quantity);
_db.Inbox.Add(new() { Id = ctx.MessageId });
await _db.SaveChangesAsync();
}
Design patterns for this topic
Pattern 1 — "Outbox table in business DB"
- Intent: atomic DB + broker.
Pattern 2 — "Inbox dedup"
- Intent: at-least-once → idempotent processing.
Pattern 3 — "MassTransit/Wolverine built-in outbox"
- Intent: less custom code.
Pattern 4 — "CDC for low-latency outbox"
- Intent: millisecond fan-out.
Pattern 5 — "TTL on inbox/outbox"
- Intent: bounded growth.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Outbox | Atomicity | 2x DB writes; relay |
| Inbox | Idempotent processing | Storage overhead |
| Polling | Simple | Higher latency |
| CDC | Low latency | Operational |
When to use / when to avoid
- Use outbox for any business-critical event.
- Use inbox when you can't make the work itself idempotent.
- Avoid XA/2PC distributed transactions.
- Avoid outbox for trivial fire-and-forget logs.
Interview Q&A
Q1. Why outbox? Atomically commit DB change + emit event without distributed transactions.
Q2. How does outbox work? Insert business + outbox rows in one tx. Relay polls outbox, publishes, marks sent.
Q3. Why inbox? Consumer-side dedup. Track processed message IDs; skip duplicates.
Q4. Polling latency? 1–5s typical. CDC for ms.
Q5. MassTransit's outbox setup? AddEntityFrameworkOutbox<TDbContext>(). Works with Publish/Send within SaveChanges scope.
Q6. Without outbox, what's the failure mode? Either DB commits and broker fails (lost event) or broker succeeds and DB fails (ghost event).
Q7. Why not 2PC/XA? Operational complexity; vendor lock-in; performance; many brokers don't support; modern guidance: avoid.
Q8. CDC pattern? Read DB log (WAL/binlog) → Debezium → Kafka. Low-latency; no polling.
Q9. Inbox cleanup? TTL — match broker retention. 7 days typical.
Q10. Multi-broker fan-out? Outbox row tagged with target brokers; relay dispatches to each.
Q11. Cost of outbox? 2x DB writes; extra relay process; storage. Generally worth it for important events.
Q12. Idempotent operation vs inbox? Idempotent op (UPSERT, conditional update) = no inbox needed. Inbox bridges non-idempotent handlers.
Gotchas / common mistakes
- ⚠️ Outbox without index on
SentAt— relay slow. - ⚠️ Forgot inbox TTL — table grows.
- ⚠️ Relay competing with main DB — separate connection pool.
- ⚠️ Relay not horizontally scalable without leader election.
- ⚠️ Outbox in different DB from business data — defeats atomicity.