Skip to main content

Inbox Pattern

New to idempotent messaging?

Start with the Idempotent Consumer Guide to understand why messages get duplicated and how the Outbox and Inbox patterns work together.

The inbox pattern deduplicates redelivered messages with an atomic claim-before-execute protocol. The guarantee it provides is precise:

  • Exactly-once for concurrent redelivery — when duplicates race, the atomic claim lets exactly one win; the others are skipped.
  • At-least-once across a process crash — the claim is persisted before the handler runs, but the claim and the post-handler "mark processed" are two steps, not one transaction. A crash mid-handler leaves the claim to expire and the message to be reclaimed and re-run. Your handler must be idempotent to be safe across this boundary.

Before You Start

  • .NET 10.0
  • Install the required packages:
    dotnet add package Excalibur.Dispatch.Patterns
    dotnet add package Excalibur.EventSourcing.SqlServer # or your provider
  • Familiarity with Dispatch pipeline and the Outbox Pattern
  • A SQL Server or PostgreSQL database for inbox storage

The Problem

Message transports may deliver the same message multiple times:

The Solution

Track processed messages and skip duplicates:

Quick Start

Configuration

services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
});

// Add SQL Server inbox store
services.AddSqlServerInboxStore(options =>
{
options.ConnectionString = connectionString;
// Optional: defaults to schema "dbo", table "inbox_messages"
options.SchemaName = "dbo";
options.TableName = "inbox_messages";
});

Automatic Deduplication

The inbox middleware automatically deduplicates messages:

// Handler is called at most once per message ID
public class CreateOrderHandler : IEventHandler<OrderCreatedEvent>
{
public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken ct)
{
// This code runs at most once per event
await _db.ExecuteAsync(
"INSERT INTO Orders ...",
new { @event.OrderId, @event.CustomerId });
}
}

Inbox Stores

SQL Server

services.AddSqlServerInboxStore(options =>
{
options.ConnectionString = connectionString;
options.SchemaName = "dbo"; // default
options.TableName = "inbox_messages"; // default
options.CommandTimeoutSeconds = 30;
options.MaxRetryCount = 3;
});

Redis

services.AddExcaliburInbox(inbox =>
{
inbox.UseRedis(redis =>
{
redis.ConnectionString("localhost:6379")
.KeyPrefix("inbox")
.Database(0);
});
});

In-Memory (Testing)

services.AddInMemoryInboxStore();

Database Schema

SQL Server

The store does not auto-create the table — create it before starting the application. The default table is [dbo].[inbox_messages]; override the schema/table via SchemaName / TableName.

The physical schema is column-agnostic by deployment mode:

  • Single-tenant (the default) — the dedup/claim key is the pair (MessageId, HandlerType) and there is no TenantId column. A single-tenant consumer pays nothing for a tenant discriminator it never uses; isolation is trivial because there are no other tenants' rows to collide with. Use the schema shown below.
  • Multi-tenant — when you register multi-tenancy (AddMultiTenancy()), the key becomes the triple (MessageId, HandlerType, TenantId) with TenantId NOT NULL, so two tenants sharing a (MessageId, HandlerType) can never dedup against each other. Use the multi-tenant variant shown below the note.

The store verifies the physical key against the registered mode at startup and fails fast on a mismatch — a multi-tenant store can never silently run against the single-tenant (column-absent) schema, and vice versa.

-- SINGLE-TENANT (the default). Use this UNLESS you register multi-tenancy.
CREATE TABLE [dbo].[inbox_messages] (
[MessageId] NVARCHAR(255) NOT NULL,
[HandlerType] NVARCHAR(500) NOT NULL,
[MessageType] NVARCHAR(500) NOT NULL,
[Payload] VARBINARY(MAX) NOT NULL,
[Metadata] NVARCHAR(MAX) NULL, -- JSON
[ReceivedAt] DATETIMEOFFSET NOT NULL,
[ProcessedAt] DATETIMEOFFSET NULL,
[Status] INT NOT NULL DEFAULT 0,
[RetryCount] INT NOT NULL DEFAULT 0,
[LastError] NVARCHAR(MAX) NULL,
[LastAttemptAt] DATETIMEOFFSET NULL,
[NextAttemptAt] DATETIMEOFFSET NULL, -- retry backoff: failed entry not re-admitted until this time
[LeaseExpiresAtUtc] DATETIMEOFFSET NULL, -- REQUIRED: backs the atomic lease-based claim (claim-before-execute)
[CorrelationId] NVARCHAR(255) NULL,
[Source] NVARCHAR(255) NULL,

-- Single-tenant: the dedup/claim key is the pair. No TenantId column.
CONSTRAINT [PK_inbox_messages] PRIMARY KEY ([MessageId], [HandlerType])
);

