Articles · Messaging & Reliability ·013

The Inbox Pattern

Runnable sample on GitHub

Sample: samples/013-inbox — idempotent consumption over real PostgreSQL. dotnet test (needs Docker).

Prerequisites: 011 — The Outbox Pattern, 012 — Messaging & Transports.

Overview

The outbox guarantees at-least-once delivery — which means the same message can arrive twice (a broker redelivery, an outbox re-publish after a crash). If your consumer isn’t idempotent, that’s a double charge, a duplicate email, two ledger entries. The inbox pattern makes consumption exactly-once in effect: the consumer records a dedup row keyed by the message id, in the same transaction as the handler’s effect, so a second delivery of the same message finds the row and is a no-op. Relay’s InboxProcessor does this around your IIntegrationEventHandler. This is the receiving half of reliable messaging; the outbox (011) was the sending half.

Why this exists

At-least-once is not a flaw to fix — it’s the only honest guarantee a distributed system can offer. Exactly-once delivery is impossible across a network (the two-generals problem); what you can achieve is exactly-once processing, by making redelivery harmless. The naive consumer — “receive message, apply effect, ack” — breaks the moment the same message arrives twice, and it will arrive twice: a consumer crashes after applying the effect but before acking, so the broker redelivers; the outbox processor crashes after publishing but before marking the row, so it re-publishes. Without dedup, every such hiccup duplicates an effect.

The inbox makes the dedup atomic with the effect. The consumer checks “have I processed message id X?”; if yes, it does nothing; if no, it applies the effect and writes the dedup row in one transaction. Either both commit (processed, recorded) or neither (failed, will retry). A duplicate finds the row and skips. The dedup row and the effect can never disagree — which is the whole game.

When to use this

Use the inbox on every consumer of at-least-once messages whose handler has any effect that must not happen twice:

  • Payments: consuming OrderPlaced to charge a card — charging twice is a real-money incident.
  • Notifications: consuming an event to send an email/SMS — duplicates annoy users and cost money.
  • Ledgers / inventory: consuming events to write ledger entries or adjust stock — double-applying corrupts the running total.
  • Any handler that mutates state or calls a non-idempotent external API in response to a message.

If you publish via the outbox (article 011) or consume from any broker, the receiving side needs the inbox. They are a matched pair.

When not to use this

  • The handler is naturally idempotent. If applying the effect twice is genuinely harmless (a set-this-value upsert, a “mark as seen” flag), you may not need the inbox’s dedup — though it’s cheap insurance, and the framework’s InboxProcessor provides it uniformly.
  • In-process domain events. Those are dispatched exactly-once in the command’s transaction (article 003); there’s no redelivery to dedup. The inbox is for messages arriving from a broker.
  • At-most-once is acceptable. If losing a message is fine and you never want a duplicate either, you might ack-before-process (at-most-once) — but that’s rare and usually wrong for anything that matters.

The costs: an inbox table (dedup rows), which grows and needs pruning (with retention longer than your max redelivery window, or you’d dedup-expire a still-in-flight message); and the discipline that handlers stage their effects on the shared transaction rather than committing independently (so the effect and the dedup row commit together).

Concepts

Dedup row. relay_inbox_messages keyed by message id. HasProcessed(id) checks it; MarkProcessed(id, type) stages it. Its uniqueness (PK on message id) also makes concurrent duplicate deliveries safe — one insert wins, the other is redelivered then skipped.

Atomic with the effect. The handler stages its effect on the shared DbContext (it does not call SaveChanges); the InboxProcessor’s unit of work commits the effect and the dedup row in one transaction. A failed handler rolls back both — so the message stays unprocessed and redelivery retries it (no dedup row was written).

InboxProcessor. A hosted service that subscribes to the broker, and for each message: dedup-checks, deserializes, dispatches to the local IIntegrationEventHandler(s), records the dedup row, and commits — with subscription retry/backoff so a momentarily-unavailable broker at startup is tolerated.

Exactly-once effect, not delivery. Delivery is at-least-once; the inbox makes the effect happen once. The distinction is the whole point.

