Skip to main content

Outbox Pattern

The outbox pattern guarantees that every message your application writes is eventually delivered to the broker — even if the broker is temporarily unavailable when your code runs.

The problem it solves

Without the outbox, a typical event-publishing flow looks like this:

1. Insert Order row into database
2. Publish OrderPlaced event to broker
3. Commit database transaction

This has a critical flaw: steps 1–3 are not atomic. If the broker call in step 2 fails, the Order is saved but no event is published. If the process crashes after step 2 but before step 3, the event is published but the Order is not saved. Either way your data and your messages are out of sync.

How the outbox solves it

The outbox replaces the direct broker call with a write to an outbox_messages table in the same database transaction as your business data:

1. Insert Order row into database
2. Insert OutboxMessage row into outbox_messages
3. Commit database transaction ← both rows commit or neither does

A background OutboxDispatcherWorker then reads the pending rows and forwards them to the broker:

4. Worker reads pending OutboxMessage rows
5. Calls ITransport.SendAsync() for each message
6. On success: marks message ProcessedAt (delivered)
7. On failure: increments AttemptCount, retries up to MaxAttempts
8. After MaxAttempts: marks message FailedAt (dead-letter)

Steps 4–8 can be retried indefinitely. The broker call is idempotent from the outbox's perspective — if the process crashes between step 5 and 6, the worker simply picks up the same row again on the next poll.

There is no direct publish path

IPublisher writes only to the outbox. There is intentionally no method to publish directly to the broker. This design choice means:

  • You never accidentally bypass the transactional guarantee
  • The outbox is always the source of truth for what needs to be sent
  • The broker being slow, unavailable, or restarted does not affect your write path

Atomic writes with EF Core

When you use OpinionatedEventing.EntityFramework, the outbox write is fully automatic for domain events raised on aggregate roots. The DomainEventInterceptor hooks DbContext.SaveChangesAsync() and harvests any IEvent instances queued on your aggregates:

// Inside your aggregate:
public void Place(decimal total)
{
Total = total;
Status = OrderStatus.Placed;
RaiseDomainEvent(new OrderPlaced(Id, total)); // queued, not written yet
}

// In your application code:
db.Orders.Add(order);
order.Place(total);
await db.SaveChangesAsync(ct);
// ↑ DomainEventInterceptor fires here, harvests OrderPlaced,
// creates OutboxMessage, writes both rows in one transaction.

If you need to publish an event from application code (not from an aggregate), inject IPublisher:

await publisher.PublishEventAsync(new OrderPlaced(orderId, total), ct);
await db.SaveChangesAsync(ct); // commits both the business row and the outbox row

OutboxMessage structure

Each row in outbox_messages contains:

ColumnPurpose
IdUnique message identifier (Guid)
MessageTypeAssembly-qualified CLR type name for deserialization
PayloadJSON-serialized message body
MessageKind"Event" or "Command"
CorrelationIdChain identifier propagated from the originating request
CausationIdID of the message that caused this one to be published
CreatedAtWhen the row was written
ProcessedAtSet when successfully dispatched to the broker
FailedAtSet when MaxAttempts is exhausted (dead-lettered)
AttemptCountNumber of dispatch attempts made
ErrorLast dispatch error message
NextAttemptAtEarliest time this message is eligible for retry (backoff)

Retry behaviour and exponential backoff

The OutboxDispatcherWorker retries failed dispatches up to OutboxOptions.MaxAttempts (default: 5). After each transient failure, the worker applies exponential backoff: the message is held back until now + min(2^n seconds, MaxRetryDelay) where n is the new attempt count. The default MaxRetryDelay is 5 minutes.

AttemptBackoff delay
1st retry2 s
2nd retry4 s
3rd retry8 s
4th retry16 s
5th retry+capped at MaxRetryDelay (default 5 min)

After MaxAttempts failures the row is marked as dead-lettered (FailedAt is set). Dead-lettered rows are never retried automatically. Use IOutboxMonitor.GetDeadLetterCountAsync() to detect accumulation and alert on it.

Retention and cleanup

By default, processed rows are deleted after 7 days and dead-lettered rows are kept indefinitely. The OutboxCleanupWorker hosted service runs hourly and enforces these limits.

Configure retention in OutboxOptions:

services.AddOpinionatedEventing(options =>
{
options.Outbox.ProcessedRetention = TimeSpan.FromDays(7); // null = keep forever
options.Outbox.FailedRetention = null; // null = keep forever (default)
options.Outbox.CleanupInterval = TimeSpan.FromHours(1); // how often the cleanup worker runs
});

Configuring the dispatcher

services.AddOpinionatedEventing(options =>
{
options.Outbox.PollInterval = TimeSpan.FromSeconds(1); // how often to poll
options.Outbox.BatchSize = 50; // messages per poll cycle
options.Outbox.MaxAttempts = 5; // before dead-lettering
options.Outbox.ConcurrentWorkers = 1; // parallel dispatch workers
options.Outbox.MaxRetryDelay = TimeSpan.FromMinutes(5); // backoff cap
options.Outbox.ProcessedRetention = TimeSpan.FromDays(7); // null to keep forever
options.Outbox.FailedRetention = null; // null to keep forever
options.Outbox.CleanupInterval = TimeSpan.FromHours(1); // cleanup sweep interval
});

IOutboxStore

IOutboxStore is an abstraction — implementations live in separate packages. The framework ships two:

ImplementationPackagePurpose
EFCoreOutboxStore<TDbContext>OpinionatedEventing.EntityFrameworkProduction use
InMemoryOutboxStoreOpinionatedEventing.TestingUnit and integration tests

You can implement IOutboxStore yourself to support other persistence backends.

Health monitoring

Use IOutboxMonitor to observe the backlog:

public class OutboxHealthEndpoint(IOutboxMonitor monitor)
{
public async Task<int> GetPendingCountAsync(CancellationToken ct)
=> await monitor.GetPendingCountAsync(ct);

public async Task<int> GetDeadLetterCountAsync(CancellationToken ct)
=> await monitor.GetDeadLetterCountAsync(ct);
}

Delivery guarantees and consumer idempotency

The outbox guarantees at-least-once delivery — under certain failure conditions the same message may be delivered more than once. Consumers are responsible for handling duplicates. See Idempotency for strategies and code examples.

EF Core migration helpers

There are two ways to create the outbox table — pick one per project and do not mix them:

  • Model-based migrations — call modelBuilder.ApplyOutboxConfiguration() in OnModelCreating and let EF generate the CREATE TABLE migration automatically via dotnet ef migrations add.
  • Migration helper methods — call migrationBuilder.CreateOutboxTable() inside a hand-authored migration and do not call ApplyOutboxConfiguration().
// In your migration's Up() method:
migrationBuilder.CreateOutboxTable();
migrationBuilder.CreateSagaStateTable(); // if using sagas

// In your migration's Down() method:
migrationBuilder.DropOutboxTable();
migrationBuilder.DropSagaStateTable();

If you are upgrading an existing deployment, add a new migration that applies only the incremental schema change:

// In the Up() method of a new migration:
migrationBuilder.AddOutboxRetentionColumns();

// In the Down() method:
migrationBuilder.DropOutboxRetentionColumns();