The outbox pattern in .NET: reliable events without distributed transactions

Short answer: when a request must change your database and tell another system about it, don't do two writes. Write the business change and an outbox row describing the event in the same database transaction, then let a background worker read the outbox and publish. Either both rows exist or neither does, so you can never save a payment and lose its event, or announce a payment that was rolled back.

A real example: the payment that nobody heard about

A wallet top-up succeeds. The code saves the payment, then calls the message broker to publish PaymentSucceeded so the notification and loyalty services can react. The broker is restarting for eight seconds. The publish throws, the user sees an error, but the payment row is already committed.

The money is in the wallet, no SMS is sent, no points are added, and support gets a ticket that says "you charged me but nothing happened". Swap the order (publish first, then save) and you get the opposite bug: the loyalty service adds points for a payment whose transaction later rolled back.

There is no ordering of two independent writes that is safe. The outbox removes the second write from the request path.

How the outbox works

  1. In the request, save the entity and an OutboxMessage row in one SaveChangesAsync call. EF Core wraps it in one transaction.
  2. A BackgroundService polls the outbox for unprocessed rows, publishes each one and marks it processed.
  3. If publishing fails, the row stays unprocessed and is retried on the next tick.
  4. Consumers deduplicate by message id, because a message can be delivered more than once.

Runnable code

The outbox table:

public sealed class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Type { get; set; } = "";
    public string Payload { get; set; } = "";
    public DateTime OccurredAtUtc { get; set; } = DateTime.UtcNow;
    public DateTime? ProcessedAtUtc { get; set; }
    public int Attempts { get; set; }
    public string? LastError { get; set; }
}

public sealed record PaymentSucceeded(Guid PaymentId, Guid UserId, long AmountRials);

Writing state and event together:

app.MapPost("/payments/{id:guid}/confirm", async (Guid id, AppDbContext db, CancellationToken ct) =>
{
    var payment = await db.Payments.FindAsync([id], ct);
    if (payment is null) return Results.NotFound();

    payment.MarkSucceeded();

    db.OutboxMessages.Add(new OutboxMessage
    {
        Type = nameof(PaymentSucceeded),
        Payload = JsonSerializer.Serialize(
            new PaymentSucceeded(payment.Id, payment.UserId, payment.AmountRials)),
    });

    await db.SaveChangesAsync(ct); // one transaction: both rows or neither
    return Results.Ok();
});

The dispatcher:

public sealed class OutboxDispatcher(
    IServiceScopeFactory scopes,
    ILogger<OutboxDispatcher> log) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
        while (await timer.WaitForNextTickAsync(ct))
        {
            await using var scope = scopes.CreateAsyncScope();
            var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var publisher = scope.ServiceProvider.GetRequiredService<IEventPublisher>();

            var batch = await db.OutboxMessages
                .Where(m => m.ProcessedAtUtc == null && m.Attempts < 10)
                .OrderBy(m => m.OccurredAtUtc)
                .Take(50)
                .ToListAsync(ct);

            foreach (var msg in batch)
            {
                try
                {
                    await publisher.PublishAsync(msg.Id, msg.Type, msg.Payload, ct);
                    msg.ProcessedAtUtc = DateTime.UtcNow;
                }
                catch (Exception ex)
                {
                    msg.Attempts++;
                    msg.LastError = ex.Message;
                    log.LogWarning(ex, "Outbox message {MessageId} failed", msg.Id);
                }
            }

            await db.SaveChangesAsync(ct);
        }
    }
}

// Program.cs
builder.Services.AddHostedService<OutboxDispatcher>();

IEventPublisher is whatever actually delivers the event: RabbitMQ, Azure Service Bus, an HTTP webhook, or even an in-process handler. The outbox doesn't care.

Details that matter in production

  • Index the unprocessed rows. A filtered index on OccurredAtUtc where ProcessedAtUtc IS NULL keeps polling cheap as the table grows.
  • Clean up. Delete processed rows older than a few days in a nightly job, or the table becomes your largest one.
  • Poison messages. Attempts < 10 stops one broken message from being retried forever. Alert on rows that hit the limit.
  • Consumers must be idempotent. Pass msg.Id as the message id and have consumers record ids they have handled. It is the same idea as idempotency keys for payment APIs.
  • Multiple instances. Two dispatchers can read the same rows. Use UPDLOCK, READPAST, ROWLOCK hints with a raw SQL query on SQL Server, or run the dispatcher on a single instance.

Pros and cons

Pros

  • No lost events and no phantom events: the database transaction is the single source of truth.
  • The request no longer depends on the broker being up; a broker outage delays events instead of failing payments.
  • Every event is stored, which gives you a free audit of what was announced and when.

Cons

  • Events are delivered with a small delay (the polling interval), not instantly.
  • At-least-once delivery means every consumer needs deduplication.
  • One more table and one more background process to monitor.

Building payment or wallet flows where events between services must not get lost? See how I can help.

Frequently asked questions

Does the outbox give me exactly-once delivery?

No. It gives at-least-once: a message can be published, then the worker crashes before marking it processed, and it is published again. Make consumers idempotent by storing the message id they have already handled.

Should I use MassTransit or CAP instead of writing my own?

If you already use a broker library, use its built-in outbox; it handles locking, retries and cleanup. A hand-written outbox like this one is fine for a single service and a few message types, and it is a good way to understand what the libraries do.

What if I run several instances of the service?

Two workers can pick the same rows. On SQL Server, read the batch with UPDLOCK, READPAST and ROWLOCK hints inside a transaction so each instance skips rows another one has locked, or run the dispatcher on one instance only.