Skip to main content

Outbox Pattern

New to reliable messaging?

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

The outbox pattern ensures reliable message publishing by storing messages in the same database transaction as your domain changes.

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 database transactions
  • A SQL Server or PostgreSQL database for outbox storage

The Problem

Without the outbox pattern, you risk inconsistency:

The Solution

Store messages in an outbox table within the same transaction:

Quick Start

Configuration with Presets

The preset-based API for outbox configuration replaces 20+ individual settings with intuitive performance presets. Choose the preset that matches your use case:

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

// Recommended: Use presets for common scenarios
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Balanced().Build()));

// Add SQL Server outbox storage
services.AddSqlServerOutboxStore(options =>
{
options.ConnectionString = connectionString;
options.SchemaName = "outbox";
});

Available Presets

PresetUse CaseKey Characteristics
HighThroughputReal-time event processing, high-volume systemsLarge batches (1000), fast polling (100ms), 8 parallel threads
BalancedMost production workloadsModerate batches (100), 1s polling, 4 parallel threads
HighReliabilityFinancial transactions, critical systemsSmall batches (10), sequential processing, extended retention (30 days)
CustomAdvanced users who need full controlDefaults to Balanced values, all settings configurable

Preset Configuration Values

SettingHighThroughputBalancedHighReliability
BatchSize100010010
PollingInterval100ms1s5s
MaxRetryCount3510
RetryDelay1 min5 min15 min
EnableParallelProcessingtruetruefalse
MaxDegreeOfParallelism841 (sequential)
MessageRetentionPeriod1 day7 days30 days
CleanupInterval15 min1 hour6 hours

Preset with Overrides

Start from a preset and override specific settings:

// High throughput with larger batches
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.HighThroughput()
.WithBatchSize(2000)
.WithProcessorId("worker-1")
.Build()));

// Balanced with custom retention
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Balanced()
.WithRetentionPeriod(TimeSpan.FromDays(14))
.WithMaxRetries(7)
.Build()));

// High reliability with disabled cleanup (manual cleanup preferred)
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.HighReliability()
.DisableAutomaticCleanup()
.Build()));

Full Custom Configuration

For advanced users who need complete control:

services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Custom()
.WithBatchSize(500)
.WithPollingInterval(TimeSpan.FromMilliseconds(500))
.WithParallelism(6)
.WithMaxRetries(5)
.WithRetryDelay(TimeSpan.FromMinutes(2))
.WithRetentionPeriod(TimeSpan.FromDays(14))
.WithCleanupInterval(TimeSpan.FromHours(2))
.WithProcessorId("custom-processor")
.EnableBackgroundProcessing()
.Build()));

Usage in Handlers

Inject IOutboxWriter into your handler and call WriteAsync to stage outbound messages. The consistency guarantee (eventually-consistent vs. transactional) is determined by configuration -- your handler code stays the same regardless of mode:

using Excalibur.Dispatch.Outbox;

public class CreateOrderHandler : IDispatchHandler<CreateOrderAction>
{
private readonly IDbConnection _db;
private readonly IOutboxWriter _outboxWriter;

public CreateOrderHandler(IDbConnection db, IOutboxWriter outboxWriter)
{
_db = db;
_outboxWriter = outboxWriter;
}

public async Task<IMessageResult> HandleAsync(
CreateOrderAction action,
IMessageContext context,
CancellationToken ct)
{
using var transaction = _db.BeginTransaction();
context.SetItem("Transaction", transaction);

// Save domain changes
var orderId = Guid.NewGuid();
await _db.ExecuteAsync(
"INSERT INTO Orders (Id, CustomerId) VALUES (@Id, @CustomerId)",
new { Id = orderId, action.CustomerId },
transaction);

// Write to outbox -- behavior depends on configured ConsistencyMode
await _outboxWriter.WriteAsync(
new OrderCreatedEvent(orderId, action.CustomerId),
destination: "orders",
ct);

transaction.Commit();
return MessageResult.Success();
}
}

For scheduled delivery, use the WriteScheduledAsync extension method:

await _outboxWriter.WriteScheduledAsync(
new ReminderEvent(orderId),
destination: "reminders",
scheduledAt: DateTimeOffset.UtcNow.AddHours(24),
ct);

Consistency Modes

Configure the outbox consistency mode via OutboxStagingOptions:

