Programming on Mars
/
JavaSpring BootKafkaPostgreSQLEvent-Driven ArchitectureRedpandaDual WriteMicroservicesAI-First

Arquitetura Orientada a Eventos na Prática: Spring Boot, Kafka e PostgreSQL

Reproduza o Dual Write Problem com chaos testing contra Spring Boot 4.0, Kafka e PostgreSQL. Veja phantom events e eventos perdidos quebrando consistência na sua máquina — e a solução.

André Lucas

February 22, 2026

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:

  1. 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.
  2. 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 @Around advice que intercepta o ChaosOrderExecutor.execute(). Ele deixa o use case rodar completamente (INSERT no banco + publish no Kafka), e então lança uma PhantomEventSimulationException. Como a exceção ocorre dentro do boundary @Transactional, o Spring faz rollback no banco -- mas o KafkaTemplate.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 EventCenário 2: Evento Perdido
TriggerAOP força rollback no banco após publishKafka fora do ar durante criação
PostgreSQLPedido NÃO existe (rollback)Pedido EXISTE (commit ok)
KafkaEvento EXISTE (já enviado)Evento NÃO existe (publish falhou)
ImpactoConsumidores processam pedido inexistenteConsumidores nunca sabem do pedido
HTTP Response500 (DB revertido pelo AOP)201 — cliente vê sucesso, evento sumiu
ReproduçãoPOST /chaos/phantom-event (profile chaos)docker-compose stop redpanda + POST /orders
SoluçãoTransactional Outbox PatternTransactional 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}
    ]
  }'
ComponenteTecnologiaVersão
LinguagemJava25
FrameworkSpring Boot4.0.3
Build ToolMavenMódulo único
Banco de DadosPostgreSQL16 (alpine)
MensageriaRedpanda (compatível com Kafka)v24.3.1
Formato de EventosJSON (Jackson)-
ORMSpring Data JPA-
MigrationsFlyway-
TestesJUnit 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 profile chaos, dispara o POST /chaos/phantom-event, e verifica que o evento existe no Kafka mas o pedido não existe no PostgreSQL
  • chaos-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 @Transactional cobre 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.

FeatureLite (Gratuito)Enterprise Kit Pro
Kafka + PostgreSQLSimSim
Design AI-FirstSimSim
3 serviços de referência (Order, Inventory, Payment)NãoSim
Transactional Outbox PatternNãoSim
ArchUnit — reforço de arquiteturaNãoSim
21 Architecture Decision RecordsNãoSim
Pipeline CI com GitHub ActionsNãoSim
Orquestração SAGANãoPlanejado (Fase 1)
Observabilidade com OpenTelemetryNãoPlanejado (Fase 2)
Helm / KubernetesNãoPlanejado (Fase 4)
Production-ReadyNãoSim

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.


Referências

Tags

JavaSpring BootKafkaPostgreSQLEvent-Driven ArchitectureRedpandaDual WriteMicroservicesAI-First
← Back to home