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
OrderPlacedto 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
InboxProcessorprovides 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.
AddRelayMaintenancewith anInboxRetentiondeletes 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
SaveChangesin 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
IMessageConsumerLifecycleto 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.
SaveChangesinside 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
SaveChangesin 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.