services.AddDispatch(dispatch =>
{
dispatch.UseOutbox(outbox =>
{
// Default: messages buffered and staged after handler completes
outbox.ConsistencyMode = OutboxConsistencyMode.EventuallyConsistent;

// OR: messages written within the ambient transaction (requires IOutboxStore)
outbox.ConsistencyMode = OutboxConsistencyMode.Transactional;
});
});
ModeBehaviorRiskRequires
EventuallyConsistent (default)Messages buffered during handler execution, flushed to outbox after handler + transaction completeMessages lost if process crashes between commit and flushNothing extra
TransactionalMessages written to IOutboxStore within the ambient transactionNone (atomic with business data)IOutboxStore registration + TransactionMiddleware

Outbox Stores

SQL Server

services.AddSqlServerOutboxStore(options =>
{
options.ConnectionString = connectionString;
options.SchemaName = "outbox";
options.OutboxTableName = "OutboxMessages";
options.DeadLetterTableName = "OutboxDeadLetters";
});

PostgreSQL

services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UsePostgres(postgres =>
{
postgres.ConnectionString(connectionString)
.SchemaName("outbox")
.TableName("outbox_messages");
});
}));

Redis

services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseRedis(redis =>
{
redis.ConnectionString("localhost:6379")
.KeyPrefix("outbox:");
});
}));

// Or with an existing ConnectionMultiplexer from DI
services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseRedis(redis =>
{
redis.Multiplexer(existingMultiplexer)
.KeyPrefix("outbox:");
});
}));

MongoDB

services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox =>
{
outbox.UseMongoDB(mongo =>
{
mongo.ConnectionString(connectionString)
.DatabaseName("myapp");
});
}));

Elasticsearch

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

Firestore

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

Cosmos DB

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

DynamoDB

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

Database Schema

SQL Server

The SQL Server store does not auto-create tables — create the schema before starting the application. The IX_OutboxMessages_Claim index backs the atomic claim predicate (status + retry-visibility) and the partition-ordered delivery guarantee.

CREATE TABLE dbo.OutboxMessages (
Id NVARCHAR(255) NOT NULL PRIMARY KEY,
MessageType NVARCHAR(500) NOT NULL,
Payload VARBINARY(MAX) NOT NULL,
Headers NVARCHAR(MAX) NULL,
Destination NVARCHAR(255) NOT NULL,
CreatedAt DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(),
ScheduledAt DATETIMEOFFSET NULL,
SentAt DATETIMEOFFSET NULL,
Status INT NOT NULL DEFAULT 0,
RetryCount INT NOT NULL DEFAULT 0,
LastError NVARCHAR(MAX) NULL,
LastAttemptAt DATETIMEOFFSET NULL,
CorrelationId NVARCHAR(255) NULL,
CausationId NVARCHAR(255) NULL,
TenantId NVARCHAR(255) NULL,
Priority INT NOT NULL DEFAULT 0,
TargetTransports NVARCHAR(MAX) NULL,
IsMultiTransport BIT NOT NULL DEFAULT 0,
LeasedAt DATETIMEOFFSET NULL,
LeasedBy NVARCHAR(255) NULL,
PartitionKey NVARCHAR(256) NULL, -- ordered delivery: per-partition FIFO
GroupKey NVARCHAR(256) NULL, -- logical message grouping
SequenceNumber BIGINT NOT NULL DEFAULT 0, -- monotonic ordering key
NextAttemptAt DATETIMEOFFSET NULL, -- retry backoff: not re-claimed until this time
INDEX IX_OutboxMessages_Status_CreatedAt (Status, CreatedAt),
INDEX IX_OutboxMessages_Claim (Status, NextAttemptAt, PartitionKey, SequenceNumber)
);
Ordering and retry-backoff columns

PartitionKey / GroupKey / SequenceNumber persist the message ordering keys, and NextAttemptAt records the per-message backoff deadline. The background processor claims rows with WHERE Status IN (Staged, Failed, PartiallyFailed) AND (NextAttemptAt IS NULL OR NextAttemptAt <= @now) ORDER BY PartitionKey, SequenceNumber — so same-partition messages are delivered in ascending SequenceNumber, and a failed message's computed backoff genuinely throttles re-delivery. See Ordering and Retry Scheduling.

PostgreSQL

The PostgreSQL store does not auto-create tables — create the schema before starting the application. The tenant_id column persists tenant isolation through enqueue → reserve → dispatch; staged messages fail with column "tenant_id" does not exist if it is missing.