Architecture

sequenceDiagram
    participant B as Broker (at-least-once)
    participant P as InboxProcessor
    participant DB as PostgreSQL (effect + relay_inbox_messages)
    B->>P: deliver message (id X)
    P->>DB: HasProcessed(X)?
    alt already processed
        DB-->>P: yes → no-op (ack)
    else first time
        P->>P: handler stages its effect
        P->>DB: COMMIT effect + dedup row (X) atomically
        Note over P,DB: handler throws ⇒ ROLLBACK both ⇒ redelivery retries
    end
    Note over B,P: a duplicate delivery of X finds the row ⇒ no-op

Building it step by step

The sample is samples/013-inbox.

1. Write an idempotent-by-construction handler — stage, don’t save

public sealed class OrderPlacedIntegrationEventHandler(IRelayDbContextAccessor accessor)
    : IIntegrationEventHandler<OrderPlacedIntegrationEvent>
{
    private readonly DbContext _context = (DbContext)accessor.DbContext;

    public Task Handle(OrderPlacedIntegrationEvent message, CancellationToken ct)
    {
        _context.Set<HandledOrder>().Add(new HandledOrder { Id = Guid.NewGuid(), OrderId = message.OrderId, EventId = message.EventId });
        return Task.CompletedTask; // staged only — the inbox commits this WITH the dedup row
    }
}

2. Map the inbox table and register the repository

modelBuilder.ApplyRelayInbox();                 // relay_inbox_messages
services.AddRelayInboxEfCore();                  // IInboxRepository
services.AddRelayEventStoreEfCore<SampleDbContext>(); // the inbox commits on this context
services.AddRelay(typeof(Program).Assembly);

3. Run the inbox processor (subscribes to the broker)

In production the InboxProcessor runs as a hosted service against your transport. The sample constructs it directly with a FakeMessageConsumer so a test can deliver messages by hand:

var processor = new InboxProcessor(consumer, scopeFactory, scopeAccessor,
    Options.Create(new InboxOptions { QueueName = "orders-inbox" }), NullLogger<InboxProcessor>.Instance);
await processor.StartAsync(default);

Complete source code

File Contents
Orders.cs The integration event + the staging handler
SampleDbContext.cs ApplyRelayInbox()
InboxDedupTests.cs Duplicate-once + failed-handler-rolls-back

Running the example

dotnet test samples/013-inbox/Inbox.Sample.Tests   # needs Docker

Testing

The two tests pin the guarantee directly:

// at-least-once delivery: the SAME message arrives twice…
await consumer.Handler!(message, default);
await consumer.Handler!(message, default);
// …but the effect happened once, and there is exactly one dedup row.
(await ctx.HandledOrders.CountAsync(h => h.OrderId == id)).Should().Be(1);
(await ctx.Set<InboxRecord>().CountAsync(r => r.MessageId == eventId)).Should().Be(1);

// a failing handler writes NO dedup row, so redelivery can retry
await act.Should().ThrowAsync<InvalidOperationException>();
(await ctx.Set<InboxRecord>().CountAsync(r => r.MessageId == failedId)).Should().Be(0);

The second test is the subtle, crucial one: a handler that fails must leave the message unprocessed (no dedup row), or a transient failure would permanently swallow the message. Atomicity of effect-and- dedup is what makes that correct.

Production considerations

  • Run the inbox on every consumer. It is the receiving counterpart to the outbox. A consumer without it will, eventually, double-apply an effect.
  • Prune dedup rows — but keep them longer than your redelivery window. AddRelayMaintenance with an InboxRetention deletes old rows. Set it generously (longer than the max time a redelivery/DLQ replay could take); pruning a row whose message is still in flight would let a duplicate through.
  • Stage effects; never SaveChanges in a handler. The inbox commits the effect and the dedup row together. A handler that saves independently breaks the atomicity and can write the effect without the dedup row (or vice versa).
  • Make the handler’s effect transactional. The atomicity covers database writes on the shared context. A handler that also calls an external API can’t roll that back — for must-not-duplicate external calls, make the call idempotent (idempotency key) or move it behind another outbox hop.
  • Resubscribe on dropped subscriptions. The processor uses IMessageConsumerLifecycle to detect a dropped subscription and resubscribe; ensure your transport supports it (RabbitMQ/in-memory do).
  • Mind the deserialization boundary. An unknown event type throws inside the transaction (so it rolls back and redelivers). If you’ve retired an event type, decide whether to drop or dead-letter it rather than redeliver forever.

