Skip to main content

Outbox Setup

The outbox pattern ensures reliable message delivery by storing messages in the same transaction as your domain changes. This guide covers configuration options for the Excalibur outbox.

Before You Start

Why Use an Outbox?

Without an outbox:

1. Save aggregate ✅
2. Publish event ❌ (network failure)
→ Inconsistent state: aggregate saved but event lost

With an outbox:

1. Save aggregate + outbox message (same transaction) ✅
2. Background processor publishes from outbox ✅
3. Mark message as processed ✅
→ Guaranteed delivery (at-least-once)

Basic Setup

services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString = connectionString)
.EnableBackgroundProcessing();
}));

Alternatively, use the unified builder:

services.AddExcalibur(excalibur =>
{
excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(opts => opts.ConnectionString = connectionString)
.EnableBackgroundProcessing();
});
});

Configuration Options

Fluent Builder API

services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseSqlServer(sql =>
{
sql.ConnectionString(connectionString)
.SchemaName("Messaging")
.TableName("OutboxMessages")
.CommandTimeout(TimeSpan.FromSeconds(60));
})
.WithProcessing(processing =>
{
processing.BatchSize(100)
.PollingInterval(TimeSpan.FromSeconds(5))
.MaxRetryCount(5)
.RetryDelay(TimeSpan.FromMinutes(1))
.EnableParallelProcessing(4);
})
.EnableBackgroundProcessing();
}));

Preset-Based API

Use presets for common scenarios:

// High throughput (event streaming, analytics)
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.HighThroughput().Build()));

// Balanced (most applications)
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Balanced().Build()));

// High reliability (financial, critical systems)
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.HighReliability().Build()));

Customize presets:

services.AddExcalibur(excalibur => excalibur.AddOutbox(
OutboxOptions.HighThroughput()
.WithBatchSize(2000)
.WithProcessorId("worker-1")
.Build()));

Preset Comparison

SettingHighThroughputBalancedHighReliability
BatchSize100010010
PollingInterval100ms1s5s
MaxRetryCount3510
RetryDelay1min5min15min
Parallelism841

Database Providers

SQL Server

outbox.UseSqlServer(sql =>
{
sql.ConnectionString(connectionString)
.SchemaName("Outbox")
.TableName("Messages")
.UseRowLocking(true); // For high concurrency
});

Postgres

outbox.UsePostgres(pg =>
{
pg.ConnectionString(connectionString)
.SchemaName("outbox")
.TableName("messages");
});

Redis

// With connection string (builder API)
outbox.UseRedis(redis =>
{
redis.ConnectionString("localhost:6379")
.KeyPrefix("outbox:")
.Database(0);
});

// With existing ConnectionMultiplexer
outbox.UseRedis(redis =>
{
redis.Multiplexer(existingMultiplexer)
.KeyPrefix("outbox:");
});

RedisOutboxOptions properties:

PropertyTypeDefaultDescription
ConnectionStringstring"localhost:6379"Redis connection string
DatabaseIdint0Redis database ID
KeyPrefixstring"outbox"Key prefix for outbox entries
SentMessageTtlSecondsint604800 (7 days)TTL for sent messages (0 = no expiration)
ConnectTimeoutMsint5000Connection timeout in milliseconds
SyncTimeoutMsint5000Sync operation timeout in milliseconds
AbortOnConnectFailboolfalseWhether to abort on connect failure
UseSslboolfalseWhether to use SSL/TLS
Passwordstring?nullRedis authentication password

MongoDB

outbox.UseMongoDB(mongo =>
{
mongo.ConnectionString("mongodb://localhost:27017")
.DatabaseName("myapp");
});

The MongoDB outbox builder (IMongoDBOutboxBuilder) supports 4 connection overloads: ConnectionString(), Client(), ClientFactory(), and BindConfiguration().

Key MongoDbOutboxOptions properties (set via builder or configuration binding):

PropertyTypeDefaultDescription
ConnectionStringstring"mongodb://localhost:27017"MongoDB connection string
DatabaseNamestring"excalibur"Database name
CollectionNamestring"outbox_messages"Collection name
SentMessageTtlSecondsint604800 (7 days)TTL for sent messages

