Saga Pattern: consistência entre microsserviços sem rollback

No monólito, "criar pedido" cabia em uma transação: reservar estoque, cobrar e gerar a entrega, tudo com BEGIN e COMMIT. Se algo falhasse, o banco desfazia. Ao dividir em microsserviços (como vimos em Monólito vs Microservices), cada serviço passa a ter seu próprio banco, e essa transação única deixa de existir. Se o estoque foi reservado e o pagamento foi recusado, quem desfaz a reserva?
O Saga Pattern responde a isso sem transação distribuída: quebra a operação em transações locais e, se uma delas falhar, executa ações que desfazem o efeito das anteriores. Neste artigo, que continua a série depois do API Gateway + BFF (que organizou como o cliente chega aos serviços), vemos como os serviços mantêm a consistência entre si.
Conceitos do Saga: transações locais e compensatórias
Saga: uma sequência de transações locais, uma por serviço. Cada transação local confirma seu trabalho no próprio banco e dispara o próximo passo por evento ou mensagem. Não há um COMMIT global.
Transação compensatória: a ação que desfaz o efeito de um passo já confirmado. Ela não é um rollback: o dado já foi gravado, então a compensação é uma nova operação de negócio (liberar a reserva, estornar o pagamento, cancelar o pedido).
Tipos de passo (classificação da documentação da Microsoft):
- Compensável: pode ser desfeito por uma compensação.
- Pivô: o ponto sem volta. Depois que ele tem sucesso, os passos seguintes precisam ser concluídos para chegar a um estado final consistente.
- Retentável: vem depois do pivô, é idempotente e pode ser repetido até dar certo.
Em código, a regra de ordem (compensáveis, no máximo um pivô, depois retentáveis) cabe em poucas linhas, no arquivo StepKinds.cs do repositório:
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);
}A ordem dos valores do enum é a própria regra: IsValid exige que o tipo nunca "volte" ao longo dos passos e que exista no máximo um pivô. Um plano que coloca o pivô antes de um passo compensável é recusado, porque esse passo não teria mais como ser desfeito.
Coreografia vs orquestração: as duas formas de coordenar uma saga.
- Coreografia: não há controlador central. Cada serviço publica eventos e reage aos eventos dos outros.
- Orquestração: um orquestrador central diz a cada participante o que executar, guarda o estado da saga e dispara as compensações.
Consistência eventual: durante a saga, o sistema pode estar parcialmente atualizado (estoque reservado, pagamento ainda não feito). Ele só volta a ficar consistente quando a saga termina, com sucesso ou compensada.
Regra de bolso: se o fluxo tem poucos serviços e poucos passos, coreografia basta; se tem muitos passos, ramificações ou regras de compensação, prefira orquestração.

Coreografia ou orquestração: arquitetura da Saga
Fluxo de sucesso e de falha de um pedido com três serviços:
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 canceladoCoreografia: o serviço de Pedido publica PedidoCriado; Estoque reage e publica EstoqueReservado; Pagamento reage e publica PagamentoAutorizado (ou PagamentoRecusado, que Estoque escuta para liberar a reserva). Simples no começo, mas conforme entram passos fica difícil enxergar o fluxo inteiro: segundo a Microsoft, é difícil rastrear a quais comandos cada participante responde, há risco de dependência cíclica e o teste de integração exige todos os serviços no ar.
Orquestração: o orquestrador envia comandos (ReservarEstoque, AutorizarPagamento), recebe as respostas e decide o próximo passo ou a compensação. O fluxo fica em um lugar só e evita dependência cíclica, ao custo de mais um componente que precisa de lógica de coordenação e vira ponto de falha.
A diferença aparece no tipo de mensagem: na orquestração o orquestrador envia um comando (uma ordem a um participante); na coreografia cada serviço publica um evento (um fato que já aconteceu, ao qual qualquer um pode reagir). Em 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);
}O participante recebe ReserveStock e responde com StockReserved. Quem decide o próximo passo é o orquestrador (que lê a resposta) ou os outros serviços (que escutam o evento).

