Distributed transactions are challenging in microservices since each service will have its own data store. The Saga pattern provides a means to handle transactions across services without a global transaction manager. Instead of an atomic transaction, a saga splits it into a sequence of local transactions with compensating transactions for failure.
In this post, I describe how you can implement the Saga pattern in C#, with real examples to demonstrate the flow.
Saga Concepts
A saga consists of.
- A series of operations (local transactions)
- Compensating actions to undo steps in case anything goes wrong
Sagas can be dealt with in two basic ways:
- Choreography: all services subscribe to events and respond accordingly (no central coordinator).
- Orchestration: a central saga orchestrator guides the flow.
Example Scenario: Order Processing Saga Consider an e-commerce process
- Create Order (Order Service)
- Reserve Inventory (Inventory Service)
- Charge Payment (Payment Service)
If payment fails, we need to.
- Cancel payment (if partial)
- Release inventory
- Cancel the order
Orchestration Example (C#)
We'll utilize a basic saga orchestrator.
Saga Orchestrator
public class OrderSagaOrchestrator
{
private readonly IOrderService _orderService;
private readonly IInventoryService _inventoryService;
private readonly IPaymentService _paymentService;
public OrderSagaOrchestrator(
IOrderService orderService,
IInventoryService inventoryService,
IPaymentService paymentService)
{
_orderService = orderService;
_inventoryService = inventoryService;
_paymentService = paymentService;
}
public async Task<bool> ProcessOrderAsync(OrderData order)
{
try
{
await _orderService.CreateOrderAsync(order);
await _inventoryService.ReserveInventoryAsync(order.OrderId, order.Items);
await _paymentService.ChargeAsync(order.OrderId, order.TotalAmount);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Saga failed: {ex.Message}. Starting compensation...");
await _paymentService.RefundAsync(order.OrderId);
await _inventoryService.ReleaseInventoryAsync(order.OrderId);
await _orderService.CancelOrderAsync(order.OrderId);
return false;
}
}
}
Example Interfaces
public interface IOrderService
{
Task CreateOrderAsync(OrderData order);
Task CancelOrderAsync(string orderId);
}
public interface IInventoryService
{
Task ReserveInventoryAsync(string orderId, List<Item> items);
Task ReleaseInventoryAsync(string orderId);
}
public interface IPaymentService
{
Task ChargeAsync(string orderId, decimal amount);
Task RefundAsync(string orderId);
}
public class OrderData
{
public string OrderId { get; set; }
public List<Item> Items { get; set; }
public decimal TotalAmount { get; set; }
}
public class Item
{
public string ProductId { get; set; }
public int Quantity { get; set; }
}
Choreography Example
In a choreography-based saga, all services listen to events. When, for example, the OrderCreated event is published, the Inventory Service hears it and reserves inventory.
Example RabbitMQ consumer for Inventory Service.
consumer.Received += async (model, ea) =>
{
var message = Encoding.UTF8.GetString(ea.Body.ToArray());
var orderCreated = JsonConvert.DeserializeObject<OrderCreatedEvent>(message);
await _inventoryService.ReserveInventoryAsync(
orderCreated.OrderId,
orderCreated.Items
);
// Then publish InventoryReserved event
};
Best Practices
- Ensure compensating transactions are always idempotent.
- Employ reliable messaging (such as RabbitMQ, Kafka) to prevent lost events.
- Log saga progress for traceability.
- Research using libraries such as MassTransit (saga support) for production.
Conclusion
The Saga pattern enables your C# services to orchestrate complex workflows without distributed transactions. With orchestration or choreography, sagas ensure data consistency between services and handle failures elegantly. By using these principles carefully, you can create scalable and resilient distributed systems.