Elasticsearch

outbox.UseElasticSearch(options =>
{
options.IndexName = "excalibur-outbox";
options.DefaultBatchSize = 100;
});

Key ElasticsearchOutboxOptions properties:

PropertyTypeDefaultDescription
IndexNamestring"excalibur-outbox"Elasticsearch index name
DefaultBatchSizeint100Default batch size for operations
RefreshPolicystring"wait_for"Index refresh policy
SentMessageRetentionDaysint7Not currently applied. The value is validated and carried, but no code path removes entries based on it — sent messages are not expired. Use the cleanup operation (see Retention and cleanup) until this is wired.

Firestore

outbox.UseFirestore(options =>
{
options.ProjectId = "my-gcp-project";
options.CollectionName = "outbox";
});

Key FirestoreOutboxOptions properties:

PropertyTypeDefaultDescription
ProjectIdstring?nullGCP project ID (required unless using emulator)
CollectionNamestring"outbox"Firestore collection name
EmulatorHoststring?nullFirestore emulator host for development
MaxBatchSizeint500Max batch size (Firestore limit: 500)
CreateCollectionIfNotExistsbooltrueAuto-create collection

Cosmos DB

outbox.UseCosmosDb(cosmos =>
{
cosmos.ConnectionString(connectionString)
.DatabaseName("myapp")
.ContainerName("outbox");
});

Key CosmosDbOutboxOptions properties:

PropertyTypeDefaultDescription
DatabaseNamestring?RequiredCosmos DB database name
ContainerNamestring"outbox"Container name
Connection.ConnectionStringstring?RequiredCosmos DB connection string
CreateContainerIfNotExistsbooltrueAuto-create container
ContainerThroughputint400Provisioned RU/s for container
UseDirectModebooltrueUse direct connection mode

DynamoDB

outbox.UseDynamoDb(options =>
{
options.Connection.Region = "us-east-1";
options.TableName = "outbox";
});

Key DynamoDbOutboxOptions properties:

PropertyTypeDefaultDescription
TableNamestring"outbox"DynamoDB table name
Connection.Regionstring?Required (AWS)AWS region
Connection.ServiceUrlstring?nullService URL (for local DynamoDB)
CreateTableIfNotExistsbooltrueAuto-create table
EnableStreamsbooltrueEnable DynamoDB Streams
DefaultTimeToLiveSecondsint604800 (7 days)TTL for items

In-Memory (Testing)

outbox.UseInMemory(); // No persistence - for tests only

Processing Configuration

Batch Size

Controls how many messages are processed per iteration:

.WithProcessing(p => p.BatchSize(100))
ScenarioRecommended Size
Low latency10-50
Standard workloads100-200
High throughput500-1000
Bulk operations1000+

Polling Interval

How often the processor checks for new messages:

.WithProcessing(p => p.PollingInterval(TimeSpan.FromSeconds(5)))
ScenarioRecommended Interval
Real-time requirements100ms - 500ms
Standard applications1s - 5s
Batch processing10s - 60s

Parallel Processing

Enable concurrent message processing:

.WithProcessing(p => p.EnableParallelProcessing(4))

Retry Configuration

Configure retry behavior for failed messages:

.WithProcessing(p =>
{
p.MaxRetryCount(5)
.RetryDelay(TimeSpan.FromMinutes(1));
})

Retention and cleanup

Whether stored entries are removed automatically depends on the store.

The in-memory inbox and the in-memory deduplicator run a periodic cleanup pass on a configurable interval, enabled by default; their entries do not accumulate without bound.

Two separate properties decide what happens to a sent message. Read them independently — a provider can have one, both, or one-with-conditions.

  • Expires on its own — the datastore removes sent entries with no host action.
  • Cleanup you can call — the store exposes a cleanup operation your host can invoke. The framework schedules nothing; calling it is your job.