CREATE TABLE IF NOT EXISTS outbox (
id SERIAL PRIMARY KEY,
message_id VARCHAR(100) NOT NULL UNIQUE,
message_type VARCHAR(500) NOT NULL,
message_metadata TEXT,
message_body TEXT NOT NULL,
tenant_id VARCHAR(255),
occurred_on TIMESTAMPTZ NOT NULL DEFAULT NOW(),
attempts INT NOT NULL DEFAULT 0,
dispatcher_id VARCHAR(100),
dispatcher_timeout TIMESTAMPTZ,
next_attempt_at TIMESTAMPTZ,
scheduled_at TIMESTAMPTZ
);

CREATE TABLE IF NOT EXISTS outbox_dead_letters (
id SERIAL PRIMARY KEY,
message_id VARCHAR(100) NOT NULL UNIQUE,
message_type VARCHAR(500) NOT NULL,
message_metadata TEXT,
message_body TEXT NOT NULL,
occurred_on TIMESTAMPTZ NOT NULL DEFAULT NOW(),
attempts INT NOT NULL DEFAULT 0,
error_message TEXT,
moved_on TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_outbox_unreserved ON outbox (occurred_on) WHERE dispatcher_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_outbox_dispatcher ON outbox (dispatcher_id) WHERE dispatcher_id IS NOT NULL;
Upgrading an existing Postgres outbox schema

If you already run an earlier outbox schema, add the tenant-isolation column before deploying — otherwise staged messages fail with column "tenant_id" does not exist:

ALTER TABLE outbox ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(255);

Background Processing

Hosted Service (Default)

// Use presets - background processing enabled by default
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Balanced().Build()));

// Add storage
services.AddSqlServerOutboxStore(opts => opts.ConnectionString = connectionString);

// Register the background service
services.AddOutboxHostedService();

Quartz Job (Scheduled Processing)

For enterprise scheduling needs, use OutboxProcessorJob from Excalibur.Jobs:

// Install: dotnet add package Excalibur.Jobs

services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Balanced().Build()));
services.AddSqlServerOutboxStore(opts => opts.ConnectionString = connectionString);

// Register the Quartz.NET outbox processor job
// Configure schedule in appsettings.json or via Quartz API

The OutboxProcessorJob integrates with Quartz.NET for scheduled outbox processing with built-in health checks and multi-database support.

Manual Processing

For serverless environments (Azure Functions, AWS Lambda):

// Use Custom preset to disable background processing
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Custom()
.WithBatchSize(50)
.WithMaxRetries(3)
.Build())); // EnableBackgroundProcessing defaults to true in presets

services.AddSqlServerOutboxStore(opts => opts.ConnectionString = connectionString);

// Process manually (e.g., Azure Function timer trigger)
public class OutboxProcessorFunction
{
private readonly IOutboxProcessor _processor;

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

Publisher Configuration

Default Publisher

The outbox uses the configured IOutboxPublisher to send messages. The default behavior dispatches through the registered message bus:

services.AddExcalibur(excalibur => excalibur.AddOutbox());
services.AddSqlServerOutboxStore(opts => opts.ConnectionString = connectionString);

// Messages are dispatched through IDispatcher by default

Transport-Specific Publisher

services.AddDispatch(dispatch =>
{
dispatch.AddHandlersFromAssembly(typeof(Program).Assembly);
dispatch.UseKafka(kafka =>
{
kafka.BootstrapServers("localhost:9092");
kafka.DefaultTopic("dispatch.events");
});
});

services.AddExcalibur(excalibur => excalibur.AddOutbox());
services.AddSqlServerOutboxStore(opts => opts.ConnectionString = connectionString);

// Register Kafka publisher for outbox
services.AddSingleton<IOutboxPublisher, KafkaOutboxPublisher>();

Custom Publisher

Implement IOutboxPublisher for custom message publishing:

public class WebhookOutboxPublisher : IOutboxPublisher
{
private readonly HttpClient _httpClient;
private readonly IOutboxStore _store;
private int _publishedCount;
private int _failedCount;

public WebhookOutboxPublisher(HttpClient httpClient, IOutboxStore store)
{
_httpClient = httpClient;
_store = store;
}

public async Task<OutboundMessage> PublishAsync(
object message,
string destination,
DateTimeOffset? scheduledAt,
CancellationToken cancellationToken)
{
// Create and stage outbound message
var payload = JsonSerializer.SerializeToUtf8Bytes(message);
var outbound = new OutboundMessage(
message.GetType().Name,
payload,
destination) { ScheduledAt = scheduledAt };

await _store.StageMessageAsync(outbound, cancellationToken);
return outbound;
}

public async Task<PublishingResult> PublishPendingMessagesAsync(
CancellationToken cancellationToken)
{
var messages = await _store.GetUnsentMessagesAsync(100, cancellationToken);
var published = 0;
var failed = 0;

foreach (var message in messages)
{
try
{
await _httpClient.PostAsync(
$"/webhooks/{message.Destination}",
new ByteArrayContent(message.Payload),
cancellationToken);

await _store.MarkSentAsync(message.Id, cancellationToken);
published++;
}
catch (Exception ex)
{
await _store.MarkFailedAsync(message.Id, ex.Message, 1, cancellationToken);
failed++;
}
}

Interlocked.Add(ref _publishedCount, published);
Interlocked.Add(ref _failedCount, failed);

return new PublishingResult { SuccessCount = published, FailureCount = failed };
}

// Implement other required methods...
}

services.AddExcalibur(excalibur => excalibur.AddOutbox());
services.AddSqlServerOutboxStore(opts => opts.ConnectionString = connectionString);
services.AddSingleton<IOutboxPublisher, WebhookOutboxPublisher>();

Error Handling

Retry Configuration

// Use HighReliability preset for aggressive retries (10 retries, 15 min delay)
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.HighReliability().Build()));

