Outbox Pattern with Entity Framework Core
Reading Time: 5 minutes
The Problem
In distributed systems, a common workflow looks like this:
- Save business data to your database
- Publish an integration event to a message broker
A naive implementation might do both in sequence:
await dbContext.SaveChangesAsync();
await messageBus.PublishAsync(new OrderPlaced(orderId));
This creates a dangerous failure window.
What if the DB save succeeds but message publishing fails?
- Your service state changed
- Downstream services never hear about it
- Data becomes inconsistent across services
This is one of the most common reliability gaps in event-driven systems.
Why Not Use Distributed Transactions?
In modern cloud systems, distributed transactions across database + broker are often:
- Not supported end-to-end
- Operationally complex
- Expensive and brittle at scale
The Outbox pattern solves this by embracing eventual consistency with reliable delivery semantics.
Outbox Pattern in One Sentence
Write business data and the event message to an OutboxMessages table in the same local database transaction, then publish from that table asynchronously.
If the transaction commits, both are persisted. If it rolls back, neither is persisted.
High-Level Flow
- Application handles command (for example, place order)
- Domain/entity changes are prepared
- Integration event is serialized into outbox row
- Single
SaveChangescommits everything atomically - Background processor reads unprocessed outbox rows
- Publishes to broker
- Marks row as processed (or records failure for retry)
Entity Framework Core Implementation
1. Outbox entity
public sealed class OutboxMessage
{
public Guid Id { get; set; }
public DateTime OccurredOnUtc { get; set; }
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public DateTime? ProcessedOnUtc { get; set; }
public string? Error { get; set; }
// Optional metadata
public int RetryCount { get; set; }
public DateTime? NextAttemptOnUtc { get; set; }
}
2. Add to DbContext
public sealed class AppDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OutboxMessage>(b =>
{
b.HasKey(x => x.Id);
b.Property(x => x.Type).HasMaxLength(500).IsRequired();
b.Property(x => x.Payload).IsRequired();
b.HasIndex(x => new { x.ProcessedOnUtc, x.NextAttemptOnUtc });
});
}
}
3. Write business data + outbox in one transaction
public sealed class OrderService
{
private readonly AppDbContext _db;
private readonly ISystemClock _clock;
public OrderService(AppDbContext db, ISystemClock clock)
{
_db = db;
_clock = clock;
}
public async Task<Guid> PlaceOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
var order = Order.Create(request.CustomerId, request.Items);
_db.Orders.Add(order);
var integrationEvent = new
{
EventId = Guid.NewGuid(),
OrderId = order.Id,
CustomerId = order.CustomerId,
OccurredOnUtc = _clock.UtcNow
};
var outbox = new OutboxMessage
{
Id = Guid.NewGuid(),
OccurredOnUtc = _clock.UtcNow,
Type = "OrderPlacedIntegrationEvent",
Payload = JsonSerializer.Serialize(integrationEvent),
NextAttemptOnUtc = _clock.UtcNow
};
_db.OutboxMessages.Add(outbox);
await _db.SaveChangesAsync(ct);
return order.Id;
}
}
At this point, you have atomic persistence of both state and event record.
Background Publisher (Hosted Service)
Use a BackgroundService to publish pending outbox rows.
public sealed class OutboxPublisher : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OutboxPublisher> _logger;
public OutboxPublisher(IServiceScopeFactory scopeFactory, ILogger<OutboxPublisher> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await PublishBatchAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Outbox publishing loop failed");
}
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
private async Task PublishBatchAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
var now = DateTime.UtcNow;
var messages = await db.OutboxMessages
.Where(x => x.ProcessedOnUtc == null && (x.NextAttemptOnUtc == null || x.NextAttemptOnUtc <= now))
.OrderBy(x => x.OccurredOnUtc)
.Take(50)
.ToListAsync(ct);
foreach (var message in messages)
{
try
{
await bus.PublishAsync(message.Type, message.Payload, ct);
message.ProcessedOnUtc = now;
message.Error = null;
}
catch (Exception ex)
{
message.RetryCount++;
message.Error = ex.Message;
message.NextAttemptOnUtc = now.AddSeconds(Math.Min(60, 2 * message.RetryCount));
}
}
await db.SaveChangesAsync(ct);
}
}
Idempotency: The Other Half of Reliability
Even with Outbox, publish can happen more than once (for example, crash after publish but before ProcessedOnUtc update).
That means consumers should be idempotent:
- Include a stable event ID
- Track processed event IDs on the consumer side
- Ignore duplicates safely
Outbox usually guarantees at-least-once delivery, not exactly-once.
EF Core SaveChanges Interceptor (Advanced Option)
To reduce manual boilerplate, you can capture domain events from tracked aggregates in a SaveChangesInterceptor and write outbox rows automatically.
Benefits:
- Consistent outbox writing
- Less repeated application code
- Cleaner command handlers
Tradeoff:
- More abstraction and complexity
- Requires clear domain event conventions
If your team is early in adoption, explicit outbox writes are often easier to reason about first.
Operational Considerations
- Retention: archive or delete processed outbox rows periodically
- Monitoring: alert on backlog growth and repeated failures
- Poison messages: move permanently failing messages to dead-letter workflow
- Batching: tune batch size and polling interval
- Concurrency: if running multiple publisher instances, apply row-locking/claim strategy to avoid duplicate contention
Minimal DI Setup
builder.Services.AddHostedService<OutboxPublisher>();
And register your IMessageBus implementation for RabbitMQ, Azure Service Bus, Kafka, etc.
Common Mistakes
- Saving business data and event in separate transactions
- No retry policy for transient broker failures
- No idempotency in consumers
- No monitoring of unprocessed outbox depth
- Infinite retries without dead-letter strategy
Summary
The Outbox pattern is one of the most practical reliability patterns in modern .NET systems.
With Entity Framework Core, implementation is straightforward:
- Persist business state and outbox event together in one transaction
- Publish asynchronously with a background worker
- Handle retries and idempotency explicitly
If your service both writes data and publishes integration events, Outbox is often the safest default architecture.
References
- Transactional outbox pattern: https://microservices.io/patterns/data/transactional-outbox.html
- EF Core interceptors: https://learn.microsoft.com/ef/core/logging-events-diagnostics/interceptors