The Saga pattern manages a distributed transaction as a sequence of local transactions: each step updates its own database and triggers the next, and when a step fails, compensating transactions undo the work of the previous steps. It is the standard answer when one business operation spans multiple services, and there are two ways to build it: choreography and orchestration.
Your order service saved the order, then the payment service timed out. Now you have a confirmed order, no charge, and no transaction to roll back. Every system that splits one business operation across multiple services eventually hits this.
The Distributed Transaction Problem
In a monolith, placing an order is one database transaction:
BEGIN TRANSACTION;
INSERT INTO orders (...) VALUES (...);
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = @id;
INSERT INTO payments (...) VALUES (...);
COMMIT;
All three operations succeed or all three roll back. ACID guarantees.
In a distributed system, each concern lives in a different service with its own database. You can't use a single transaction. If the payment fails after the inventory is reserved, how do you undo the reservation?
This is where the Saga pattern comes in.
What Is a Saga?
A Saga is a sequence of local transactions. Each local transaction updates its own database and publishes an event or message to trigger the next step. If a step fails, the saga executes compensating transactions to undo the previous steps.
There are two approaches: Choreography and Orchestration.
Choreography: Event-Driven Saga
Each service listens for events and decides what to do next. No central coordinator.
Implementation
// Order Service - starts the saga
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, Guid>
{
private readonly IOrderRepository _repository;
private readonly IEventBus _eventBus;
public async Task<Result<Guid>> Handle(PlaceOrderCommand command, CancellationToken ct)
{
var order = Order.Create(command.CustomerId, command.Items);
order.SetStatus(OrderStatus.Pending);
_repository.Add(order);
await _repository.UnitOfWork.SaveChangesAsync(ct);
await _eventBus.PublishAsync(new OrderPlacedEvent(
order.Id, order.CustomerId, order.Items, order.TotalAmount), ct);
return order.Id;
}
}
// Inventory Service - step 2
public class OrderPlacedEventHandler : IEventHandler<OrderPlacedEvent>
{
private readonly IInventoryRepository _repository;
private readonly IEventBus _eventBus;
public async Task Handle(OrderPlacedEvent @event, CancellationToken ct)
{
var reservationResult = await _repository.ReserveStockAsync(
@event.OrderId, @event.Items, ct);
if (reservationResult.IsSuccess)
{
await _eventBus.PublishAsync(
new InventoryReservedEvent(
@event.OrderId, @event.Items, @event.TotalAmount), ct);
}
else
{
await _eventBus.PublishAsync(
new InventoryReservationFailedEvent(
@event.OrderId, reservationResult.Error), ct);
}
}
}
// Payment Service - step 3
public class InventoryReservedEventHandler : IEventHandler<InventoryReservedEvent>
{
private readonly IPaymentService _paymentService;
private readonly IEventBus _eventBus;
public async Task Handle(InventoryReservedEvent @event, CancellationToken ct)
{
var paymentResult = await _paymentService.ChargeAsync(
@event.OrderId, @event.TotalAmount, ct);
if (paymentResult.IsSuccess)
{
await _eventBus.PublishAsync(
new PaymentCompletedEvent(@event.OrderId, paymentResult.PaymentId), ct);
}
else
{
// Compensate: release inventory
await _eventBus.PublishAsync(
new PaymentFailedEvent(@event.OrderId, paymentResult.Error), ct);
}
}
}
// Inventory Service - compensation
public class PaymentFailedEventHandler : IEventHandler<PaymentFailedEvent>
{
private readonly IInventoryRepository _repository;
public async Task Handle(PaymentFailedEvent @event, CancellationToken ct)
{
// Compensating action: release reserved stock
await _repository.ReleaseStockAsync(@event.OrderId, ct);
}
}
Choreography Pros and Cons
Pros:
- Simple to implement for 2-3 steps
- Loosely coupled - services only know about events
- No single point of failure
Cons:
- Hard to understand the full flow (logic spread across services)
- Difficult to add new steps
- Testing the complete saga requires all services
- Cyclic dependencies can form
Orchestration: Central Coordinator
A central Saga Orchestrator manages the flow. It sends commands to services and handles their responses.
Implementation with MassTransit
MassTransit's state machine sagas are the most common way to build orchestrators in .NET. I have a full walkthrough in Implementing the Saga Pattern With MassTransit; here's the core of it:
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
public OrderSaga()
{
InstanceState(x => x.CurrentState);
Event(() => OrderPlaced, x => x.CorrelateById(c => c.Message.OrderId));
Event(() => InventoryReserved, x => x.CorrelateById(c => c.Message.OrderId));
Event(() => InventoryReservationFailed, x => x.CorrelateById(c => c.Message.OrderId));
Event(() => PaymentCompleted, x => x.CorrelateById(c => c.Message.OrderId));
Event(() => PaymentFailed, x => x.CorrelateById(c => c.Message.OrderId));
Event(() => InventoryReleased, x => x.CorrelateById(c => c.Message.OrderId));
Initially(
When(OrderPlaced)
.Then(context =>
{
context.Saga.OrderId = context.Message.OrderId;
context.Saga.CustomerId = context.Message.CustomerId;
context.Saga.Items = context.Message.Items;
context.Saga.TotalAmount = context.Message.TotalAmount;
})
.Send(context => new ReserveInventoryCommand(
context.Saga.OrderId,
context.Saga.Items))
.TransitionTo(AwaitingInventory));
During(AwaitingInventory,
When(InventoryReserved)
.Send(context => new ProcessPaymentCommand(
context.Saga.OrderId,
context.Saga.TotalAmount))
.TransitionTo(AwaitingPayment),
When(InventoryReservationFailed)
.Send(context => new CancelOrderCommand(
context.Saga.OrderId,
"Insufficient inventory"))
.TransitionTo(Failed)
.Finalize());
During(AwaitingPayment,
When(PaymentCompleted)
.Send(context => new ConfirmOrderCommand(context.Saga.OrderId))
.TransitionTo(Completed)
.Finalize(),
When(PaymentFailed)
.Send(context => new ReleaseInventoryCommand(
context.Saga.OrderId,
context.Saga.Items))
.Send(context => new CancelOrderCommand(
context.Saga.OrderId,
"Payment failed"))
.TransitionTo(Compensating));
During(Compensating,
When(InventoryReleased)
.TransitionTo(Failed)
.Finalize());
}
public State AwaitingInventory { get; private set; }
public State AwaitingPayment { get; private set; }
public State Compensating { get; private set; }
public State Completed { get; private set; }
public State Failed { get; private set; }
public Event<OrderPlacedEvent> OrderPlaced { get; private set; }
public Event<InventoryReservedEvent> InventoryReserved { get; private set; }
public Event<InventoryReservationFailedEvent> InventoryReservationFailed { get; private set; }
public Event<PaymentCompletedEvent> PaymentCompleted { get; private set; }
public Event<PaymentFailedEvent> PaymentFailed { get; private set; }
public Event<InventoryReleasedEvent> InventoryReleased { get; private set; }
}
Saga State
public class OrderSagaState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; }
public Guid OrderId { get; set; }
public Guid CustomerId { get; set; }
public List<OrderItem> Items { get; set; }
public decimal TotalAmount { get; set; }
}
Registration
The .Send(...) calls in the state machine need a destination queue, and EndpointConvention.Map declares it once per command type:
EndpointConvention.Map<ReserveInventoryCommand>(new Uri("queue:reserve-inventory"));
EndpointConvention.Map<ProcessPaymentCommand>(new Uri("queue:process-payment"));
EndpointConvention.Map<ReleaseInventoryCommand>(new Uri("queue:release-inventory"));
EndpointConvention.Map<ConfirmOrderCommand>(new Uri("queue:confirm-order"));
EndpointConvention.Map<CancelOrderCommand>(new Uri("queue:cancel-order"));
builder.Services.AddMassTransit(x =>
{
x.AddSagaStateMachine<OrderSaga, OrderSagaState>()
.EntityFrameworkRepository(r =>
{
r.ExistingDbContext<SagaDbContext>();
r.UsePostgres();
});
x.UsingRabbitMq((context, cfg) =>
{
cfg.ConfigureEndpoints(context);
});
});
Orchestration Pros and Cons
Pros:
- Easy to understand - the entire flow is in one place
- Easy to add steps or change the order
- Centralized error handling and compensation
- Easy to test the orchestrator in isolation
Cons:
- The orchestrator is a single point of failure
- Can become complex for large sagas
- More infrastructure (state machine, persistence)
Choosing Between Approaches
Here's how the two approaches compare:
- Best for: choreography suits 2-3 simple steps; orchestration suits complex multi-step flows
- Coupling: choreography is loosely coupled through events; orchestration adds medium coupling through commands
- Visibility: choreographed flows are hard to trace across services; an orchestrator shows the whole flow in one place
- Adding steps: choreography requires new event handlers in multiple services; orchestration means modifying one state machine
- Testing: choreography needs integration tests spanning services; an orchestrator can be unit tested in isolation
- Error handling: distributed across services vs. centralized in the orchestrator
Use choreography for simple, two-step sagas (e.g., order confirmation triggering a notification).
Use orchestration for anything with more than two steps or complex compensation logic.
Idempotency
Saga steps must be idempotent - executing the same step twice produces the same result. Messages can be delivered more than once.
public class ReserveInventoryCommandHandler
{
public async Task Handle(ReserveInventoryCommand command, CancellationToken ct)
{
// Check if already reserved (idempotent)
var existing = await _repository.GetReservationAsync(command.OrderId, ct);
if (existing is not null)
{
return; // Already processed, skip
}
await _repository.ReserveStockAsync(command.OrderId, command.Items, ct);
}
}
Duplicate delivery isn't unique to sagas. It comes with message queue patterns in general, so every consumer needs a check like this.
When NOT to Use a Saga
Before reaching for a saga, ask whether you actually have a distributed transaction:
- If all the data lives in one database, use a regular transaction. A saga is strictly worse: more code, weaker guarantees.
- If you control the service boundaries, consider merging them. The cheapest distributed transaction is the one you design away.
- If the flow doesn't need compensation (nothing to undo), simple pub/sub between services is enough.
Sagas also expose intermediate states to users. An order can be "pending payment" for seconds or minutes, and your UI and support processes must handle that. It's the same tradeoff you face when weighing distributed transactions against the outbox pattern: strict consistency or availability, not both.
Summary
Define every local transaction, compensating action, timeout, and terminal state as part of the workflow. Use choreography only while the flow remains easy to reconstruct; orchestration gives longer workflows explicit state and a single place to reason about recovery. Make each step idempotent and publish transitions through the outbox pattern.
Frequently Asked Questions
What is the Saga pattern?
A saga is a sequence of local transactions across multiple services. Each step updates its own database and triggers the next step, and if a step fails, compensating transactions undo the work of the previous steps.
What is the difference between choreography and orchestration?
In choreography, each service reacts to events and decides what to do next, with no central coordinator. In orchestration, a central saga orchestrator sends commands to services and tracks the state of the whole flow.
Is the Saga pattern the same as a distributed transaction?
No. A distributed transaction (two-phase commit) locks resources across services to get atomicity. A saga trades that atomicity for availability: it allows intermediate states and uses compensating actions instead of rollbacks.
What is a compensating transaction?
A compensating transaction semantically undoes a completed saga step, such as refunding a payment or releasing reserved inventory. It is a new operation, not a database rollback, so it must be designed explicitly.
When should I avoid the Saga pattern?
Avoid sagas when the operations live in one database, where a regular transaction is simpler and stronger. Sagas only pay off when a business process genuinely spans multiple services with separate data stores.



