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), PostgreSQL 16 e Onion Architecture. 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.
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.
Agora vamos ao ponto onde a consistência quebra. O OrderService no módulo app/:
// app/ -- @Transactional, mas sem atomicidade com Kafka
@Service
public class OrderService {
private final CreateOrderUseCase createOrderUseCase;
private final CancelOrderUseCase cancelOrderUseCase;
private final OrderRepository orderRepository;
@Transactional
public UUID createOrder(Set<OrderItem> items, UUID customerId) {
return createOrderUseCase.execute(
new CreateOrderUseCase.Input(items, customerId));
}
}
E o publisher que implementa a port de eventos:
// app/ -- Kafka Producer (Dual Write)
@Service
public class OrderCreatedPublisher implements OrderEventPublisher {
private static final String TOPIC = "order.created";
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
@Override
public void publish(OrderCreatedEvent event) {
try {
String payload = objectMapper.writeValueAsString(event);
kafkaTemplate.send(TOPIC, event.orderId().toString(), payload);
} catch (JacksonException e) {
throw new RuntimeException("Failed to serialize OrderCreatedEvent", e);
}
}
}
Leia com atenção. O @Transactional envolve o save no banco. O publish no Kafka acontece dentro do mesmo método, mas não faz parte da transação. O risco real:
t=0ms -> POST /orders chega
t=1ms -> @Transactional inicia
t=3ms -> orderRepository.save(order) [escrita no banco, dentro da transação]
t=5ms -> orderEventPublisher.publish() [envio pro Kafka, FORA da transação]
t=6ms -> Kafka confirma [evento está no Kafka]
t=7ms -> commit do banco falha [erro de rede, disco cheio, constraint violation]
Resultado: Evento existe no Kafka. Order NÃO existe no PostgreSQL.
Consumidores downstream processam um pedido fantasma.
Três cenários de falha:
Sem retry. Sem compensação. Inconsistência silenciosa.
O código parece correto. Compila. Passa nos testes unitários. Funciona em dev. Funciona... até não funcionar.
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.
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
cd app && 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.
O problema: um pedido é persistido no PostgreSQL, mas o evento order.created nunca é publicado. Consumidores downstream nunca ficam sabendo que o pedido foi criado.
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
# 2. Derrube o Kafka
docker-compose stop redpanda
# 3. Tente criar outro pedido
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}]}'
# 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 mas não tem evento correspondente no Kafka. Nenhum consumidor downstream sabe que ele existe.
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 |
| 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.
Na outra ponta do pipeline, um consumer escuta eventos de cancelamento:
// app/ -- Consumer Kafka
@Service
public class OrderCancelledConsumer {
private static final Logger log = LoggerFactory.getLogger(OrderCancelledConsumer.class);
private final OrderService orderService;
private final ObjectMapper objectMapper;
@KafkaListener(topics = "order.cancelled", groupId = "order-service")
public void onOrderCancelled(String message) {
try {
var payload = objectMapper.readValue(message, OrderCancelledPayload.class);
log.info("Received order.cancelled event for orderId={}", payload.orderId());
orderService.cancelOrder(payload.orderId());
} catch (JacksonException e) {
log.error("Failed to deserialize order.cancelled event", e);
} catch (BusinessException e) {
log.warn("Failed to cancel order: {}", e.getMessage());
}
}
record OrderCancelledPayload(UUID eventId, UUID orderId, String reason, Instant occurredAt) {}
}
O sistema usa dois tópicos Kafka:
| Tópico | Papel |
|---|---|
order.created | Publicado pelo app após criar um pedido |
order.cancelled | Consumido pelo app pra cancelar um pedido |
Eventos são JSON puro. Sem Avro, sem Schema Registry. A versão Lite mantém a simplicidade pra focar nos conceitos arquiteturais, não em infraestrutura de serialização.
A infraestrutura é toda containerizada:
services:
postgres:
image: postgres:16-alpine
ports: ["5432:5432"]
environment:
POSTGRES_DB: orders_db
POSTGRES_USER: mars
POSTGRES_PASSWORD: mars
redpanda:
image: redpandadata/redpanda:v24.3.1
ports: ["9092:9092", "8081:8081", "9644:9644"]
redpanda-console:
image: redpandadata/console:v2.8.0
ports: ["8888:8080"]
Redpanda é uma plataforma de streaming compatível com Kafka que roda sem ZooKeeper e sem JVM. Implementa o protocolo Kafka nativamente, funcionando como drop-in replacement pro Apache Kafka em desenvolvimento local e workloads de produção.
Pra rodar:
# 1. Sobe a infraestrutura
docker-compose up -d
# 2. Build de todos os módulos
mvn clean install
# 3. Roda a aplicação
cd app && 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 | Multi-módulo |
| 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.
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:
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 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 KafkaA 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.
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.
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.
Faça:
CLAUDE.md ou equivalente -- humanos e IAs se beneficiamNão faça:
@Transactional cobre operações fora do bancoVocê 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 esse padrão — e vai muito além. Ele inclui Helm charts, pipelines CI/CD, Apache Avro com Schema Registry, observabilidade com OpenTelemetry, e padrões como SAGA e CQRS. É a evolução production-grade do que a versão Lite ensina.
Se a versão Lite já tem chaos testing com AOP e reprodução automatizada de falhas, imagina o que a versão Pro entrega.
Você precisa entender o problema antes de valorizar a solução. É por isso que o Lite vem primeiro.
| Feature | Lite (Gratuito) | Enterprise Kit Pro |
|---|---|---|
| Onion Architecture | Sim | Sim |
| Kafka + PostgreSQL | Sim | Sim |
| Design AI-First | Básico | Completo |
| Transactional Outbox Pattern | Não | Sim |
| Apache Avro + Schema Registry | Não | Sim |
| Helm charts / Kubernetes | Não | Sim |
| Pipelines CI/CD | Não | Sim |
| Observabilidade OpenTelemetry | Não | Sim |
| Padrões SAGA / CQRS | Não | Sim |
| Production-Ready | Não | Sim |
O Lite te ensina o problema. O Pro te dá a solução. Veja o que muda →
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.
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.
O Mars Enterprise Kit Pro resolve o Dual Write com Transactional Outbox Pattern implementado end-to-end — e inclui tudo que falta pra ir pra produção: Helm charts, CI/CD, Apache Avro com Schema Registry, observabilidade com OpenTelemetry, SAGA e CQRS.
Conheça o Mars Enterprise Kit Pro →
Se quiser trocar ideias sobre arquitetura event-driven, Onion Architecture ou design AI-First, me procure no LinkedIn.
