Padrão Saga para transações distribuídas em microserviços

1. Visão Geral

Em uma aplicação monolítica, você coloca @Transactional em um método e o banco de dados cuida do resto: ou tudo é feito commit, ou tudo é feito rollback. Quando você migra para microserviços, cada serviço tem seu próprio banco de dados, então uma única transação ACID não consegue mais cobrir todo o fluxo de negócio.

Este artigo apresenta o padrão Saga, a forma mais comum de resolver esse problema. Após ler, você entenderá os dois tipos de saga (choreography e orchestration), saberá escrever um pequeno saga orchestrator em Java 21, testar o fluxo de compensação e conhecerá os erros comuns ao colocar saga em produção.

2. O Problema: Um Fluxo de Negócio, Múltiplos Bancos de Dados

Considere um fluxo de pedido que envolve três serviços:

  1. InventoryService gerencia o estoque.
  2. PaymentService debita o cliente.
  3. ShippingService cria a nota de envio.

Suponha que a etapa 1 seja bem-sucedida, mas a etapa 2 falhe porque o cliente não tem fundos suficientes. Neste ponto, a mercadoria foi reservada no estoque, mas nenhum pagamento foi realizado. Você não consegue fazer “rollback” do banco de dados do InventoryService a partir do PaymentService.

A solução clássica é o two-phase commit (2PC). No entanto, o 2PC é pouco usado em microserviços porque:

  • Ele bloqueia recursos durante todo o tempo de espera por respostas de todas as partes, reduzindo drasticamente a taxa de transferência.
  • O coordenador é um ponto único de falha.
  • Muitos sistemas como Kafka, MongoDB e APIs REST de terceiros não suportam transações XA.

3. O que é Saga?

Saga divide uma grande transação em uma sequência de transações locais. Cada transação local é executada em um serviço e faz commit imediatamente no banco de dados desse serviço. Cada etapa vem acompanhada de uma transação compensadora, que é uma operação de negócio que “desfaz” essa etapa.

Etapa Ação Compensação
1 Reservar estoque (reserve) Liberar estoque (release)
2 Debitar (charge) Reembolsar (refund)
3 Criar envio (ship) Cancelar envio (cancel)

Se a etapa n falhar, a saga executa a compensação das etapas n-1, n-2, …, 1 em ordem reversa.

Dois pontos importantes que diferenciam a compensação de um rollback real:

  • A compensação não apaga o histórico. Ela cria uma nova mudança, por exemplo, uma transação de reembolso. Por um curto período, outros serviços podem ter visto o estado intermediário.
  • Saga garante consistência eventual, não garante isolamento como ACID.

Existem duas formas de coordenar uma saga:

  • Choreography: cada serviço ouve eventos e decide por si mesmo qual é o próximo passo. Não há um controlador central.
  • Orchestration: um orquestrador chama cada serviço em sequência e é responsável por executar a compensação quando há um erro.

4. Orquestração de Saga em Java 21

Esta seção constrói um orquestrador pure Java, sem dependência de framework, para que você entenda claramente o mecanismo interno.

4.1. Definindo um Passo de Saga

import java.util.function.Consumer;

public interface SagaStep<C> {
    String name();
    void execute(C context);
    void compensate(C context);

    static <C> SagaStep<C> of(String name, Consumer<C> action, Consumer<C> compensation) {
        return new SagaStep<>() {
            public String name() { return name; }
            public void execute(C ctx) { action.accept(ctx); }
            public void compensate(C ctx) { compensation.accept(ctx); }
        };
    }
}

Cada etapa tem dois comportamentos: execute para o fluxo normal e compensate para desfazer. O genérico C é um contexto compartilhado entre as etapas, como informações do pedido. O método factory of permite declarar etapas usando lambdas de forma concisa.

4.2. Orchestrator

import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;

public record SagaResult(boolean success, String failedStep, String reason) {
    static SagaResult ok() { return new SagaResult(true, null, null); }
    static SagaResult failed(String step, String reason) { return new SagaResult(false, step, reason); }
}

public class SagaOrchestrator<C> {
    private final List<SagaStep<C>> steps;

    public SagaOrchestrator(List<SagaStep<C>> steps) {
        this.steps = List.copyOf(steps);
    }

    public SagaResult run(C context) {
        Deque<SagaStep<C>> completed = new ArrayDeque<>();
        for (SagaStep<C> step : steps) {
            try {
                step.execute(context);
                completed.push(step);
            } catch (RuntimeException ex) {
                compensate(completed, context);
                return SagaResult.failed(step.name(), ex.getMessage());
            }
        }
        return SagaResult.ok();
    }

    private void compensate(Deque<SagaStep<C>> completed, C context) {
        while (!completed.isEmpty()) {
            SagaStep<C> step = completed.pop();
            try {
                step.compensate(context);
            } catch (RuntimeException ex) {
                // Produção: salve para retry, não deve ser ignorado
                System.err.println("Compensation failed at " + step.name() + ": " + ex.getMessage());
            }
        }
    }
}

