Event-driven architecture (EDA) has become the backbone of modern distributed systems, but it is also one of the most misapplied patterns in the industry. Teams reach for Kafka because "it's what everyone uses" and then build a system that is harder to understand than a monolith, with all the operational overhead of a distributed system and none of the benefits.A arquitetura orientada a eventos (EDA) se tornou a espinha dorsal dos sistemas distribuídos modernos, mas também é um dos padrões mais mal aplicados da indústria. Times adotam o Kafka porque "é o que todo mundo usa" e então constroem um sistema mais difícil de entender do que um monolito, com todo o overhead operacional de um sistema distribuído e nenhum dos benefícios.
This article explains how EDA works, when it is the right choice, and how to apply it correctly using Apache Kafka — covering topics, partitions, consumer groups, event sourcing, and the Outbox pattern for reliable event publishing.Este artigo explica como a EDA funciona, quando é a escolha certa e como aplicá-la corretamente usando o Apache Kafka — abordando tópicos, partições, grupos de consumidores, event sourcing e o padrão Outbox para publicação confiável de eventos.
Why Events, Not Requests?Por Que Eventos e Não Requisições?
In a request-driven architecture, Service A calls Service B synchronously. The communication is direct, but it creates tight coupling:Em uma arquitetura orientada a requisições, o Serviço A chama o Serviço B de forma síncrona. A comunicação é direta, mas cria um acoplamento forte:
- Temporal coupling: If B is down, A fails. A must know B's address and availability.Acoplamento temporal: Se B está fora do ar, A falha. A precisa conhecer o endereço e a disponibilidade de B.
- Behavioral coupling: A must know what B does with the request. A change in B's behavior requires coordination with A.Acoplamento comportamental: A precisa saber o que B faz com a requisição. Uma mudança no comportamento de B requer coordenação com A.
- Scaling coupling: B must scale to handle A's request rate at peak.Acoplamento de escala: B precisa escalar para suportar a taxa de requisições de A no pico.
In an event-driven architecture, A publishes an event ("something happened") to a broker. B — and any other interested service — subscribes and reacts asynchronously. The producer does not know about the consumer. They are decoupled in time, behavior, and scale.Em uma arquitetura orientada a eventos, A publica um evento ("algo aconteceu") em um broker. B — e qualquer outro serviço interessado — assina e reage assincronamente. O produtor não conhece o consumidor. Eles são desacoplados no tempo, comportamento e escala.
producer
consumer
consumer
consumer
Kafka FundamentalsFundamentos do Kafka
Topics and PartitionsTópicos e Partições
A Topic is a named, durable log of events. Topics are split into Partitions — the unit of parallelism. Events within a partition are ordered. Events across partitions are not.Um Tópico é um log durável e nomeado de eventos. Os tópicos são divididos em Partições — a unidade de paralelismo. Eventos dentro de uma partição são ordenados. Eventos entre partições não são.
Partition assignment is controlled by the message key. All events with the same key go to the same partition, guaranteeing order for that key. For an order service, the key is typically the order ID — ensuring all events for a given order are processed in sequence.A atribuição de partições é controlada pela chave da mensagem. Todos os eventos com a mesma chave vão para a mesma partição, garantindo ordem para essa chave. Para um serviço de pedidos, a chave é tipicamente o ID do pedido — garantindo que todos os eventos de um pedido sejam processados em sequência.
# Producer: publishing with a partition key (Java/Spring Boot)
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> kafka;
public void publishOrderPlaced(Order order) {
OrderEvent event = OrderEvent.builder()
.eventType("order.placed")
.orderId(order.getId())
.customerId(order.getCustomerId())
.totalAmount(order.getTotal())
.occurredAt(Instant.now())
.build();
// Key = orderId → all events for this order go to the same partition
kafka.send("orders", order.getId(), event);
}
}Consumer GroupsGrupos de Consumidores
A Consumer Group is a set of consumers that cooperatively process a topic. Kafka assigns each partition to exactly one consumer in the group — enabling horizontal scaling. If you have 12 partitions and 4 consumers, each consumer processes 3 partitions.Um Grupo de Consumidores é um conjunto de consumidores que processam cooperativamente um tópico. O Kafka atribui cada partição a exatamente um consumidor no grupo — habilitando escalabilidade horizontal. Se você tem 12 partições e 4 consumidores, cada consumidor processa 3 partições.
Different services consuming the same topic use different consumer groups. Each group maintains its own independent offset — Billing and Notifications both read every event, independently.Serviços diferentes consumindo o mesmo tópico usam grupos de consumidores diferentes. Cada grupo mantém seu próprio offset independente — Billing e Notifications leem cada evento de forma independente.
Sizing tip: The number of partitions is the ceiling on consumer group parallelism. You cannot have more active consumers in a group than partitions. Plan partition counts conservatively — they can be increased but never decreased without downtime.Dica de dimensionamento: O número de partições é o teto do paralelismo do grupo de consumidores. Você não pode ter mais consumidores ativos num grupo do que partições. Planeje os contadores de partições de forma conservadora — eles podem ser aumentados, mas nunca reduzidos sem downtime.
The Outbox Pattern: Reliable Event PublishingO Padrão Outbox: Publicação Confiável de Eventos
One of the most common and damaging bugs in event-driven systems: the application saves to the database, then publishes the event, and crashes between the two. The database has the data but the event was never published. Downstream services never know the order was placed.Um dos bugs mais comuns e danosos em sistemas orientados a eventos: a aplicação salva no banco de dados, depois publica o evento, e trava entre os dois. O banco tem os dados mas o evento nunca foi publicado. Serviços downstream nunca ficam sabendo que o pedido foi feito.
The Outbox Pattern solves this with transactional guarantees:O Padrão Outbox resolve isso com garantias transacionais:
- In the same database transaction that updates application state, write the event to an
outboxtable.Na mesma transação do banco de dados que atualiza o estado da aplicação, escreva o evento em uma tabelaoutbox. - A separate relay process reads unprocessed rows from the outbox and publishes them to Kafka.Um processo relay separado lê as linhas não processadas do outbox e as publica no Kafka.
- On successful publish, mark the row as processed.Após publicação bem-sucedida, marque a linha como processada.
-- Outbox table schema
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregate_type VARCHAR(100) NOT NULL, -- 'Order'
aggregate_id VARCHAR(100) NOT NULL, -- order ID
event_type VARCHAR(100) NOT NULL, -- 'order.placed'
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ -- NULL = not yet published
);
-- Application code: single transaction
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
VALUES ('Order', $orderId, 'order.placed', $eventJson);
COMMIT;For high-volume systems, use Debezium — a CDC (Change Data Capture) tool that tails the database WAL log and publishes events to Kafka automatically, without polling overhead. The outbox table becomes the source of truth for all events.Para sistemas de alto volume, use o Debezium — uma ferramenta CDC (Change Data Capture) que monitora o log WAL do banco e publica eventos no Kafka automaticamente, sem overhead de polling. A tabela outbox se torna a fonte de verdade para todos os eventos.
Event SourcingEvent Sourcing
Event Sourcing takes the event log one step further: instead of storing the current state of an entity, you store the sequence of events that led to that state. Current state is a derived projection computed by replaying the event log.O Event Sourcing vai um passo além no log de eventos: em vez de armazenar o estado atual de uma entidade, você armazena a sequência de eventos que levou a esse estado. O estado atual é uma projeção derivada computada ao reproduzir o log de eventos.
# Event store: the source of truth
events = [
{"type": "OrderCreated", "orderId": "123", "items": [...], "at": "10:00"},
{"type": "ItemAdded", "orderId": "123", "item": {...}, "at": "10:05"},
{"type": "PaymentApplied", "orderId": "123", "amount": 99.9, "at": "10:06"},
{"type": "OrderShipped", "orderId": "123", "trackingId": "T456", "at": "14:30"},
]
# Current state is derived by replaying
def rebuild_order(events):
order = {}
for event in events:
match event["type"]:
case "OrderCreated": order = {"id": event["orderId"], "status": "created", ...}
case "PaymentApplied": order["status"] = "paid"
case "OrderShipped": order["status"] = "shipped"; order["trackingId"] = event["trackingId"]
return orderWhen to Use Event SourcingQuando Usar Event Sourcing
Event Sourcing adds significant complexity. Apply it selectively:O Event Sourcing adiciona complexidade significativa. Aplique-o seletivamente:
- Use it when: you need a full audit trail, temporal queries ("what was the order status at 2pm yesterday?"), or the ability to replay events to build new projections.Use quando: você precisar de uma trilha de auditoria completa, consultas temporais ("qual era o status do pedido às 14h de ontem?") ou a capacidade de reproduzir eventos para construir novas projeções.
- Avoid it when: you just need async communication between services. For that, the Outbox pattern is sufficient.Evite quando: você só precisa de comunicação assíncrona entre serviços. Para isso, o padrão Outbox é suficiente.
Anti-pattern: Using Event Sourcing for every entity in the system. The additional infrastructure, snapshot strategies, and projection management costs are only justified for aggregates where history truly matters.Anti-padrão: Usar Event Sourcing para cada entidade no sistema. Os custos adicionais de infraestrutura, estratégias de snapshot e gerenciamento de projeções só são justificados para agregados onde o histórico realmente importa.
Schema Evolution: Kafka Schema RegistryEvolução de Schema: Kafka Schema Registry
Events are contracts. Once published, consumers depend on the event structure. Changing an event schema without coordination breaks consumers. The Confluent Schema Registry enforces compatibility rules:Eventos são contratos. Uma vez publicados, os consumidores dependem da estrutura do evento. Alterar um schema de evento sem coordenação quebra os consumidores. O Confluent Schema Registry impõe regras de compatibilidade:
- BACKWARD: New consumers can read events written by old producers (add optional fields, remove fields).BACKWARD: Novos consumidores podem ler eventos escritos por produtores antigos (adicionar campos opcionais, remover campos).
- FORWARD: Old consumers can read events written by new producers.FORWARD: Consumidores antigos podem ler eventos escritos por novos produtores.
- FULL: Both. The safest but most restrictive.FULL: Ambos. O mais seguro, mas também o mais restritivo.
# Avro schema for order.placed (v1)
{
"type": "record",
"name": "OrderPlaced",
"namespace": "com.example.orders",
"fields": [
{"name": "orderId", "type": "string"},
{"name": "customerId", "type": "string"},
{"name": "totalAmount","type": "double"},
{"name": "occurredAt", "type": "string"}
]
}
# v2: adding an optional field (BACKWARD compatible)
{"name": "currency", "type": ["null", "string"], "default": null}Dead Letter TopicsDead Letter Topics
What happens when a consumer fails to process an event? Retrying indefinitely blocks the partition and prevents processing of newer events. The solution is a Dead Letter Topic (DLT): after N retries, the event is moved to a .DLT topic for inspection and reprocessing.O que acontece quando um consumidor falha ao processar um evento? Tentar novamente indefinidamente bloqueia a partição e impede o processamento de eventos mais novos. A solução é um Dead Letter Topic (DLT): após N tentativas, o evento é movido para um tópico .DLT para inspeção e reprocessamento.
Spring Kafka's @RetryableTopic handles this automatically, with configurable retry topics and exponential backoff between attempts before sending to the DLT.O @RetryableTopic do Spring Kafka lida com isso automaticamente, com tópicos de retry configuráveis e backoff exponencial entre tentativas antes de enviar ao DLT.
Key TakeawaysPontos Principais
- Use EDA to eliminate temporal coupling between services — not just because it is fashionable.Use EDA para eliminar o acoplamento temporal entre serviços — não apenas porque está na moda.
- Choose partition keys that guarantee order for business-critical event sequences (order ID, user ID).Escolha chaves de partição que garantam ordem para sequências de eventos críticas para o negócio (ID do pedido, ID do usuário).
- Always use the Outbox Pattern or CDC (Debezium) to ensure atomicity between database writes and event publishing.Sempre use o Padrão Outbox ou CDC (Debezium) para garantir atomicidade entre escritas no banco e publicação de eventos.
- Use the Schema Registry with BACKWARD compatibility to evolve events without breaking consumers.Use o Schema Registry com compatibilidade BACKWARD para evoluir eventos sem quebrar os consumidores.
- Reserve Event Sourcing for aggregates where history and auditability are genuine requirements.Reserve o Event Sourcing para agregados onde histórico e auditabilidade são requisitos genuínos.
- Always configure Dead Letter Topics — poison messages should never halt a consumer indefinitely.Configure sempre Dead Letter Topics — mensagens com problema nunca devem travar um consumidor indefinidamente.