DEV Community

DotNet Full Stack Dev
DotNet Full Stack Dev

Posted on

Implementing the Saga Pattern in C#

The Saga pattern is a design pattern that addresses the complexities involved in managing distributed transactions and ensuring data consistency across microservices. It breaks down a long-running transaction into a series of smaller, manageable transactions. Each transaction is coordinated by a saga orchestrator or via a choreography approach. If any transaction fails, the pattern ensures compensating actions are executed to maintain data integrity.

Why Use the Saga Pattern?

  1. Data Consistency: Ensures eventual consistency across microservices.
  2. Resilience: Improves fault tolerance by handling failures gracefully.
  3. Scalability: Better suited for distributed systems, avoiding the need for distributed transactions.

Approaches to Implementing the Saga Pattern

  1. Orchestration: A central coordinator (orchestrator) manages the entire saga's flow.
  2. Choreography: Each service listens for events and decides when to act and when to trigger the next step.

Example.  Order Processing System.

We will implement a simple order processing system using the orchestration approach. The system will involve the following services:

  1. Order Service: Handles order creation.
  2. Inventory Service: Manages inventory stock.
  3. Payment Service: Processes payments.
  4. Saga Orchestrator: Coordinates the saga's flow.

Step-by-Step Implementation

 

Step 1. Setting Up the Services

First, create a new ASP.NET Core project for each service.

dotnet new webapi -n OrderService
dotnet new webapi -n InventoryService
dotnet new webapi -n PaymentService
dotnet new webapi -n SagaOrchestrator
Enter fullscreen mode Exit fullscreen mode

Step 2. Defining the Models

Define the models shared across the services.

OrderService/Models/Order.cs