// Or customize retry behavior
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Balanced()
.WithMaxRetries(7)
.WithRetryDelay(TimeSpan.FromMinutes(2))
.Build()));

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

Dead Letter Handling

services.AddSqlServerOutboxStore(options =>
{
options.ConnectionString = connectionString;
options.DeadLetterTableName = "DeadLetterMessages";
});

// Add dead letter queue handler
services.AddSqlServerDeadLetterQueue(opts => opts.ConnectionString = connectionString);

Ordering and Retry Scheduling

Partition-Ordered Delivery

Each outbound message carries three ordering fields — PartitionKey, GroupKey, and a monotonically increasing SequenceNumber. The polling claim selects eligible rows in (PartitionKey, SequenceNumber) order, so messages that share a PartitionKey are delivered in ascending SequenceNumber (per-partition FIFO). Messages without a PartitionKey have no cross-message ordering guarantee. GroupKey is an independent label for logical grouping and does not affect claim order.

This is message-level ordering persisted on each row. It is distinct from Partitioned Outbox processing, which shards the processor loops for throughput.

Retry Backoff Is Applied

When delivery fails, the processor computes an exponential backoff delay and records the absolute next-attempt time on the row's NextAttemptAt column. The claim predicate excludes the message until that time elapses (NextAttemptAt IS NULL OR NextAttemptAt <= @now), so the configured retry delay genuinely throttles re-delivery rather than re-claiming the message as soon as its lease expires.

A circuit-breaker-open short-circuit is treated differently: because no delivery was actually attempted, no backoff is applied — the message stays immediately eligible and retries as soon as the breaker closes.

Backoff scheduling requires a store that implements the optional IBackoffSchedulableOutboxStore capability (MarkFailedWithBackoffAsync). The SQL Server and PostgreSQL stores implement it (the Postgres implementation is signature-identical to SQL Server for cross-provider consistency). Other providers (Redis, MongoDB, Elasticsearch, DynamoDB, Cosmos DB) do not yet implement it and are unaffected: the processor falls back to the plain MarkFailedAsync path (immediate re-eligibility), so no store is broken — matching the fail-open pattern used by IDeadLetterableOutboxStore. The capability is forwarded transparently through the telemetry and encrypting outbox-store decorators, so it survives a decorated store chain.

Cleanup

Automatic Cleanup

All presets enable automatic cleanup by default with appropriate intervals:

// Balanced: 7-day retention, hourly cleanup
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Balanced().Build()));

// HighReliability: 30-day retention, 6-hour cleanup interval
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.HighReliability().Build()));

// Custom retention
services.AddExcalibur(excalibur => excalibur.AddOutbox(OutboxOptions.Balanced()
.WithRetentionPeriod(TimeSpan.FromDays(14))
.WithCleanupInterval(TimeSpan.FromHours(2))
.Build()));

services.AddSqlServerOutboxStore(opts => opts.ConnectionString = connectionString);

Manual Cleanup

