Leveraging the Outbox Pattern with Transactional Third-Party Calls in C#
When building modern distributed systems, it's common to rely on third-party services for functionality such as payments, notifications, or data synchronization. Ensuring that calls to these third-party endpoints are executed reliably and within the bounds of a single transaction can be challenging. This is where the Outbox Pattern shines, enabling reliable message processing while adhering to transactional integrity.
In this post, we’ll explore how to implement the Outbox Pattern in C# to coordinate two third-party API calls within the same logical transaction.
Problem Statement
Imagine you’re building an order-processing system. When an order is created, you need to:
- Notify a payment gateway to process the payment.
- Inform a shipping service to prepare the order for dispatch.
Both operations need to be consistent with the creation of the order in your database. If either fails, the system should not leave the order in an inconsistent state.
The Outbox Pattern
The Outbox Pattern decouples the persistence of data from the execution of side effects (e.g., API calls). Here’s how it works:
- Transactional Outbox: Store the events or operations to be executed in an "outbox" table as part of the same database transaction when persisting your primary business data (e.g., the order).
- Outbox Processor: A background worker reliably reads the outbox table and performs the operations asynchronously.
Implementation in C#
1. Define the Outbox Table
1CREATE TABLE Outbox ( 2 Id UNIQUEIDENTIFIER PRIMARY KEY, 3 EventType NVARCHAR(100), 4 Payload NVARCHAR(MAX), 5 Processed BIT DEFAULT 0, 6 ProcessedAt DATETIMEOFFSET NULL 7); 8
2. Represent Events in Code
Define a base class for events and specific event types:
1public abstract class OutboxEvent 2{ 3 public Guid Id { get; set; } = Guid.NewGuid(); 4 public string EventType { get; set; } 5 public string Payload { get; set; } 6 public bool Processed { get; set; } = false; 7 public DateTimeOffset? ProcessedAt { get; set; } 8} 9 10public class PaymentEvent : OutboxEvent 11{ 12 public PaymentEvent(Guid orderId, decimal amount) 13 { 14 EventType = nameof(PaymentEvent); 15 Payload = JsonSerializer.Serialize(new { OrderId = orderId, Amount = amount }); 16 } 17} 18 19public class ShippingEvent : OutboxEvent 20{ 21 public ShippingEvent(Guid orderId, string address) 22 { 23 EventType = nameof(ShippingEvent); 24 Payload = JsonSerializer.Serialize(new { OrderId = orderId, Address = address }); 25 } 26} 27
3. Transactional Outbox Logic
When creating an order, enqueue the events in the same database transaction:
1public async Task CreateOrderAsync(Order order, PaymentEvent paymentEvent, ShippingEvent shippingEvent) 2{ 3 using var transaction = await _dbContext.Database.BeginTransactionAsync(); 4 5 try 6 { 7 // Persist the order 8 _dbContext.Orders.Add(order); 9 10 // Add outbox events 11 _dbContext.OutboxEvents.Add(paymentEvent); 12 _dbContext.OutboxEvents.Add(shippingEvent); 13 14 // Commit transaction 15 await _dbContext.SaveChangesAsync(); 16 await transaction.CommitAsync(); 17 } 18 catch 19 { 20 await transaction.RollbackAsync(); 21 throw; 22 } 23} 24
4. Outbox Processor
A background service reads unprocessed events and invokes the appropriate third-party APIs:
1public class OutboxProcessor : BackgroundService 2{ 3 private readonly DbContext _dbContext; 4 private readonly IHttpClientFactory _httpClientFactory; 5 6 public OutboxProcessor(DbContext dbContext, IHttpClientFactory httpClientFactory) 7 { 8 _dbContext = dbContext; 9 _httpClientFactory = httpClientFactory; 10 } 11 12 protected override async Task ExecuteAsync(CancellationToken stoppingToken) 13 { 14 while (!stoppingToken.IsCancellationRequested) 15 { 16 var events = await _dbContext.OutboxEvents 17 .Where(e => !e.Processed) 18 .ToListAsync(stoppingToken); 19 20 foreach (var outboxEvent in events) 21 { 22 try 23 { 24 // Process event 25 await ProcessEventAsync(outboxEvent); 26 27 // Mark as processed 28 outboxEvent.Processed = true; 29 outboxEvent.ProcessedAt = DateTimeOffset.UtcNow; 30 } 31 catch (Exception ex) 32 { 33 // Log and skip failed events 34 Console.WriteLine($"Error processing event {outboxEvent.Id}: {ex.Message}"); 35 } 36 } 37 38 await _dbContext.SaveChangesAsync(stoppingToken); 39 40 // Wait before polling again 41 await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); 42 } 43 } 44 45 private async Task ProcessEventAsync(OutboxEvent outboxEvent) 46 { 47 var client = _httpClientFactory.CreateClient(); 48 49 switch (outboxEvent.EventType) 50 { 51 case nameof(PaymentEvent): 52 var paymentData = JsonSerializer.Deserialize<PaymentData>(outboxEvent.Payload); 53 await client.PostAsJsonAsync("https://api.paymentgateway.com/payments", paymentData); 54 break; 55 56 case nameof(ShippingEvent): 57 var shippingData = JsonSerializer.Deserialize<ShippingData>(outboxEvent.Payload); 58 await client.PostAsJsonAsync("https://api.shippingservice.com/ship", shippingData); 59 break; 60 61 default: 62 throw new InvalidOperationException($"Unknown event type: {outboxEvent.EventType}"); 63 } 64 } 65} 66 67record PaymentData(Guid OrderId, decimal Amount); 68record ShippingData(Guid OrderId, string Address); 69
Benefits of the Outbox Pattern
- Reliability: Guarantees that third-party API calls are executed even in the face of transient errors.
- Transactional Integrity: Ensures that database changes and external side effects occur together or not at all.
- Scalability: Decouples event production from event consumption, allowing the processing logic to scale independently.
Diagram of the Outbox Pattern
To better visualize the Outbox Pattern, here is a UML-style diagram illustrating the key components and their interactions:
image to add
Conclusion
The Outbox Pattern is a powerful approach to ensure transactional consistency in distributed systems. By implementing it in your C# application, you can reliably coordinate complex operations involving third-party services while keeping your system robust and maintainable.