Pontos de projeto:
- Estado da saga persistido: o orquestrador precisa saber em que passo está, mesmo após reinício.
- Publicação atômica: gravar no banco e publicar a mensagem precisam acontecer juntos. A solução recomendada é o Transactional Outbox (o evento é gravado na mesma transação local e um processo separado o publica).
- Consumidores idempotentes: brokers como RabbitMQ e Azure Service Bus entregam pelo menos uma vez, então a mesma mensagem pode chegar duas vezes.
- Correlação: um identificador da saga (aqui, o
OrderId) em logs, mensagens e traces.
Em .NET, há duas rotas: usar uma biblioteca de mensageria com suporte a sagas (MassTransit, NServiceBus, Wolverine) ou escrever o orquestrador sobre a sua infraestrutura. O exemplo a seguir mostra a mecânica da orquestração com compensação, sem dependências externas, para o conceito ficar visível.
Saga com orquestrador em C# e .NET, passo a passo
O exemplo é um orquestrador de pedido com três passos: reservar estoque, autorizar pagamento e criar entrega. Os serviços são simulados em memória; num sistema real, cada ISagaStep chamaria outro microsserviço por HTTP ou mensagem. Os blocos são os arquivos do repositório, sem cortes; por isso as mensagens de log e os comentários aparecem em inglês e português.
Código completo, com README e instruções para rodar: github.com/ceseidl/saga-pattern-dotnet
4.1. Contrato do passo
Arquivo 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. Orquestrador
Arquivo 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;
}
}
Em destaque, o laço de execução: só o passo concluído entra na pilha, e a primeira falha dispara a compensação.

E o laço de compensação, que desempilha em ordem inversa e repete cada compensação até 3 vezes:

4.3. Passos com compensação idempotente
Arquivo 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 segue o mesmo contrato, com execução e compensação vazias no exemplo.
O que torna os passos seguros para repetição, em destaque:

4.4. Execução
Arquivo Program.cs: antes das sagas, ele valida dois planos com SagaPlan e mostra o comando virando evento; depois roda o fluxo feliz, o de falha e a mensagem duplicada.
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}");Saída resumida:
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 = TruePontos importantes do código
- O orquestrador empilha cada passo concluído. Quando um passo falha, ele desempilha: o último concluído é o primeiro desfeito.
- Apenas os passos já concluídos são compensados. O passo que falhou não entra na pilha.
- A compensação tem retentativas porque ela mesma pode falhar. Se esgotar, o resultado é
CompensationFailede alguém precisa agir (alerta, fila de intervenção). - Cada passo usa o
OrderIdcomo chave de idempotência: executar ou compensar duas vezes tem o mesmo efeito de uma. OperationCanceledExceptionnão dispara compensação automática neste exemplo; em produção, decida essa regra explicitamente.- Este exemplo não persiste o estado da saga. Se o processo cair no meio, o estado se perde. Em produção, grave o passo atual e os já concluídos a cada transição.
- O código usa
ILoggere a injeção de dependência do shared framework do ASP.NET Core; em um projeto de console puro, adicione o pacoteMicrosoft.Extensions.Hosting.
4.5. Com biblioteca: o esqueleto de uma state machine
Com MassTransit, a orquestração é modelada como máquina de estados: uma classe de estado persistida e uma classe que descreve estados e eventos. O esqueleto base, conforme a documentação:
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);
}
}O estado (CurrentState) fica no repositório da saga, o que resolve a persistência que o exemplo anterior deixou de fora. Os eventos e o comportamento por estado são declarados em seguida, conforme o guia oficial. Antes de adotar, confira a licença: segundo a documentação oficial, o MassTransit v9 é um produto comercial.

Boas práticas e erros comuns ao implementar Saga