public class OutboxCleanupJob
{
private readonly IOutboxStore _store;

public async Task CleanupAsync(CancellationToken ct)
{
var cutoff = DateTimeOffset.UtcNow.AddDays(-7);
var deleted = await _store.CleanupSentMessagesAsync(cutoff, batchSize: 1000, ct);
_logger.LogInformation("Deleted {Count} processed messages", deleted);
}
}

Monitoring

Health Checks

services.AddHealthChecks()
.AddOutboxHealthCheck(options =>
{
options.UnhealthyInactivityTimeout = TimeSpan.FromMinutes(5);
options.DegradedInactivityTimeout = TimeSpan.FromMinutes(2);
options.UnhealthyFailureRatePercent = 20.0;
options.DegradedFailureRatePercent = 5.0;
});

Metrics

Outbox metrics are included in the core Dispatch metrics:

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

Validation Rules

The preset-based API validates configuration at build time:

RuleError Message
BatchSize >= 1"BatchSize must be at least 1."
BatchSize <= 10000"BatchSize cannot exceed 10000."
PollingInterval >= 10ms"PollingInterval must be at least 10ms."
MaxRetryCount >= 0"MaxRetryCount cannot be negative."
MaxDegreeOfParallelism >= 1"MaxDegreeOfParallelism must be at least 1."
RetryDelay > 0"RetryDelay must be positive."
RetentionPeriod > 0"RetentionPeriod must be positive."
RetentionPeriod >= CleanupInterval (when cleanup enabled)"RetentionPeriod must be greater than or equal to CleanupInterval."
ProcessorId not empty"ProcessorId cannot be null or whitespace."

Best Practices

PracticeRecommendation
Use presetsStart with Balanced, adjust only if needed
Transaction scopeKeep outbox add in same transaction as domain changes
Batch sizeUse preset defaults (HighThroughput: 1000, Balanced: 100, HighReliability: 10)
Processing intervalUse preset defaults; 100ms for real-time, 1-5s for standard
Retention7 days for most workloads, 30 days for compliance
MonitoringAlert on high pending count or age
Preset selectionHighReliability for financial, Balanced for most, HighThroughput for event streaming

Troubleshooting

Messages Not Processing

-- Check unprocessed messages
SELECT TOP 100 *
FROM [outbox].[OutboxMessages]
WHERE [ProcessedAt] IS NULL
ORDER BY [CreatedAt];

-- Check failed messages
SELECT *
FROM [outbox].[OutboxMessages]
WHERE [Error] IS NOT NULL;

High Latency

  • Increase batch size
  • Reduce processing interval
  • Add database indexes
  • Scale out processors (with locking)

Event Sourcing Outbox Integration

When using event sourcing, integration events can be staged to the unified outbox automatically during aggregate save. The EventSourcedRepository supports three staging strategies controlled by OutboxStagingStrategy:

Staging Strategies

StrategyBehaviorTrade-off
Auto (default)Framework selects the best available strategyNo configuration needed
TransactionalStages events in the same DB transaction as the event appendZero message loss, adds save latency
EventuallyConsistentStages events after a successful append in a separate callMinimal latency, tiny loss window on crash
DeferredNo staging during save; a background service picks up events laterZero added latency, higher delivery delay

Configuration

services.AddExcalibur(excalibur => excalibur.AddEventSourcing(es =>
{
es.UseSqlServer(sql => sql.ConnectionString(connectionString));

// Per-aggregate staging strategy
es.AddRepository<Order>(id => new Order(id), opts =>
{
opts.OutboxStagingStrategy = OutboxStagingStrategy.Transactional;
});
}));

// Register the unified outbox store (required for Transactional and EventuallyConsistent)
services.AddExcalibur(excalibur => excalibur.AddOutbox(outbox => outbox.UseSqlServer(opts =>
{
opts.ConnectionString = connectionString;
})));

How Auto Resolution Works

When OutboxStagingStrategy.Auto is configured (the default), the repository checks at save time:

  1. If an ITransactionalEventStore (a transactional event store) and an ITransactionalOutboxWriter are registered, uses Transactional
  2. If only IOutboxStore is registered, uses EventuallyConsistent
  3. If neither is registered, uses Deferred (no staging)

Selecting OutboxStagingStrategy.Transactional explicitly (rather than Auto) without both pieces of infrastructure fails fast at startup via a ValidateOnStart guard, naming exactly what is missing — it never silently degrades to non-atomic staging.

ITransactionalEventStore

