The Outbox Pattern for Reliable Messaging in .NET
You save an order to the database and publish an OrderPlaced event to a message broker. The database write succeeds. The publish fails. Now your system is inconsistent — the order exists but nothing downstream knows about it. This is the dual-write problem, and the outbox pattern solves it.
The Problem
Databases and message brokers are separate systems. You can't wrap both in a single transaction (well, you can with distributed transactions, but that path leads to pain and poor performance). If either operation fails independently, your data and your messages drift apart.
How the Outbox Works
Instead of publishing directly to a broker, you write the message to an outbox table in the same database transaction as your business data. A separate background process reads the outbox and publishes messages to the broker. If publishing fails, the process retries. The message stays in the outbox until it's confirmed delivered.
The flow:
- Begin transaction
- Save business data (e.g., insert order)
- Save message to outbox table
- Commit transaction
- Background worker reads outbox, publishes to broker, marks as processed
Steps 2 and 3 are atomic — they succeed or fail together.
The Outbox Table
public class OutboxMessage
{
public Guid Id { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
}
Configure it in EF Core:
modelBuilder.Entity<OutboxMessage>(builder =>
{
builder.ToTable("OutboxMessages");
builder.HasKey(m => m.Id);
builder.Property(m => m.Payload).HasColumnType("nvarchar(max)");
builder.HasIndex(m => m.ProcessedAt)
.HasFilter("[ProcessedAt] IS NULL");
});
The filtered index ensures efficient queries for unprocessed messages.
Writing to the Outbox
When saving business data, add the outbox message in the same transaction:
public class PlaceOrderHandler
{
private readonly AppDbContext _db;
public PlaceOrderHandler(AppDbContext db) => _db = db;
public async Task<Guid> Handle(PlaceOrderCommand command, CancellationToken ct)
{
var order = new Order(command.CustomerEmail);
foreach (var line in command.Lines)
order.AddLine(line.Product, line.Quantity, line.UnitPrice);
_db.Orders.Add(order);
var @event = new OrderPlacedEvent(order.Id, order.CustomerEmail, order.Total);
_db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
Type = nameof(OrderPlacedEvent),
Payload = JsonSerializer.Serialize(@event),
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct);
return order.Id;
}
}
Both the order and the message are saved in a single SaveChangesAsync call — one transaction, guaranteed consistency.
The Background Publisher
A hosted service polls the outbox and publishes messages:
public class OutboxProcessor : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IMessagePublisher _publisher;
private readonly ILogger<OutboxProcessor> _logger;
public OutboxProcessor(
IServiceScopeFactory scopeFactory,
IMessagePublisher publisher,
ILogger<OutboxProcessor> logger)
{
_scopeFactory = scopeFactory;
_publisher = publisher;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var messages = await db.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.CreatedAt)
.Take(20)
.ToListAsync(ct);
foreach (var message in messages)
{
try
{
await _publisher.PublishAsync(message.Type, message.Payload, ct);
message.ProcessedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to publish outbox message {MessageId}", message.Id);
break;
}
}
await db.SaveChangesAsync(ct);
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}
Handling Duplicates
The outbox guarantees at-least-once delivery. If the publisher succeeds but crashes before marking the message as processed, it will publish again on restart. Consumers must be idempotent — processing the same message twice should produce the same result.
Common strategies:
- Track processed message IDs in the consumer
- Use natural idempotency (e.g., upserting a record is naturally idempotent)
Using MassTransit's Built-in Outbox
MassTransit provides a production-ready transactional outbox with EF Core:
builder.Services.AddMassTransit(x =>
{
x.AddEntityFrameworkOutbox<AppDbContext>(o =>
{
o.UseSqlServer();
o.UseBusOutbox();
});
x.UsingRabbitMq((context, cfg) =>
{
cfg.ConfigureEndpoints(context);
});
});
This handles message serialisation, retry logic, and delivery confirmation out of the box.
When to Use the Outbox
Use the outbox pattern whenever you need to reliably publish events or messages as part of a business operation. It's essential in microservice architectures where services communicate via messaging, and it's a far better solution than hoping both writes succeed or building manual compensation logic.