.NET Microservices — Outbox Pattern for Reliable Messaging
Transactional Outbox → Relay to Service Bus
Introduction
The Outbox Pattern solves one of the hardest problems in distributed systems: guaranteeing that database changes and message publishing happen atomically. Without this pattern, you face the classic dual-write problem where your database commits successfully but message publishing fails, leading to data inconsistency.
The solution: store messages in a database "outbox" table within the same transaction as your business data, then have a separate process relay those messages to Service Bus.
The Dual-Write Problem
Without Outbox Pattern
User Order Request
│
▼
┌──────────────┐ ┌───────────────┐
│ Save to DB │ │ Send to SB │
└──────────────┘ └───────────────┘
│ │
▼ ▼
SUCCESS ❌ FAILURE → Inconsistent!
With Outbox Pattern
User Order Request
│
▼
┌───────────────────────────────────┐
│ SINGLE TRANSACTION │
│ 1. Save Order │
│ 2. Save to Outbox │
└───────────────────────────────────┘
│
▼
┌──────────────┐ ┌───────────────┐
│ DB Commit │ │ ACK from SB │
│ (Atomic) │ │ (Later) │
└──────────────┘ └───────────────┘
│
▼
┌───────────────────────────────────┐
│ Background Processor │
│ • Reads outbox │
│ • Sends to Service Bus │
│ • Marks as processed │
└───────────────────────────────────┘
Implementation
Database Models
// Order Entity
public class Order
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public OrderStatus Status { get; set; }
public decimal TotalAmount { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
// Navigation
public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}
// OrderItem
public class OrderItem
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public string ProductId { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
// Navigation
public Order Order { get; set; }
}
// Outbox Message
public class OutboxMessage
{
public Guid Id { get; set; }
public string MessageType { get; set; }
public string Payload { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ProcessedAt { get; set; }
public int RetryCount { get; set; }
public string? LastError { get; set; }
// Index for efficient querying
public DateTime IndexProcessedAt => ProcessedAt ?? DateTime.MaxValue;
}
DbContext Configuration
public class OrderDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<OrderItem> OrderItems => Set<OrderItem>();
public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Order configuration
modelBuilder.Entity<Order>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.TotalAmount).HasPrecision(18, 2);
entity.HasMany(e => e.Items)
.WithOne(i => i.Order)
.HasForeignKey(i => i.OrderId);
});
// Outbox message configuration
modelBuilder.Entity<OutboxMessage>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.MessageType).HasMaxLength(200);
entity.HasIndex(e => e.ProcessedAt);
entity.HasIndex(e => e.CreatedAt);
});
}
}
Transactional Service
Creating Order with Outbox
public class OrderService
{
private readonly OrderDbContext _dbContext;
private readonly ILogger<OrderService> _logger;
public OrderService(OrderDbContext dbContext, ILogger<OrderService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
// Start transaction
await using var transaction = await _dbContext.Database
.BeginTransactionAsync();
try
{
// 1. Create Order
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = request.CustomerId,
Status = OrderStatus.Pending,
TotalAmount = request.Items.Sum(i => i.Quantity * i.UnitPrice),
CreatedAt = DateTime.UtcNow
};
_dbContext.Orders.Add(order);
// 2. Add Order Items
foreach (var item in request.Items)
{
var orderItem = new OrderItem
{
Id = Guid.NewGuid(),
OrderId = order.Id,
ProductId = item.ProductId,
Quantity = item.Quantity,
UnitPrice = item.UnitPrice
};
_dbContext.OrderItems.Add(orderItem);
}
// 3. Save order and items
await _dbContext.SaveChangesAsync();
// 4. Create outbox message
var outboxMessage = new OutboxMessage
{
Id = Guid.NewGuid(),
MessageType = "OrderCreated",
Payload = JsonSerializer.Serialize(new OrderCreatedEvent
{
OrderId = order.Id,
CustomerId = order.CustomerId,
TotalAmount = order.TotalAmount,
Items = request.Items.Select(i => new OrderItemEvent
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList(),
CreatedAt = order.CreatedAt
}),
CreatedAt = DateTime.UtcNow
};
_dbContext.OutboxMessages.Add(outboxMessage);
// 5. Save outbox message
await _dbContext.SaveChangesAsync();
// 6. Commit transaction
await transaction.CommitAsync();
_logger.LogInformation("Order {OrderId} created with outbox message {MessageId}",
order.Id, outboxMessage.Id);
return order;
}
catch (Exception ex)
{
// Rollback on any failure
await transaction.RollbackAsync();
_logger.LogError(ex, "Failed to create order");
throw;
}
}
}
Outbox Processor (Background Service)
Processing Messages
public class OutboxProcessor : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly ServiceBusClient _serviceBusClient;
private readonly ILogger<OutboxProcessor> _logger;
private readonly TimeSpan _pollingInterval = TimeSpan.FromSeconds(5);
private const int MaxBatchSize = 100;
public OutboxProcessor(
IServiceProvider serviceProvider,
ServiceBusClient serviceBusClient,
ILogger<OutboxProcessor> logger)
{
_serviceProvider = serviceProvider;
_serviceBusClient = serviceBusClient;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Outbox processor starting");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessOutboxMessagesAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing outbox messages");
}
await Task.Delay(_pollingInterval, stoppingToken);
}
_logger.LogInformation("Outbox processor stopping");
}
private async Task ProcessOutboxMessagesAsync(CancellationToken cancellationToken)
{
using var scope = _serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<OrderDbContext>();
// Get unprocessed messages
var messages = await dbContext.OutboxMessages
.Where(m => m.ProcessedAt == null)
.OrderBy(m => m.CreatedAt)
.Take(MaxBatchSize)
.ToListAsync(cancellationToken);
if (!messages.Any())
{
return;
}
_logger.LogInformation("Processing {Count} outbox messages", messages.Count);
var sender = _serviceBusClient.CreateSender("order-events");
foreach (var message in messages)
{
try
{
// Send to Service Bus
var serviceBusMessage = new ServiceBusMessage(message.Payload)
{
ContentType = "application/json",
MessageId = message.Id.ToString(),
Subject = message.MessageType,
ApplicationProperties =
{
["MessageType"] = message.MessageType,
["CreatedAt"] = message.CreatedAt.ToString("O")
}
};
await sender.SendMessageAsync(serviceBusMessage, cancellationToken);
// Mark as processed
message.ProcessedAt = DateTime.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Processed outbox message {MessageId} for {MessageType}",
message.Id, message.MessageType);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process message {MessageId}", message.Id);
// Update retry count and error
message.RetryCount++;
message.LastError = ex.Message;
// Don't mark as processed - will retry
await dbContext.SaveChangesAsync(cancellationToken);
}
}
}
}
Event Classes
Domain Events
public record OrderCreatedEvent
{
public Guid OrderId { get; init; }
public Guid CustomerId { get; init; }
public decimal TotalAmount { get; init; }
public List<OrderItemEvent> Items { get; init; }
public DateTime CreatedAt { get; init; }
}
public record OrderItemEvent
{
public string ProductId { get; init; }
public int Quantity { get; init; }
public decimal UnitPrice { get; init; }
}
public record OrderStatusChangedEvent
{
public Guid OrderId { get; init; }
public OrderStatus OldStatus { get; init; }
public OrderStatus NewStatus { get; init; }
public string? Reason { get; init; }
public DateTime ChangedAt { get; init; }
}
Advanced: Cleanup and Monitoring
Cleanup Old Messages
public class OutboxCleanupService : IHostedService
{
public async Task CleanupOldMessagesAsync(OrderDbContext dbContext)
{
var cutoff = DateTime.UtcNow.AddDays(7);
// Delete processed messages older than 7 days
var deleted = await dbContext.OutboxMessages
.Where(m => m.ProcessedAt != null && m.ProcessedAt < cutoff)
.ExecuteDeleteAsync();
_logger.LogInformation("Cleaned up {Count} old outbox messages", deleted);
}
}
Monitoring
public class OutboxMetrics
{
public async Task<OutboxStatus> GetStatusAsync(OrderDbContext dbContext)
{
var total = await dbContext.OutboxMessages.CountAsync();
var pending = await dbContext.OutboxMessages
.Where(m => m.ProcessedAt == null)
.CountAsync();
var failed = await dbContext.OutboxMessages
.Where(m => m.ProcessedAt == null && m.RetryCount >= 3)
.CountAsync();
return new OutboxStatus
{
Total = total,
Pending = pending,
Failed = failed,
ProcessedToday = await dbContext.OutboxMessages
.Where(m => m.ProcessedAt >= DateTime.UtcNow.Date)
.CountAsync()
};
}
}
Best Practices
| Practice | Description |
|---|---|
| Same transaction | Order and outbox must be in same transaction |
| Idempotent consumers | Handle duplicate messages gracefully |
| Process frequently | Don't let messages accumulate |
| Monitor backlog | Alert on pending message buildup |
| Set retry limits | Move to dead letter after failures |
| Clean up old messages | Delete processed messages regularly |
Comparison: Without vs With Outbox
| Aspect | Without Outbox | With Outbox |
|---|---|---|
| Consistency | Dual-write can fail | Guaranteed atomic |
| Complexity | Simple but risky | More complex but reliable |
| Latency | Immediate | Slight delay |
| Cost | None | Additional storage |
| Recovery | Manual | Automatic retry |
Azure Integration Hub - Advanced Level