Saga Pattern: consistency across microservices without rollback

In a monolith, "create order" fit in a single transaction: reserve stock, charge the customer and create the shipment, all with BEGIN and COMMIT. If anything failed, the database undid it. Once you split into microservices (as we saw in Monolith vs Microservices), each service gets its own database and that single transaction is gone. If the stock was reserved and the payment was declined, who undoes the reservation?
The Saga Pattern answers this without a distributed transaction: it breaks the operation into local transactions and, if one of them fails, runs actions that undo the effect of the previous ones. This article continues the series after API Gateway + BFF (which organized how the client reaches the services); here we look at how the services stay consistent with each other.
Saga concepts: local and compensating transactions
Saga: a sequence of local transactions, one per service. Each local transaction commits its work in its own database and triggers the next step through an event or message. There is no global COMMIT.
Compensating transaction: the action that undoes the effect of a step that was already committed. It is not a rollback: the data has already been written, so the compensation is a new business operation (release the reservation, refund the payment, cancel the order).
Step types (classification from the Microsoft documentation):
- Compensable: can be undone by a compensation.
- Pivot: the point of no return. Once it succeeds, the following steps must be completed to reach a consistent final state.
- Retriable: comes after the pivot, is idempotent and can be repeated until it succeeds.
In code, the ordering rule (compensable steps, at most one pivot, then retriable steps) fits in a few lines, in the repository's StepKinds.cs file:
namespace SagaDemo;
// EN: Order matters: compensable, then the pivot, then retriable.
// PT: A ordem importa: compensáveis, o pivô, depois retentáveis.
public enum StepKind { Compensable, Pivot, Retriable }
public sealed record StepInfo(string Name, StepKind Kind);
public static class SagaPlan
{
// EN: Valid = kinds never go backwards and there is one pivot
// at most (after it, there is no way back).
// PT: Válido = os tipos nunca voltam e há no máximo um pivô
// (depois dele, não há volta).
public static bool IsValid(IReadOnlyList<StepInfo> steps) =>
steps.Count(s => s.Kind == StepKind.Pivot) <= 1
&& steps.Zip(steps.Skip(1))
.All(p => p.First.Kind <= p.Second.Kind);
}The order of the enum values is the rule itself: IsValid requires that the kind never "goes backwards" along the steps and that there is at most one pivot. A plan that puts the pivot before a compensable step is rejected, because that step would no longer have a way to be undone.
Choreography vs orchestration: the two ways to coordinate a saga.
- Choreography: there is no central controller. Each service publishes events and reacts to the events of the others.
- Orchestration: a central orchestrator tells each participant what to execute, keeps the saga state and triggers the compensations.
Eventual consistency: during the saga, the system may be partially updated (stock reserved, payment not made yet). It only becomes consistent again when the saga ends, either successfully or compensated.
Rule of thumb: if the flow has few services and few steps, choreography is enough; if it has many steps, branches or compensation rules, prefer orchestration.

Choreography or orchestration: Saga architecture
Success and failure flows for an order across three services:
Sucesso:
Pedido criado → Estoque reservado → Pagamento autorizado
→ Entrega criada → Pedido confirmado
Falha no pagamento:
Pedido criado → Estoque reservado → Pagamento recusado
↓ compensação (ordem inversa)
Reserva liberada → Pedido canceladoChoreography: the Order service publishes PedidoCriado; Stock reacts and publishes EstoqueReservado; Payment reacts and publishes PagamentoAutorizado (or PagamentoRecusado, which Stock listens to in order to release the reservation). It is simple at first, but as steps are added it becomes hard to see the whole flow: according to Microsoft, it is hard to track which commands each participant responds to, there is a risk of cyclic dependencies, and integration testing requires all services to be running.
Orchestration: the orchestrator sends commands (ReservarEstoque, AutorizarPagamento), receives the responses and decides the next step or the compensation. The flow lives in one place and avoids cyclic dependencies, at the cost of one more component that needs coordination logic and becomes a point of failure.
The difference shows in the type of message: in orchestration, the orchestrator sends a command (an order to one participant); in choreography, each service publishes an event (a fact that has already happened, which anyone can react to). In Messages.cs:
namespace SagaDemo;
// EN: Command: an order to ONE participant (orchestration).
// PT: Comando: uma ordem para UM participante (orquestração).
public sealed record ReserveStock(Guid OrderId, decimal Total);
// EN: Event: a fact that already happened; anyone may react
// to it (choreography).
// PT: Evento: um fato que já aconteceu; qualquer um pode reagir
// a ele (coreografia).
public sealed record StockReserved(Guid OrderId);
public static class StockHandler
{
// EN: The participant receives the command and answers
// with an event.
// PT: O participante recebe o comando e responde
// com um evento.
public static StockReserved Handle(ReserveStock command) =>
new(command.OrderId);
}The participant receives ReserveStock and answers with StockReserved. The one who decides the next step is the orchestrator (which reads the response) or the other services (which listen to the event).

