There's a class of bug that's almost invisible until it causes a customer support ticket: the application saved the data, but the email didn't send, the webhook didn't fire, or the downstream process never ran. The cause is almost always the same — a gap between the database commit and the side effect.
The problem
The naive implementation looks like this: save the order to the database, then send the confirmation email. If the process crashes between the commit and the email send, the order exists but the customer never heard from you. Retry logic helps but creates duplicates. Transactions don't span external services.
The outbox pattern
The solution is to write the intent to send the email into the same database transaction as the order. A separate background process reads the outbox table and processes messages. If it fails, it retries. If it succeeds, it marks the message as processed. The order and the email are now atomically linked.
Outbox message processing in .NET
public class OutboxProcessor : BackgroundService {
protected override async Task ExecuteAsync(CancellationToken ct) {
while (!ct.IsCancellationRequested) {
var messages = await _repo.GetUnprocessedAsync(batchSize: 20, ct);
foreach (var msg in messages) {
try {
await _dispatcher.DispatchAsync(msg.Type, msg.Payload, ct);
await _repo.MarkProcessedAsync(msg.Id, ct);
} catch (Exception ex) {
await _repo.IncrementRetryAsync(msg.Id, ex.Message, ct);
}
}
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}Why we use it everywhere
Every engagement we take on ships with an outbox implementation. The overhead is minimal — it's a table, a background job, and a dispatch interface. The benefit is eliminating an entire category of subtle, hard-to-reproduce data consistency bugs. That's a trade we make every time.