O que a arquitetura orientada a eventos realmente significa
A arquitetura orientada a eventos é um estilo de comunicação no qual os serviços registram eventos — declarações no passado de que algo aconteceu — e reagem a eles, em vez de chamarem uns aos outros diretamente. Um serviço de pedidos não acessa um serviço de faturamento para dizer “fature isto”. Ele registra order.placed e segue em frente. Um serviço de faturamento que se importa com esse fato assina esse evento e realiza a fatura no seu próprio tempo.
A palavra importante é fato. Um evento é imutável e já é verdade. order.placed descreve algo que aconteceu; nenhum consumidor pode vetá-lo e nenhum produtor está esperando por uma resposta. Uma requisição é uma pergunta com uma resposta, e quem chama fica bloqueado até que ela chegue. Um evento é uma declaração com um público, e o produtor encerra sua tarefa no momento em que a declaração se torna durável.
Essa diferença parece pequena, mas muda tudo. Como o produtor não chama ninguém, ele não precisa saber quem está interessado. Como os consumidores não respondem, eles podem ser lentos, reiniciados ou adicionados posteriormente sem que o produtor perceba. O custo é que o sistema deixa de ter um único fluxo de controle que você possa seguir, e a correção agora depende de cada participante lidar com duplicatas, atrasos e reordenações de forma resiliente.
Este guia é sobre a maquinaria que torna essas garantias reais e sobre os casos em que a troca não vale a pena.
Comandos ordenam, eventos descrevem
A fonte mais comum de confusão em sistemas orientados a eventos é chamar ambos os tipos de mensagem de “eventos”. Mantenha-os separados.
Um comando é uma instrução: reserve-inventory, charge-card, send-welcome-email. Ele é imperativo, é endereçado a um handler que deve agir, e pode falhar ou ser recusado. Existe exatamente um dono para um comando, e o remetente geralmente se importa com o resultado.
Um evento é uma descrição: inventory-reserved, card-charged, user-registered. Ele está no passado, é um fato pertencente ao produtor e pode ter zero, um ou mil consumidores. Nenhum consumidor pode rejeitá-lo, porque ele já é verdade. O produtor não sabe nem se importa com quem o lê.
// A command asks for something and expects a handler to decide.
await commands.send("reserve-inventory", { orderId, quantity: 2 });
// An event reports that a decision was made. Nobody may reject it.
await events.publish("inventory.reserved", { orderId, quantity: 2 });
A nomenclatura não é pedantismo. Um canal cheio de comandos é uma chamada de procedimento distribuída e possui todo o acoplamento de uma. Um canal cheio de eventos é um broadcast e pode ser estendido sem pedir permissão. Se o nome de uma mensagem não tiver tempo verbal — inventory-reservation — ninguém que leia o código posteriormente conseguirá distinguir se é uma requisição ou um fato.
Um teste útil: o remetente pode prosseguir sem saber o resultado? Se sim, provavelmente é um evento. Se o remetente precisar tomar uma decisão com base no resultado, é um comando, e você deve roteá-lo para um único handler em vez de transmiti-lo via broadcast.
É comum precisar de ambos em um único fluxo. Um serviço de checkout envia um comando charge-card para o serviço de pagamento e aguarda a resposta, pois não pode confirmar o pedido sem ela. Assim que o pagamento é bem-sucedido, o serviço de pagamento publica payment.captured, e todas as outras partes interessadas reagem de forma assíncrona. O comando é a espinha dorsal síncrona; os eventos são o fan-out. Misturá-los deliberadamente não tem problema — o erro é fingir que um comando é um evento e surpreender-se quando ninguém responde.
Eventos, event sourcing e CQRS são três ideias diferentes
Esses três conceitos são relacionados, frequentemente usados juntos, mas inteiramente separáveis. Tratá-los como uma coisa só é a maneira mais rápida de construir um sistema que seja mais complicado do que o próprio problema.
Event-driven é um estilo de comunicação. Os serviços trocam fatos através de um broker. O banco de dados continua sendo a fonte da verdade, e você poderia remover o broker amanhã e perderia apenas o desacoplamento.
Event sourcing é um estilo de persistência. Em vez de armazenar a linha atual, você armazena a sequência ordenada de eventos que a produziu, e o estado atual é um fold sobre essa sequência. O log é a fonte da verdade. Reconstruir o saldo de uma conta significa reproduzir seus depósitos e saques. Isso proporciona uma trilha de auditoria perfeita e a capacidade de viajar no tempo, ao custo de leituras mais complexas e um processo de migração mais difícil.
CQRS (Command Query Responsibility Segregation) trata de separar o modelo de escrita do modelo de leitura. Comandos passam por um modelo otimizado para validação e invariantes; queries leem de uma ou mais projeções otimizadas para busca. Os dois modelos podem compartilhar um banco de dados ou usar stores completamente diferentes.
Você pode adotar qualquer um deles sem os outros:
- Event-driven sem event sourcing: os serviços publicam fatos, mas cada um mantém uma tabela normal.
- Event sourcing sem um broker: um único serviço armazena seus eventos em seu próprio banco de dados.
- CQRS sem eventos: dois modelos sobre os mesmos dados, sincronizados de forma síncrona.
A maioria das equipes deve começar com comunicação event-driven simples e um banco de dados normal. Event sourcing é um compromisso sério, e recorrer a ele apenas porque a arquitetura parece impressionante é um caminho seguro para o arrependimento.
Pub/sub, topics e fan-out
O transporte que faz os eventos funcionarem é o publish/subscribe. Produtores publicam em um canal nomeado, geralmente chamado de topic ou exchange, e consumidores se inscrevem nos topics de seu interesse. O broker gerencia o roteamento.
A propriedade fundamental é que o produtor não endereça os consumidores. Ele publica apenas uma vez em orders, e o broker entrega para cada assinatura: faturamento, logística, indexação de busca, analytics, detecção de fraude. Adicionar um sexto consumidor é uma mudança de configuração nesse consumidor, não uma mudança de código no produtor.
// One publish, many independent subscribers.
await broker.publish("orders", {
type: "order.placed",
data: { orderId, customerId, totalCents },
});
Existem dois modelos principais de broker, e a escolha afeta o que você pode construir:
- Brokers baseados em log, como Kafka, mantêm cada evento por uma janela de retenção e permitem que cada grupo de consumidores rastreie sua própria posição. Consumidores podem reproduzir o histórico (replay), e vários grupos podem ler o mesmo topic de forma independente.
- Brokers baseados em fila ou exchange, como RabbitMQ, roteiam cada mensagem para uma ou mais filas, e a mensagem é tipicamente removida após a confirmação (acknowledgement). O roteamento é rico; o replay não faz parte do modelo.
Um topic deve ser nomeado com base no fato que ele carrega, não pelo consumidor que o lê. orders e users envelhecem bem; billing-inbox não, porque no dia em que surgir um segundo consumidor, o nome se tornará mentiroso. Mantenha os topics estáveis e deixe que as assinaturas sejam a parte que muda.
O modelo de confirmação (acknowledgement) é o que define as garantias de entrega. Um consumidor que confirma antes de realizar o trabalho corre o risco de perder um evento em caso de crash; aquele que confirma após realizar o trabalho corre o risco de processá-lo duas vezes. Quase todo broker assume o segundo comportamento por padrão, e é por isso que a idempotência não é opcional. Alguns sistemas também permitem que uma assinatura lógica receba um evento apenas uma vez, mesmo com vários consumidores concorrentes — uma work queue — enquanto outros entregam a cada assinante sua própria cópia — um broadcast. Saiba qual modelo um topic fornece antes de confiar em qualquer um deles.
Consistência eventual e por que os consumidores ficam defasados
No momento em que um produtor para de esperar, o sistema torna-se eventualmente consistente. Após o order.placed ser commitado, o pedido existe imediatamente no banco de dados de pedidos, mas ainda não na fatura, no índice de busca ou no warehouse de analytics. Existe uma janela — de milissegundos sob carga normal, de minutos durante um incidente — onde essas visões divergem.
Isso não é um defeito a ser escondido; é a propriedade definidora deste estilo. Todo caminho de leitura construído sobre um evento precisa responder a duas perguntas: quão defasado isso pode estar e o que o usuário vê nesse intervalo?
Alguns hábitos tornam isso gerenciável:
- Leia suas próprias escritas da fonte. Após a ação de um usuário, redirecione-o para uma visualização servida pelo modelo de escrita, e não por uma projeção que ainda não foi atualizada.
- Mostre status honestos. Um
202 Acceptedcomstatus: "processing"é melhor do que uma página que oscila entre vazia e preenchida. - Meça o lag. A lacuna entre o evento publicado mais recente e a posição de um consumidor é o sinal de saúde mais útil de todo o sistema.
Um exemplo concreto torna essa janela tangível. Um cliente faz um pedido e a página de confirmação é servida pelo serviço de pedidos, portanto, está imediatamente correta. A página da conta do usuário é servida por uma projeção construída a partir de order.placed, então, pelos próximos duzentos milissegundos, ela não mostra pedidos. Se a projeção estiver alguns segundos atrasada durante um deploy, o cliente verá “nenhum pedido” e abrirá um ticket de suporte. Nada disso é um bug no fluxo de eventos; é o fluxo funcionando conforme projetado, e a UI deve ser construída prevendo isso.
O lag é normal e cresce por razões comuns: um pico de tráfego, uma API downstream lenta, o reinício de um consumidor, um rebalanceamento. Um lag que cresce sem limites é um problema de capacidade, e ele é invisível a menos que você o coloque em um dashboard. Uma boa projeção rastreia sua própria posição e a expõe, transformando a lacuna em um número sobre o qual você pode criar alertas, em vez de um sentimento que você descobre através de reclamações.
Existe uma garantia de consistência que vale a pena manter mesmo em um sistema eventualmente consistente: leituras monotônicas. Um consumidor nunca deve retroceder. Se um evento chega com um timestamp mais antigo do que um já aplicado, aplicá-lo fora de ordem pode ressuscitar dados deletados ou regredir um contador. Versione suas projeções por número de sequência ou offset, não pelo horário do relógio, e ignore qualquer coisa mais antiga do que o que você já processou.
O problema da escrita dupla e a transactional outbox
Aqui está a falha que pega quase todo mundo. Um serviço precisa alterar seu banco de dados e publicar um evento. Ele grava a linha e, em seguida, chama o broker. E se a publicação falhar? E se o processo for encerrado entre as duas operações?
- Commit primeiro, depois publica: a linha existe, o evento nunca aconteceu e os serviços downstream perdem a alteração silenciosamente.
- Publica primeiro, depois commit: o evento anuncia um estado que nunca foi salvo, e os consumidores agem com base em um fato que não é verdadeiro.
Não há como tornar uma escrita no banco de dados e uma publicação de rede atômicas. Este é o dual-write problem, e ele não pode ser resolvido apenas ordenando as duas chamadas com mais cuidado. Um broker que suporte transações não ajuda, porque o banco de dados é um sistema separado.
A resposta padrão é a transactional outbox. Em vez de publicar diretamente, grave o evento em uma tabela outbox na mesma transação da alteração de estado. Ou ambas as linhas sofrem commit, ou nenhuma delas. Um relay separado — um worker de polling ou um conector de change-data-capture acompanhando o log do banco de dados — lê as linhas não publicadas e as envia para o broker.
BEGIN;
INSERT INTO orders (id, customer_id, status, total_cents)
VALUES ($1, $2, 'placed', $3);
INSERT INTO outbox (id, topic, payload)
VALUES ($1, 'orders', $2);
COMMIT;
O relay então deleta ou marca as linhas assim que publicadas. Se ele travar após a publicação, mas antes da marcação, o evento será publicado duas vezes — e é exatamente por isso que os consumidores devem ser idempotentes. Se ele travar antes de publicar, a linha ainda estará lá e será processada na próxima passagem. De qualquer forma, nenhum evento é perdido.
Dois detalhes são importantes. O relay deve reivindicar as linhas com FOR UPDATE SKIP LOCKED (ou equivalente) para que múltiplas instâncias do relay não publiquem a mesma linha simultaneamente. E a outbox deve ser limpa periodicamente, pois uma tabela que apenas cresce acabará se tornando o maior item do seu banco de dados.
Entrega at-least-once e consumidores idempotentes
Todo broker que valha a pena oferece entrega at-least-once (pelo menos uma vez). Ele não oferece exactly-once, porque exactly-once através de uma rede e de falhas (crashes) é efetivamente impossível. Um consumidor pode processar um evento, travar antes de confirmá-lo e recebê-lo novamente ao reiniciar. Um outbox relay pode publicar a mesma linha duas vezes. Um produtor pode tentar reenviar um timeout que, na verdade, teve sucesso.
Este é o contrato, não um bug. Seus consumidores devem ser idempotentes: processar o mesmo evento duas vezes deve resultar no mesmo estado final que processá-lo apenas uma vez.
Existem três padrões práticos:
Idempotência natural. Algumas operações já são seguras para repetir. Definir um status como shipped duas vezes é a mesma coisa que definir uma vez. Inserir com ON CONFLICT DO UPDATE converge. Prefira estas abordagens sempre que possível.
Uma tabela de deduplicação. Registre cada ID de evento processado com uma constraint de unicidade, na mesma transação do trabalho realizado. Se a inserção gerar um conflito, o evento já foi tratado e o consumidor retorna antecipadamente. Esta é a solução de propósito geral e a primeira a ser considerada.
Chaves de idempotência do provedor. Gateways de pagamento e muitas APIs aceitam uma chave estável e retornam o resultado original em vez de repetir o efeito colateral. Combine isso com sua própria deduplicação, pois a chave protege a chamada, não a lógica ao redor dela.
const seen = await client.query(
`INSERT INTO processed_events (event_id, consumer)
VALUES ($1, 'billing')
ON CONFLICT DO NOTHING
RETURNING event_id`,
[event.id],
);
if (seen.rowCount === 0) return { skipped: true };
Observe que a chave de deduplicação é o ID do evento, não o ID do pedido. Isso torna o consumidor seguro mesmo quando o produtor publica legitimamente dois eventos diferentes sobre o mesmo pedido — order.placed e order.cancelled são fatos distintos e ambos devem ser processados.
A idempotência é uma propriedade do efeito, não do transporte. Um broker pode filtrar IDs de eventos duplicados na borda, o que ajuda, mas ele não pode saber se o seu handler já enviou um e-mail ou cobrou um cartão. Apenas o consumidor, dentro da mesma transação que seu efeito colateral, pode decidir isso. É por isso que a deduplicação deve estar ao lado da escrita e não em uma camada de middleware que executa antes dela.
Ordenação e particionamento
Um broker que distribui mensagens entre muitas partições não pode prometer uma ordem global. O Kafka ordena registros dentro de uma partição; o RabbitMQ ordena dentro de uma fila atendida por um único consumidor. Em todo o sistema, os eventos chegam na ordem que a rede e o escalonamento permitirem.
Isso não é um problema, desde que você escolha uma partition key que corresponda à ordenação que seus consumidores precisam. Publique cada evento de um cliente com key = customerId, e todos os eventos desse cliente cairão na mesma partição e serão processados em ordem. Clientes diferentes serão distribuídos entre as partições e processados em paralelo.
await producer.publish("orders", {
key: event.data.customerId, // ordering is per key
value: event,
});
A armadilha é escolher uma chave que não corresponda ao invariante. Use a chave por orderId quando os consumidores precisarem de ordenação por cliente, e você terá um paralelismo indesejado e uma ordenação na qual não poderá confiar. Use uma única constante como chave para tudo e você terá a ordem perfeita, mas sem nenhum paralelismo.
Mais dois alertas. Alterar a contagem de partições posteriormente muda o mapeamento da chave para a partição, portanto, eventos de uma mesma entidade podem acabar divididos entre duas partições e perder sua ordem relativa. Defina a contagem com uma margem de folga. E, se um consumidor processa uma partição serialmente, um único evento lento bloqueia tudo o que vem depois dele, portanto, mantenha o trabalho por evento limitado.
Evolução e versionamento de schema
Um evento é um contrato entre um produtor e consumidores que são implantados de forma independente. O produtor será atualizado enquanto consumidores antigos ainda estiverem em execução, e um novo consumidor lerá eventos escritos meses atrás. O formato do payload deve sobreviver a ambas as direções.
As regras são as mesmas de uma API pública:
- Adicione, não renomeie nem remova. Novos campos são opcionais e os consumidores definem valores padrão para eles.
- Versione quando o significado mudar. Um campo
versionpermite que um handler faça uma ramificação explícita em vez de tentar adivinhar. - Nunca reutilize o nome de um campo para um conceito diferente. É assim que começa uma corrupção silenciosa de dados.
- Trate eventos antigos como válidos para sempre. Replay significa que o payload de ontem ainda deve ser capaz de ser parseado hoje.
export type OrderPlacedV2 = {
type: "order.placed";
version: 2;
data: {
orderId: string;
totalCents: number;
currency?: string; // added later; v1 events simply lack it
};
};
Em escala, um schema registry transforma isso em um problema gerenciado. Os produtores registram um schema — Avro, Protobuf ou JSON Schema — e o registry atribui a ele um id, impõe um modo de compatibilidade e rejeita qualquer alteração que quebraria os leitores existentes. Mesmo sem um registry, manter um arquivo de schema versionado no repositório e revisar as alterações nele já proporciona a maior parte do benefício.
Essa disciplina compensa precisamente durante incidentes. Quando um deploy quebra um consumidor, a primeira pergunta é se o produtor alterou um payload de uma forma que ninguém concordou.
Testes de contrato são a versão simplificada de um registry. Mantenha um arquivo de fixture com eventos reais por versão e faça com que cada consumidor parseie todos eles no CI. Um consumidor que falha em uma fixture v1 falhará em produção na primeira vez que um evento antigo for reprocessado via replay. Isso custa apenas alguns arquivos e evita a classe de erros que, de outra forma, apareceria dias depois como uma projeção corrompida.
Coreografia e orquestração
Um processo de negócio de múltiplas etapas construído com eventos pode ser coordenado de duas maneiras, e a diferença é significativa.
Coreografia significa que cada serviço escuta eventos e reage a eles, sem um coordenador central. O serviço de pedidos publica order.placed; o de inventário reserva o estoque e publica inventory.reserved; o de pagamento processa a cobrança e publica payment.captured; o de envio reage a isso. Cada serviço conhece apenas os eventos que consome e produz. É um modelo flexível, extensível e fácil de adicionar novas etapas. Por outro lado, é difícil visualizar o processo completo, pois o fluxo existe apenas como a soma das inscrições de todos os envolvidos.
Orquestração significa que um componente central — um orquestrador de saga ou gerenciador de processos — diz explicitamente a cada serviço o que fazer e rastreia o estado do processo. O fluxo fica em um único lugar, o que o torna visível, testável e fácil de analisar. O custo é ter um coordenador do qual todos os serviços devem depender, podendo se tornar um gargalo e um ponto único de falha.
Nenhuma das abordagens é universalmente correta:
- A coreografia é adequada para reações simples, majoritariamente independentes e etapas estáveis.
- A orquestração é adequada para processos longos com muitas ramificações condicionais, timeouts e compensações.
Uma divisão pragmática comum é coreografar o “caminho feliz” entre alguns serviços e introduzir um orquestrador apenas para o processo que se tornou complexo o suficiente para precisar de um.
O sinal de que a coreografia foi longe demais é quando uma alteração exige a edição de muitos serviços ao mesmo tempo, ou quando ninguém na equipe consegue descrever um processo sem abrir cinco repositórios. Quando adicionar uma única regra de negócio significa mexer em seis consumidores, o fluxo deixou de ser um conjunto de reações independentes e se tornou um programa distribuído sem autor. Esse é o momento de movê-lo para um orquestrador.
Sagas e compensações
Em um monólito, uma operação de múltiplas etapas pode ser envolvida em uma transação de banco de dados e revertida em caso de falha. Entre serviços, não existe uma transação compartilhada, portanto, um processo distribuído não pode simplesmente ser abortado. Se o pagamento foi bem-sucedido e o envio falhou, você não pode “desfazer” o pagamento com um ROLLBACK.
O padrão saga resolve isso. Uma saga é uma sequência de transações locais, onde cada uma publica um evento que dispara a próxima etapa. Se uma etapa falha, a saga executa ações compensatórias para as etapas que já tiveram sucesso: estornar o pagamento, liberar o estoque reservado, marcar o pedido como cancelado. A compensação não é um rollback — é uma nova ação de negócio que anula o efeito anterior, e ela própria é um evento que deve ser idempotente.
// Forward path
// order.placed -> inventory.reserved -> payment.captured -> order.confirmed
// If payment fails, compensate the steps that already ran.
await events.publish("payment.failed", { orderId, reason });
// inventory service listens and releases the reservation
Duas regras de design mantêm as sagas organizadas. Primeiro, cada etapa deve ser idempotente, pois uma tentativa de reexecução (retry) pode rodá-la novamente. Segundo, cada etapa precisa de uma ação compensatória definida previamente — se uma etapa não puder ser desfeita, a saga não poderá falhar com segurança após ela, e essa etapa deve ser a última ou requer um design diferente. As sagas também tornam os estados intermediários visíveis, portanto, a UI deve exibir “reservando estoque” e “aguardando pagamento” em vez de fingir que a operação é atômica.
Uma saga precisa de seu próprio estado. Ou o orquestrador armazena a etapa atual em uma tabela, ou cada serviço rastreia os eventos que recebeu. Esse estado é o que permite que um processo seja retomado após uma queda, que se defina um timeout para uma etapa que nunca respondeu e que se saiba quais compensações ainda são necessárias. Uma saga sem estado persistido é apenas uma sequência de mensagens que eventualmente ficará travada em um estado que ninguém consegue reconstruir.
Os timeouts merecem atenção especial, pois uma etapa que nunca responde é a falha mais comum. Se o pagamento não for bem-sucedido nem falhar dentro de uma janela de tempo, a saga deve decidir: tentar novamente, compensar ou colocar o pedido em espera para revisão manual. Deixá-lo indeciso significa que o pedido ficará no limbo para sempre, retendo um estoque que nunca será liberado.
Dead-letter queues e replay
Um evento que um consumer não consegue processar falhará todas as vezes que for tentado: payload malformado, um bug no handler, ou uma linha referenciada que não existe. Tentá-lo infinitamente consome um slot do consumer e bloqueia tudo o que estiver atrás dele na partição ou fila.
A solução é uma dead-letter queue (DLQ). Após um número configurado de tentativas, o broker move o evento — payload, headers, contagem de tentativas e o último erro — para uma fila separada que nenhum consumer lê. O tráfego saudável continua fluindo, e um operador pode inspecionar o evento que falhou, corrigir a causa e fazer o replay.
O replay é o “superpoder silencioso” dos sistemas event-driven. Como os eventos são duráveis, você pode reprocessar o histórico após corrigir um bug: reconstruir uma projeção que foi computada incorretamente, fazer o backfill de um serviço que foi adicionado tardiamente ou re-executar um dia de eventos contra uma nova lógica. O requisito é que os consumers sejam idempotentes, pois o replay enviará a eles eventos que eles podem já ter processado.
Trate a DLQ como uma superfície operacional, não como um cemitério. Configure alertas para a profundidade da fila, coloque-a no dashboard e construa um caminho de replay antes de precisar dele às 2 da manhã. Uma dead-letter queue que ninguém monitora é onde os bugs se escondem.
O replay também exige uma estratégia de retenção. Você só consegue reprocessar eventos que o broker ainda possui, portanto, a janela de retenção define até onde uma correção pode retroagir. Um tópico que mantém dados por sete dias não consegue reconstruir uma projeção após um bug que rodou por duas semanas. Defina a retenção com base na recuperação que você realmente deseja e lembre-se de que cada byte é multiplicado pela replicação e pelo armazenamento próprio de cada consumer.
Visualizando o fluxo de eventos
A parte mais difícil de sistemas orientados a eventos não é construí-los; é entender o que aconteceu depois que algo deu errado. Uma única ação do usuário pode produzir uma dúzia de eventos em seis serviços, e a falha pode estar no terceiro consumidor do quinto evento.
Três práticas tornam o fluxo observável:
- Correlation id. Gere um id na borda, coloque-o em cada evento e em cada linha de log, e propague-o por cada salto. Um único grep então reconstrói todo o fluxo.
- Tracing. OpenTelemetry e ferramentas semelhantes modelam um evento como um span vinculado ao evento que o causou, o que transforma o fluxo em um grafo que você pode ler.
- Consumer lag e profundidade da DLQ. Essas duas métricas capturam a maioria dos problemas antes que um usuário perceba: um consumidor ficando para trás ou um handler que começou a falhar.
await events.publish("order.placed", {
...event,
correlationId: req.id, // set once, carried everywhere
});
Registre o id e o tipo do evento tanto no lado da publicação quanto no do consumo. Sem isso, debugar significa correlacionar timestamps entre serviços e torcer para que os relógios estejam sincronizados.
Um dashboard útil para um fluxo de eventos possui três linhas: taxa de publicação por tipo de evento, consumer lag por grupo e profundidade da DLQ por consumidor. Uma taxa de publicação que cai para zero significa que um produtor parou; um lag que sobe significa que um consumidor não está conseguindo acompanhar; uma profundidade de DLQ que cresce significa que um handler está quebrado. Juntos, esses três sinais explicam a maioria dos incidentes antes que qualquer pessoa precise abrir um log.
Eventos “thin”, eventos “fat” e o contrato de payload
Uma pergunta recorrente de design é quanta informação um evento deve carregar. Um evento thin (magro) contém apenas um identificador e um tipo — order.placed com um orderId. Um evento fat (gordo), ou enriquecido, carrega um snapshot completo: itens da linha, totais, o endereço de entrega exatamente como estava no momento do pedido.
Eventos fat tornam os consumidores mais simples e robustos. Um indexador de busca que recebe o pedido completo não precisa fazer uma chamada de volta ao serviço de pedidos, o que remove uma dependência de runtime e um modo de falha. No entanto, eles também ampliam o contrato: cada campo passa a ser algo de que um consumidor pode depender, tornando a alteração da estrutura mais difícil, além de que o evento pode carregar dados que um determinado consumidor não tem permissão para ver.
Eventos thin mantêm o contrato minimalista e o payload pequeno, mas cada consumidor deve buscar o estado atual, o que reintroduz o acoplamento e pode resultar na leitura de dados que foram alterados desde então. O evento deixa de ser um fato completo e torna-se um ponteiro.
Um padrão viável é incluir os campos que definem o fato e que são seguros para compartilhar, referenciando todo o restante por id. order.placed deve carregar o id do pedido, o id do cliente e o total, porque esses são o fato. Ele não deve embutir o perfil completo do cliente. O teste é simples: um consumidor consegue entender o evento para a finalidade que ele anuncia sem se tornar uma cópia do seu banco de dados?
Independentemente da escolha, congele os valores que não podem mudar. Se um preço foi cotado no checkout, o evento deve carregar esse preço, mesmo que o instinto de um evento thin sugira buscá-lo posteriormente — porque, mais tarde, o preço pode ser diferente, e o evento descreveria então um fato que nunca aconteceu.
Escolhendo um broker sem guerras de framework
O broker é a decisão menos interessante e aquela sobre a qual as equipes mais discutem. Três famílias cobrem quase todos os casos.
- Brokers baseados em log, como Kafka e NATS JetStream, retêm eventos por um período e permitem que cada grupo de consumidores rastreie sua própria posição. Escolha-os quando replay, alto throughput ou muitos leitores independentes forem requisitos.
- Brokers baseados em exchange, como RabbitMQ, roteiam mensagens para filas usando chaves de roteamento, com confirmação (acknowledgement) por mensagem, prioridades e dead-letter exchanges. Escolha-os quando o roteamento e a distribuição de tarefas importarem mais do que o histórico.
- Filas em nuvem, como SQS e Pub/Sub, são totalmente gerenciadas e efetivamente ilimitadas, ao custo de APIs de nível mais baixo e menos controle sobre ordenação e agendamento.
A orientação honesta é começar com o que sua plataforma já executa e o que sua equipe já compreende. Um sistema correto em um broker familiar vence um sistema teoricamente perfeito em um broker que ninguém sabe operar. Migre quando uma limitação específica — replay, roteamento ou throughput — realmente causar problemas, e não porque uma palestra em uma conferência preferiu outra ferramenta.
Independentemente de qual você escolher, esconda-o atrás de uma interface de publisher simples em seu próprio código. publish(topic, event) é uma junção estável; um SDK de fornecedor não é. Isso mantém a decisão do broker reversível e permite que os testes publiquem em um coletor em memória em vez de um cluster real.
Testando um fluxo de eventos
Eventos são assíncronos, o que torna os testes estranhos até que você separe as partes.
Teste o producer fazendo asserções na outbox, não no broker. Após chamar o serviço, a linha de estado e a linha da outbox devem existir, e o payload deve corresponder ao schema. Nenhum broker é necessário.
Teste o consumer como uma função pura de um evento. Forneça a ele um payload e faça a asserção no estado resultante. Em seguida, forneça o mesmo payload duas vezes e confirme que a segunda execução não gera efeito (no-op) — este é o teste de idempotência, e ele captura a classe de bugs que só aparece durante uma redelivery em produção.
Teste a conectividade (wiring) com um broker real em um container: publique um evento, aguarde o consumer processá-lo e faça a asserção no estado final. Use um group id ou fila única por execução de teste para que os offsets commitados nunca vazem entre as execuções, e faça asserções nos registros recebidos em vez de basear-se no tempo.
test("reprocessing an event is a no-op", async () => {
await onOrderPlaced(event);
await onOrderPlaced(event); // redelivery
const { rows } = await pool.query(
"SELECT count(*)::int AS n FROM invoices WHERE order_id = $1",
[event.data.orderId],
);
expect(rows[0].n).toBe(1);
});
Mantenha a asserção assíncrona determinística fazendo polling pelo estado esperado com um timeout, em vez de usar um sleep por um intervalo fixo. Um teste que passa porque esperou tempo suficiente é um teste que causará flakiness em uma máquina mais lenta.
Quando a arquitetura orientada a eventos brilha e quando ela atrapalha
A arquitetura orientada a eventos é uma troca, e vale a pena ser explícito sobre qual lado dessa moeda você está.
Ela brilha quando você precisa de desacoplamento entre times que fazem deploy de forma independente, fan-out para múltiplos leitores, replay de histórico, uma trilha de auditoria durável ou um feed natural para analytics e busca. Ela se encaixa em sistemas onde uma reação downstream pode ter um leve atraso e onde adicionar um novo consumidor não deve exigir alterações no produtor.
Ela atrapalha quando o domínio é pequeno e tem formato de CRUD, quando uma operação deve ser imediata e fortemente consistente, ou quando o time é pequeno demais para operar um broker e raciocinar sobre consistência eventual. Nesses casos, um monólito bem estruturado, com módulos claros e um único banco de dados, é mais simples, mais rápido de construir e mais fácil de depurar. Eventos sempre podem ser adicionados posteriormente, e um monólito modular é um ponto de partida muito melhor do que um sistema distribuído que ninguém consegue rastrear.
Uma regra razoável: não introduza um broker até que você consiga nomear o problema específico que ele resolve. “Microservices usam eventos” não é a definição de um problema. Escreva o que você espera ganhar — um novo consumidor sem tocar no produtor, replay após um bug, uma trilha de auditoria — e verifique depois se você conseguiu. Se a resposta honesta for “queríamos parecer modernos”, o broker é um custo sem retorno.
Se você decidir adotá-la, faça-o incrementalmente. Comece com um único evento que resolva um problema real, execute-o em produção por um tempo e aprenda como lag, duplicatas e mudanças de schema se comportam no seu time e na sua infraestrutura antes de tornar os eventos a espinha dorsal do sistema.
Melhores práticas
- Nomeie eventos no passado e defina-os no produtor; nomeie comandos separadamente.
- Grave o evento em um outbox na mesma transação da alteração de estado.
- Publique através de um relay ou conector CDC, nunca diretamente de um request handler.
- Torne cada consumer idempotente com uma chave de deduplicação derivada do id do evento.
- Escolha uma partition key que corresponda à ordenação que cada consumer realmente necessita.
- Versione os payloads dos eventos e evolua-os de forma aditiva; nunca mude a finalidade de um campo.
- Mantenha os consumers pequenos e com propósito único; um consumer por projeção ou reação.
- Defina ações compensatórias para cada etapa antes de construir uma saga.
- Limite as tentativas de reprocessamento (retries), encaminhe eventos esgotados para uma dead-letter queue e crie um caminho de replay.
- Propague um correlation id em cada evento e registre-o em ambos os lados.
- Monitore o consumer lag e a profundidade da DLQ, e configure alertas com base na tendência.
- Comece com um modular monolith e adicione eventos quando o desacoplamento ou o replay forem necessidades reais.
Erros comuns
- Chamar comandos de eventos e acabar com uma chamada de procedimento distribuída.
- Confundir event-driven, event sourcing e CQRS e adotar os três de uma vez.
- Publicar diretamente de um request handler e enfrentar o problema de dual-write.
- Assumir a entrega exactly-once e cobrar em dobro na primeira redelivery.
- Definir chaves de eventos aleatoriamente e depois esperar a ordenação por entidade.
- Renomear ou remover um campo do payload e quebrar consumidores em versões antigas.
- Construir coreografias sem ter como visualizar o processo de ponta a ponta.
- Tentar processar um poison event infinitamente e bloquear a partição atrás dele.
- Nunca olhar a dead-letter queue.
- Adicionar um broker para um app CRUD e pagar o imposto da complexidade sem necessidade.
Próximos passos
O transporte por trás de um sistema orientado a eventos geralmente é um log ou um broker, portanto o Apache Kafka cobre o modelo de log reproduzível e o RabbitMQ cobre a mensageria focada em roteamento. Como os eventos são a forma como os serviços em um sistema distribuído se comunicam, o guia de Microservices explica de onde vêm as fronteiras dos serviços, para começar. Se tudo isso parece complexo demais para o seu problema, o guia de Modular Monolith é o contraponto honesto: a maioria dos sistemas deve permanecer como uma única unidade de deploy até que surja um motivo real para a divisão.