Outbox Pattern a reliable way of saving state to your database and publishing a message/event to a message broker
Achieve Data Consistency and Reliable Messaging Without Distributed Transactions Using the Outbox Pattern
Data consistency in a distributed system where activities span several tasks is very challenging. A typical example is an activity that updates a database and sends a message to a message broker. Such an operation is challenging to implement atomically because the resources are different. Traditionally, such problems have been solved with distributed transactions, which also have their challenges. Another approach that keeps things reliable without the hassle of dealing with distributed transactions is the Outbox Pattern.
Understanding Distributed Transactions
With distributed transactions, we have the guarantee of all or nothing for a group of actions on multiple resources, keeping data consistency across a system. Usually this is done using some protocol like two-phase commit (2PC). The tricky thing is a distributed transaction can really add lots of complexity and performance overhead. It can become the bottleneck very easily if you factor in that from my perspective scalability and low coupling / independence (which I think are highly related) are the most important things in microservices architecture.
Introducing the Outbox Pattern
The Outbox Pattern solves the dual-write problem by making database and message broker updates atomic, consistent, and durable. It does this by moving the responsibility of managing system events from the application to the database.
Here’s how it works:
- Transactional Write: The application writes the business data and publishes the message to the message broker within a database transaction.
- Outbox Processing: A separate process/thread consumes events from the outbox table and publishes them to the message broker.
- Message Deletion: Once published successfully the message is deleted from outbox table or marked as sent.
By separating database operation with message publishing, Outbox Pattern guarantees that messages won’t be lost and the system will not be in an inconsistent state without using distributed transactions.
Implementing the Outbox Pattern: A Code Example
Implementing the Outbox Pattern in C# with ASP.NET Core and Entity Framework Core examples saving an order to a database and publishing an event to a message broker.
1. Setting Up the Database
We will have two tables:
- Orders table to store order data.
- OutboxEvents table to store events that need to be published.
Entity Classes:
public class Order
{
public Guid Id { get; set; }
public string CustomerName { get; set; }
public decimal TotalAmount { get; set; }
public DateTime CreatedAt { get; set; }
}
public class OutboxEvent
{
public Guid Id { get; set; }
public string EventType { get; set; }
public string Payload { get; set; }
public DateTime CreatedAt { get; set; }
public bool Processed { get; set; }
}
DbContext:
public class AppDbContext : DbContext
{
public DbSet<Order> Orders { get; set; }
public DbSet<OutboxEvent> OutboxEvents { get; set; }
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>().ToTable("Orders");
modelBuilder.Entity<OutboxEvent>().ToTable("OutboxEvents");
}
}
2. Creating the Order and Saving the Outbox Event
This ensures both operations are part of the same database transaction.
public class OrderService
{
private readonly AppDbContext _context;
public OrderService(AppDbContext context)
{
_context = context;
}
public async Task<Order> CreateOrderAsync(Order order)
{
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// Save the order
order.Id = Guid.NewGuid();
order.CreatedAt = DateTime.UtcNow;
_context.Orders.Add(order);
await _context.SaveChangesAsync();
// Create an outbox event
var outboxEvent = new OutboxEvent
{
Id = Guid.NewGuid(),
EventType = "OrderCreated",
Payload = JsonSerializer.Serialize(order),
CreatedAt = DateTime.UtcNow,
Processed = false
};
_context.OutboxEvents.Add(outboxEvent);
await _context.SaveChangesAsync();
// Commit transaction
await transaction.CommitAsync();
return order;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}
3. Processing the Outbox Table
A background service retrieves and processes unprocessed events from the OutboxEvents table.
public class OutboxProcessor : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
public OutboxProcessor(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _serviceProvider.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var messageBrokerClient = scope.ServiceProvider.GetRequiredService<IMessageBrokerClient>();
var events = await context.OutboxEvents
.Where(e => !e.Processed)
.ToListAsync(stoppingToken);
foreach (var outboxEvent in events)
{
try
{
// Publish the event
await messageBrokerClient.PublishAsync(outboxEvent.EventType, outboxEvent.Payload);
// Mark as processed
outboxEvent.Processed = true;
context.OutboxEvents.Update(outboxEvent);
await context.SaveChangesAsync(stoppingToken);
}
catch (Exception ex)
{
// Log the error and continue
Console.WriteLine($"Failed to process event {outboxEvent.Id}: {ex.Message}");
}
}
// Delay to avoid busy looping
await Task.Delay(1000, stoppingToken);
}
}
}
4. IMessageBrokerClient Interface
This is a simple interface for publishing messages to a broker (e.g., RabbitMQ, Kafka, Service Bus).
public interface IMessageBrokerClient
{
Task PublishAsync(string eventType, string payload);
}
public class FakeMessageBrokerClient : IMessageBrokerClient
{
public Task PublishAsync(string eventType, string payload)
{
Console.WriteLine($"Published event: {eventType}, Payload: {payload}");
return Task.CompletedTask;
}
}
5. Registering Dependencies and Hosted Service
In Program.cs register the services:
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddDbContext<AppDbContext>(options =>options.UseSqlServer("YourConnectionString"));
builder.Services.AddScoped<OrderService>();
builder.Services.AddSingleton<IMessageBrokerClient, FakeMessageBrokerClient>();
builder.Services.AddHostedService<OutboxProcessor>();
var app = builder.Build();
app.Run();
Ensuring At-Least-Once Delivery
The Outbox Pattern inherently provides at-least-once delivery guarantees. If the message publication fails or the system crashes before marking the event as processed, the OutboxProcessor will retry, potentially leading to duplicate messages. Therefore, it’s crucial to design idempotent consumers that can handle duplicate messages gracefully.
Benefits of the Outbox Pattern
- Data Consistency: The state in the database and the messages sent to the broker are consistent.
- Decoupling: Decouples the data persistence concern from that of message publication, resulting in a more maintainable codebase.
- Reliability: Guarantees that messages will be eventually delivered despite failures.
Conclusion
The Outbox Pattern is responsible for maintaining consistency between your database and message broker without utilizing distributed transactions. This C# example will show how to robustly implement the pattern in a modern.NET application.