Skip to content

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:

DELETE FROM Inbox WHERE ProcessedAt < NOW() - INTERVAL '7 days';

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

db.Outbox.Add(new() { Type = "OrderPlaced", Targets = ["rabbitmq", "kafka"] });

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.

Further reading