1. O problema: o dia em que o @Transactional parou de funcionar
No monólito, fechar um pedido era assim:
@Transactional
public void fecharPedido(PedidoRequest req) {
pedidoRepository.save(pedido); // tabela PEDIDO
pagamentoRepository.save(pagamento); // tabela PAGAMENTO
estoqueRepository.baixar(itens); // tabela ESTOQUE
}
Se o estoque falhasse, o banco fazia rollback de tudo. ACID de graça.
Aí o sistema foi quebrado em microsserviços — Pedido, Pagamento e Estoque — e cada um ganhou seu próprio banco (database per service). Agora o fluxo vira três chamadas, para três processos, com três transações independentes.
O que acontece se o pagamento foi aprovado e o estoque falhou? O cliente foi cobrado por um produto que não vai receber. Não existe mais um rollback() que desfaça tudo.
Esse é o problema das transações distribuídas: garantir que uma operação de negócio que envolve vários serviços termine em um estado consistente, mesmo com falhas de rede, timeouts e serviços fora do ar.
Existem duas grandes famílias de solução:
| Abordagem | Consistência | Ideia central |
|---|---|---|
| 2PC (Two-Phase Commit) | Forte (atômica) | Um coordenador trava todo mundo até todos concordarem |
| Saga | Eventual | Cada passo comita sozinho; se der errado, compensa |
Vamos às duas.
2. Two-Phase Commit (2PC): a solução "clássica"
O 2PC (padronizado no XA, e no Java via JTA) funciona em duas fases:
- Prepare: o coordenador pergunta a cada participante "você consegue comitar?". Cada banco executa a operação, grava no log, mantém os locks e responde SIM ou NÃO.
-
Commit/Rollback: se todos disseram SIM, o coordenador manda
commit. Se alguém disse NÃO (ou não respondeu), mandarollbackpara todos.
No papel é perfeito: atomicidade entre bancos diferentes. Na prática, em microsserviços, ele cobra caro:
- É bloqueante. Se o coordenador cair depois do prepare e antes do commit, os participantes ficam "em dúvida", segurando locks em linhas que outras transações precisam.
- Latência = o participante mais lento. Todo mundo espera todo mundo. Um serviço lento derruba a vazão do fluxo inteiro.
- Acoplamento síncrono. Todos os serviços precisam estar no ar ao mesmo tempo. Isso vai contra a ideia de serviços independentes.
- Suporte limitado. O Kafka não participa de transações XA, e boa parte dos bancos NoSQL e APIs REST de terceiros (gateway de pagamento, por exemplo) também não.
💡 Quando 2PC ainda faz sentido? Dentro de um mesmo serviço, quando você precisa escrever atomicamente em dois recursos XA que você controla (dois bancos relacionais, um banco + uma fila JMS) e o volume é baixo. Entre microsserviços, quase nunca.
Em sistemas distribuídos modernos, a escolha costuma ser abrir mão da consistência imediata em troca de disponibilidade e escala (o famoso trade-off do teorema CAP). É aí que entra a Saga.
3. O Padrão Saga
O conceito vem de um artigo de 1987 (Garcia-Molina e Salem) sobre transações de longa duração, e foi popularizado no mundo de microsserviços por Chris Richardson.
Uma Saga é uma sequência de transações locais. Cada passo:
- atualiza o banco do seu próprio serviço e comita;
- publica um evento/mensagem que dispara o próximo passo.
Se um passo falha, a Saga executa transações de compensação para desfazer, semanticamente, o que os passos anteriores já fizeram.
Repare na palavra semanticamente. Compensar não é ROLLBACK: o dado já foi comitado e pode até ter sido lido por outro processo. Compensar é executar uma nova ação de negócio que anula o efeito da anterior:
| Transação | Compensação |
|---|---|
| Criar pedido | Cancelar pedido (status CANCELADO, não DELETE) |
| Cobrar cartão | Estornar cobrança |
| Reservar estoque | Liberar reserva |
| Enviar e-mail de confirmação | Enviar e-mail de cancelamento 😅 |
O último exemplo mostra por que nem tudo é compensável. Por isso se classificam os passos assim:
- Compensáveis: podem ser desfeitos (cobrança, reserva).
- Pivô: o ponto de não retorno. Se ele comita, a Saga vai até o fim.
- Retentáveis: vêm depois do pivô e são garantidos de terminar (com retry), como agendar a entrega.
🧠 Dica de modelagem: coloque os passos que mais falham (validação de estoque, antifraude) antes dos passos caros de compensar (cobrança no cartão).
Existem duas formas de coordenar uma Saga: coreografia e orquestração.
4. Coreografia: cada serviço sabe o próximo passo
Não existe coordenador. Cada serviço escuta eventos e reage:
-
Pedidocria o pedido comoPENDENTEe publica PedidoCriado. -
Pagamentoconsome, cobra e publica PagamentoAprovado (ou PagamentoRecusado). -
Estoqueconsome, reserva e publica EstoqueReservado (ou EstoqueFalhou). -
Pagamentoescuta EstoqueFalhou e estorna.Pedidoescuta e cancela.
Vantagens: baixíssimo acoplamento, nenhum ponto central de falha, rápido de começar.
Desvantagens: a lógica do fluxo fica espalhada por N serviços. Para responder "em que passo está o pedido 123?" você precisa juntar logs de todo mundo. Com 5+ passos vira um emaranhado de eventos, e é fácil criar dependências cíclicas.
👉 Boa para fluxos curtos (2–4 passos) e estáveis.
5. Orquestração: um maestro conduzindo a Saga
Um componente, o orquestrador, guarda a máquina de estados da Saga. Ele envia comandos ("processe o pagamento") e reage às respostas ("pagamento aprovado/recusado"), decidindo o próximo passo ou disparando compensações.
Vantagens: o fluxo inteiro está num lugar só, é fácil de testar, de monitorar (uma tabela saga_state responde "onde está o pedido 123?") e de evoluir. Os serviços participantes ficam "burros": só executam comandos.
Desvantagens: o orquestrador vira peça crítica. Ele precisa persistir estado, ser idempotente e ter alta disponibilidade. Também existe o risco de concentrar regra de negócio demais nele.
👉 Boa para fluxos longos, com muitas regras, ou quando a auditabilidade importa.
Resumo da escolha
| Critério | Coreografia | Orquestração |
|---|---|---|
| Nº de passos | Poucos | Muitos |
| Visibilidade do fluxo | Baixa | Alta |
| Acoplamento | Mínimo | Serviços → orquestrador |
| Testes de ponta a ponta | Difíceis | Mais simples |
| Ponto central de falha | Não | Sim (mitigável) |
Na implementação a seguir vou usar orquestração, porque ela deixa os conceitos mais visíveis no código.
6. Os três problemas que ninguém te conta
Antes do código, três armadilhas que derrubam a maioria das Sagas em produção.
6.1 Dual-write → Transactional Outbox
O código ingênuo:
@Transactional
public void criarPedido(...) {
pedidoRepository.save(pedido);
kafkaTemplate.send("pagamento.commands", comando); // 💣
}
Se o send funcionar e o commit do banco falhar, você publicou um comando para um pedido que não existe. Se o commit funcionar e o Kafka estiver fora, o pedido fica PENDENTE para sempre. São dois sistemas e não há transação entre eles.
A solução é o Transactional Outbox: grave o evento numa tabela outbox na mesma transação do dado de negócio. Um processo separado (um poller ou o Debezium via CDC) lê essa tabela e publica no Kafka.
6.2 At-least-once → consumidores idempotentes
Com outbox + retries, a garantia é pelo menos uma vez. Mensagens vão chegar duplicadas: o relay publica e cai antes de marcar como enviada, o consumidor processa e cai antes de comitar o offset, um rebalance acontece no meio, etc.
Todo consumidor precisa ser idempotente: processar a mesma mensagem duas vezes tem que dar o mesmo resultado de processar uma vez. A forma mais robusta é uma tabela processed_messages com o messageId como chave primária, gravada na mesma transação do efeito de negócio.
6.3 Falta de isolamento → contramedidas
Sagas não têm o "I" do ACID. Enquanto a Saga roda, outros processos enxergam estados intermediários. Contramedidas comuns:
-
Semantic lock: status como
PENDENTEouAPROVACAO_PENDENTEsinalizam "não mexa, tem saga em andamento". -
Valores comutativos: prefira
saldo = saldo - 100asaldo = 900. - Reler o valor antes de agir, quando ele pode ter mudado.
-
Versionamento (
@Version) para optimistic locking.
7. Mão na massa: Saga orquestrada com Java 17, Spring Boot 3 e Kafka
Cenário
Um e-commerce com três serviços:
- order-service (orquestrador + dono do pedido)
- payment-service
- inventory-service
Fluxo feliz: Pedido PENDENTE → Pagamento → Estoque → Pedido APROVADO.
Se o estoque falhar: Estorno do pagamento → Pedido CANCELADO.
Tópicos Kafka:
| Tópico | Quem publica | Quem consome |
|---|---|---|
payment.commands |
Orquestrador | payment-service |
inventory.commands |
Orquestrador | inventory-service |
saga.replies |
Participantes | Orquestrador |
Use o sagaId como chave da mensagem: assim todas as mensagens de uma mesma saga caem na mesma partição e mantêm a ordem.
7.1 Infra local (docker-compose)
services:
kafka:
image: apache/kafka:3.8.0
ports: ["9092:9092"]
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
postgres:
image: postgres:16
ports: ["5432:5432"]
environment:
POSTGRES_USER: saga
POSTGRES_PASSWORD: saga
POSTGRES_DB: orders
Kafka em modo KRaft, sem Zookeeper.
7.2 Dependências (pom.xml do order-service)
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
</dependencies>
7.3 Contratos de mensagem com records e sealed interfaces
Java 17 deixa os contratos enxutos e fechados: o compilador sabe exatamente quais comandos existem.
public sealed interface SagaCommand
permits ProcessPayment, RefundPayment, ReserveInventory {
UUID sagaId();
}
public record ProcessPayment(UUID sagaId, UUID orderId, BigDecimal amount)
implements SagaCommand {}
public record RefundPayment(UUID sagaId, UUID orderId, BigDecimal amount)
implements SagaCommand {}
public record ReserveInventory(UUID sagaId, UUID orderId, List<OrderItem> items)
implements SagaCommand {}
public record OrderItem(String sku, int quantity) {}
// Resposta única para todos os participantes
public record SagaReply(
UUID messageId, // usado para idempotência
UUID sagaId,
SagaStep step, // PAYMENT, INVENTORY, REFUND
boolean success,
String reason) {}
public enum SagaStep { PAYMENT, INVENTORY, REFUND }
7.4 Estado da Saga (a máquina de estados persistida)
public enum SagaStatus {
STARTED,
PAYMENT_APPROVED,
COMPENSATING,
COMPLETED,
CANCELLED
}
@Entity
@Table(name = "saga_state")
public class SagaState {
@Id
private UUID sagaId;
private UUID orderId;
@Enumerated(EnumType.STRING)
private SagaStatus status;
private String failureReason;
private Instant updatedAt;
@Version // optimistic lock: evita duas respostas mudando o estado ao mesmo tempo
private Long version;
protected SagaState() {}
public SagaState(UUID sagaId, UUID orderId) {
this.sagaId = sagaId;
this.orderId = orderId;
this.status = SagaStatus.STARTED;
this.updatedAt = Instant.now();
}
public void transitionTo(SagaStatus next) {
this.status = next;
this.updatedAt = Instant.now();
}
// getters...
}
7.5 Outbox
@Entity
@Table(name = "outbox")
public class OutboxMessage {
@Id
private UUID id;
private String topic;
private String messageKey;
private String type; // ex.: "ProcessPayment"
@Column(columnDefinition = "jsonb")
@JdbcTypeCode(SqlTypes.JSON)
private String payload;
private Instant createdAt;
private Instant publishedAt;
protected OutboxMessage() {}
public OutboxMessage(String topic, String key, String type, String payload) {
this.id = UUID.randomUUID();
this.topic = topic;
this.messageKey = key;
this.type = type;
this.payload = payload;
this.createdAt = Instant.now();
}
public void markPublished() { this.publishedAt = Instant.now(); }
// getters...
}
Um componente que enfileira comandos na outbox. Ele roda dentro da transação de quem chamou (MANDATORY garante isso):
@Component
@RequiredArgsConstructor
public class OutboxWriter {
private final OutboxRepository repository;
private final ObjectMapper mapper;
@Transactional(propagation = Propagation.MANDATORY)
public void enqueue(String topic, SagaCommand command) {
enqueue(topic, command.sagaId().toString(), command);
}
@Transactional(propagation = Propagation.MANDATORY)
public void enqueue(String topic, String key, Object payload) {
try {
repository.save(new OutboxMessage(
topic,
key,
payload.getClass().getSimpleName(),
mapper.writeValueAsString(payload)));
} catch (JsonProcessingException e) {
throw new IllegalStateException("Falha ao serializar mensagem", e);
}
}
}
E o relay, que publica o que está pendente. Aqui uso polling para manter o exemplo simples; em produção, Debezium + Outbox Event Router é uma ótima alternativa.
@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxRelay {
private final OutboxRepository repository;
private final KafkaTemplate<String, String> kafka;
@Scheduled(fixedDelay = 500)
@Transactional
public void publishPending() {
// SELECT ... ORDER BY created_at LIMIT 100 FOR UPDATE SKIP LOCKED
List<OutboxMessage> batch = repository.lockNextBatch(100);
for (OutboxMessage msg : batch) {
ProducerRecord<String, String> record =
new ProducerRecord<>(msg.getTopic(), msg.getMessageKey(), msg.getPayload());
record.headers().add("type", msg.getType().getBytes(StandardCharsets.UTF_8));
record.headers().add("messageId", msg.getId().toString().getBytes(StandardCharsets.UTF_8));
try {
kafka.send(record).get(5, TimeUnit.SECONDS); // espera o ack
msg.markPublished();
} catch (Exception e) {
log.warn("Falha ao publicar {}, tenta no próximo ciclo", msg.getId(), e);
break; // preserva a ordem: não pula mensagens
}
}
}
}
public interface OutboxRepository extends JpaRepository<OutboxMessage, UUID> {
@Query(value = """
SELECT * FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT :limit
FOR UPDATE SKIP LOCKED
""", nativeQuery = true)
List<OutboxMessage> lockNextBatch(@Param("limit") int limit);
}
O FOR UPDATE SKIP LOCKED permite subir várias instâncias do serviço sem que duas publiquem a mesma linha ao mesmo tempo.
⚠️ Repare: se o
sendfuncionar e a instância cair antes do commit, a mensagem será publicada de novo. Isso é esperado. É por isso que o próximo passo, idempotência, não é opcional.
7.6 O orquestrador
Iniciando a Saga. Pedido, estado da Saga e primeiro comando são gravados numa única transação local:
@Service
@RequiredArgsConstructor
public class OrderSagaOrchestrator {
private final OrderRepository orders;
private final SagaStateRepository sagas;
private final OutboxWriter outbox;
@Transactional
public UUID start(CreateOrderRequest req) {
Order order = orders.save(Order.pending(req.customerId(), req.items(), req.total()));
UUID sagaId = UUID.randomUUID();
sagas.save(new SagaState(sagaId, order.getId()));
outbox.enqueue("payment.commands",
new ProcessPayment(sagaId, order.getId(), order.getTotal()));
return order.getId();
}
Reagindo às respostas. Toda a lógica de transição fica aqui, legível de cima a baixo:
@Transactional
public void handle(SagaReply reply) {
SagaState saga = sagas.findById(reply.sagaId())
.orElseThrow(() -> new IllegalStateException("Saga desconhecida " + reply.sagaId()));
Order order = orders.getReferenceById(saga.getOrderId());
switch (reply.step()) {
case PAYMENT -> onPayment(saga, order, reply);
case INVENTORY -> onInventory(saga, order, reply);
case REFUND -> onRefund(saga, order, reply);
}
}
private void onPayment(SagaState saga, Order order, SagaReply reply) {
if (saga.getStatus() != SagaStatus.STARTED) return; // resposta duplicada/atrasada
if (reply.success()) {
saga.transitionTo(SagaStatus.PAYMENT_APPROVED);
outbox.enqueue("inventory.commands",
new ReserveInventory(saga.getSagaId(), order.getId(), order.itemsAsDto()));
} else {
// nada a compensar: o pagamento nem aconteceu
order.cancel(reply.reason());
saga.transitionTo(SagaStatus.CANCELLED);
}
}
private void onInventory(SagaState saga, Order order, SagaReply reply) {
if (saga.getStatus() != SagaStatus.PAYMENT_APPROVED) return;
if (reply.success()) {
order.approve();
saga.transitionTo(SagaStatus.COMPLETED);
} else {
// compensação: estorna o que já foi cobrado
saga.transitionTo(SagaStatus.COMPENSATING);
outbox.enqueue("payment.commands",
new RefundPayment(saga.getSagaId(), order.getId(), order.getTotal()));
}
}
private void onRefund(SagaState saga, Order order, SagaReply reply) {
if (saga.getStatus() != SagaStatus.COMPENSATING) return;
if (reply.success()) {
order.cancel("Estoque indisponível");
saga.transitionTo(SagaStatus.CANCELLED);
} else {
// compensação falhou: NÃO dá pra simplesmente desistir
throw new CompensationFailedException(saga.getSagaId(), reply.reason());
}
}
}
Dois detalhes importantes:
-
Guard clauses por status (
if (status != X) return) tornam o orquestrador idempotente: uma resposta repetida não avança o estado duas vezes. -
Compensações precisam funcionar. Se o estorno falhar, a exceção faz o Kafka reentregar a mensagem (com retry e, no limite, Dead Letter Topic + alerta para um humano). Uma saga parada em
COMPENSATINGé um incidente, não um detalhe.
O listener que liga o Kafka ao orquestrador:
@Component
@RequiredArgsConstructor
public class SagaReplyListener {
private final OrderSagaOrchestrator orchestrator;
private final ObjectMapper mapper;
@KafkaListener(topics = "saga.replies", groupId = "order-orchestrator")
public void onReply(String payload) throws JsonProcessingException {
orchestrator.handle(mapper.readValue(payload, SagaReply.class));
}
}
7.7 Um participante idempotente: payment-service
@Entity
@Table(name = "processed_messages")
public class ProcessedMessage {
@Id
private UUID messageId;
private Instant processedAt = Instant.now();
protected ProcessedMessage() {}
public ProcessedMessage(UUID messageId) { this.messageId = messageId; }
}
@Component
@RequiredArgsConstructor
@Slf4j
public class PaymentCommandListener {
private final ProcessedMessageRepository processed;
private final PaymentService paymentService;
private final OutboxWriter outbox;
private final ObjectMapper mapper;
@KafkaListener(topics = "payment.commands", groupId = "payment-service")
@Transactional
public void onCommand(@Payload String payload,
@Header("type") String type,
@Header("messageId") String messageId) throws JsonProcessingException {
UUID msgId = UUID.fromString(messageId);
if (processed.existsById(msgId)) {
log.info("Mensagem {} já processada, ignorando", msgId);
return;
}
SagaReply reply = switch (type) {
case "ProcessPayment" -> {
var cmd = mapper.readValue(payload, ProcessPayment.class);
yield paymentService.charge(cmd);
}
case "RefundPayment" -> {
var cmd = mapper.readValue(payload, RefundPayment.class);
yield paymentService.refund(cmd);
}
default -> throw new IllegalArgumentException("Comando desconhecido: " + type);
};
processed.save(new ProcessedMessage(msgId)); // mesma transação do efeito
outbox.enqueue("saga.replies", reply.sagaId().toString(), reply); // resposta também via outbox
}
}
save em processed_messages, alteração do pagamento e resposta na outbox: tudo na mesma transação. Se duas instâncias processarem a mesma mensagem em paralelo, a PK duplicada faz uma delas falhar e dar rollback.
O paymentService.charge também deve ser idempotente do lado do gateway: envie o sagaId como idempotency key na chamada ao provedor de pagamento (Stripe, Adyen, Pagar.me e afins suportam isso).
7.8 Configuração do Kafka
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
properties:
enable.idempotence: true
max.in.flight.requests.per.connection: 5
consumer:
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
auto-offset-reset: earliest
enable-auto-commit: false
listener:
ack-mode: record
E um error handler com retry exponencial e Dead Letter Topic:
@Configuration
public class KafkaErrorConfig {
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<String, String> template) {
var recoverer = new DeadLetterPublishingRecoverer(template); // publica em <topico>.DLT
var backoff = new ExponentialBackOffWithMaxRetries(5);
backoff.setInitialInterval(1_000);
backoff.setMultiplier(2.0);
backoff.setMaxInterval(30_000);
var handler = new DefaultErrorHandler(recoverer, backoff);
handler.addNotRetryableExceptions(IllegalArgumentException.class,
JsonProcessingException.class);
return handler;
}
}
Erros de desserialização ou comandos inválidos vão direto para a DLT. Falhas transitórias (banco fora, timeout) são retentadas.
8. Não deixe sagas "penduradas": timeouts
E se o inventory-service ficar fora por horas? A saga fica em PAYMENT_APPROVED indefinidamente, com o dinheiro do cliente preso.
Um job simples resolve:
@Scheduled(fixedDelay = 60_000)
@Transactional
public void expireStuckSagas() {
Instant limit = Instant.now().minus(Duration.ofMinutes(15));
sagas.findByStatusAndUpdatedAtBefore(SagaStatus.PAYMENT_APPROVED, limit)
.forEach(saga -> {
saga.transitionTo(SagaStatus.COMPENSATING);
outbox.enqueue("payment.commands", new RefundPayment(/* ... */));
});
}
Como o orquestrador ignora respostas que não batem com o status atual, se a resposta do estoque chegar atrasada ela é descartada com segurança. Para sagas muito longas (horas, dias), vale considerar um motor de workflow como Temporal ou Camunda, que já trazem timers, retries e histórico prontos.
9. Observabilidade: rastreando uma saga de ponta a ponta
Uma saga cruza vários serviços de forma assíncrona. Sem observabilidade, debugar é adivinhação.
-
Correlation ID: propague o
sagaIdem headers do Kafka e coloque no MDC dos logs (MDC.put("sagaId", ...)). -
Tracing distribuído: Spring Boot 3 usa Micrometer Tracing. Com
spring.kafka.template.observation-enabled=trueespring.kafka.listener.observation-enabled=true, o trace atravessa produtor e consumidor e aparece inteiro no Zipkin, Jaeger ou Grafana Tempo. -
Métricas de negócio: sagas por status, tempo médio até
COMPLETED, taxa de compensação, mensagens em DLT. Um pico deCOMPENSATINGcostuma ser o primeiro sinal de problema num serviço downstream. -
A tabela
saga_stateé seu melhor painel de suporte: "o pedido 123 está emCOMPENSATINGdesde 14:02".
10. Checklist de produção
- [ ] Cada passo da saga tem uma compensação definida (ou é pivô/retentável)
- [ ] Eventos publicados via Outbox, nunca
save()+send()soltos - [ ] Todos os consumidores são idempotentes (
processed_messagesou chave natural) - [ ] Chamadas externas usam idempotency key
- [ ]
sagaIdcomo chave da mensagem para garantir ordem por saga - [ ] Producer com
acks=alleenable.idempotence=true - [ ] Retry com backoff + DLT + alerta na DLT
- [ ] Timeout para sagas paradas
- [ ] Contramedidas de isolamento (status
PENDENTE,@Version) - [ ] Tracing e correlation ID ponta a ponta
- [ ] Testes de integração com Testcontainers (Kafka + Postgres) cobrindo o caminho feliz e os de compensação
Conclusão
Transações distribuídas não são um problema de framework, são um problema de modelagem. O 2PC tenta esconder a distribuição atrás de uma atomicidade que custa disponibilidade e escala. A Saga assume que a rede falha, que mensagens duplicam e que serviços caem, e projeta o fluxo para convergir a um estado consistente mesmo assim.
O resumo em uma frase: transação local + outbox + consumidor idempotente + compensação bem definida. Domine esses quatro pilares e o resto (coreografia ou orquestração, Kafka ou outro broker) vira detalhe de implementação.
Se você já implementou Sagas em produção, conta nos comentários: coreografia ou orquestração? Qual foi a compensação mais difícil de modelar? 👇
Referências
- Hector Garcia-Molina e Kenneth Salem, Sagas (ACM SIGMOD, 1987)
- Chris Richardson, Microservices Patterns (Manning) e microservices.io
- Documentação do Spring for Apache Kafka
- Documentação do Debezium: Outbox Event Router





Top comments (0)