.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

PracticeDescription
Same transactionOrder and outbox must be in same transaction
Idempotent consumersHandle duplicate messages gracefully
Process frequentlyDon't let messages accumulate
Monitor backlogAlert on pending message buildup
Set retry limitsMove to dead letter after failures
Clean up old messagesDelete processed messages regularly

Comparison: Without vs With Outbox

AspectWithout OutboxWith Outbox
ConsistencyDual-write can failGuaranteed atomic
ComplexitySimple but riskyMore complex but reliable
LatencyImmediateSlight delay
CostNoneAdditional storage
RecoveryManualAutomatic retry

Azure Integration Hub - Advanced Level