Os pontos principais:

  • completed é uma pilha (ArrayDeque com push/pop). Cada etapa bem-sucedida é colocada na pilha, então ao fazer pop obtemos a ordem reversa correta.
  • A etapa com falha não está na pilha. Como ela não fez commit em nada, não precisa ser compensada.
  • Uma compensação com falha não deve parar as outras compensações. Por isso, cada chamada de compensate é envolvida em seu próprio try/catch.

4.3. Serviços Simulados

import java.math.BigDecimal;
import java.util.*;

public record Order(String id, String customerId, String sku, int quantity, BigDecimal amount) {}

public class InventoryService {
    private final Map<String, Integer> stock = new HashMap<>(Map.of("BOOK-01", 10));
    public void reserve(Order o) {
        int left = stock.get(o.sku()) - o.quantity();
        if (left < 0) throw new IllegalStateException("Out of stock");
        stock.put(o.sku(), left);
    }
    public void release(Order o) { stock.merge(o.sku(), o.quantity(), Integer::sum); }
    public int available(String sku) { return stock.get(sku); }
}

public class PaymentService {
    private final Map<String, BigDecimal> balances = new HashMap<>(Map.of("alice", new BigDecimal("100")));
    public void charge(Order o) {
        BigDecimal balance = balances.get(o.customerId());
        if (balance.compareTo(o.amount()) < 0) throw new IllegalStateException("Insufficient funds");
        balances.put(o.customerId(), balance.subtract(o.amount()));
    }
    public void refund(Order o) { balances.merge(o.customerId(), o.amount(), BigDecimal::add); }
    public BigDecimal balance(String customerId) { return balances.get(customerId); }
}

public class ShippingService {
    private final Set<String> shipments = new HashSet<>();
    public void ship(Order o) { shipments.add(o.id()); }
    public void cancel(Order o) { shipments.remove(o.id()); }
    public boolean hasShipment(String orderId) { return shipments.contains(orderId); }
}

Na prática, cada método acima seria uma chamada REST/gRPC ou uma mensagem enviada a outro serviço. Esta simulação é apenas para ilustração e teste.

4.4. Montando o Saga de Pedido

import java.math.BigDecimal;
import java.util.List;

public class OrderSagaDemo {
    public static void main(String[] args) {
        var inventory = new InventoryService();
        var payment = new PaymentService();
        var shipping = new ShippingService();

        var saga = new SagaOrchestrator<Order>(List.of(
            SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
            SagaStep.of("charge-payment", payment::charge, payment::refund),
            SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
        ));

        var cheap = new Order("ORD-1", "alice", "BOOK-01", 2, new BigDecimal("40"));
        var pricey = new Order("ORD-2", "alice", "BOOK-01", 3, new BigDecimal("500"));

        System.out.println(saga.run(cheap));
        System.out.println(saga.run(pricey));
        System.out.println("Stock: " + inventory.available("BOOK-01"));
        System.out.println("Balance: " + payment.balance("alice"));
    }
}

Resultado:

SagaResult[success=true, failedStep=null, reason=null]
SagaResult[success=false, failedStep=charge-payment, reason=Insufficient funds]
Stock: 8
Balance: 60

O pedido ORD-1 passou pelas três etapas. O pedido ORD-2 conseguiu reservar 3 livros, mas falhou na etapa de débito. O orquestrador então chamou inventory.release, então o estoque voltou para 8 (10 − 2 do pedido anterior) em vez de 5.

5. Saga Choreography com Eventos

Com choreography, nenhum serviço “sabe” o fluxo completo. Cada serviço publica um evento após terminar seu trabalho, e outro serviço reage a esse evento. O exemplo abaixo usa ApplicationEventPublisher do Spring em um processo para fins de ilustração. Em um sistema real, você o substituiria por Kafka ou RabbitMQ.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
public record StockReserved(Order order) {}
public record PaymentFailed(Order order, String reason) {}

@Component
public class PaymentHandler {
    private final PaymentService payment;
    private final ApplicationEventPublisher events;

    public PaymentHandler(PaymentService payment, ApplicationEventPublisher events) {
        this.payment = payment;
        this.events = events;
    }

    @EventListener
    public void on(StockReserved event) {
        try {
            payment.charge(event.order());
        } catch (IllegalStateException ex) {
            events.publishEvent(new PaymentFailed(event.order(), ex.getMessage()));
        }
    }
}

@Component
public class InventoryCompensationHandler {
    private final InventoryService inventory;

    public InventoryCompensationHandler(InventoryService inventory) {
        this.inventory = inventory;
    }

    @EventListener
    public void on(PaymentFailed event) {
        inventory.release(event.order());
    }
}

PaymentHandler ouve StockReserved. Se o débito falhar, ele publica PaymentFailed. Por sua vez, InventoryCompensationHandler recebe este evento e libera a mercadoria do estoque. Para o código funcionar, InventoryService e PaymentService também precisam ser declarados como beans.