Design points:
- Persisted saga state: the orchestrator needs to know which step it is on, even after a restart.
- Atomic publishing: writing to the database and publishing the message must happen together. The recommended solution is the Transactional Outbox (the event is written in the same local transaction and a separate process publishes it).
- Idempotent consumers: brokers such as RabbitMQ and Azure Service Bus deliver at least once, so the same message may arrive twice.
- Correlation: a saga identifier (here, the
OrderId) in logs, messages and traces.
In .NET, there are two routes: use a messaging library with saga support (MassTransit, NServiceBus, Wolverine) or write the orchestrator on top of your own infrastructure. The following example shows the mechanics of orchestration with compensation, with no external dependencies, so the concept stays visible.
Orchestrated Saga in C# and .NET, step by step
The example is an order orchestrator with three steps: reserve stock, authorize payment and create the shipment. The services are simulated in memory; in a real system, each ISagaStep would call another microservice over HTTP or messaging. The blocks are the repository's files, uncut; that is why the log messages and comments appear in both English and Portuguese.
Full code, with README and instructions to run it: github.com/ceseidl/saga-pattern-dotnet
4.1. Step contract
File Saga.cs.
namespace SagaDemo;
public sealed record OrderContext(
Guid OrderId, string CustomerId, decimal Total);
public interface ISagaStep
{
string Name { get; }
Task ExecuteAsync(OrderContext ctx, CancellationToken ct);
Task CompensateAsync(OrderContext ctx, CancellationToken ct);
}
public enum SagaOutcome
{
Completed,
Compensated,
CompensationFailed
}4.2. Orchestrator
File OrderSagaOrchestrator.cs.
namespace SagaDemo;
public sealed class OrderSagaOrchestrator(
IReadOnlyList<ISagaStep> steps,
ILogger<OrderSagaOrchestrator> logger)
{
private const int MaxCompensationAttempts = 3;
public async Task<SagaOutcome> RunAsync(
OrderContext ctx, CancellationToken ct = default)
{
var completed = new Stack<ISagaStep>();
foreach (var step in steps)
{
try
{
logger.LogInformation(
"[{Order}] executing / executando {Step}",
ctx.OrderId, step.Name);
await step.ExecuteAsync(ctx, ct);
completed.Push(step);
}
catch (Exception ex)
when (ex is not OperationCanceledException)
{
logger.LogWarning(ex,
"[{Order}] {Step} failed; starting "
+ "compensation / falhou; iniciando "
+ "compensação",
ctx.OrderId, step.Name);
return await CompensateAsync(ctx, completed, ct);
}
}
return SagaOutcome.Completed;
}
private async Task<SagaOutcome> CompensateAsync(
OrderContext ctx, Stack<ISagaStep> completed,
CancellationToken ct)
{
var allOk = true;
// EN: Reverse order: the last completed step is
// undone first.
// PT: Ordem inversa: o último passo concluído é
// desfeito primeiro.
while (completed.TryPop(out var step))
{
var ok = false;
for (var attempt = 1;
attempt <= MaxCompensationAttempts && !ok;
attempt++)
{
try
{
// EN: Must be idempotent.
// PT: Precisa ser idempotente.
await step.CompensateAsync(ctx, ct);
ok = true;
logger.LogInformation(
"[{Order}] compensated / "
+ "compensado {Step}",
ctx.OrderId, step.Name);
}
catch (Exception ex)
when (ex is not OperationCanceledException)
{
logger.LogWarning(ex,
"[{Order}] compensation of {Step} "
+ "failed / compensação falhou "
+ "({Attempt}/{Max})",
ctx.OrderId, step.Name, attempt,
MaxCompensationAttempts);
await Task.Delay(
TimeSpan.FromMilliseconds(100 * attempt),
ct);
}
}
if (!ok)
{
allOk = false;
logger.LogError(
"[{Order}] compensation of {Step} exhausted "
+ "retries: manual intervention / "
+ "compensação esgotou tentativas: "
+ "intervenção manual",
ctx.OrderId, step.Name);
}
}
return allOk
? SagaOutcome.Compensated
: SagaOutcome.CompensationFailed;
}
}
Highlighted, the execution loop: only a completed step goes onto the stack, and the first failure triggers the compensation.