-- Backs the failed-entry re-admission claim and the received-order scan.
CREATE INDEX [IX_inbox_messages_Status_ReceivedAt]
ON [dbo].[inbox_messages] ([Status], [ReceivedAt]);
Multi-tenant variant

Register multi-tenancy with AddMultiTenancy() and provision the table with the multi-tenant key instead — add a TenantId NVARCHAR(255) COLLATE Latin1_General_BIN2 NOT NULL column and make it part of the primary key:

-- MULTI-TENANT. Use this ONLY when multi-tenancy is registered.
CREATE TABLE [dbo].[inbox_messages] (
[MessageId] NVARCHAR(255) NOT NULL,
[HandlerType] NVARCHAR(500) NOT NULL,
-- ... same columns as above ...
[TenantId] NVARCHAR(255) COLLATE Latin1_General_BIN2 NOT NULL,

-- Multi-tenant: tenant is part of identity. The dedup/claim key is the triple.
CONSTRAINT [PK_inbox_messages] PRIMARY KEY ([MessageId], [HandlerType], [TenantId])
);

A genuinely untenanted system row (or a row anchored during a single-tenant→multi-tenant migration) binds the reserved sentinel '__untenanted__'. The framework rejects that exact identifier as a tenant id, so the sentinel can never collide with a tenant literal.

To grow an existing single-tenant table into the multi-tenant key, run the shipped expand-contract migration (002_MigrateToMultiTenant.sql) during a maintenance window with the store stopped: it adds TenantId NOT NULL DEFAULT '__untenanted__' (anchoring existing rows to the sentinel) and rebuilds the primary key as (MessageId, HandlerType, TenantId). After it completes, register multi-tenancy and restart — the startup handshake then confirms the triple key.

LeaseExpiresAtUtc is required

The SQL Server inbox store claims each message with an atomic lease before the handler runs. The LeaseExpiresAtUtc column backs that claim — without it, every dispatch fails with Invalid column name 'LeaseExpiresAtUtc'. If you provisioned the table with an earlier schema, add the column:

ALTER TABLE [dbo].[inbox_messages]
ADD [LeaseExpiresAtUtc] DATETIMEOFFSET NULL;

The PostgreSQL store uses the equivalent lease_expires_at timestamptz null column.

Retry Backoff Schedule

When an inbox entry fails processing, the inbox processor computes an exponential backoff delay (IBackoffCalculator.CalculateDelay(attempt)) and records the absolute next-attempt time on the entry's NextAttemptAt column. The retryable-fetch predicate excludes the entry until that time elapses:

WHERE Status = @FailedStatus
AND RetryCount < @MaxRetries
AND (NextAttemptAt IS NULL OR NextAttemptAt <= @now)

so the configured retry delay genuinely throttles redelivery rather than re-admitting a failed entry on a fixed window.

Backoff scheduling uses the optional IBackoffSchedulableInboxStore capability (MarkFailedWithBackoffAsync). The SQL Server inbox store implements it; stores that do not implement it fall back to the existing MarkFailedAsync immediate-retry path (fail-open, no crash). The capability is forwarded transparently through the telemetry and encrypting inbox-store decorators.

Schema migration (SQL Server)

The NextAttemptAt column backs this feature and is included in the schema above. Inbox tables created before this column existed must add it — the store does not auto-create or alter tables:

ALTER TABLE [dbo].[inbox_messages]
ADD [NextAttemptAt] DATETIMEOFFSET NULL;

A NULL NextAttemptAt keeps the entry immediately eligible, so existing rows are unaffected.

Message Identity

Default Identity

By default, the inbox uses IDispatchMessage.MessageId:

public record OrderCreatedEvent(
Guid OrderId,
string CustomerId) : IDispatchEvent
{
// MessageId from IDispatchMessage is used
public string MessageId { get; init; } = Guid.NewGuid().ToString();
}

Custom Identity

For business-key based deduplication, implement IMessageIdProvider:

public class OrderMessageIdProvider : IMessageIdProvider
{
public string? GetMessageId(IDispatchMessage message, IMessageContext context)
{
return message switch
{
// Use order ID for order events
OrderCreatedEvent e => $"order-created-{e.OrderId}",

// Use composite key for payments
PaymentReceivedEvent e => $"payment-{e.OrderId}-{e.PaymentId}",

// Default to message ID from context
_ => context.MessageId
};
}
}

// Register and use with [Idempotent] attribute
services.AddSingleton<IMessageIdProvider, OrderMessageIdProvider>();

[Idempotent(Strategy = MessageIdStrategy.Custom)]
public class OrderHandler : IEventHandler<OrderCreatedEvent> { }

Idempotency Keys

Use the [Idempotent] attribute with different MessageIdStrategy values to control how message IDs are extracted:

From Message Headers (Default)

// Uses "MessageId" header by default
[Idempotent(Strategy = MessageIdStrategy.FromHeader)]
public class OrderHandler : IEventHandler<OrderCreatedEvent> { }

// Custom header name
[Idempotent(Strategy = MessageIdStrategy.FromHeader, HeaderName = "X-Idempotency-Key")]
public class PaymentHandler : IEventHandler<PaymentEvent> { }

Composite Keys

// Uses {HandlerType}:{CorrelationId} format
[Idempotent(Strategy = MessageIdStrategy.CompositeKey)]
public class MultiTenantHandler : IEventHandler<TenantEvent> { }

Cleanup

Manual Cleanup

The IInboxStoreAdmin.CleanupAsync method removes processed entries older than the specified cutoff timestamp. Administrative operations (cleanup, statistics, failed entry queries) are on the separate IInboxStoreAdmin interface:

public class InboxCleanupJob
{
private readonly IInboxStoreAdmin _adminStore;
private readonly ILogger<InboxCleanupJob> _logger;

public async Task CleanupAsync(CancellationToken ct)
{
var olderThan = DateTimeOffset.UtcNow.AddDays(-7);
var deleted = await _adminStore.CleanupAsync(olderThan, ct);
_logger.LogInformation("Cleaned up {Count} expired inbox entries", deleted);
}
}

Scheduled Cleanup with Hosted Service

Register the inbox hosted service for automatic background cleanup:

services.AddInboxHostedService();

Per-Handler Deduplication

The inbox store tracks messages by a composite key of (MessageId, HandlerType) (extended to (MessageId, HandlerType, TenantId) in a multi-tenant deployment), allowing multiple handlers to process the same message independently:

// Both handlers can process the same event - tracked separately
[Idempotent]
public class SendEmailHandler : IEventHandler<OrderCreatedEvent> { }

[Idempotent]
public class UpdateInventoryHandler : IEventHandler<OrderCreatedEvent> { }

Each handler's processing is tracked independently, so SendEmailHandler and UpdateInventoryHandler each deduplicate the same OrderCreatedEvent separately — concurrent redeliveries are blocked by the atomic claim, and a crash mid-handler results in a reclaim-and-retry (so handlers must be idempotent).

Distributed Deduplication

The inbox stores provide atomic "first writer wins" semantics via TryMarkAsProcessedAsync():

// SQL Server uses MERGE with HOLDLOCK for atomic check-and-mark
// Redis uses atomic SET NX operations

// Both ensure only one instance processes each message
public class SqlServerInboxStore : IInboxStore
{
public async ValueTask<bool> TryMarkAsProcessedAsync(
string messageId,
string handlerType,
CancellationToken cancellationToken)
{
// Returns true if this is the first processor
// Returns false if already processed (duplicate)
}
}

For multi-instance deployments, the atomic deduplication check prevents race conditions without requiring explicit distributed locks.

Provider-Native Transactional Inbox (SQL Server, PostgreSQL, MongoDB & Cosmos DB)

The default inbox guarantee is exactly-once for concurrent redelivery, at-least-once across a process crash — because the claim and the "mark processed" are two steps, not one transaction, a crash between the handler and the mark leaves the message to be reclaimed and re-run (so your handler must be idempotent).

SQL Server, PostgreSQL, MongoDB, and Azure Cosmos DB can do better. Their provider-native transactional inbox runs the duplicate check, your handler, and the processed-mark inside a single native transaction that commits or rolls back atomically. Your handler's own writes — enlisted on the same transaction — commit together with the mark, or not at all. This closes the crash window: there is no state where the handler's effect is durable but the mark is missing.

The guarantee holds on the success path. If the handler throws, the whole native transaction rolls back — nothing is marked processed, and the message is redelivered for retry.

Enablement differs by provider