Erros comuns
- Compensação que não é idempotente: ela pode ser executada mais de uma vez (retentativa, redelivery) e estornar duas vezes.
- Tratar compensação como rollback: ela é uma nova operação de negócio e pode ser parcial (cancelar uma passagem pode não devolver 100% do valor).
- Gravar no banco e publicar a mensagem em dois passos separados: um crash entre os dois deixa o sistema inconsistente. Use Outbox.
- Ignorar a falta de isolamento: sagas concorrentes enxergam dados intermediários, e isso gera leituras sujas e atualizações perdidas.
- Saga sem monitoramento: sem saber em que passo cada saga parou, não há como achar as travadas.
- Compensação sem tratamento de falha: quando ela esgota as tentativas, precisa haver alerta e fila de intervenção manual.
Dicas de produção
- Persista o estado da saga a cada transição e correlacione tudo por um id (logs, mensagens, traces com OpenTelemetry).
- Use Outbox na publicação e consumidor idempotente (por exemplo, uma tabela de mensagens processadas com restrição de unicidade) na recepção. Não substitua a idempotência do consumidor pela deduplicação do broker.
- Defina timeouts por passo: um passo pode ficar bloqueado sem falhar.
- Ordene os passos para deixar o que não tem volta (o pivô) o mais tarde possível, depois das validações.
- Contramedidas de isolamento, segundo a Microsoft: semantic lock (marcar o registro como "em processamento"), atualizações comutativas, reler valores antes de atualizar e visão pessimista (reordenar passos).
- Nem toda falha pede compensação: retente falhas transitórias antes e só compense quando não houver como avançar.
- Para decisões de alto impacto ou ambíguas, considere pausar a saga para revisão humana.
Exemplo: consumidor idempotente (Inbox)
Uma mensagem pode chegar duas vezes. O consumidor registra a chave da mensagem e ignora a repetição. Aqui em memória, para mostrar a ideia; em produção, a chave vai para uma tabela com restrição de unicidade, gravada na mesma transação do efeito de negócio. Arquivo 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);
}
}
Chave por consumidor e por mensagem (e não só pela mensagem), para que vários consumidores do mesmo evento não se bloqueiem entre si, como recomenda a documentação da Microsoft.
Quando usar Saga e quando evitar

Use quando
- Uma operação de negócio passa por vários serviços, cada um com seu banco.
- Você precisa de consistência sem acoplar os serviços por uma transação distribuída.
- Dá para definir uma compensação de negócio para cada passo.
Evite (ou adie) quando
- Tudo roda em um serviço e um banco: uma transação local resolve.
- O fluxo tem poucos passos: a coordenação custa mais do que o problema.
- Exige consistência forte e imediata: a saga é eventualmente consistente por natureza (a Microsoft também aponta transações fortemente acopladas e compensação não confiável como contraindicações).
- Há ações que não podem ser desfeitas (efeitos externos, atos com valor legal) e não existe compensação possível. Elas só podem ficar depois do pivô.
- O sistema é um CRUD simples e a saga só adiciona complexidade.
- O problema se resolve com retentativa e as falhas são quase todas transitórias: compensação só acrescenta complexidade.
- O time ainda está no monólito: antes de uma saga, revise se a divisão em serviços é mesmo necessária.
Alternativas
- Transação local no monólito modular: a opção mais simples enquanto os módulos compartilham o banco.
- Two-phase commit / transação distribuída: dá consistência forte, mas acopla os serviços; o guia de microsserviços da Microsoft não recomenda transações distribuídas com mensageria, pelas razões do teorema CAP, e prioriza disponibilidade e tolerância a partição.
- Retry + idempotência: quando só falhas transitórias importam.
- Workflows duráveis (por exemplo, Durable Task / Durable Functions) quando o fluxo é longo e precisa de estado persistido gerenciado.
Saga em um olhar: pilares e compensações

A saga troca o COMMIT global por transações locais mais compensações. Coreografia serve a fluxos curtos; orquestração, a fluxos com mais passos e regras. Em qualquer caso, o que sustenta a saga em produção são os mesmos pilares: estado persistido, Outbox, consumidores idempotentes, compensações idempotentes e observabilidade.
Reflexão prática: antes de adotar, liste os passos do seu fluxo mais crítico e escreva, para cada um, qual é a compensação e se ela pode falhar. Se algum passo não tiver compensação possível, ele é o seu pivô e deve ficar no fim da sequência. Se você não consegue escrever as compensações, talvez esses dados devam estar no mesmo serviço.
Referências e código do Saga Pattern
- Código do artigo no 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, atomicidade e idempotência)
- microservices.io: Pattern: Saga
- MassTransit: Saga State Machine
Leia também
- Kafka vs RabbitMQ em .NET: compara os brokers que carregam os eventos e comandos da saga.
- Monólito vs Microservices: explica os custos da distribuição que tornam a saga necessária.
- Tolerância a Falhas vs Resiliência em .NET: retries e fallbacks complementam as compensações quando um passo falha.
Quer uma revisão da arquitetura do seu backend .NET? Conheça o serviço de backend e arquitetura .NET.