And the compensation loop, which pops in reverse order and retries each compensation up to 3 times:

4.3. Steps with idempotent compensation
File Steps.cs.
using System.Collections.Concurrent;
namespace SagaDemo;
// EN: Each step would call another microservice (HTTP or
// message). Here they run in memory.
// PT: Cada passo chamaria outro microsserviço (HTTP ou
// mensagem). Aqui rodam em memória.
// EN: The idempotency key is the OrderId.
// PT: A chave de idempotência é o OrderId.
public sealed class ReserveStockStep : ISagaStep
{
private readonly ConcurrentDictionary<Guid, bool>
_reservations = new();
public string Name => "ReservarEstoque";
public Task ExecuteAsync(
OrderContext ctx, CancellationToken ct)
{
// EN: Upsert: repeating does not duplicate.
// PT: Upsert: repetir não duplica.
_reservations[ctx.OrderId] = true;
return Task.CompletedTask;
}
public Task CompensateAsync(
OrderContext ctx, CancellationToken ct)
{
// EN: Removing what does not exist is a no-op.
// PT: Remover o que não existe é no-op.
_reservations.TryRemove(ctx.OrderId, out _);
return Task.CompletedTask;
}
}
public sealed class AuthorizePaymentStep(bool failOnExecute)
: ISagaStep
{
private readonly ConcurrentDictionary<Guid, decimal>
_authorizations = new();
public string Name => "AutorizarPagamento";
public Task ExecuteAsync(
OrderContext ctx, CancellationToken ct)
{
if (failOnExecute)
throw new InvalidOperationException(
"Card declined / Cartão recusado");
// EN: Does not charge the same order twice.
// PT: Não cobra duas vezes o mesmo pedido.
_authorizations.TryAdd(ctx.OrderId, ctx.Total);
return Task.CompletedTask;
}
public Task CompensateAsync(
OrderContext ctx, CancellationToken ct)
{
// EN: Cancel the authorization.
// PT: Cancelar a autorização.
_authorizations.TryRemove(ctx.OrderId, out _);
return Task.CompletedTask;
}
}
public sealed class CreateShipmentStep : ISagaStep
{
public string Name => "CriarEntrega";
public Task ExecuteAsync(
OrderContext ctx, CancellationToken ct)
=> Task.CompletedTask;
public Task CompensateAsync(
OrderContext ctx, CancellationToken ct)
=> Task.CompletedTask;
}CreateShipmentStep follows the same contract, with empty execution and compensation in the example.
What makes the steps safe to repeat, highlighted:

4.4. Execution
File Program.cs: before the sagas, it validates two plans with SagaPlan and shows the command turning into an event; then it runs the happy path, the failure path and the duplicate message.
using SagaDemo;
var services = new ServiceCollection();
services.AddLogging(b => b
.AddSimpleConsole(o => o.SingleLine = true)
.SetMinimumLevel(LogLevel.Information));
var provider = services.BuildServiceProvider();
var logger = provider
.GetRequiredService<ILogger<OrderSagaOrchestrator>>();
async Task<SagaOutcome> Run(bool paymentFails)
{
ISagaStep[] steps =
[
new ReserveStockStep(),
new AuthorizePaymentStep(failOnExecute: paymentFails),
new CreateShipmentStep()
];
var orchestrator = new OrderSagaOrchestrator(steps, logger);
var order = new OrderContext(
Guid.NewGuid(), "cli-42", 199.90m);
return await orchestrator.RunAsync(order);
}
StepInfo[] valid =
[
new("ReservarEstoque", StepKind.Compensable),
new("CobrarCartao", StepKind.Pivot),
new("EmitirNota", StepKind.Retriable)
];
StepInfo[] invalid = [valid[1], valid[0]];
Console.WriteLine($"Plan / Plano: {SagaPlan.IsValid(valid)}");
Console.WriteLine($"Pivot first / Pivô primeiro: "
+ $"{SagaPlan.IsValid(invalid)}");
var command = new ReserveStock(Guid.NewGuid(), 10m);
var evt = StockHandler.Handle(command);
Console.WriteLine($"Command / Comando: "
+ $"{command.GetType().Name} -> {evt.GetType().Name}");
Console.WriteLine($"Happy path / Fluxo feliz -> "
+ $"{await Run(paymentFails: false)}");
Console.WriteLine($"Failure path / Fluxo de falha -> "
+ $"{await Run(paymentFails: true)}");
var inbox = new InboxStore();
var consumer = new StockConsumer(inbox, new ReserveStockStep());
var msg = new SagaMessage(
Guid.NewGuid(), Guid.NewGuid(), "ReservarEstoque");
var octx = new OrderContext(Guid.NewGuid(), "cli-42", 10m);
await consumer.HandleAsync(msg, octx, CancellationToken.None);
// EN: Redelivery of the same message.
// PT: Reentrega da mesma mensagem.
await consumer.HandleAsync(msg, octx, CancellationToken.None);
var ignored = !inbox.TryClaim("estoque", msg.MessageId);
Console.WriteLine($"Inbox: second delivery ignored / "
+ $"segunda entrega ignorada = {ignored}");Condensed output:
Plan / Plano: True
Pivot first / Pivô primeiro: False
Command / Comando: ReserveStock -> StockReserved
Happy path / Fluxo feliz -> Completed
... AutorizarPagamento failed; starting compensation ...
... compensated / compensado ReservarEstoque
Failure path / Fluxo de falha -> Compensated
Inbox: second delivery ignored / segunda entrega ignorada = TrueKey points in the code
- The orchestrator pushes each completed step onto a stack. When a step fails, it pops: the last one completed is the first one undone.
- Only the steps that were already completed are compensated. The step that failed does not go onto the stack.
- The compensation has retries because it can fail too. If they run out, the result is
CompensationFailedand someone needs to act (alert, intervention queue). - Each step uses the
OrderIdas the idempotency key: executing or compensating twice has the same effect as doing it once. OperationCanceledExceptiondoes not trigger automatic compensation in this example; in production, decide that rule explicitly.- This example does not persist the saga state. If the process dies midway, the state is lost. In production, write the current step and the completed ones on every transition.
- The code uses
ILoggerand the dependency injection from the ASP.NET Core shared framework; in a plain console project, add theMicrosoft.Extensions.Hostingpackage.
4.5. With a library: the skeleton of a state machine
With MassTransit, orchestration is modeled as a state machine: a persisted state class and a class that describes states and events. The base skeleton, according to the documentation:
public class OrderState : SagaStateMachineInstance
{
public Guid CorrelationId { get; set; }
public string CurrentState { get; set; }
}
public class OrderStateMachine
: MassTransitStateMachine<OrderState>
{
public State Submitted { get; private set; } = null!;
public State Accepted { get; private set; } = null!;
public OrderStateMachine()
{
InstanceState(x => x.CurrentState);
}
}The state (CurrentState) lives in the saga repository, which solves the persistence that the previous example left out. Events and per-state behavior are declared next, following the official guide. Before adopting it, check the license: according to the official documentation, MassTransit v9 is a commercial product.

Saga best practices and common implementation mistakes