The middleware always probes the store's SupportsTransactional capability and uses the transactional path when it is available, falling back transparently to the at-least-once idempotent claim protocol otherwise — never a false atomic advertisement. How a store reports that capability differs:

  • SQL Server and PostgreSQL — always on. The relational stores run the handler inside a local IDbTransaction and report SupportsTransactional = true unconditionally. No option to set: registering AddSqlServerInboxStore(...) / the Postgres inbox store is all that's required.
  • MongoDB and Cosmos DB — opt-in. These report the capability only once you configure the native transaction primitive (a replica-set session / a shared logical partition), because without it the primitive isn't available.
// SQL Server / PostgreSQL — nothing to configure; the transactional path is
// active as soon as the relational inbox store is registered.

// MongoDB — requires a replica set (transactions are a replica-set feature)
services.Configure<MongoDbInboxOptions>(options =>
{
options.EnableTransactions = true;
});

// Azure Cosmos DB — the processed-mark and the handler's batch must share one
// logical partition, so set the shared partition-key value to opt in.
services.Configure<CosmosDbInboxOptions>(options =>
{
options.SharedPartitionKey = "inbox";
});

No handler code change is required for the exactly-once mark itself — the middleware selects the transactional path automatically for any store that reports the capability.

Enlisting handler writes in the same transaction

For your handler's own writes to commit atomically with the processed-mark, enlist them on the native transaction handed to the middleware. Read the scope from the current message context — inject IMessageContextAccessor (the context flows on an AsyncLocal, so it is available inside your handler) — and cast it to the provider-native handle:

public class OrderCreatedHandler(IMessageContextAccessor contextAccessor, IMongoCollection<Order> orders)
: IEventHandler<OrderCreatedEvent>
{
public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken ct)
{
// Non-null only on the transactional path; null under the at-least-once claim path.
var scope = contextAccessor.MessageContext?.GetInboxTransactionScope();
var order = new Order(@event.OrderId, @event.CustomerId);

if (scope is not null)
{
// MongoDB: obtain the native session and pass it to your driver calls.
var session = scope.AsMongoSession();
await orders.InsertOneAsync(session, order, cancellationToken: ct);
// This write commits atomically with the inbox processed-mark.
}
else
{
// Fallback: no scoped transaction — use your own connection/session.
// Writes are NOT atomic with the mark, so this path must be idempotent.
await orders.InsertOneAsync(order, cancellationToken: ct);
}
}
}

On Cosmos DB, obtain the batch instead with scope.AsCosmosBatch() and add your operations to the returned TransactionalBatch. Writes made outside the scope are not enlisted and are therefore not atomic with the mark.

On SQL Server and PostgreSQL, obtain the active IDbTransaction with scope.AsSqlTransaction() and enlist your own commands (Dapper, ADO.NET) on it — they commit atomically with the processed-mark:

public class OrderCreatedHandler(IMessageContextAccessor contextAccessor)
: IEventHandler<OrderCreatedEvent>
{
public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken ct)
{
var scope = contextAccessor.MessageContext?.GetInboxTransactionScope();

if (scope is not null)
{
// Relational: enlist your write on the same local transaction.
var tx = scope.AsSqlTransaction();
await tx.Connection!.ExecuteAsync(
"INSERT INTO orders (id, customer_id) VALUES (@Id, @CustomerId)",
new { Id = @event.OrderId, CustomerId = @event.CustomerId },
transaction: tx);
// Commits atomically with the inbox processed-mark.
}
else
{
// Fallback: no scoped transaction — use your own connection.
// Writes are NOT atomic with the mark, so this path must be idempotent.
}
}
}

AsSqlTransaction() fails loudly with InvalidOperationException if called on a non-relational scope (for example a MongoDB or Cosmos DB scope), surfacing a provider mismatch immediately rather than returning null.

Provider requirements
  • MongoDBEnableTransactions requires a replica set. Even with the flag set, starting a transaction against a standalone server fails loudly at runtime.
  • Cosmos DB — a TransactionalBatch is single-partition, so the processed-mark and the handler's writes must share one logical partition. Set SharedPartitionKey; without it the store reports SupportsTransactional = false and falls back to the claim protocol.

Health Checks

services.AddHealthChecks()
.AddInboxHealthCheck(options =>
{
options.UnhealthyInactivityTimeout = TimeSpan.FromMinutes(5);
options.DegradedInactivityTimeout = TimeSpan.FromMinutes(2);
});

Metrics

Inbox metrics are included in the core Dispatch metrics:

services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddDispatchMetrics();
// Includes inbox-related metrics:
// - dispatch.messages.processed
// - dispatch.messages.duplicates
// - dispatch.messages.duration
});

Testing

Verify Idempotency

Test idempotency by registering the in-memory inbox store and verifying duplicate messages are ignored:

public class OrderHandlerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;

public OrderHandlerTests(WebApplicationFactory<Program> factory)
{
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Use in-memory inbox for testing
services.AddInMemoryInboxStore();
});
});
}

[Fact]
public async Task Duplicate_Message_Is_Ignored()
{
// Arrange
using var scope = _factory.Services.CreateScope();
var dispatcher = scope.ServiceProvider.GetRequiredService<IDispatcher>();
var db = scope.ServiceProvider.GetRequiredService<IDbConnection>();

var orderId = Guid.NewGuid();
var @event = new OrderCreatedEvent(orderId, "customer-1");

// Act - Dispatch same event twice
await dispatcher.PublishAsync(@event);
await dispatcher.PublishAsync(@event);

// Assert - Only one order created (handler is [Idempotent])
var orders = await db.QueryAsync<Order>(
"SELECT * FROM Orders WHERE Id = @Id",
new { Id = orderId });
orders.Should().HaveCount(1);
}
}

Unit Test with IInboxStore

For unit tests, mock the inbox store:

[Fact]
public async Task Handler_Skips_Duplicate_Message()
{
// Arrange
var inboxStore = A.Fake<IInboxStore>();
var messageId = "order-123";
var handlerType = typeof(CreateOrderHandler).FullName!;

// First call returns false (not processed), second returns true (duplicate)
A.CallTo(() => inboxStore.TryMarkAsProcessedAsync(messageId, handlerType, A<CancellationToken>._))
.ReturnsNextFromSequence(true, false);

// Act & Assert via handler invocation
}

Declarative Idempotency

For handler-level control over idempotency, use the [Idempotent] attribute. This enables per-handler deduplication based on the handler's configuration.

Quick Start

// 1. Register an inbox store (required for persistent deduplication)
services.AddSqlServerInboxStore(options =>
{
options.ConnectionString = connectionString;
});

// 2. Mark handlers with the attribute
[Idempotent]
public class PaymentHandler : IEventHandler<PaymentEvent>
{
public async Task HandleAsync(PaymentEvent @event, CancellationToken ct)
{
// Duplicate messages are automatically skipped
await _paymentService.ProcessAsync(@event, ct);
}
}

The IdempotentHandlerMiddleware automatically detects handlers with [Idempotent] and applies deduplication.

The [Idempotent] Attribute

PropertyTypeDefaultDescription
RetentionMinutesint1440 (24h)How long to track processed message IDs
UseInMemoryboolfalseUse fast in-memory deduplication
StrategyMessageIdStrategyFromHeaderHow to extract message IDs
HeaderNamestring"MessageId"Header name for FromHeader strategy

Usage Examples

// Default settings (24h retention, persistent storage, from header)
[Idempotent]
public class OrderHandler : IEventHandler<OrderCreatedEvent> { }

// Custom retention period
[Idempotent(RetentionMinutes = 60)]
public class NotificationHandler : IEventHandler<NotificationEvent> { }

// High-throughput with in-memory storage
[Idempotent(UseInMemory = true, RetentionMinutes = 5)]
public class MetricsHandler : IEventHandler<MetricsEvent> { }

// Use correlation ID for deduplication
[Idempotent(Strategy = MessageIdStrategy.FromCorrelationId)]
public class SagaHandler : IEventHandler<SagaEvent> { }

// Custom header name
[Idempotent(Strategy = MessageIdStrategy.FromHeader, HeaderName = "X-Idempotency-Key")]
public class ApiHandler : IEventHandler<ApiEvent> { }

Message ID Strategies

StrategyDescriptionUse Case
FromHeaderExtract from message header (default: MessageId)Standard message transports
FromCorrelationIdUse IMessageContext.CorrelationIdSaga/workflow scenarios
CompositeKey{HandlerType}:{CorrelationId}Multi-handler with same message
CustomUse registered IMessageIdProviderBusiness-key extraction

Custom Message ID Provider

For complex ID extraction, implement IMessageIdProvider:

public class OrderIdempotencyProvider : IMessageIdProvider
{
public string? GetMessageId(IDispatchMessage message, IMessageContext context)
{
return message switch
{
// Use order ID for order events
OrderCreatedEvent e => $"order-{e.OrderId}",

// Use composite key for payments
PaymentReceivedEvent e => $"payment-{e.OrderId}-{e.PaymentId}",

// Fall back to message ID
_ => context.MessageId
};
}
}

// Register the provider
services.AddSingleton<IMessageIdProvider, OrderIdempotencyProvider>();

// Use custom strategy in handler
[Idempotent(Strategy = MessageIdStrategy.Custom)]
public class OrderHandler : IEventHandler<OrderCreatedEvent> { }

In-Memory vs Persistent Storage

AspectIn-MemoryPersistent (IInboxStore)
PerformanceFaster (no I/O)Slower (database calls)
DurabilityLost on restartSurvives restarts
DistributedSingle instance onlyShared across instances
Best ForServerless, testingProduction clusters

When to Use In-Memory

  • Serverless functions: Short-lived processes where persistence adds latency
  • Single-instance deployments: No need for distributed deduplication
  • High-throughput, low-risk: Metrics, telemetry where duplicates are acceptable
  • Testing: Fast, isolated test execution
[Idempotent(UseInMemory = true, RetentionMinutes = 5)]
public class MetricsHandler : IEventHandler<MetricsCollectedEvent> { }

When to Use Persistent Storage

  • Multi-instance deployments: Kubernetes, App Service scale-out
  • Critical handlers: Payments, orders, inventory updates
  • Long retention needs: Hours or days of deduplication window
  • Audit requirements: Need to query processed message history
// Uses IInboxStore (must be registered)
[Idempotent(RetentionMinutes = 1440)] // 24 hours
public class PaymentHandler : IEventHandler<PaymentEvent> { }

Best Practices for Declarative Idempotency

  • Mark only handlers that need it: Not all handlers require idempotency. Logging handlers or read-only handlers can safely receive duplicates.
  • Use appropriate retention: Match retention to your message redelivery window (typically 1-7 days).
  • Choose storage wisely: Use UseInMemory = true for single-instance, high-throughput handlers; use persistent storage for multi-instance or critical handlers.
  • Register an inbox store: For persistent deduplication, register a store via AddExcaliburInbox(inbox => inbox.UseSqlServer(...)), inbox.UseRedis(...), or AddInMemoryInboxStore().

Processing Modes

The inbox supports three processing modes:

ModeComponentUse Case
Attribute-based (automatic)IdempotentHandlerMiddlewareMost scenarios — deduplicates handlers marked with [Idempotent]
Pipeline-levelInboxMiddlewareFull inbox semantics for all messages in the pipeline
Background serviceInboxService via AddInboxHostedService()Background cleanup and reprocessing of failed inbox entries
ManualIInboxProcessorServerless environments (Azure Functions, AWS Lambda) — trigger processing on demand

When you register an inbox store, the IdempotentHandlerMiddleware automatically handles deduplication for handlers marked with [Idempotent]. Each incoming message is checked against the inbox before the handler executes:

services.AddSqlServerInboxStore(options =>
{
options.ConnectionString = connectionString;
});

// Handlers marked with [Idempotent] are automatically deduplicated
[Idempotent]
public class OrderHandler : IEventHandler<OrderCreatedEvent> { }

Pipeline-Level Mode

For full inbox semantics applied to all messages in the pipeline (not just handlers with [Idempotent]), register InboxMiddleware:

services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
dispatch.UseMiddleware<InboxMiddleware>();
});

services.AddSqlServerInboxStore(options =>
{
options.ConnectionString = connectionString;
});

This mode applies inbox deduplication to all messages regardless of whether handlers are marked with [Idempotent].

Background Service

Register the inbox hosted service for background cleanup and maintenance:

services.AddInboxHostedService();

Manual Processing

For serverless or manual trigger scenarios:

public class InboxCleanupFunction
{
private readonly IInboxProcessor _processor;

[Function("CleanupInbox")]
public async Task Run([TimerTrigger("0 */30 * * * *")] TimerInfo timer)
{
await _processor.DispatchPendingMessagesAsync(CancellationToken.None);
}
}

Best Practices

PracticeRecommendation
Retention7 days minimum, match message TTL
IdentityUse business keys when possible
ScopePer-handler for multi-subscriber scenarios
CleanupRegular cleanup to manage storage
LockingUse distributed locks in clusters
DeclarativeUse [Idempotent] for handler-specific control
StrategyMatch ID strategy to message source

Next Steps

See Also