Arquitetura Orientada a Eventos na Prática: Spring Boot, Kafka e PostgreSQL -- e uma IA que Testa Tudo pra Você
Da Teoria pra Trincheira
No artigo anterior, falei sobre Arquitetura Orientada a Eventos como base pra transformação digital. Conceitos como Event Sourcing, CQRS, Saga Pattern -- a teoria por trás de sistemas que se comunicam por eventos em vez de chamadas diretas.
Teoria é importante. Mas teoria sem código é PowerPoint.
Este artigo é o oposto. Aqui, vamos colocar a mão na massa com um microserviço real: Java 25, Spring Boot 4.0, Kafka (via Redpanda) e PostgreSQL 16. E o projeto tem um detalhe intencional: ele implementa o Dual Write anti-pattern -- um problema clássico de sistemas distribuídos que a maioria dos desenvolvedores não percebe até quebrar em produção.
Vamos entender o problema, ver o código que o expõe, e discutir como resolvê-lo.
O Que é o Dual Write Problem em Sistemas Event-Driven?
Cenário que a maioria de nós já viveu.
Você salva uma informação no base de dados. Em seguida, publica um evento no Kafka. Duas escritas em dois sistemas separados, dentro da mesma operação.
O que acontece quando o commit no banco funciona, mas o publish no Kafka falha?
sequenceDiagram
participant HR as HTTP Request
participant OS as Order Service
participant DB as Database
participant K as Kafka
HR->>OS: POST /orders
OS->>DB: INSERT order
DB-->>OS: OK
OS-xK: publish("order.created") ❌
Note over OS,K: FALHA — evento nunca entregue
O pedido existe no banco. Nenhum evento foi publicado. Consumidores downstream nunca ficam sabendo. O sistema está inconsistente, e ninguém recebe alerta.
O problema de Dual Write ocorre quando um serviço escreve em dois sistemas separados -- como um banco de dados e um message broker -- sem garantia atômica entre ambos. Se a primeira escrita funciona e a segunda falha, os sistemas ficam silenciosamente inconsistentes.
O @Transactional do Spring cobre o banco. Mas o Kafka vive fora desse boundary transacional. Não existe atomicidade nativa entre os dois.
Construí este projeto pra expor essa falha de forma intencional. Entender o problema é o primeiro passo pra resolvê-lo. Mais adiante neste artigo, mostro como o Transactional Outbox Pattern resolve isso — e como o Mars Enterprise Kit Pro implementa esse padrão end-to-end.
Como o Dual Write Problem Aparece no Spring Boot e Kafka
Agora vamos ao ponto onde a consistência quebra. Este é o código real do CreateOrderUseCase — exatamente o que roda neste projeto:
// domain/usecase/CreateOrderUseCase.java
@Service
public class CreateOrderUseCase {
@Transactional
public UUID execute(final Input input) {
var result = Order.create(input.customerId(), input.items());
orderRepository.save(result.domain());
// ⚠️ DUAL WRITE: sem garantia de atomicidade entre DB e Kafka.
// Se o publish falhar, o pedido existe no PostgreSQL mas o evento é silenciosamente perdido.
try {
orderEventPublisher.publish(result.event());
} catch (Exception e) {
log.warn("DUAL WRITE FAILURE — EVENT LOST for orderId={}. " +
"Order saved in DB but event NOT published to Kafka. Cause: {}",
result.domain().id(), e.getMessage());
}
return result.domain().id();
}
}
O @Transactional envolve o save no banco. O Kafka vive fora desse boundary transacional — não existe atomicidade nativa entre os dois.
O risco real quando o Kafka está fora do ar:
t=0ms -> POST /orders chega
t=1ms -> @Transactional inicia
t=3ms -> orderRepository.save(order) [INSERT no banco, dentro da transação]
t=5ms -> orderEventPublisher.publish() [envio pro Kafka, FORA da transação — lança exceção]
t=5ms -> catch(Exception e) [exceção engolida, WARN logado]
t=6ms -> @Transactional commita [commit no banco OK]
t=6ms -> HTTP 201 retornado ao cliente [cliente vê SUCESSO]
Resultado: Order EXISTE no PostgreSQL. Evento NÃO existe no Kafka.
O cliente recebeu 201. Ninguém sabe que o evento foi perdido.
O Catch que Esconde o Problema
Este padrão aparece constantemente em código de produção:
try {
orderEventPublisher.publish(result.event());
} catch (Exception e) {
log.warn("Failed to publish event: {}", e.getMessage());
}
Parece defensivo. Parece resiliente. Na prática, está escondendo uma falha de consistência de dados.
O banco commitou. O cliente recebeu 201 Created. O WARN dispara e se perde no ruído de um sistema em produção. Os consumidores downstream nunca recebem o evento. O estoque nunca é reservado. O billing nunca cobra. O pedido existe no banco e em nenhum outro lugar do sistema.
Dois cenários de falha da mesma causa raiz:
- Banco funciona, Kafka falha — A exceção é engolida pelo catch. O banco commita normalmente. O cliente recebe HTTP 201 sem nenhuma indicação de que algo deu errado. Evento silenciosamente perdido. Ninguém downstream fica sabendo.
- Kafka funciona, banco faz rollback — Evento publicado, pedido nunca persistido. Consumidores agem sobre um pedido fantasma.
O código parece correto. Compila. Passa nos testes unitários. Funciona em dev. Funciona... até não funcionar.
Por Que o JPA Torna Isso Pior do Que Você Imagina
Tem uma dimensão desse problema que a maioria dos desenvolvedores não percebe. A timeline acima assume que orderRepository.save() dispara o SQL imediatamente. Não dispara.
O JPA adia o INSERT para o momento do flush — que acontece logo antes do commit da transação. Isso cria uma ordem de execução sutil, mas crítica:
t=0ms -> @Transactional inicia
t=1ms -> orderRepository.save(order) [JPA enfileira INSERT — SEM SQL ainda]
t=3ms -> orderEventPublisher.publish() [KafkaTemplate.send() despacha IMEDIATAMENTE]
t=4ms -> Kafka recebe o evento ✅
t=5ms -> @Transactional prepara o commit
t=5ms -> JPA faz flush → SQL INSERT é executado
t=6ms -> PostgreSQL avalia as constraints
t=6ms -> VIOLAÇÃO DE CONSTRAINT → ROLLBACK
Resultado: Evento EXISTE no Kafka. Pedido NÃO existe no PostgreSQL.
Sem endpoint de chaos. Sem PhantomEventChaosAspect.
Isso é um Phantom Event natural.
O gatilho mais comum no mundo real: o cliente faz retry de uma requisição e você adiciona uma constraint idempotency_key para evitar pedidos duplicados.
-- V2__add_idempotency_key.sql
ALTER TABLE orders ADD COLUMN idempotency_key VARCHAR(255);
CREATE UNIQUE INDEX orders_idempotency_key_idx ON orders(idempotency_key);
// Cliente envia a mesma requisição duas vezes (retry de rede, duplo clique, etc.)
// Primeira requisição: funciona normalmente.
// Segunda requisição (mesmo idempotency_key):
// 1. @Transactional inicia
// 2. orderRepository.save(order) <- JPA enfileira INSERT, sem SQL ainda
// 3. orderEventPublisher.publish() <- Kafka recebe o evento ✅
// 4. JPA flush dispara INSERT
// 5. PostgreSQL: UNIQUE VIOLATION em idempotency_key -> ROLLBACK
//
// Resultado: Kafka tem evento duplicado. PostgreSQL não tem pedido duplicado.
// Consumidores downstream processam um pedido que não existe.
Sem AOP. Sem profile de chaos. Só uma constraint, um retry, e o comportamento normal de flush do JPA.
Prove o Dual Write na Prática: Chaos Testing
Falar sobre falha é uma coisa. Ver a falha acontecer é outra.
O projeto inclui dois cenários de chaos testing que você pode executar pra ver o Dual Write quebrando na sua máquina. Não é simulação -- é a inconsistência real entre PostgreSQL e Kafka.
Cenário 1: Phantom Event (Evento Fantasma no Kafka)
O problema: um evento order.created existe no Kafka, mas o pedido não existe no PostgreSQL. Qualquer consumidor que processar esse evento vai referenciar um pedido fantasma.
O projeto tem um endpoint de chaos (POST /chaos/phantom-event) que usa um interceptor AOP pra forçar um rollback no banco depois que o evento já foi enviado pro Kafka. Pra ativá-lo, inicie a aplicação com o profile chaos:
# Inicie a aplicação com o profile chaos
SPRING_PROFILES_ACTIVE=chaos mvn spring-boot:run
# Dispare o cenário de phantom event
curl -s -X POST http://localhost:8082/chaos/phantom-event \
-H "Content-Type: application/json" \
-d '{
"customerId": "550e8400-e29b-41d4-a716-446655440000",
"items": [
{"productId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "quantity": 2, "unitPrice": 149.95}
]
}'
Resposta:
{
"orderId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"existsInDb": false,
"eventSentToKafka": true,
"dbRolledBack": true,
"explanation": "PHANTOM EVENT: The order.created event was published to Kafka, but the order does NOT exist in PostgreSQL. Any consumer processing this event will reference a non-existent order."
}
Verifique você mesmo -- o pedido não existe no banco, mas o evento está no Kafka:
# Pedido NÃO existe no PostgreSQL (rollback)
docker-compose exec postgres psql -U mars -d orders_db -c \
"SELECT * FROM orders WHERE id = '<orderId>';"
# → (0 rows)
# Evento EXISTE no Kafka
docker-compose exec redpanda rpk topic consume order.created --num 1 --offset end
# → Payload com o orderId fantasma
Como funciona internamente: o
PhantomEventChaosAspecté um@Aroundadvice que intercepta oChaosOrderExecutor.execute(). Ele deixa o use case rodar completamente (INSERT no banco + publish no Kafka), e então lança umaPhantomEventSimulationException. Como a exceção ocorre dentro do boundary@Transactional, o Spring faz rollback no banco -- mas oKafkaTemplate.send()já despachou o evento. Todos os beans de chaos usam@Profile("chaos")e não existem no profile padrão.
Cenário 2: Evento Perdido (Kafka Fora do Ar)
O problema: um pedido é persistido no PostgreSQL, mas o evento order.created nunca é publicado. Consumidores downstream nunca ficam sabendo que o pedido foi criado. O cliente recebe HTTP 201 — e não tem a menor ideia que algo deu errado.
Esse cenário não precisa de endpoint especial. Basta derrubar o Redpanda antes de criar um pedido:
# 1. Crie um pedido baseline (tudo saudável)
curl -s -X POST http://localhost:8082/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"550e8400-e29b-41d4-a716-446655440000","items":[{"productId":"6ba7b810-9dad-11d1-80b4-00c04fd430c8","quantity":1,"unitPrice":50.00}]}'
# → 201 Created — pedido no banco, evento no Kafka ✅
# 2. Derrube o Kafka
docker-compose stop redpanda
# 3. Crie outro pedido — Kafka fora do ar
curl -s -X POST http://localhost:8082/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","items":[{"productId":"11111111-2222-3333-4444-555555555555","quantity":1,"unitPrice":99.99}]}'
# → 201 Created — mas o evento foi silenciosamente perdido ⚠️
# 4. Suba o Kafka de volta
docker-compose start redpanda
sleep 10
# 5. Compare: banco tem 2 pedidos, Kafka tem só 1 evento
docker-compose exec postgres psql -U mars -d orders_db -c \
"SELECT COUNT(*) FROM orders;"
# → 2
docker-compose exec redpanda rpk topic consume order.created \
--format '%v\n' | wc -l
# → 1
O pedido #2 existe no banco, o cliente recebeu 201, mas não tem evento correspondente no Kafka. Nenhum consumidor downstream sabe que ele existe. Nenhum erro foi retornado. A inconsistência é invisível.
Nos logs da aplicação você vai encontrar apenas um WARN — não um erro, não um alerta:
WARN DUAL WRITE FAILURE — EVENT LOST for orderId=eebd2af8-...
Order saved in DB but event NOT published to Kafka. Cause: Send failed
Essa linha é fácil de ignorar. E em produção, frequentemente é.
Duas Faces do Mesmo Problema
Ambos os cenários são causados pela mesma raiz: sem atomicidade entre PostgreSQL e Kafka.
| Cenário 1: Phantom Event | Cenário 2: Evento Perdido | |
|---|---|---|
| Trigger | AOP força rollback no banco após publish | Kafka fora do ar durante criação |
| PostgreSQL | Pedido NÃO existe (rollback) | Pedido EXISTE (commit ok) |
| Kafka | Evento EXISTE (já enviado) | Evento NÃO existe (publish falhou) |
| Impacto | Consumidores processam pedido inexistente | Consumidores nunca sabem do pedido |
| HTTP Response | 500 (DB revertido pelo AOP) | 201 — cliente vê sucesso, evento sumiu |
| Reprodução | POST /chaos/phantom-event (profile chaos) | docker-compose stop redpanda + POST /orders |
| Solução | Transactional Outbox Pattern | Transactional Outbox Pattern |
Ambas as falhas são silenciosas em produção. Sem erros nos logs, sem alertas, sem retries. O sistema continua operando com estado inconsistente entre banco e broker.
Quer reproduzir esses cenários na sua máquina? O repositório está aberto: mars-enterprise-kit-lite. Em 5 minutos você vê o Dual Write quebrando de verdade.
Subindo o Projeto
Toda a infraestrutura sobe com Docker Compose. Clone o repositório e você está a três comandos de um sistema rodando:
Pra rodar:
# 1. Sobe a infraestrutura
docker-compose up -d
# 2. Build do projeto
mvn clean install
# 3. Roda a aplicação
mvn spring-boot:run
# 4. Cria um pedido
curl -X POST http://localhost:8082/orders \
-H "Content-Type: application/json" \
-d '{
"customerId": "550e8400-e29b-41d4-a716-446655440000",
"items": [
{"productId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "quantity": 2, "unitPrice": 149.95}
]
}'
| Componente | Tecnologia | Versão |
|---|---|---|
| Linguagem | Java | 25 |
| Framework | Spring Boot | 4.0.3 |
| Build Tool | Maven | Módulo único |
| Banco de Dados | PostgreSQL | 16 (alpine) |
| Mensageria | Redpanda (compatível com Kafka) | v24.3.1 |
| Formato de Eventos | JSON (Jackson) | - |
| ORM | Spring Data JPA | - |
| Migrations | Flyway | - |
| Testes | JUnit 5, Mockito, TestContainers, REST Assured | - |
Clone, suba, teste. Em menos de 5 minutos você tem um microserviço event-driven rodando local. Se curtiu, deixa uma star no repositório — ajuda outros devs a encontrarem o projeto.
AI-First: O Projeto Foi Desenhado pra IA
Aqui a coisa fica interessante.
O projeto foi desenhado desde o início pra ser operado por um agente de IA. Não como um adicional -- como uma restrição de design de primeira classe.
O arquivo CLAUDE.md não é apenas documentação. É um prompt disfarçado de README. Ele diz ao Claude Code:
- As regras da arquitetura e a direção das dependências
- O modelo de domínio e suas invariantes
- As convenções de nomenclatura, teste e empacotamento
- Como rodar o projeto end-to-end
O diretório .mars/docs/ contém a base de conhecimento da IA -- ADRs, responsabilidades dos módulos, convenções de código.
Comandos customizados e skills do Claude Code:
/generate-prp-- gera um Product Requirements Prompt pra uma nova feature, baseado na arquitetura existente/execute-prp-- implementa a feature com TDD (Red, Green, Refactor)chaos-phantom-event-- executa o cenário de Phantom Event end-to-end: sobe a infra, inicia o app com profilechaos, dispara oPOST /chaos/phantom-event, e verifica que o evento existe no Kafka mas o pedido não existe no PostgreSQLchaos-testing-- executa o cenário de Evento Perdido: derruba o Redpanda, cria um pedido, e verifica que o pedido existe no banco mas o evento se perdeu no Kafka
A IA não adivinha. Ela lê as regras, entende os boundaries, e opera dentro deles. Os chaos skills vão além -- eles provam os problemas arquiteturais que o artigo descreve, automatizando a reprodução das falhas de Dual Write. Isso é Context Engineering aplicado à infraestrutura de desenvolvimento.
Smoke Test End-to-End com IA e Docker Compose
Quando o Claude Code roda o smoke test, essa é a sequência:
1. docker compose up -d (PostgreSQL 16 + Redpanda)
2. Espera os serviços ficarem healthy
3. Verifica se os tópicos Kafka existem (order.created + order.cancelled)
4. POST /orders -> valida 201 Created + orderId
5. Consome evento order.created -> valida que orderId confere
6. Publica order.cancelled -> { orderId, reason: "smoke-test" }
7. GET /orders/{orderId} -> valida status = CANCELLED
8. docker compose down
9. Reporta PASS ou FAIL com logs
Ciclo completo validado: cria via REST, publica no Kafka, consome cancelamento, atualiza estado, verifica via REST.
Além do smoke test, o Claude Code executa os chaos skills automaticamente:
# Phantom Event -- prova que Kafka tem evento pra pedido que não existe no banco
Run the chaos-phantom-event skill
# Evento Perdido -- prova que banco tem pedido sem evento no Kafka
Run the chaos-testing skill with scenario lost-event
O que um dev junior levaria 30 minutos pra fazer manualmente, a IA faz em segundos -- e repete exatamente da mesma forma toda vez.
Por Que Design AI-First Beneficia Todo Desenvolvedor
O smoke test não é o ponto. O design é o ponto.
Quando você estrutura um projeto pra que uma IA consiga operá-lo end-to-end, você é forçado a tornar tudo explícito. As regras de arquitetura vivem num documento, não na cabeça de alguém. As convenções estão escritas, não são conhecimento tribal. A sequência de teste é um script, não um checklist manual.
Isso beneficia todo desenvolvedor do time, não apenas a IA.
O CLAUDE.md funciona como documentação de onboarding. O .mars/docs/ é a base de conhecimento da arquitetura. A sequência do smoke test são os critérios de aceite pra "o sistema funciona".
Design AI-First não é sobre substituir desenvolvedores. É sobre fazer o projeto tão bem documentado e tão explícito que qualquer um -- humano ou IA -- consiga operá-lo com confiança.
Boas Práticas
Faça:
- Exponha anti-patterns intencionalmente em projetos educacionais -- ensina mais que acertar de primeira. Melhor ainda: construa chaos tests que reproduzam as falhas -- um endpoint de phantom event ou um script que derruba o Kafka torna o problema tangível pro time inteiro
Não faça:
- Nunca assuma que
@Transactionalcobre operações fora do banco - Nunca publique eventos sem considerar o que acontece se o broker estiver fora
- Nunca vá pra produção com Dual Write sem Transactional Outbox ou equivalente
Resolvendo o Dual Write: Transactional Outbox Pattern
Você acabou de ver o problema quebrando. Phantom events, eventos perdidos, inconsistência silenciosa. Em dev, isso é um exercício. Em produção, isso é uma sexta-feira à noite com o celular tocando.
O problema de Dual Write neste projeto é intencional. Deixei ele lá pra você ver, entender, e sentir por que importa. Em um sistema de produção, você nunca colocaria isso no ar sem uma solução.
O Transactional Outbox Pattern resolve o problema de Dual Write escrevendo o evento numa tabela outbox dentro da mesma transação de banco que os dados de negócio. Um processo separado faz polling da outbox e publica os eventos no message broker. Como a escrita de negócio e a escrita do evento compartilham uma única transação, a atomicidade é garantida.
O Mars Enterprise Kit Pro resolve com o Transactional Outbox Pattern implementado end-to-end — três serviços completamente construídos (Order, Inventory, Payment), fronteiras reforçadas por ArchUnit, e pipeline CI pronto no dia um.
Você precisa entender o problema antes de valorizar a solução. É por isso que o Lite vem primeiro.
| Feature | Lite (Gratuito) | Enterprise Kit Pro |
|---|---|---|
| Kafka + PostgreSQL | Sim | Sim |
| Design AI-First | Sim | Sim |
| 3 serviços de referência (Order, Inventory, Payment) | Não | Sim |
| Transactional Outbox Pattern | Não | Sim |
| ArchUnit — reforço de arquitetura | Não | Sim |
| 21 Architecture Decision Records | Não | Sim |
| Pipeline CI com GitHub Actions | Não | Sim |
| Orquestração SAGA | Não | Planejado (Fase 1) |
| Observabilidade com OpenTelemetry | Não | Planejado (Fase 2) |
| Helm / Kubernetes | Não | Planejado (Fase 4) |
| Production-Ready | Não | Sim |
O Lite te ensina o problema. O Pro te dá a solução. Veja o que muda →
Conclusão: Entendendo o Dual Write Antes de Resolver
Este projeto existe pra fechar a lacuna entre teoria e prática em Arquitetura Orientada a Eventos. O artigo anterior cobriu os conceitos. Este mostrou o código.
O Dual Write é o ponto central. Não porque é a resposta certa, mas porque expor o problema é a melhor forma de ensinar por que a solução importa. E agora você pode provar o problema na sua máquina: dispare o POST /chaos/phantom-event e veja o evento fantasma no Kafka, ou derrube o Redpanda e veja o evento perdido. Não é teoria -- é inconsistência real que você pode observar, debugar e entender.
Quer experimentar? Clone e quebre coisas.
O Mars Enterprise Kit Lite é gratuito, open-source e roda na sua máquina em 5 minutos. Clone, suba o Docker Compose, rode os chaos tests e veja o Dual Write quebrando de verdade.
github.com/andrelucasti/mars-enterprise-kit-lite
Leia o CLAUDE.md e deixe o Claude Code reproduzir as falhas de Dual Write pra você.
Se o projeto te ajudou a entender o Dual Write, deixa uma star no repo. Custa zero e ajuda outros devs a encontrarem esse conteúdo.
Precisa disso em produção?
O Mars Enterprise Kit Pro resolve o Dual Write com Transactional Outbox Pattern implementado end-to-end — três serviços prontos para produção, Onion Architecture reforçada por ArchUnit, e pipeline CI pronto desde o primeiro clone.
Conheça o Mars Enterprise Kit Pro →
Se quiser trocar ideias sobre arquitetura event-driven ou design AI-First, me procure no LinkedIn.