The event-store side of the atomic path. An event store provider backed by a transactional database (SQL Server, PostgreSQL) implements the optional ITransactionalEventStore extension of IEventStore to enable the Transactional strategy:

namespace Excalibur.EventSourcing;

public interface ITransactionalEventStore : IEventStore
{
ValueTask<AppendResult> AppendWithOutboxStagingAsync(
string aggregateId,
string aggregateType,
IEnumerable<IDomainEvent> events,
long expectedVersion,
Func<IDbTransaction, CancellationToken, ValueTask> stageOutbox,
CancellationToken cancellationToken);
}

This is a store-owned unit of work: the store opens and owns a single connection and transaction, runs the optimistic-concurrency version check, appends the events, invokes your stageOutbox callback on that same transaction (only when the version check succeeds), then commits. On a concurrency conflict or any throw from stageOutbox, the whole transaction rolls back, so neither the events nor the outbox rows persist. Because the transaction never leaves the store, appending events and staging outbox rows on two different transactions is structurally impossible.

SqlServerEventStore implements ITransactionalEventStore. You do not call AppendWithOutboxStagingAsync directly — EventSourcedRepository invokes it on your behalf when the resolved strategy is Transactional, supplying a stageOutbox callback that enlists each integration event's outbox write through ITransactionalOutboxWriter on the store's transaction. NoSQL event stores do not implement this interface; use EventuallyConsistent or Deferred staging with them.

ITransactionalOutboxWriter

Relational outbox providers (SQL Server, PostgreSQL) implement ITransactionalOutboxWriter to stage outbox rows on the event store's database transaction (the stageOutbox callback above calls into it):

public interface ITransactionalOutboxWriter
{
ValueTask StageMessageAsync(
OutboundMessage message,
IDbTransaction transaction,
CancellationToken cancellationToken);
}

NoSQL providers (CosmosDB, DynamoDB, MongoDB, etc.) do not implement this interface. Use EventuallyConsistent or Deferred staging with NoSQL event stores.

Standard Header Names

The OutboxHeaderNames class provides well-known constants used in outbox message headers and event metadata:

ConstantValuePurpose
AggregateId"aggregate-id"Aggregate that produced the event
AggregateType"aggregate-type"Aggregate type name
TenantId"tenant-id"Multi-tenant routing
CorrelationId"correlation-id"Distributed tracing
CausationId"causation-id"Cause-effect linking

Partitioned Outbox

At high event rates (100K+ events/sec), the single outbox table becomes a contention bottleneck. Partitioned outbox splits processing into multiple independent loops, each handling a subset of messages.

Enable Partitioned Processing

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

outbox.UsePartitionedProcessing(opts =>
{
opts.Strategy = OutboxPartitionStrategy.ByTenantHash;
opts.PartitionCount = 8;
opts.ProcessorCountPerPartition = 1;
opts.PollingInterval = TimeSpan.FromSeconds(1);
opts.ErrorBackoffInterval = TimeSpan.FromSeconds(5);
});
}));

Partitioning Strategies

StrategyDescriptionUse Case
NoneSingle processor loop (default)Low-to-moderate throughput
PerShardOne partition per tenant shardWhen tenant sharding is active
ByTenantHashXxHash32(tenantId) % N partitionsHigh throughput without sharding infrastructure

How It Works

Each partition runs an independent processor loop with its own error isolation. When ProcessorCountPerPartition > 1, multiple concurrent processors handle the same partition.

Configuration Options

OptionDefaultDescription
StrategyNonePartitioning strategy
PartitionCount8Number of partitions (for ByTenantHash)
ProcessorCountPerPartition1Concurrent processor instances per partition
PollingInterval1sDelay when no messages are available
ErrorBackoffInterval5sDelay after a processing error
ShardIds[]Required shard IDs when Strategy is PerShard

Custom Partitioner

Implement IOutboxPartitioner for custom routing logic:

public interface IOutboxPartitioner
{
int GetPartition(string tenantId);
int PartitionCount { get; }
}

Design Principles

PrincipleDescription
Preset-based APIHighThroughput(), Balanced(), HighReliability(), Custom() factory methods
Immutable optionsOutboxOptions created via fluent IOutboxOptionsBuilder
Override supportPresets provide opinionated defaults; .With*() methods allow surgical overrides
Fail-fast validationValidation at Build() time, not at registration
API consistencyParallel InboxOptions presets for consistent experience

Next Steps

See Also