Implementation of Saga pattern in .NET 8.0

The Saga pattern is a design pattern used to manage and coordinate distributed transactions in microservices architectures. Unlike traditional monolithic transactions that ensure ACID (Atomicity, Consistency, Isolation, Durability) properties through a single database transaction, distributed transactions span multiple services, making it difficult to maintain ACID properties. The Saga pattern addresses this by breaking down a transaction into a series of smaller, independent steps (or sub-transactions) that are executed in a predefined sequence.

Each step in a saga has a corresponding compensating transaction to undo the changes made by the step if something goes wrong. This ensures eventual consistency rather than immediate consistency.

Example in .NET

Let's implement a simple example in .NET where a saga is used to manage an order processing workflow involving multiple microservices: InventoryService, PaymentService, and ShippingService.

  1. InventoryService: Reserves inventory for the order.
  2. PaymentService: Processes the payment.
  3. ShippingService: Ships the order.

Each service will have a compensating transaction to undo its action if something goes wrong.

Step 1. Define the Services

Keep the tutorial simple. Instead of creating separate services, I have just created simple classes. In real-world use cases, these services would be independently deployable components.

public class InventoryService
{
    public async Task<bool> ReserveInventoryAsync(Order order)
    {
        Console.WriteLine($"Reserving inventory for order {order.Id}");
        await Task.Delay(100); // Simulating async work
        return true; // Assume success for simplicity
    }

    public async Task UndoReserveInventoryAsync(Order order)
    {
        Console.WriteLine($"Undoing inventory reservation for order {order.Id}");
        await Task.Delay(100); // Simulating async work
    }
}
public class IPaymentService
{
    public async Task<bool> ProcessPaymentAsync(Order order)
    {
        Console.WriteLine($"Processing payment for order {order.Id}");
        await Task.Delay(100); // Simulating async work
        return true; // Assume success for simplicity
    }

    public async Task UndoProcessPaymentAsync(Order order)
    {
        Console.WriteLine($"Undoing payment for order {order.Id}");
        await Task.Delay(100); // Simulating async work
    }
}

public class IShippingService
{
    public async Task<bool> ShipOrderAsync(Order order)
    {
        Console.WriteLine($"Shipping order {order.Id}");
        await Task.Delay(100); // Simulating async work
        return true; // Assume success for simplicity
    }

    public async Task UndoShipOrderAsync(Order order)
    {
        Console.WriteLine($"Undoing shipping for order {order.Id}");
        await Task.Delay(100); // Simulating async work
    }
}

Step 2. Define the Saga

The OrderSagaService class orchestrates the steps involved in processing an order, coordinating the interactions between the InventoryService, PaymentService, and ShippingService. If any step fails, it triggers a compensation mechanism to undo previously completed steps, ensuring the system maintains a consistent state

public class OrderSagaService
{
    private readonly InventoryService _inventoryService;
    private readonly IPaymentService _paymentService;
    private readonly IShippingService _shippingService;

    public OrderSagaService(InventoryService inventoryService,
                     IPaymentService paymentService,
                     IShippingService shippingService)
    {
        _inventoryService = inventoryService;
        _paymentService = paymentService;
        _shippingService = shippingService;
    }

    public async Task<bool> ProcessOrderAsync(Order order)
    {
        try
        {
            bool inventoryReserved = await _inventoryService.ReserveInventoryAsync(order);
            if (!inventoryReserved) throw new Exception("Failed to reserve inventory");

            bool paymentProcessed = await _paymentService.ProcessPaymentAsync(order);
            if (!paymentProcessed) throw new Exception("Failed to process payment");

            bool orderShipped = await _shippingService.ShipOrderAsync(order);
            if (!orderShipped) throw new Exception("Failed to ship order");

            return true;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
            await CompensateAsync(order);
            return false;
        }
    }

    private async Task CompensateAsync(Order order)
    {
        await _shippingService.UndoShipOrderAsync(order);
        await _paymentService.UndoProcessPaymentAsync(order);
        await _inventoryService.UndoReserveInventoryAsync(order);
    }
}

Step 3. Define the model

The Order class represents the structure of an order, containing properties such as ID, Product, and Amount. This model is used to encapsulate the order details and is passed between services and the saga for processing.

public class Order
 {
     public int Id { get; set; }
     public string Product { get; set; }
     public decimal Amount { get; set; }
 }

Step 4. Create a Minimal API

using OrderSaga.Api.Inventory;
using OrderSaga.Api.Payment;
using OrderSaga.Api.Shipping;
using OrderSaga.Api.Saga;
using OrderSaga.Api.Models;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

// Register services with dependency injection
builder.Services.AddTransient<InventoryService>();
builder.Services.AddTransient<IPaymentService>();
builder.Services.AddTransient<IShippingService>();
builder.Services.AddTransient<OrderSagaService>();

// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.MapPost("/api/orders", async (Order order, OrderSagaService orderSaga) =>
{
    bool success = await orderSaga.ProcessOrderAsync(order);
    if (success)
    {
        return Results.Ok("Order processed successfully");
    }
    else
    {
        return Results.BadRequest("Order processing failed and compensated");
    }
});

app.Run();

The Saga pattern can be implemented in various ways depending on the complexity of your system, the technologies you are using, and your specific requirements. Here are some common approaches to implementing the Saga pattern.

  1. Orchestration-based Saga: In the orchestration-based approach, a central controller (orchestrator) manages the sequence of steps involved in the saga. This is the method we've used in the example above with OrderSagaService. The orchestrator knows the logic of the saga, calling each service in order and handling compensating transactions if something goes wrong.
  2. Choreography-based Saga: In the choreography-based approach, there is no central controller. Instead, each service involved in the saga publishes events to a message broker (like RabbitMQ, Kafka, or Azure Service Bus), and other services listen for those events and react accordingly. Each service knows which events to listen for and which events to publish. This approach can lead to more decoupled and scalable systems but can also be more complex to manage and debug.
  3. State Machine-based Saga: A state machine can be used to manage the states and transitions of a saga. Each step in the saga represents a state, and the transitions are the events that trigger the next state. This approach can be implemented using libraries such as Automatonymous for .NET.
  4. Workflow-based Saga: Using a workflow engine like NServiceBus or MassTransit, you can define workflows that represent the saga. These tools often provide built-in support for sagas, making it easier to manage long-running processes and compensations.
  5. Transactional Outbox Pattern: The transactional outbox pattern ensures that messages are sent to the message broker only if the local transaction succeeds. This can be combined with a saga to ensure reliable message delivery and eventual consistency across services.

Note. Orchestration and choreography are the two most common patterns, but state machines, workflow engines, and the transactional outbox pattern are also viable options depending on your needs.

Summary

This setup uses a minimal API in .NET to handle HTTP requests for processing orders using the Saga pattern. We've defined services for inventory, payment, and shipping, as well as the OrderSagaService class to manage the overall workflow. The minimal API is configured in Program.cs, where we set up dependency injection and map the HTTP POST endpoint for order processing.

This approach provides a simple and clean way to handle distributed transactions in a microservices architecture with minimal boilerplate code.