Girô: dois serviços, nenhum broker e o padrão Transactional Outbox
Como fiz um mercado e um estoque conversarem por eventos sem Kafka nem RabbitMQ, usando só PostgreSQL: outbox, inbox idempotente e FOR UPDATE SKIP LOCKED.
O Girô é um projeto de portfólio: um mercado virtual cujo estoque se repõe sozinho. São dois sistemas separados, o mercado-service (a vitrine, onde a venda acontece) e o estoque-service (o almoxarifado, que tem os lotes). Cada um tem seu próprio PostgreSQL e nenhum enxerga o banco do outro.
A regra de negócio é simples: uma venda debita a prateleira. Quando o saldo cai abaixo do mínimo, o mercado pede reposição ao estoque, que manda um lote até o nível ideal. Se o estoque está zerado, o produto vira esgotado e a demanda fica registrada. Quando chega lote novo, a reposição dispara sozinha.
Parece fácil. O que eu aprendi foi que a parte difícil não é a regra, é fazer dois sistemas concordarem sobre o que aconteceu.
O problema: salvar e avisar não são atômicos
A primeira ideia que vem à cabeça é algo assim:
@Transactional
public void checkout(...) {
prateleira.debitar(qtd); // grava no banco
estoqueClient.pedirReposicao(...); // chama o outro serviço por HTTP
}
Isso quebra de dois jeitos:
- O HTTP funciona, mas o commit falha depois. O estoque mandou um lote para uma venda que não existe.
- O commit funciona, mas o HTTP falha (estoque fora do ar, timeout). A prateleira ficou abaixo do mínimo e ninguém vai pedir reposição.
O banco e a rede não participam da mesma transação. Não tem @Transactional que resolva isso.
A resposta comum é "coloca um Kafka". Só que um broker é mais uma peça para subir, monitorar e entender, e ele não resolve o problema sozinho: publicar no Kafka também é uma chamada de rede fora da transação do banco.
A solução: Transactional Outbox
A ideia do outbox é parar de tentar fazer duas coisas atômicas e fazer uma só: gravar no banco.
- Na mesma transação que debita a prateleira, grava uma linha na tabela
outbox_eventcom statusPENDING. Ou as duas coisas commitam, ou nenhuma. - Um job agendado lê os eventos pendentes, entrega para o outro serviço e marca como
SENT.
Se o estoque estiver fora do ar, o evento continua na tabela esperando. Nada se perde.
O publicador ficou assim (resumido):
@Transactional
public int publicarProntos() {
var agora = relogio.instant();
int enviados = 0;
for (var evento : eventos.travarProntosParaEnvio(agora, propriedades.tamanhoLote())) {
try {
transporte.enviar(evento);
evento.marcarEnviado(relogio.instant());
enviados++;
} catch (FalhaEnvioException e) {
var espera = backoff(evento.getTentativas() + 1);
evento.agendarNovaTentativa(e.getMessage(), agora.plus(espera));
}
}
return enviados;
}
Quando o envio falha, o evento é reagendado com backoff exponencial (1s, 2s, 4s… até um teto), para não martelar um serviço que já está caído.
FOR UPDATE SKIP LOCKED
E se tiver duas instâncias do mercado rodando? Ou se uma rodada do job demorar e a próxima começar antes de ela terminar? As duas iam pegar os mesmos eventos e enviar em dobro.
Aqui aprendi um recurso do PostgreSQL que eu não conhecia:
SELECT * FROM outbox_event
WHERE status = 'PENDING' AND proxima_tentativa_em <= :agora
ORDER BY criado_em
LIMIT :limite
FOR UPDATE SKIP LOCKED
FOR UPDATE trava as linhas lidas até o fim da transação. SKIP LOCKED faz o outro publicador pular as linhas travadas em vez de ficar esperando. Resultado: vários publicadores trabalham em paralelo, cada um com o seu pedaço da fila, sem fila externa nenhuma.
Mas o evento ainda pode chegar duas vezes
O outbox garante que o evento sai. Ele não garante que sai uma vez só. Se o estoque recebe o evento, responde 204, e o commit do lado do mercado falha logo depois, o evento continua PENDING e vai ser enviado de novo.
Isso tem nome: entrega at-least-once. Não dá para evitar, então o lado que recebe precisa aguentar duplicata. É o padrão Inbox, ou consumidor idempotente:
INSERT INTO inbox_event (event_id, processado_em)
VALUES (:eventId, :processadoEm)
ON CONFLICT (event_id) DO NOTHING
Cada evento tem um eventId (UUID). Antes de processar, o consumidor tenta registrar esse id. Se o INSERT afetou 1 linha, o evento é novo. Se afetou 0, é duplicata e é ignorado. O registro na inbox e o efeito do evento (creditar a prateleira, por exemplo) commitam juntos. E se duas entregas do mesmo evento chegarem ao mesmo tempo, a chave primária faz uma esperar a outra: só uma insere.
Um detalhe que gostei: a Inbox e a Outbox usam @Transactional(propagation = MANDATORY). Se alguém chamar uma delas fora de uma transação de negócio, dá erro na hora. Assim o código não deixa eu esquecer a regra.
Contratos que quebram a compilação
Os eventos são records do Java que implementam uma interface selada:
public sealed interface EventoIntegracao
permits ReposicaoSolicitada, ReposicaoEnviada, ReposicaoNegada, ProdutoClassificado {
UUID eventId();
Instant ocorridoEm();
}
Como a interface é sealed, todo switch sobre ela pode ser exaustivo. Se eu criar um evento novo e esquecer de tratar em algum lugar, o projeto não compila. Prefiro mil vezes descobrir isso no build do que em produção.
Os contratos ficam duplicados nos dois serviços, de propósito: assim eles continuam independentes de verdade. Para não divergirem, os dois têm os mesmos arquivos JSON de exemplo em src/test/resources/contratos/, e testes de contrato conferem que ambos leem e escrevem o mesmo formato.
LLM só na periferia
O projeto tem um terceiro serviço, o tag-worker, em Python com FastAPI, que usa um LLM para classificar produtos ("COCA COLA 2L PET" vira bebida, refrigerante, 2l). Uma decisão importante: IA não decide nada no núcleo. Quantidade de reposição é regra determinística e auditável. Se o worker estiver fora do ar, o produto é salvo sem tags e a venda segue normalmente.
O bug que só um teste de verdade pegaria
O estoque chama o tag-worker com o HttpClient do JDK. Funcionava nos testes com servidor falso, mas quebrava contra o worker real: o corpo da requisição chegava vazio.
O motivo: por padrão o HttpClient do Java tenta fazer upgrade para HTTP/2 em texto puro (h2c), e o uvicorn, que roda o FastAPI, descartava o corpo nesse caminho. A correção foi uma linha, forçar HttpClient.Version.HTTP_1_1. Quem pegou o bug foi um teste de contrato que sobe o tag-worker de verdade a partir do Dockerfile, com Testcontainers. Virou regra do projeto: todo cliente HTTP Java usa HTTP/1.1.
O que levo daqui
- Rede e banco não são atômicos. Toda vez que um sistema grava algo e avisa outro, vale perguntar o que acontece se um dos dois falhar no meio.
- Outbox resolve o "sai sempre", inbox resolve o "processa uma vez". Um sem o outro fica pela metade.
- PostgreSQL faz mais do que eu imaginava.
SKIP LOCKEDeON CONFLICTsubstituíram uma infraestrutura inteira para esse tamanho de projeto. - Deixe o compilador e os testes cobrarem as regras. Interface selada,
propagation = MANDATORYe teste de contrato contra o serviço real pegaram erros que revisão de código deixaria passar.
Ainda falta
Nem tudo está pronto, e anotar o que falta também faz parte do diário:
- Um evento que recebe 400 é reenviado para sempre (com backoff de até 5 minutos). Falta um status
FAILED, uma dead-letter, para tirar da fila o que nunca vai dar certo. - A reserva feita no checkout ainda não expira, e não existe confirmação de pagamento.
- Os endpoints entre serviços ainda não têm autenticação.
| Peça | Tecnologia |
|---|---|
| mercado-service e estoque-service | Java 25 + Spring Boot 4 |
| Bancos | PostgreSQL 17, um por serviço, migrations com Flyway |
| Testes | JUnit 5 + Testcontainers |
| tag-worker | Python 3.12 + FastAPI |
| Fronts | React + Vite |