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 delivery is confirmed, the entry is deleted. 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.

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 in your cluster processes the outbox at any time. If the assigned node goes down, the leader reassigns the daemon to another node within one heartbeat cycle (default: 15 seconds).

This prevents concurrent workers from racing on the same group and producing duplicate deliveries.

When delivery to the message broker fails, the worker logs the error and leaves the entry in place. It will be retried on the next poll cycle. There is no exponential backoff at the outbox level; retries at the broker layer (such as Kafka producer retries) handle that.

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.