Common mistakes
- A compensation that is not idempotent: it can run more than once (retry, redelivery) and refund twice.
- Treating compensation as a rollback: it is a new business operation and can be partial (canceling a flight ticket may not return 100% of the amount).
- Writing to the database and publishing the message in two separate steps: a crash between the two leaves the system inconsistent. Use Outbox.
- Ignoring the lack of isolation: concurrent sagas see intermediate data, and that causes dirty reads and lost updates.
- A saga with no monitoring: without knowing which step each saga stopped at, there is no way to find the stuck ones.
- Compensation with no failure handling: when it runs out of attempts, there must be an alert and a manual intervention queue.
Production tips
- Persist the saga state on every transition and correlate everything by an id (logs, messages, traces with OpenTelemetry).
- Use Outbox when publishing and an idempotent consumer (for example, a table of processed messages with a uniqueness constraint) when receiving. Do not replace the consumer's idempotency with the broker's deduplication.
- Define timeouts per step: a step can stay blocked without failing.
- Order the steps so that what cannot be undone (the pivot) comes as late as possible, after the validations.
- Isolation countermeasures, according to Microsoft: semantic lock (mark the record as "in progress"), commutative updates, re-reading values before updating and pessimistic view (reordering steps).
- Not every failure calls for compensation: retry transient failures first and only compensate when there is no way to move forward.
- For high-impact or ambiguous decisions, consider pausing the saga for human review.
Example: idempotent consumer (Inbox)
A message may arrive twice. The consumer records the message key and ignores the repetition. Here it is in memory, to show the idea; in production, the key goes into a table with a uniqueness constraint, written in the same transaction as the business effect. File Inbox.cs:
using System.Collections.Concurrent;
namespace SagaDemo;
using MessageKey = (string Consumer, Guid MessageId);
public sealed record SagaMessage(
Guid MessageId, Guid SagaId, string Type);
public sealed class InboxStore
{
private readonly ConcurrentDictionary<MessageKey, bool>
_processed = new();
// EN: TryAdd is atomic: only one caller wins the message key.
// PT: TryAdd é atômico: só um chamador "ganha" a chave.
public bool TryClaim(string consumer, Guid messageId) =>
_processed.TryAdd((consumer, messageId), true);
}
public sealed class StockConsumer(
InboxStore inbox, ReserveStockStep reserve)
{
public async Task HandleAsync(
SagaMessage message,
OrderContext ctx,
CancellationToken ct)
{
if (!inbox.TryClaim("estoque", message.MessageId))
// EN: Duplicate: acknowledge and ignore it.
// PT: Duplicada: confirma a mensagem e ignora.
return;
await reserve.ExecuteAsync(ctx, ct);
}
}
The key is per consumer and per message (not just per message), so that several consumers of the same event do not block each other, as the Microsoft documentation recommends.
When to use Saga and when to avoid it

Use it when
- A business operation goes through several services, each with its own database.
- You need consistency without coupling the services through a distributed transaction.
- You can define a business compensation for each step.
Avoid (or postpone) it when
- Everything runs in one service and one database: a local transaction solves it.
- The flow has few steps: the coordination costs more than the problem.
- It requires strong, immediate consistency: a saga is eventually consistent by nature (Microsoft also lists tightly coupled transactions and unreliable compensation as contraindications).
- There are actions that cannot be undone (external effects, acts with legal value) and no compensation is possible. They can only come after the pivot.
- The system is a simple CRUD and the saga only adds complexity.
- The problem is solved by retrying and the failures are almost all transient: compensation only adds complexity.
- The team is still on the monolith: before a saga, review whether splitting into services is really necessary.
Alternatives
- Local transaction in a modular monolith: the simplest option while the modules share the database.
- Two-phase commit / distributed transaction: gives strong consistency, but couples the services; Microsoft's microservices guide does not recommend distributed transactions with messaging, for CAP theorem reasons, and prioritizes availability and partition tolerance.
- Retry + idempotency: when only transient failures matter.
- Durable workflows (for example, Durable Task / Durable Functions) when the flow is long and needs managed persisted state.
Saga at a glance: pillars and compensations

The saga swaps the global COMMIT for local transactions plus compensations. Choreography fits short flows; orchestration fits flows with more steps and rules. In either case, what sustains a saga in production is the same set of pillars: persisted state, Outbox, idempotent consumers, idempotent compensations and observability.
A practical exercise: before adopting it, list the steps of your most critical flow and write down, for each one, what the compensation is and whether it can fail. If some step has no possible compensation, it is your pivot and must go at the end of the sequence. If you cannot write the compensations, perhaps that data should live in the same service.
Saga Pattern references and source code
- Article code on GitHub: ceseidl/saga-pattern-dotnet
- Microsoft Learn: Saga distributed transactions pattern
- Microsoft Learn: Compensating Transaction pattern
- Microsoft Learn: Idempotent Consumer pattern
- Microsoft Learn: Transactional Outbox pattern with Azure Cosmos DB
- Microsoft Learn: Subscribing to events (integration events, atomicity and idempotency)
- microservices.io: Pattern: Saga
- MassTransit: Saga State Machine
Keep reading
- Kafka vs RabbitMQ in .NET: compares the brokers that carry the saga events and commands.
- Monolith vs Microservices: explains the distribution costs that make a saga necessary.
- Fault Tolerance vs Resilience in .NET: retries and fallbacks complement compensations when a step fails.
Want an architecture review of your .NET backend? See the backend and .NET architecture service.