ProviderExpires on its ownCleanup you can call
SQL Server, PostgreSQL, OracleNoYes
Marten, in-memoryNoYes
Redis, MongoDBYes — native expiry, on by default (7 days)Yes
Cosmos DBYes — container and per-document TTL, on by default (7 days)Yes
DynamoDBYes, on the default path. When CreateTableIfNotExists is true (the default) the store creates the table and enables TTL on it. If you manage the table yourself, enable TTL on the expiry attribute or nothing is deletedYes
FirestoreNo, until you act. The store writes an expireAt field but never creates a TTL policy — Firestore deletes nothing until you configure that policy on the field yourselfYes
ElasticsearchNo — its retention setting is not currently appliedYes

Every provider gives you a callable cleanup operation. What differs is whether anything happens if you never call it: on the relational stores, Marten and in-memory, nothing does — entries accumulate until you remove them.

What this means for erasure

An erasure request is not satisfied by waiting for a retention window to expire — and on the relational stores there is no window at all. Any personal data in a message payload remains readable in the outbox until that entry is removed. The framework provides no mechanism for rendering an existing outbox payload unreadable in place; the available paths today are explicit deletion of the affected entries, or a cleanup pass that covers them.

Do not treat a provider's native TTL as an erasure control. It is time-based, not subject-based: it cannot target one data subject, its default window is long, and on DynamoDB and Firestore it deletes nothing at all unless the store's TTL feature has been enabled.

Scheduling your own cleanup

On a provider with no native expiry, run the cleanup operation from your own host — a background service, a cron job, or your database's own maintenance tooling — because nothing schedules it for you. Size the interval against your outbox volume and whatever retention your compliance obligations require.

Background Processing

Hosted Service

Enable automatic background processing:

outbox.EnableBackgroundProcessing();

This registers an IHostedService that continuously processes the outbox.

Manual Processing

For serverless or custom scenarios:

// Don't enable background processing
outbox.UseSqlServer(opts => opts.ConnectionString = connectionString);

// Manually trigger processing
var processor = services.GetRequiredService<IOutboxProcessor>();
await processor.DispatchPendingMessagesAsync(CancellationToken.None);

Multi-Instance Deployment

Processor ID

Assign unique IDs to prevent duplicate processing:

OutboxOptions.Balanced()
.WithProcessorId(Environment.MachineName)
.Build()

Health Checks

Monitor outbox health:

services.AddHealthChecks()
.AddCheck<OutboxHealthCheck>("outbox");

The health check reports:

  • Healthy: Processing normally
  • Degraded: High pending count or old messages
  • Unhealthy: Processing failures

Observability

Metrics

services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter("Excalibur.Outbox.*");
});

Metrics exported:

  • excalibur.outbox.pending — Pending message count
  • excalibur.outbox.processed — Messages processed per interval
  • excalibur.outbox.failed — Failed message count
  • excalibur.outbox.age_ms — Age of oldest pending message

Logging

Outbox operations are logged automatically. Configure log levels:

{
"Logging": {
"LogLevel": {
"Excalibur.Outbox": "Information"
}
}
}

Best Practices

PracticeReason
Use presetsTested configurations for common scenarios
Set processor IDPrevent duplicate processing in multi-instance
Plan sent-message removalPrevent unbounded table growth — see Retention and cleanup for which providers expire rows themselves
Monitor pending countDetect processing bottlenecks
Use appropriate batch sizeBalance throughput vs. latency

Troubleshooting

Messages not being processed

  1. Verify EnableBackgroundProcessing() is called
  2. Check logs for processing errors
  3. Ensure database connection is valid

High pending count

  1. Increase batch size or parallelism
  2. Check for slow downstream handlers
  3. Monitor for retry storms

Duplicate messages

Ensure your handlers are idempotent. The outbox guarantees at-least-once delivery.

public class OrderCreatedHandler : IEventHandler<OrderCreated>
{
public async Task HandleAsync(OrderCreated @event, CancellationToken ct)
{
// Idempotent: check if already processed
if (await _store.ExistsAsync(@event.OrderId))
return;

// Process...
}
}

See Also

  • Outbox Pattern — Conceptual overview of the transactional outbox pattern
  • Inbox Pattern — Idempotent message processing with the inbox pattern
  • Event Store Setup — Configure event stores and aggregate repositories
  • Worker Services — Deploy dedicated background workers for outbox processing