Skip to content

Inbox Pattern

The inbox pattern ensures idempotent message consumption -- even if the same message is delivered more than once, your handlers process it only once. Modulus provides a built-in inbox implementation that the consumer pipeline applies to every IIntegrationEventHandler<T> automatically.

The Problem

Message brokers guarantee at-least-once delivery, not exactly-once. Duplicate messages occur in several scenarios:

  • Network retries: The broker delivers a message, but the consumer's acknowledgment is lost. The broker re-delivers.
  • Outbox re-publishing: The OutboxProcessor published a message but crashed before marking it as processed. On restart, it publishes the same message again.
  • Broker failover: During broker cluster failover, messages in flight may be re-queued.
  • Consumer timeout: The consumer takes too long to process a message. The broker assumes it failed and re-delivers.

Without deduplication, a handler that processes a payment, sends an email, or updates inventory could execute these side effects multiple times.

How It Works

Modulus solves this in the ConsumerDispatcher, the transport-agnostic consumer pipeline that dispatches every delivered message. Before invoking each handler, it checks an inbox store to determine whether that handler has already processed the message.

Unlike the outbox, the inbox has no polling loop to accelerate — dedup happens inline at transport-delivery time, which is already push-driven — so the outbox's change notification has no inbox counterpart by design.

Processing Flow

