O pedido chega, precisa debitar o pagamento e reservar estoque — e esses dois passos vivem em serviços com deploy, banco e ciclo de vida próprios. Se o estoque recusar depois que o pagamento já foi capturado, alguém precisa desfazer o pagamento, e não existe um COMMIT único amarrando os dois bancos. Este texto assume que o corte de serviço já aconteceu — se essa decisão ainda está em aberto, bounded contexts é o artigo anterior — e resolve o que vem depois: como coordenar a transação sem two-phase commit (2PC), quando usar eventos e quando usar um orquestrador central, e como provar que a compensação realmente rodou quando um passo falha.
Por que 2PC não escala entre serviços implantados de forma independente
A tentação é replicar o que o banco já resolve sozinho: um coordenador manda todo mundo preparar, todo mundo confirma, ou todo mundo desfaz. É exatamente isso que o protocolo de duas fases faz — e ele continua especificado e funcional para recursos XA sob um único gerenciador de transações. O XAResource do Java SE 25 documenta o contrato: "at transaction commit time, the resource managers are informed by the transaction manager to prepare, commit, or rollback a transaction according to the two-phase commit protocol" — prepare(Xid) é o voto, e os recursos ficam retidos até o gerenciador decidir. A especificação Jakarta Transactions 2.0 descreve o mesmo desenho: o TransactionManager invoca XAResource.prepare e depois XAResource.commit em cada recurso do grupo.
O problema não é o protocolo — é aplicá-lo entre serviços que ninguém implanta, versiona ou reinicia juntos. Chris Richardson resume assim na página do padrão saga: "2PC is not an option", e completa na primeira parte da série: usar 2PC numa arquitetura de microsserviços "is generally a bad idea. It's a form of synchronous communication that results in runtime coupling that significantly impacts the availability of an application". O guia de padrões da AWS chega à mesma conclusão para banco-por-serviço: "the two-phase commit is not an option" porque "each transaction is distributed across various databases, and there is no single controller that can coordinate a process that's similar to the two-phase commit". Quatro problemas concretos aparecem nessas fontes, não um genérico "2PC é ruim":
- Bloqueio síncrono. Prepare-então-commit é uma ida e volta síncrona; os participantes esperam o coordenador decidir (Richardson: "runtime coupling").
- Disponibilidade do coordenador. Sem um controlador único vivo, transações preparadas ficam paradas (AWS: "no single controller"; a recuperação via
XAResource.recoverexiste justamente porque branches ficam "in prepared or heuristically completed states" quando o coordenador cai). - Tecnologia mista. Só participam recursos que implementam
XAResource— bancos, filas ou serviços HTTP fora desse contrato não entram no mesmoXidglobal. - Recursos retidos por muito tempo. O
preparemantém locks até ocommitourollbackchegar; um coordenador lento (ou fora do ar) segura esses recursos indefinidamente.
A saga troca isso por outra premissa: cada passo comita localmente, sem esperar ninguém. Se um passo posterior falhar, o trabalho anterior "já está committed" (Richardson, parte 1) — e desfazer significa uma transação compensatória nova, não um rollback.
Saga coreografada: eventos, sem coordenador central
Na coreografia "each local transaction publishes domain events that trigger local transactions in other services" (Richardson) — "there isn't a central coordinator"; os serviços "subscribe to each other's events". A Microsoft descreve o mesmo desenho como serviços que "exchange events without a centralized controller".
Caminho feliz para pedido → pagamento → estoque:
OrderServicegrava o pedido comoPENDINGe publicaOrderPlaced{sagaId, orderId, amount}.PaymentService, ao ouvirOrderPlaced, captura o pagamento e publicaPaymentCaptured{sagaId, paymentId}.InventoryService, ao ouvirPaymentCaptured, reserva o estoque e publicaInventoryReserved{sagaId}.OrderService, ao ouvirInventoryReserved, confirma o pedido e publicaOrderConfirmed{sagaId}.
Quando o estoque recusa depois que o pagamento já foi capturado:
InventoryServicepublicaInventoryRejected{sagaId, reason}.PaymentService, ao ouvirInventoryRejected, roda a compensação (estorno) e publicaPaymentRefunded{sagaId}.OrderService, ao ouvirInventoryRejectedouPaymentRefunded, rejeita o pedido e publicaOrderRejected{sagaId}.
Nenhum serviço chama outro diretamente; cada um reage a um evento de domínio que já aconteceu. A Microsoft resume a regra de compensação da mesma forma: ao falhar, o serviço "publishes a failure message. Services that subscribe to that message can run predefined compensating actions." O ganho é desacoplamento — o custo é que ninguém sozinho enxerga o fluxo inteiro; ele está distribuído nos handlers de três serviços diferentes.
Saga orquestrada: uma máquina de estado central
Na orquestração "an orchestrator (object) tells the participants what local transactions to execute" (Richardson). A comunicação vira comando/resposta: o orquestrador "sends a command message", processa a resposta e decide o próximo passo. Ele também persiste o progresso da saga e, ao falhar, "executes the compensating transactions in bottom to top order" (parte 4 da série). A Microsoft descreve o mesmo papel: um "centralized controller ... stores and interprets the states of each task, and handles failure recovery by using compensating transactions."
Mesmo fluxo, agora orquestrado:
- A API cria a instância da saga (
sagaId), grava o pedido comoPENDING. - O orquestrador envia o comando
CapturePayment;PaymentServicerespondePaymentCaptured; o orquestrador registra o passo. - O orquestrador envia
ReserveInventory;InventoryServiceresponde. SeInventoryReserved, o orquestrador confirma o pedido e marca a sagaCOMPLETED.
Se a resposta for de falha, o orquestrador envia RefundPayment, espera PaymentRefunded, rejeita o pedido localmente e marca a saga COMPENSATED. O ponto que costuma confundir: participantes não assinam eventos de domínio uns dos outros — eles recebem comandos e respondem só ao orquestrador. É essa diferença de assinatura de mensagem, não o desenho do banco, que separa os dois estilos.
Critério objetivo, não preferência de time
As mesmas fontes dão testes verificáveis, não "depende do gosto":
| Critério | Coreografia quando... | Orquestração quando... |
|---|---|---|
| Nº de participantes | Poucos serviços, sem lógica de coordenação (Microsoft; AWS: "only a few participants") | Fluxo complexo ou cresce com o tempo (Microsoft); Richardson: lógica "scattered around the participating services" fica difícil de acompanhar |
| Dono do processo | Ninguém precisa possuir o processo — é colaboração pura | Um serviço (aqui, Order) é o dono natural da sequência |
| Acoplamento | Aceita acoplamento a contratos de evento e risco de dependência cíclica | Participantes acoplam só ao contrato comando/resposta com o orquestrador; evita ciclo |
| Visibilidade do progresso | Aceita não ter uma visão única do que está em andamento | Precisa de uma máquina de estado consultável — "stores and interprets the states of each task" |
| Domínio de falha | Um serviço central de processo seria um SPOF inaceitável | Aceita (e reforça) um coordenador único; AWS admite que ele "can become a single point of failure" |
Regra prática para o caso deste artigo: três participantes com um dono de processo claro (Order) → orquestração como padrão. Coreografia se justifica quando o time quer explicitamente não ter um serviço de processo e consegue manter o grafo de eventos pequeno e acíclico — o risco que a Microsoft e a AWS citam é justamente PaymentService reagir a um evento e sem querer republicar algo que OrderService já escuta, fechando um ciclo.
Observando a compensação: como provar que ela rodou
"Sagas restauraram a consistência" não é uma frase que se verifica lendo o código do handler — é uma frase que se verifica lendo o log. A Microsoft é direta sobre isso no padrão de transação compensatória: a infraestrutura precisa "reliably monitor compensation logic progress" e permitir "correlate and audit both the original operation and its compensation end-to-end"; passos compensatórios "can fail" e precisam ser idempotentes. Quatro sinais cobrem isso sem exigir tracing distribuído:
sagaIdem toda mensagem e toda linha de log — comando, evento, resposta.- Log de passos append-only:
sagaId, step, dir=FORWARD|COMPENSATE, ok. Na orquestração esse log é a máquina de estado persistida; na coreografia é a única forma de reconstruir o fluxo depois, já que nenhum serviço vê o todo. - Eventos de resultado, não só
*Failed: publiquePaymentRefunded,InventoryReleased,OrderRejected— o sucesso da compensação é informação tão importante quanto a falha original. - Contador de compensação (um
LongAdderjá serve em produção): compensação rodou de fato se e somente se existe uma linhaCOMPENSATEcomok=truee o evento de resultado correspondente para aquelesagaId. Alerte seFORWARDfalhou e não apareceu nenhumCOMPENSATEdepois.
Exemplo prático em Java 25
O demo abaixo roda em processo, sem broker: um Bus publica/assina por tipo de mensagem, o orquestrador manda comandos e reage a respostas, e o SagaLog é ao mesmo tempo o log estruturado e a fonte de verdade de compensação. Compila e roda direto com java SagaDemo.java (Java 25, sem preview, sem dependência externa).
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.LongAdder;
import java.util.function.Consumer;
public final class SagaDemo {
public static void main(String[] args) {
Bus bus = new Bus();
SagaLog log = new SagaLog();
Set<String> paymentShouldFail = Set.of();
Set<String> outOfStock = Set.of("saga-2");
new PaymentService(bus, paymentShouldFail);
new InventoryService(bus, outOfStock);
Orchestrator orchestrator = new Orchestrator(bus, log);
System.out.println("--- saga-1: caminho feliz ---");
orchestrator.start("saga-1", "order-1");
System.out.println("--- saga-2: estoque recusa, compensa pagamento e pedido ---");
orchestrator.start("saga-2", "order-2");
System.out.println("--- saga-2: mesma mensagem de novo (reentrega) ---");
bus.publish(new Msg("cmd.reserveInventory", "saga-2", "order-2", ""));
System.out.printf("compensacoes ok = %d%n", log.compensateOkCount());
System.out.printf("compensated(saga-1) = %s%n", log.compensated("saga-1"));
System.out.printf("compensated(saga-2) = %s%n", log.compensated("saga-2"));
}
}
record Msg(String type, String sagaId, String orderId, String detail) {}
final class Bus {
private final Map<String, List<Consumer<Msg>>> handlers = new ConcurrentHashMap<>();
void on(String type, Consumer<Msg> handler) {
handlers.computeIfAbsent(type, k -> new CopyOnWriteArrayList<>()).add(handler);
}
void publish(Msg m) {
for (Consumer<Msg> h : handlers.getOrDefault(m.type(), List.of())) h.accept(m);
}
}
final class SagaLog {
enum Dir { FORWARD, COMPENSATE }
record Line(String sagaId, String step, Dir dir, boolean ok) {}
private final List<Line> lines = new CopyOnWriteArrayList<>();
private final Set<String> seen = ConcurrentHashMap.newKeySet();
private final LongAdder compensateOk = new LongAdder();
void record(String sagaId, String step, Dir dir, boolean ok) {
if (!seen.add(sagaId + ":" + step + ":" + dir)) {
System.out.printf("sagaId=%s step=%s dir=%s ok=%s duplicate=true%n", sagaId, step, dir, ok);
return;
}
lines.add(new Line(sagaId, step, dir, ok));
System.out.printf("sagaId=%s step=%s dir=%s ok=%s%n", sagaId, step, dir, ok);
if (dir == Dir.COMPENSATE && ok) compensateOk.increment();
}
boolean compensated(String sagaId) {
return lines.stream().anyMatch(l -> l.sagaId().equals(sagaId) && l.dir() == Dir.COMPENSATE && l.ok());
}
long compensateOkCount() { return compensateOk.sum(); }
}
final class PaymentService {
private final Bus bus;
private final Set<String> shouldFail;
private final Set<String> captureAttempted = ConcurrentHashMap.newKeySet();
private final Set<String> refundAttempted = ConcurrentHashMap.newKeySet();
PaymentService(Bus bus, Set<String> shouldFail) {
this.bus = bus;
this.shouldFail = shouldFail;
bus.on("cmd.capturePayment", this::onCapture);
bus.on("cmd.refundPayment", this::onRefund);
}
private void onCapture(Msg m) {
if (!captureAttempted.add(m.sagaId())) return;
if (shouldFail.contains(m.sagaId())) {
bus.publish(new Msg("reply.paymentFailed", m.sagaId(), m.orderId(), "cartao-recusado"));
return;
}
bus.publish(new Msg("reply.paymentCaptured", m.sagaId(), m.orderId(), ""));
}
private void onRefund(Msg m) {
if (!refundAttempted.add(m.sagaId())) return;
bus.publish(new Msg("reply.paymentRefunded", m.sagaId(), m.orderId(), ""));
}
}
final class InventoryService {
private final Bus bus;
private final Set<String> outOfStock;
private final Set<String> reserveAttempted = ConcurrentHashMap.newKeySet();
InventoryService(Bus bus, Set<String> outOfStock) {
this.bus = bus;
this.outOfStock = outOfStock;
bus.on("cmd.reserveInventory", this::onReserve);
}
private void onReserve(Msg m) {
if (!reserveAttempted.add(m.sagaId())) return;
if (outOfStock.contains(m.sagaId())) {
bus.publish(new Msg("reply.inventoryRejected", m.sagaId(), m.orderId(), "sem-estoque"));
return;
}
bus.publish(new Msg("reply.inventoryReserved", m.sagaId(), m.orderId(), ""));
}
}
final class Orchestrator {
private final Bus bus;
private final SagaLog log;
Orchestrator(Bus bus, SagaLog log) {
this.bus = bus;
this.log = log;
bus.on("reply.paymentCaptured", this::onPaymentCaptured);
bus.on("reply.paymentFailed", this::onPaymentFailed);
bus.on("reply.inventoryReserved", this::onInventoryReserved);
bus.on("reply.inventoryRejected", this::onInventoryRejected);
bus.on("reply.paymentRefunded", this::onPaymentRefunded);
}
void start(String sagaId, String orderId) {
bus.publish(new Msg("cmd.capturePayment", sagaId, orderId, ""));
}
private void onPaymentCaptured(Msg m) {
log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.FORWARD, true);
bus.publish(new Msg("cmd.reserveInventory", m.sagaId(), m.orderId(), ""));
}
private void onPaymentFailed(Msg m) {
log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.FORWARD, false);
log.record(m.sagaId(), "ORDER", SagaLog.Dir.COMPENSATE, true);
}
private void onInventoryReserved(Msg m) {
log.record(m.sagaId(), "INVENTORY", SagaLog.Dir.FORWARD, true);
log.record(m.sagaId(), "ORDER", SagaLog.Dir.FORWARD, true);
}
private void onInventoryRejected(Msg m) {
log.record(m.sagaId(), "INVENTORY", SagaLog.Dir.FORWARD, false);
bus.publish(new Msg("cmd.refundPayment", m.sagaId(), m.orderId(), ""));
}
private void onPaymentRefunded(Msg m) {
log.record(m.sagaId(), "PAYMENT", SagaLog.Dir.COMPENSATE, true);
log.record(m.sagaId(), "ORDER", SagaLog.Dir.COMPENSATE, true);
}
}
Para a saga-2, a sequência de log é PAYMENT FORWARD ok=true → INVENTORY FORWARD ok=false → PAYMENT COMPENSATE ok=true → ORDER COMPENSATE ok=true. Ao final, compensateOkCount() == 2 e compensated("saga-2") == true; compensated("saga-1") fica false, porque nada precisou ser desfeito. A reentrega manual de cmd.reserveInventory para saga-2 não gera evento novo: reserveAttempted.add já retornou false na primeira tentativa, então o handler sai sem publicar — a idempotência está no serviço, não só no log. O SagaLog tem sua própria checagem de duplicidade (seen) como segunda camada, para o caso de dois orquestradores (ou uma reentrega em outro ponto do fluxo) tentarem gravar o mesmo sagaId:step:dir duas vezes.
A coreografia usaria o mesmo Bus, mas sem a classe Orchestrator: cada serviço assinaria eventos de domínio em vez de comandos.
// PaymentService assina o evento de domínio, não um comando do orquestrador
bus.on("OrderPlaced", m -> capturarPagamentoEPublicar("PaymentCaptured", m));
bus.on("InventoryRejected", m -> estornarEPublicar("PaymentRefunded", m));
// InventoryService reage ao evento do serviço anterior na cadeia
bus.on("PaymentCaptured", m -> reservarEPublicar("InventoryReserved", m));
A troca de arquitetura acontece inteira na fiação (bus.on): quem assina o quê. O SagaLog e a lógica de idempotência não mudam — a observabilidade descrita na seção anterior serve para os dois estilos.
Armadilhas comuns
- Tratar compensação como rollback. O passo anterior já comitou local (Richardson: "already committed"); a Microsoft reforça que "data can't be rolled back because saga participants commit changes". Correção: modele a compensação como uma transação de negócio nova (estorno, cancelamento), não como desfazer um
UPDATE. Observe pelo parFORWARD ok=trueseguido deCOMPENSATE ok=trueno log — se só existe o primeiro, a compensação nunca terminou. - Dual-write sem outbox. Gravar o estado local e publicar o evento são duas operações; uma pode falhar depois da outra. Isso é assunto de infraestrutura de mensageria, coberto em idempotência e outbox — aqui fica só o aviso de que o demo em processo não tem esse problema porque não há dual-write.
- Compensação não-idempotente. Reentrega de mensagem (reconexão de fila, retry de cliente) não pode estornar duas vezes. Correção: o handler verifica um conjunto de tentativas por
sagaIdantes de agir, como emcaptureAttempted/refundAttempted/reserveAttemptedacima. Observe contando quantas vezesduplicate=trueaparece no log — se o número cresce, algum produtor está reentregando mais do que deveria. - Ciclo na coreografia. Se
PaymentServicereagir aInventoryRejectedpublicando algo queOrderServicejá escuta como gatilho de novo pagamento, o grafo de eventos fecha um ciclo. Correção: desenhe o grafo de eventos antes de codar e confirme que é acíclico; se não for, é sinal de que a saga precisa de um orquestrador. Observe rastreandosagaIdatravés dos tipos de evento publicados — umsagaIdque aparece mais vezes do que passos existem no fluxo indica ciclo. - Orquestrador como SPOF não declarado. O demo acima roda em processo e perde tudo se o processo cair no meio de uma saga. Em produção o estado do orquestrador (o próprio
SagaLog) precisa sobreviver ao restart do processo. Observe monitorando sagas com passoFORWARDgravado há mais tempo que o SLA do fluxo sem o passo seguinte — é o sinal de orquestrador que caiu no meio. - Isolamento fantasma. Entre
PaymentCapturedeInventoryReserved, o pedido existe comoPENDINGe outro fluxo pode tentar operar sobre ele. Mantenha o estadoPENDINGexplícito até confirmação ou rejeição — é o "semantic lock" que a saga não resolve sozinha (esse é assunto de modelagem de domínio, já coberto em bounded contexts, não repetido aqui).
Recomendação
Para o caso de três participantes com um dono de processo claro — pedido, pagamento, estoque, com Order como serviço que inicia e fecha o fluxo — a orquestração é o padrão default: dá visibilidade única do progresso, evita ciclo de eventos e concentra a lógica de compensação num lugar auditável. A DevDojo trocaria para coreografia apenas quando o time quiser deliberadamente não ter um serviço de processo (poucos participantes, sem plano de crescer) e conseguir manter o grafo de eventos pequeno e acíclico — nesse caso, o ganho de desacoplamento compensa a perda de visibilidade central. Em qualquer um dos dois estilos, o critério de "funcionou" não é o código compilar: é o sagaId aparecer em toda mensagem, o log de passos registrar FORWARD e COMPENSATE de forma append-only, e o evento de resultado (PaymentRefunded, não só InventoryRejected) existir para provar que a compensação terminou.