Inbox Pattern
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.Patternsdotnet 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 noTenantIdcolumn. 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)withTenantIdNOT 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]);
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 requiredThe 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.
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
IDbTransactionand reportSupportsTransactional = trueunconditionally. No option to set: registeringAddSqlServerInboxStore(...)/ 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.
- MongoDB —
EnableTransactionsrequires a replica set. Even with the flag set, starting a transaction against a standalone server fails loudly at runtime. - Cosmos DB — a
TransactionalBatchis single-partition, so the processed-mark and the handler's writes must share one logical partition. SetSharedPartitionKey; without it the store reportsSupportsTransactional = falseand 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
| Property | Type | Default | Description |
|---|---|---|---|
RetentionMinutes | int | 1440 (24h) | How long to track processed message IDs |
UseInMemory | bool | false | Use fast in-memory deduplication |
Strategy | MessageIdStrategy | FromHeader | How to extract message IDs |
HeaderName | string | "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
| Strategy | Description | Use Case |
|---|---|---|
FromHeader | Extract from message header (default: MessageId) | Standard message transports |
FromCorrelationId | Use IMessageContext.CorrelationId | Saga/workflow scenarios |
CompositeKey | {HandlerType}:{CorrelationId} | Multi-handler with same message |
Custom | Use registered IMessageIdProvider | Business-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
| Aspect | In-Memory | Persistent (IInboxStore) |
|---|---|---|
| Performance | Faster (no I/O) | Slower (database calls) |
| Durability | Lost on restart | Survives restarts |
| Distributed | Single instance only | Shared across instances |
| Best For | Serverless, testing | Production 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 = truefor 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(...), orAddInMemoryInboxStore().
Processing Modes
The inbox supports three processing modes:
| Mode | Component | Use Case |
|---|---|---|
| Attribute-based (automatic) | IdempotentHandlerMiddleware | Most scenarios — deduplicates handlers marked with [Idempotent] |
| Pipeline-level | InboxMiddleware | Full inbox semantics for all messages in the pipeline |
| Background service | InboxService via AddInboxHostedService() | Background cleanup and reprocessing of failed inbox entries |
| Manual | IInboxProcessor | Serverless environments (Azure Functions, AWS Lambda) — trigger processing on demand |
Attribute-Based Mode (Recommended)
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
| Practice | Recommendation |
|---|---|
| Retention | 7 days minimum, match message TTL |
| Identity | Use business keys when possible |
| Scope | Per-handler for multi-subscriber scenarios |
| Cleanup | Regular cleanup to manage storage |
| Locking | Use distributed locks in clusters |
| Declarative | Use [Idempotent] for handler-specific control |
| Strategy | Match ID strategy to message source |
Next Steps
- Outbox Pattern -- Reliable publishing
- Dead Letter -- Handle failed messages
See Also
- Outbox Pattern -- Ensure reliable message publishing with transactional outbox storage
- Idempotent Consumer Guide -- Narrative walkthrough of deduplication concepts and strategies
- Outbox Setup & Configuration -- Infrastructure setup for outbox and inbox stores