Skip to content

Transactional Outbox

The transactional outbox solves the dual-write problem: your business data and your message enqueue happen in the same database transaction. Either both commit, or neither does. Your service cannot write to the database without the message following, and cannot send a message without the database write persisting.

  1. Atomic write. When ProduceAsync is called within a transaction scope, the outbox entry is written to the database in the same transaction as your business data. Both commit together.

  2. Background delivery. A daemon polls the outbox, picks up pending entries, and delivers them to the message broker. Your endpoint returns immediately after the commit.

  3. Delete on success. Once a batch’s delivery is confirmed, its entries are deleted in one database call. No status fields, no state machine: entry present means pending, entry absent means delivered.

  4. Automatic retry. If delivery fails, the entry stays in place and is retried on the next poll cycle. The outbox guarantees at-least-once delivery.

Pick the approach that fits your context:

You’re writing a…ProviderRecommended approach
Kafka consumer or mediator handlerEF Core or MongoDB[Transactional] attribute
Controller, endpoint, or simple serviceEF CoreImplicit (SaveChangesAsync)
Hosted service or background jobEF Core or MongoDBIUnitOfWork
Anything needing conditional commit logicEF Core or MongoDBIUnitOfWork

The simplest option for consumers and handlers. Add the attribute to your class and the pipeline handles begin, commit, and rollback automatically. Each retry attempt gets a fresh transaction.

[Transactional]
public sealed class MyHandler : IRequestHandler<MyCommand>
{
public async Task HandleAsync(MyCommand request, CancellationToken ct)
{
await eventRepository.InsertAsync(request.Event, ct);
await producer.ProduceAsync(..., ct);
// Transaction committed automatically by middleware.
}
}

Produce messages and call SaveChangesAsync. The outbox entry and your business entities share the same EF Core change tracker, so they’re flushed atomically in a single implicit database transaction. No ceremony needed.

public async Task HandleAsync(PlacePizzaOrder request, CancellationToken ct)
{
dbContext.PizzaOrders.Add(new PizzaOrder(request));
await producer.ProduceAsync(..., ct);
await dbContext.SaveChangesAsync(ct);
}

For scenarios outside the pipeline, such as hosted services or background jobs, or when you need explicit control over when to commit or roll back. Inject IUnitOfWork, call BeginAsync, do your work, then CommitAsync. Works with both EF Core and MongoDB.

await using var tx = await unitOfWork.BeginAsync(ct);
// ... business writes and produce calls ...
await tx.CommitAsync(ct);

If anything throws before CommitAsync, await using aborts the transaction automatically. For provider-specific examples, see EF Core and MongoDB.

Call UseOutbox() on your persistence provider to enable the outbox infrastructure. Once it is configured, all producers route through the outbox by default. Call UseDirect() on any producer that should bypass it.

emit.AddMongoDb(mongo =>
{
mongo.Configure((sp, ctx) => { ... })
.UseOutbox(options =>
{
options.PollingInterval = TimeSpan.FromSeconds(5); // default
options.BatchSize = 100; // default
options.MaxConcurrentGroups = 32; // default
});
});
emit.AddKafka(kafka =>
{
kafka.ConfigureClient(cfg => cfg.BootstrapServers = "localhost:9092");
kafka.Topic<string, PizzaOrdered>("pizzas", topic =>
{
topic.Producer(); // routes through outbox by default
});
kafka.Topic<string, AnalyticsEvent>("analytics", topic =>
{
topic.Producer(p => p.UseDirect()); // bypasses the outbox
});
});
OptionDefaultRangeDescription
PollingInterval5 secondsminimum 1 secondIdle wait between poll cycles. When a full batch is fetched and fully delivered, the next poll starts immediately to drain the backlog.
BatchSize1001 to 10,000Maximum entries fetched per poll cycle, ordered by sequence (FIFO).
MaxConcurrentGroups32minimum 1Maximum entry groups delivered in parallel within a poll cycle.

[Transactional] and IUnitOfWork come from the persistence provider and do not need UseOutbox(); the outbox decides only where producers send messages. See Unit of Work.

For provider-specific registration details, see EF Core and MongoDB.

Messages are ordered by group key. The default group key for a Kafka producer is kafka:{topic} (for example, kafka:pizzas). When a partition key is present, the group key is kafka:{topic}:{partitionKey}. All entries within the same group are delivered strictly in sequence.

The worker processes different groups in parallel, up to MaxConcurrentGroups at a time, so two topics make progress independently. Within a single group, entries are delivered one at a time in enqueue order.

The outbox worker runs as a daemon: exactly one instance of your service processes the outbox at any time, and only the entries that service enqueued. If the assigned node goes down, the leader reassigns the daemon to another node once its registration expires (NodeRegistrationTtl, default: 90 seconds).

This prevents concurrent workers from racing on the same group and producing duplicate deliveries. Where the message broker supports transactions, as Kafka does, each batch is delivered atomically.

When delivery to the message broker fails, the worker logs the error and leaves the batch in place. The whole batch is delivered again on the next poll cycle, so an entry that can never be delivered holds back the entries after it until you deal with it. There is no exponential backoff at the outbox level; retries at the broker layer (such as Kafka producer retries) handle that. If the worker’s node cannot reach the broker, the worker moves to another node after MaxConsecutiveFailures failures. The worker only reads entries of the providers its node has; an entry of another provider waits for a node that has it, without holding back the others.

The outbox is stubborn persistence, not smart retry logic. It keeps trying until it succeeds. If you need dead-lettering or routing after repeated failures, that belongs at the broker layer. The outbox guarantees at-least-once delivery; everything else is upstream.