Skip to main content

Saga Patterns: Distributed Transaction Management

Sagas manage transactions that span multiple services. Instead of ACID transactions, sagas use a sequence of local transactions with compensating actions for rollback.

TL;DR

PatternCoordinationBest For
ChoreographyEvents, no central coordinatorSimple flows, few services
OrchestrationCentral coordinatorComplex flows, many services

The Problem

Distributed transactions (2PC) don't scale:

❌ Two-Phase Commit Problems:
• Locks resources across services
• Coordinator is single point of failure
• Doesn't work across different databases
• Performance bottleneck

Saga Solution

If any step fails, compensating actions run:

Pattern 1: Choreography

Services communicate through events, no central coordinator:

Implementation

// Order Service - starts saga
public class OrderService
{
public async Task CreateOrder(CreateOrderCommand cmd)
{
var order = Order.Create(cmd.CustomerId, cmd.Items);
await _orderRepository.Save(order);

// Publish event to start saga
await _eventBus.Publish(new OrderCreatedEvent(
order.Id,
order.Items.Select(i => new OrderItemDto(i.ProductId, i.Quantity)).ToList()
));
}
}

// Inventory Service - reacts to OrderCreated
public class InventoryEventHandler : IEventHandler<OrderCreatedEvent>
{
public async Task Handle(OrderCreatedEvent @event)
{
try
{
foreach (var item in @event.Items)
{
await _inventoryService.Reserve(item.ProductId, item.Quantity, @event.OrderId);
}

await _eventBus.Publish(new StockReservedEvent(@event.OrderId));
}
catch (InsufficientStockException)
{
await _eventBus.Publish(new StockReservationFailedEvent(@event.OrderId));
}
}
}

// Payment Service - reacts to StockReserved
public class PaymentEventHandler : IEventHandler<StockReservedEvent>
{
public async Task Handle(StockReservedEvent @event)
{
var order = await _orderClient.GetOrder(@event.OrderId);

try
{
await _paymentService.ProcessPayment(order.CustomerId, order.Total);
await _eventBus.Publish(new PaymentProcessedEvent(@event.OrderId));
}
catch (PaymentFailedException)
{
await _eventBus.Publish(new PaymentFailedEvent(@event.OrderId));
}
}
}

// Inventory Service - compensates on PaymentFailed
public class PaymentFailedHandler : IEventHandler<PaymentFailedEvent>
{
public async Task Handle(PaymentFailedEvent @event)
{
// Compensating action
await _inventoryService.ReleaseReservation(@event.OrderId);
}
}

// Order Service - compensates on failures
public class OrderCompensationHandler :
IEventHandler<StockReservationFailedEvent>,
IEventHandler<PaymentFailedEvent>
{
public async Task Handle(StockReservationFailedEvent @event)
{
await _orderService.CancelOrder(@event.OrderId, "Insufficient stock");
}

public async Task Handle(PaymentFailedEvent @event)
{
await _orderService.CancelOrder(@event.OrderId, "Payment failed");
}
}

Choreography Trade-offs

ProsCons
Loose couplingHard to understand flow
Simple servicesNo central view of saga state
No single point of failureCyclic dependencies possible
Easy to add servicesDifficult to test end-to-end

Pattern 2: Orchestration

Central coordinator manages the saga:

Implementation