Comparação rápida dos dois tipos:

  • Choreography é simples e pouco acoplado quando o fluxo tem apenas 2–3 etapas. Com fluxos mais longos, a lógica fica espalhada por vários serviços, dificultando o rastreamento e facilitando ciclos de eventos.
  • Orchestration centraliza o fluxo em um só lugar, fácil de ler, testar e salvar estado. Em troca, o orquestrador se torna um componente que você precisa operar cuidadosamente.

6. Testando o Comportamento de Compensação

Adicione a dependência de teste:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

class OrderSagaTest {

    private final InventoryService inventory = new InventoryService();
    private final PaymentService payment = new PaymentService();
    private final ShippingService shipping = new ShippingService();

    private final SagaOrchestrator<Order> saga = new SagaOrchestrator<>(List.of(
        SagaStep.of("reserve-stock", inventory::reserve, inventory::release),
        SagaStep.of("charge-payment", payment::charge, payment::refund),
        SagaStep.of("create-shipment", shipping::ship, shipping::cancel)
    ));

    @Test
    void givenInsufficientFunds_whenRunSaga_thenStockIsReleased() {
        var order = new Order("ORD-9", "alice", "BOOK-01", 3, new BigDecimal("500"));

        SagaResult result = saga.run(order);

        assertThat(result.success()).isFalse();
        assertThat(result.failedStep()).isEqualTo("charge-payment");
        assertThat(inventory.available("BOOK-01")).isEqualTo(10);
        assertThat(payment.balance("alice")).isEqualByComparingTo("100");
        assertThat(shipping.hasShipment("ORD-9")).isFalse();
    }

    @Test
    void givenValidOrder_whenRunSaga_thenAllStepsCommitted() {
        var order = new Order("ORD-10", "alice", "BOOK-01", 1, new BigDecimal("30"));

        assertThat(saga.run(order).success()).isTrue();
        assertThat(inventory.available("BOOK-01")).isEqualTo(9);
        assertThat(payment.balance("alice")).isEqualByComparingTo("70");
        assertThat(shipping.hasShipment("ORD-10")).isTrue();
    }
}

O primeiro teste verifica o mais importante da saga: após falha, todos os efeitos colaterais foram desfeitos. O estoque volta para 10, o saldo fica intacto e nenhuma nota de envio é criada. O segundo teste confirma que o fluxo bem-sucedido faz commit de todas as três etapas.

7. Erros Comuns e Como Evitá-los

Nota: O orquestrador neste artigo mantém o estado em memória. Se o processo morrer no meio do caminho, você não sabe em que etapa estava e também não consegue compensar. Em produção, salve o estado da saga (etapa atual, etapas concluídas) em um banco de dados após cada etapa, depois permita retomar quando reiniciar.

Compensation deve ser idempotente. Mensagens podem ser enviadas novamente várias vezes, então refund rodando duas vezes não deve reembolsar duas vezes. Salve o orderId de transações já processadas e ignore solicitações duplicadas.

Compensation não deve “desistir”. O catch que imprime log na seção 4.2 é apenas para ilustração. Na prática, uma compensação com falha deve ser retentada com backoff. Após várias tentativas ainda falhando, envie para uma fila de letra morta para processamento manual.

Dual write. Se o serviço fizer commit no banco de dados e enviar uma mensagem para o Kafka, uma das duas operações pode falhar. Use Transactional Outbox: escreva o evento em uma tabela outbox na mesma transação local, depois outro processo lê essa tabela e publica.

Falta de isolamento. Entre o débito e o reembolso, o usuário pode ver o saldo reduzido temporariamente. Você pode reduzir esse impacto com semantic lock, por exemplo, definindo o status do pedido como PENDING até que a saga termine. Outra opção é reordenar os passos, colocando o mais propenso a falha primeiro.

Existem etapas que não podem ser compensadas. Um email já enviado não pode ser revogado. Coloque esses passos (chamados pivot ou retriable step) no final da saga, após todas as etapas que podem falhar serem bem-sucedidas.

Quando NÃO usar saga:

  • Todos os dados estão em um único banco de dados. Nesse caso, apenas use uma transação local para simplificar.
  • O negócio exige consistência forte absoluta e não tolera estados intermediários, como alguns processos de contabilidade principal. Neste caso, você deve reconsiderar como dividir as fronteiras dos serviços.
  • O fluxo tem apenas um serviço que escreve dados, enquanto outros serviços apenas leem.

8. Conclusão

Saga substitui uma transação distribuída por uma sequência de transações locais acompanhadas de transações compensatórias, permitindo que o sistema alcance consistência eventual sem precisar de 2PC. Você construiu um orquestrador pequeno que usa uma pilha para compensar em ordem reversa, viu como o choreography se coordena através de eventos, e escreveu testes para confirmar que todos os efeitos colaterais são desfeitos quando há um erro.

Ao colocar saga em produção, lembre-se de quatro coisas: salve o estado da saga de forma durável, escreva compensações idempotentes com retry, use Transactional Outbox para evitar dual write, e ordene os passos para que operações que não podem ser compensadas fiquem no final do fluxo.

microservicessagadistributed transactionspring bootjava