public class Order
{
    public int Id { get; set; }
    public string ProductId { get; set; }
    public int Quantity { get; set; }
    public string Status { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

InventoryService/Models/Inventory.cs

public class Inventory
{
    public string ProductId { get; set; }
    public int Stock { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

PaymentService/Models/Payment.cs

public class Payment
{
    public int OrderId { get; set; }
    public string Status { get; set; }
    public decimal Amount { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Step 3. Creating the Saga Orchestrator

The orchestrator manages the workflow of the saga.

SagaOrchestrator/Controllers/SagaController.cs

[ApiController]
[Route("[controller]")]
public class SagaController : ControllerBase
{
    private readonly IHttpClientFactory _httpClientFactory;
    public SagaController(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }
    [HttpPost]
    public async Task<IActionResult> ProcessOrder([FromBody] Order order)
    {
        order.Status = "Pending";     
        // Step 1: Create Order
        var orderResponse = await CreateOrder(order);
        if (!orderResponse.IsSuccessStatusCode)
        {
            return BadRequest("Order creation failed.");
        }

        // Step 2: Reserve Inventory
        var inventoryResponse = await ReserveInventory(order);
        if (!inventoryResponse.IsSuccessStatusCode)
        {
            await CancelOrder(order.Id);
            return BadRequest("Inventory reservation failed.");
        }
        // Step 3: Process Payment
        var paymentResponse = await ProcessPayment(order);
        if (!paymentResponse.IsSuccessStatusCode)
        {
            await ReleaseInventory(order.ProductId, order.Quantity);
            await CancelOrder(order.Id);
            return BadRequest("Payment processing failed.");
        }
        order.Status = "Completed";
        return Ok(order);
    }
    private async Task<HttpResponseMessage> CreateOrder(Order order)
    {
        var client = _httpClientFactory.CreateClient();
        return await client.PostAsJsonAsync("http://localhost:5001/api/orders", order);
    }
    private async Task<HttpResponseMessage> ReserveInventory(Order order)
    {
        var client = _httpClientFactory.CreateClient();
        return await client.PostAsJsonAsync("http://localhost:5002/api/inventory/reserve", new { order.ProductId, order.Quantity });
    }
    private async Task<HttpResponseMessage> ProcessPayment(Order order)
    {
        var client = _httpClientFactory.CreateClient();
        return await client.PostAsJsonAsync("http://localhost:5003/api/payments", new { order.Id, Amount = order.Quantity * 10 });
    }
    private async Task<HttpResponseMessage> CancelOrder(int orderId)
    {
        var client = _httpClientFactory.CreateClient();
        return await client.DeleteAsync($"http://localhost:5001/api/orders/{orderId}");
    }
    private async Task<HttpResponseMessage> ReleaseInventory(string productId, int quantity)
    {
        var client = _httpClientFactory.CreateClient();
        return await client.PostAsJsonAsync("http://localhost:5002/api/inventory/release", new { productId, quantity });
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4. Implementing the Services

 

OrderService/Controllers/OrdersController.cs

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private static readonly List<Order> Orders = new();
    [HttpPost]
    public IActionResult CreateOrder([FromBody] Order order)
    {
        order.Id = Orders.Count + 1;
        order.Status = "Created";
        Orders.Add(order);
        return CreatedAtAction(nameof(GetOrderById), new { id = order.Id }, order);
    }
    [HttpDelete("{id}")]
    public IActionResult CancelOrder(int id)
    {
        var order = Orders.FirstOrDefault(o => o.Id == id);
        if (order == null)
        {
            return NotFound();
        }
        Orders.Remove(order);
        return NoContent();
    }
    [HttpGet("{id}")]
    public IActionResult GetOrderById(int id)
    {
        var order = Orders.FirstOrDefault(o => o.Id == id);
        if (order == null)
        {
            return NotFound();
        }
        return Ok(order);
    }
}
Enter fullscreen mode Exit fullscreen mode

InventoryService/Controllers/InventoryController.cs

[ApiController]
[Route("api/[controller]")]
public class InventoryController : ControllerBase
{
    private static readonly List<Inventory> Inventories = new()
    {
        new Inventory { ProductId = "Product1", Stock = 100 },
        new Inventory { ProductId = "Product2", Stock = 200 }
    };
    [HttpPost("reserve")]
    public IActionResult ReserveInventory([FromBody] dynamic request)
    {
        string productId = request.productId;
        int quantity = request.quantity;
        var inventory = Inventories.FirstOrDefault(i => i.ProductId == productId);
        if (inventory == null || inventory.Stock < quantity)
        {
            return BadRequest("Insufficient stock.");
        }
        inventory.Stock -= quantity;
        return Ok();
    }
    [HttpPost("release")]
    public IActionResult ReleaseInventory([FromBody] dynamic request)
    {
        string productId = request.productId;
        int quantity = request.quantity;
        var inventory = Inventories.FirstOrDefault(i => i.ProductId == productId);
        if (inventory == null)
        {
            return NotFound();
        }

        inventory.Stock += quantity;
        return Ok();
    }
}
Enter fullscreen mode Exit fullscreen mode

PaymentService/Controllers/PaymentsController.cs

[ApiController]
[Route("api/[controller]")]
public class PaymentsController : ControllerBase
{
    private static readonly List<Payment> Payments = new();
    [HttpPost]
    public IActionResult ProcessPayment([FromBody] dynamic request)
    {
        int orderId = request.orderId;
        decimal amount = request.amount;
        var payment = new Payment 
        { 
            OrderId = orderId, 
            Amount = amount, 
            Status = "Processed" 
        };
        Payments.Add(payment);
        return Ok(payment);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 5. Running the Application

  1. Start the Services: Run each service (OrderService, InventoryService, PaymentService, SagaOrchestrator) on different ports.
  2. Test the Saga: Use a tool like Postman to send a POST request to the SagaOrchestrator's /saga endpoint with an order payload.
{
  "productId": "Product1",
  "quantity": 10
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The Saga pattern is crucial for managing distributed transactions and ensuring data consistency across microservices. By breaking down a transaction into smaller steps and coordinating them through an orchestrator, we can handle failures gracefully and maintain data integrity. The provided example demonstrates how to implement the Saga pattern using ASP.NET Core, handling order processing across multiple services.

Top comments (4)

Collapse
 
leol profile image
Леонид Ляшко

If you fall here await ReleaseInventory(order.ProductId, order.Quantity); you will not do this await CancelOrder(order.Id);

Collapse
 
dotnetfullstackdev profile image
DotNet Full Stack Dev

Thanks for your observation, but here we need to do both

  1. ReleaseInventory - for adjust offset in inventory side 2.CancelOrder - for adjust offset in order side

These two will do different adjustments when payment fails

Collapse
 
leol profile image
Леонид Ляшко

Sorry, but I mean something else. In saga we should not only suggest that operation can fail, but also think what if compensation operation fail.

Thread Thread
 
dotnetfullstackdev profile image
DotNet Full Stack Dev

No problem! We should learn together, and grow together!
If that is the case, then we have to think about different techniques to handle compensation operation failures
for ex :

  • Retry the Compensating Action
  • Compensate the Compensating Action
  • Design for Partial Consistency

likewise, we have a few alerting techniques as well

  • Triggering alerts for manual intervention.
  • Logging the failure for further diagnosis.
  • send the failed event/message to a Dead Letter Queue
  • Notify the user of the issue

Hope here I got you a few points for your question