Common mistakes

  • Consuming at-least-once messages without dedup. The double-charge / double-email incident. If it’s from a broker, it needs the inbox.
  • SaveChanges inside the handler. Breaks the effect+dedup atomicity. Stage only.
  • Pruning the inbox too aggressively. Delete a dedup row while its message could still be redelivered and you’ve re-opened the duplicate window. Retention must exceed the redelivery/DLQ horizon.
  • Acking before processing (at-most-once) by accident. Some hand-rolled consumers ack on receipt, losing messages on failure. The inbox processes-then-records; don’t subvert it.
  • Assuming the inbox makes external calls idempotent. It makes database effects exactly-once. A non-idempotent external API call in a handler can still duplicate on retry — guard it separately.

Tradeoffs

Benefits. Exactly-once effect despite at-least-once delivery; effect and dedup commit atomically (a failed handler retries cleanly); concurrent duplicate deliveries are safe (unique dedup key); and it pairs with the outbox for end-to-end reliability.

Costs. A dedup table to store and prune (with a retention floor tied to redelivery), the discipline to stage rather than save in handlers, and the residual responsibility to make non-database external effects idempotent yourself.

Alternatives

  • Idempotent handlers without an explicit inbox. If every handler’s effect is naturally idempotent (pure upserts), you can skip the dedup table. Works, but relies on every handler staying idempotent forever — the explicit inbox is uniform insurance and handles the not-naturally-idempotent cases.
  • Broker-native deduplication (e.g. Service Bus dedup window). Some brokers dedup by message id for a window. Convenient, but the window is finite and broker-specific, and it dedups delivery, not your effect-with-its-transaction. The inbox’s atomic effect+dedup is stronger and portable.
  • At-most-once (ack before process). Avoids duplicates by risking loss. Acceptable only where losing a message is fine and a duplicate is unacceptable — a narrow, usually-wrong trade for important work.

Objectively: for at-least-once messages with non-idempotent effects, the inbox is the correct pattern; the alternatives are weaker (broker dedup), riskier (at-most-once), or rely on perfect handler discipline (naturally-idempotent everywhere).

Lessons from production systems

  • The double-charge is the canonical inbox-was-missing incident. Teams that paired inbox with outbox from the start never have it; teams that “added messaging quickly” discover at-least-once the hard way, in a billing report.
  • Inbox retention is a footgun in both directions. Too short re-opens the duplicate window; too long and the table grows unbounded. The durable answer is retention comfortably above the worst-case redelivery/DLQ-replay time, plus monitoring of table size.
  • Staging vs saving trips people up first. The instinct is to SaveChanges in the handler; doing so silently breaks atomicity and produces rare, baffling “effect happened but dedup didn’t” bugs. Teach “stage, the processor commits” as the rule.
  • External side effects remain the hard edge. The inbox makes DB effects exactly-once; emails and third-party charges still need idempotency keys. The mature pattern is: DB effect via the inbox, external effect via an idempotency key or a second outbox hop.

Should you use this?

Situation Recommendation
Consuming at-least-once messages with non-idempotent effects Strong fit — exactly-once effect
Charging, notifying, ledger/inventory writes from events Strong fit
Naturally idempotent handlers Optional — cheap insurance; the framework provides it uniformly
In-process domain events No — those are exactly-once already (article 003)
At-most-once acceptable Consider ack-before-process (rare)

Next steps

You now have both halves — the outbox sends reliably, the inbox receives idempotently. The next article puts them together end-to-end: a command publishes, the outbox relays it through a broker, and a consumer receives it — the complete reliable-messaging flow across a service boundary.

➡️ 014 — Reliable Messaging