// Saga orchestrator
public class PlaceOrderSaga : Saga<PlaceOrderSagaData>,
IAmStartedBy<PlaceOrderCommand>,
IHandleMessages<OrderCreatedReply>,
IHandleMessages<StockReservedReply>,
IHandleMessages<StockReservationFailedReply>,
IHandleMessages<PaymentProcessedReply>,
IHandleMessages<PaymentFailedReply>
{
protected override void ConfigureHowToFindSaga(SagaPropertyMapper<PlaceOrderSagaData> mapper)
{
mapper.MapSaga(s => s.OrderId)
.ToMessage<PlaceOrderCommand>(m => m.OrderId)
.ToMessage<OrderCreatedReply>(m => m.OrderId)
.ToMessage<StockReservedReply>(m => m.OrderId)
// ... other mappings
}

public async Task Handle(PlaceOrderCommand message, IMessageHandlerContext context)
{
Data.OrderId = message.OrderId;
Data.CustomerId = message.CustomerId;
Data.Items = message.Items;
Data.CurrentStep = SagaStep.CreatingOrder;

// Step 1: Create order
await context.Send(new CreateOrderCommand(message.OrderId, message.CustomerId, message.Items));
}

public async Task Handle(OrderCreatedReply message, IMessageHandlerContext context)
{
Data.CurrentStep = SagaStep.ReservingStock;

// Step 2: Reserve stock
await context.Send(new ReserveStockCommand(Data.OrderId, Data.Items));
}

public async Task Handle(StockReservedReply message, IMessageHandlerContext context)
{
Data.StockReserved = true;
Data.CurrentStep = SagaStep.ProcessingPayment;

// Step 3: Process payment
await context.Send(new ProcessPaymentCommand(Data.OrderId, Data.CustomerId, message.Total));
}

public async Task Handle(StockReservationFailedReply message, IMessageHandlerContext context)
{
// Compensation: Cancel order
await context.Send(new CancelOrderCommand(Data.OrderId, "Insufficient stock"));
MarkAsComplete();
}

public async Task Handle(PaymentProcessedReply message, IMessageHandlerContext context)
{
Data.PaymentProcessed = true;
Data.CurrentStep = SagaStep.CreatingShipment;

// Step 4: Create shipment
await context.Send(new CreateShipmentCommand(Data.OrderId));
}

public async Task Handle(PaymentFailedReply message, IMessageHandlerContext context)
{
// Compensation: Release stock, then cancel order
Data.CurrentStep = SagaStep.Compensating;
await context.Send(new ReleaseStockCommand(Data.OrderId));
await context.Send(new CancelOrderCommand(Data.OrderId, "Payment failed"));
MarkAsComplete();
}
}

public class PlaceOrderSagaData : ContainSagaData
{
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
public SagaStep CurrentStep { get; set; }
public bool StockReserved { get; set; }
public bool PaymentProcessed { get; set; }
}

Orchestration Trade-offs

ProsCons
Clear flow visibilityOrchestrator can be bottleneck
Easier to understandSingle point of failure
Centralized error handlingCoupling to orchestrator
Easier testingMore complex orchestrator code

Choosing Between Patterns

┌─────────────────────────────────────────────────────────┐
│ CHOREOGRAPHY vs ORCHESTRATION │
├─────────────────────────────────────────────────────────┤
│ │
│ Use CHOREOGRAPHY when: │
│ • Few services (2-4) │
│ • Simple, linear flow │
│ • Services are truly independent │
│ • Team prefers decentralized approach │
│ │
│ Use ORCHESTRATION when: │
│ • Many services (5+) │
│ • Complex flow with branches │
│ • Need visibility into saga state │
│ • Complex compensation logic │
│ • Regulatory/audit requirements │
│ │
└─────────────────────────────────────────────────────────┘

Compensation Design

Principles

  1. Semantic undo - Not technical rollback
  2. Idempotent - Can be retried safely
  3. Order matters - Reverse order of forward steps
// Forward: Reserve → Charge → Ship
// Compensation: Cancel Shipment → Refund → Release

public class OrderSagaCompensations
{
// Compensation must be idempotent
public async Task ReleaseStock(Guid orderId)
{
var reservation = await _reservationRepo.GetByOrderId(orderId);
if (reservation == null || reservation.Status == ReservationStatus.Released)
return; // Already compensated

reservation.Release();
await _reservationRepo.Save(reservation);
}

// Semantic compensation (refund, not "un-charge")
public async Task RefundPayment(Guid orderId)
{
var payment = await _paymentRepo.GetByOrderId(orderId);
if (payment == null || payment.Status == PaymentStatus.Refunded)
return; // Already compensated

await _paymentGateway.Refund(payment.TransactionId, payment.Amount);
payment.MarkRefunded();
await _paymentRepo.Save(payment);
}
}

Quick Reference

┌─────────────────────────────────────────────────────────┐
│ SAGA PATTERNS QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ CHOREOGRAPHY │
│ • Services publish/subscribe events │
│ • No central coordinator │
│ • Best for simple flows │
│ │
│ ORCHESTRATION │
│ • Central saga coordinator │
│ • Explicit flow control │
│ • Best for complex flows │
│ │
│ COMPENSATION RULES │
│ • Semantic undo (not rollback) │
│ • Must be idempotent │
│ • Reverse order of forward steps │
│ │
└─────────────────────────────────────────────────────────┘

Next Steps