The ConsumerDispatcher follows this logic for each incoming message:

  1. No inbox registered: If no IInboxStore is registered in the DI container, the dispatcher falls through to direct handler execution. The inbox is entirely optional.
  2. Inbox registered:
    1. Save the event to the inbox store (records that this message arrived; the save itself is idempotent).
    2. For each registered handler, check HasBeenProcessed(eventId, handlerName) -- has this specific handler already completed this specific event?
    3. If already processed -- skip that handler.
    4. If not yet processed -- reserve the (eventId, handlerName) pair with TryReserve. The reservation is an atomic database claim: under concurrent duplicate deliveries, exactly one delivery wins it.
    5. On winning the reservation -- invoke the handler, then call MarkConsumerProcessed(eventId, handlerName).
    6. On losing it -- if the pair completed in the meantime, skip; otherwise the dispatch retries with backoff rather than acknowledging past a live reservation (acknowledging would lose the message if the reservation's owner crashed).
  3. The message is acknowledged only after all handlers succeed. If any handler throws, the dispatcher retries in-process per ConsumerRetry; on redelivery, only the handlers that have not completed re-run.

Per-handler deduplication

The inbox tracks processing at the (eventId, handlerName) level. If an event has three handlers, each handler is independently tracked. Handler A being marked as processed does not affect whether Handler B or Handler C runs.

Crash Recovery: Stale Reservations

A reservation is normally short-lived: claim, run the handler, mark processed. If the owning process crashes mid-handler, its reservation is left behind unprocessed. Such a reservation goes stale after MessagingOptions.ConsumerReservationTimeout (default 5 minutes) and is atomically taken over by whichever delivery encounters it next -- the broker's redelivery or a dead-letter replay. This preserves at-least-once semantics: a crash can never silently drop a handler execution.

The timeout only matters for hard crashes. When a dispatch exhausts its in-process retries and the message is dead-lettered, the dispatcher releases the reservations it took (ReleaseReservation), so a prompt modulus dlq replay executes the outstanding handlers immediately instead of failing against a still-fresh reservation.

Size the timeout to your slowest handler

ConsumerReservationTimeout must exceed the worst-case handler execution time. If a handler legitimately runs longer than the timeout, a concurrent delivery can treat its reservation as abandoned and execute the handler a second time.

IInboxStore Interface

The IInboxStore interface defines the contract for inbox persistence:

csharp
public interface IInboxStore
{
    Task Save(IIntegrationEvent @event, CancellationToken cancellationToken = default);

    Task<bool> HasBeenProcessed(Guid messageId, string handlerName, CancellationToken cancellationToken = default);

    Task<bool> TryReserve(Guid messageId, string handlerName, TimeSpan staleAfter, CancellationToken cancellationToken = default);

    Task MarkConsumerProcessed(Guid messageId, string handlerName, CancellationToken cancellationToken = default);

    Task ReleaseReservation(Guid messageId, string handlerName, CancellationToken cancellationToken = default);
}
MethodDescription
SavePersists the incoming event as an InboxMessage.
HasBeenProcessedChecks if a specific handler has already completed a specific message (a live reservation does not count).
TryReserveAtomically claims the (messageId, handlerName) pair before execution. Returns false when already processed or when another delivery holds a reservation younger than staleAfter; takes over older unprocessed reservations.
MarkConsumerProcessedMarks a reserved pair as successfully processed.
ReleaseReservationReleases a reservation the caller holds for the pair, so a later delivery (e.g. a dead-letter replay) can reserve and execute immediately instead of waiting out the stale timeout. A no-op for already-processed pairs.

InboxMessage Model

Each incoming event is stored as an InboxMessage:

csharp
public sealed class InboxMessage
{
    public required Guid Id { get; init; }
    public required string Type { get; init; }
    public required string Content { get; init; }
    public required DateTime OccurredOnUtc { get; init; }
}
PropertyTypeDescription
IdGuidThe EventId from the integration event. Used as the deduplication key.
TypestringAssembly-qualified type name of the event.
ContentstringJSON-serialized event payload.
OccurredOnUtcDateTimeWhen the original event was raised.

InboxMessageConsumer Model

Per-handler tracking is stored in the InboxMessageConsumer table with a composite primary key:

csharp
public sealed class InboxMessageConsumer
{
    public required Guid InboxMessageId { get; init; }
    public required string Name { get; init; }
    public DateTime ReservedOnUtc { get; set; }
    public DateTime? ProcessedOnUtc { get; set; }
}
PropertyTypeDescription
InboxMessageIdGuidForeign key to the InboxMessage.
NamestringThe fully qualified name of the handler type.
ReservedOnUtcDateTimeWhen the pair was claimed. A reservation older than ConsumerReservationTimeout with no ProcessedOnUtc is considered abandoned.
ProcessedOnUtcDateTime?When the handler completed. null while the reservation is live (or abandoned).

The composite key (InboxMessageId, Name) makes the reservation insert an atomic claim: when two deliveries race for the same pair, the database constraint lets exactly one win.

ConsumerDispatcher

The ConsumerDispatcher is the consumer pipeline that every transport hands delivered messages to. It resolves the event type, deserializes the body, and invokes all registered IIntegrationEventHandler<TEvent> implementations inside a DI scope, each wrapped with the inbox check.

You do not need to create or register anything. The pipeline is wired by AddModulusMessaging.

Behavior Summary

csharp
// Simplified pseudocode of the ConsumerDispatcher's per-message handling
var handlers = ResolveHandlers(eventType);

// No inbox? Fall through to direct execution
if (inboxStore is null)
{
    foreach (var handler in handlers)
        await handler.Handle(@event, cancellationToken);
    return;
}

// Save the event to the inbox (idempotent)
await inboxStore.Save(@event, cancellationToken);

foreach (var handler in handlers)
{
    // Skip handlers that already completed this event
    if (await inboxStore.HasBeenProcessed(@event.EventId, handler.Name, cancellationToken))
        continue;

    // Claim the pair; losing to a live reservation triggers a retry, not an acknowledge
    if (!await inboxStore.TryReserve(@event.EventId, handler.Name, reservationTimeout, cancellationToken))
        throw new InboxReservationPendingException(...);

    await handler.Handle(@event, cancellationToken);

    // Mark the reserved pair as completed
    await inboxStore.MarkConsumerProcessed(@event.EventId, handler.Name, cancellationToken);
}

The inbox is optional

If you do not register an IInboxStore in the DI container, the dispatcher delegates to the handlers directly. No deduplication occurs. This lets you opt in to the inbox pattern only when you need it.

EfInboxStore

The EfInboxStore is the built-in Entity Framework Core implementation of IInboxStore. It handles concurrent insert race conditions gracefully -- if two threads attempt to save the same inbox message simultaneously, the second insert is caught and ignored rather than throwing an exception.

Handling Concurrent Deliveries

When the same message arrives on two threads (or two replicas) simultaneously:

  1. Both call Save(@event) -- one inserts, the other catches the duplicate key violation and ignores it.
  2. Both call TryReserve for the same handler -- the composite primary key lets exactly one insert the reservation row. That delivery runs the handler.
  3. The loser backs off and retries. Once the winner calls MarkConsumerProcessed, the loser's next attempt sees HasBeenProcessed return true and acknowledges without executing.
  4. If the winner crashes instead, its reservation goes stale after ConsumerReservationTimeout, and the loser (or a later redelivery/replay) takes it over and executes the handler.

This gives exactly-once execution per handler under concurrent duplicates while preserving at-least-once delivery under crashes -- no distributed locks required. EfInboxStore requires a relational provider (the takeover path uses ExecuteUpdateAsync and the claim relies on the enforced primary key); the EF InMemory provider is not supported.

Usage Example

To enable the inbox pattern, register the inbox database context and store:

csharp
// Registers InboxDbContext and the EF Core inbox store
builder.Services.AddModulusInbox(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Database")));

That is all. The ConsumerDispatcher detects the registered IInboxStore and activates deduplication for all handlers automatically.

Your handler code does not change at all:

csharp
public sealed class ReserveInventoryHandler
    : IIntegrationEventHandler<OrderCreatedEvent>
{
    private readonly IInventoryService _inventoryService;

    public ReserveInventoryHandler(IInventoryService inventoryService)
    {
        _inventoryService = inventoryService;
    }

    public async Task Handle(
        OrderCreatedEvent @event,
        CancellationToken cancellationToken)
    {
        // This handler will only execute once per event, even if
        // the message is delivered multiple times by the broker.
        await _inventoryService.ReserveAsync(
            @event.OrderId,
            @event.Items,
            cancellationToken);
    }
}

Ensure the inbox tables exist

The InboxMessage and InboxMessageConsumer tables live in the InboxDbContext that ships with the package. Ensure the corresponding tables exist in your database via EF Core migrations (app.UseModulusMessagingMigrationsAsync() applies them at startup).

Outbox + Inbox: End-to-End Reliability

The outbox and inbox patterns complement each other to provide reliable end-to-end message delivery:

LayerPatternGuarantee
Publisher sideOutboxMessages are persisted as rows before publishing and published at least once. Atomic with the domain change when the outbox is mapped into your application DbContext.
Consumer sideInboxMessages are processed exactly once per handler, regardless of how many times they are delivered.

Together, they provide effectively exactly-once processing:

  • The outbox ensures no messages are lost.
  • The inbox ensures no messages are processed more than once.

Retention

Inbox rows are the deduplication memory, so cleanup is a trade-off the outbox side doesn't have: a purged row re-opens the window for its message. If the broker redelivers a message after its inbox row is gone — including a modulus dlq replay of an old dead-letter — every handler executes again.

The built-in sweep handles this with an age-based policy measured from the event's OccurredOn:

csharp
builder.Services.AddModulusMessaging(options =>
{
    options.Retention.Enabled = true;
    options.Retention.InboxAge = TimeSpan.FromDays(7); // must exceed the broker's max redelivery horizon
});

Size InboxAge beyond the latest possible redelivery of a message: broker redelivery windows, your dead-letter replay habits, and any manual re-publish tooling. Seven days is a safe default for most deployments; teams that replay months-old DLQ messages should either keep inbox rows longer or ensure handlers are naturally idempotent.

The sweep never races live work: a message with an unprocessed reservation younger than one hour is skipped whatever its age, so a handler mid-execution (e.g. a DLQ replay of an old event) cannot have its reservation deleted out from under it. Reservations older than that are crashed owners' leftovers and are removed with the message.

One-off cleanup uses modulus inbox purge, which previews the row count until you pass --confirm. Configuration details for the sweep are shared with the outbox — see Outbox Pattern § Retention & Cleanup.

Best Practices

  • Always pair outbox with inbox. The outbox guarantees at-least-once publishing, which means duplicates will occur. The inbox ensures each handler processes each message only once.
  • Include inbox tables in your migrations. The InboxMessage and InboxMessageConsumer tables must exist in your database. Run EF Core migrations to create them.
  • Monitor the inbox table. Like the outbox, the inbox table grows over time. Enable MessagingOptions.Retention (see Retention) to age old rows out safely.
  • Keep handlers idempotent where possible. Even with the inbox pattern, designing naturally idempotent handlers (e.g., upserts instead of inserts) adds an extra layer of safety.
  • Do not rely solely on the inbox for correctness. The inbox is a safety net. Design your system to be resilient to duplicate processing at the domain level as well.

See Also

Released under the MIT License.