Leveraging the Outbox Pattern with Transactional Third-Party Calls in C#

C#.NETOutbox PatternPatterns
12-12-2024Tim De Belderreading time: 4 minutes

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:

  1. Notify a payment gateway to process the payment.
  2. 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:

  1. 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).
  2. 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

  1. Reliability: Guarantees that third-party API calls are executed even in the face of transient errors.
  2. Transactional Integrity: Ensures that database changes and external side effects occur together or not at all.
  3. 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.