Introdução
Confiabilidade em arquitetura orientada a eventos exige mais do que publicar mensagens e torcer para que tudo funcione. Eu preciso pensar em pelo menos uma vez, idempotência, transação local com Outbox, estados observáveis, ordenação por chave e compensação. Quando eu deixo esses pontos de lado, o sistema vira um laboratório de duplicidade, retries mal gerenciados e estados inconsistentes entre uma operação local e o próximo processamento assíncrono.
Este artigo mostra um caminho prático para construir um fluxo com PostgreSQL e Azure Service Bus Emulator, mantendo a complexidade em um laboratório reproduzível. A ideia central é simples: eu registro o efeito local e a intenção de publicar em uma única transação, depois processar eventos com inbox e saga, sem fingir que exactly-once é uma promessa real em sistemas distribuídos.
Pré-requisitos
Eu recomendo Docker Desktop ou Docker Engine, Compose, .NET 10 e um ambiente local com memória suficiente para rodar PostgreSQL, SQL Server para o emulador e a API. O exemplo completo está disponível no repositório de exemplos do blog: BlogSamples/Messaging/EventDriven/.
Infraestrutura e estrutura necessária para uma implementação robusta
Uma implementação robusta de eventos não é apenas um endpoint HTTP e um worker. Ela precisa de uma pequena plataforma local com quatro blocos bem definidos:
- uma API para aceitar o comando de negócio e persistir o efeito local;
- um banco PostgreSQL como fonte de verdade do estado do pedido, da saga e das mensagens de integração;
- um broker local, neste caso o Azure Service Bus Emulator, para transporte, roteamento, sessões e DLQ;
- workers para publicar mensagens do Outbox e processar mensagens de forma idempotente.
A estrutura mínima do exemplo precisa contemplar:
Endpoints/para criar e consultar pedidos;Contracts/com envelopes versionados e DTOs;Persistence/comDbContext, migrations e modelos de pedido, outbox, inbox e saga;Saga/com o orquestrador e as transições de estado;ServiceBus/com publisher, consumidores e adaptadores para o emulador;docker-compose.ymleREADME.mdpara subir a stack localmente.
Além disso, a solução precisa de alguns componentes operacionais que muitos exemplos deixam de lado:
- tabelas separadas para pedido, estoque, pagamento, saga, outbox e inbox;
- correlation IDs e causation IDs em todos os eventos;
- retries controlados, DLQ e tratamento explícito de falhas transitórias;
- uma política de idempotência local para cada consumidor;
- observabilidade suficiente para responder “onde a saga ficou travada?”.
Sem esses blocos, o fluxo pode parecer correto em um cenário simples e falhar em produção quando a rede, o broker ou o estado do sistema começam a variar.
Por que uma arquitetura orientada a eventos vira caos
O caos começa quando eu confio em dois writes separados e espero que o segundo aconteça. Em sistemas distribuídos, uma mensagem pode ser enviada, o banco pode falhar no commit e a aplicação pode tentar novamente. Sem tratamento, eu tenho um estado local diferente do que foi publicado. O padrão Outbox resolve esse problema ao transformar a publicação em parte da transação local. O padrão Inbox resolve a duplicidade do consumidor, que é um dos problemas mais comuns em entregas pelo menos uma vez.
Esse tipo de problema aparece quando eu preciso integrar domínios diferentes, como pedido, estoque, pagamento e notificação, e cada um deles tem seu próprio ciclo de processamento. O erro comum é achar que o evento é apenas um detalhe operacional. Na prática, ele precisa ser tratado como parte do contrato de negócio. Se eu não pensar em retry, compensação, ordenação, observabilidade e idempotência desde o começo, a aplicação começa a acumular estados não percebidos e a cada falha eu ganho mais ruído do que valor.
Eu também vejo equipes tentarem resolver tudo com polling, retries agressivos e logs dispersos. Isso costuma piorar a situação. Uma arquitetura orientada a eventos não é um mecanismo mágico para tornar tudo distribuído e resiliente; ela é uma forma de organizar a complexidade quando a operação precisa ser desacoplada e tolerar falhas temporárias. Se eu não tiver um fluxo com estados explícitos, a experiência do usuário vira uma mistura de transações em andamento e falhas invisíveis.
A arquitetura do exemplo com PostgreSQL e Azure Service Bus
O exemplo local usa PostgreSQL como banco da aplicação e um tópico do Azure Service Bus Emulator com subscriptions filtradas. A API cria um pedido, registra um evento no Outbox e um orquestrador consome o fluxo. O fluxo é simples, mas já cobre as decisões que mais costumam causar problemas: ordem por pedido, compensação, retry e DLQ.

A decisão de usar PostgreSQL como sistema de verdade da aplicação vem do fato de eu precisar de uma transação local forte para registrar o efeito de negócio e, ao mesmo tempo, persistir a intenção de publicar. O Azure Service Bus entra como camada de entrega assíncrona e orquestração entre passos independentes. Esse desenho funciona bem quando eu preciso de uma linha de tempo observável e de um ponto único onde o estado do pedido pode ser consultado. Em outras palavras, o banco cuida do estado; o broker cuida da propagação.
Eu vejo essa combinação sendo útil quando o fluxo envolve múltiplos sistemas, cada um com tempo de resposta variável, e eu preciso evitar que uma falha de um subsistema derrube a operação inteira. O ponto não é tornar tudo distribuído por gosto; o ponto é separar o que precisa ser consistente localmente do que pode ser processado de forma assíncrona e compensável.
⚠️ Atenção: se o seu problema ainda é simples, com uma única transação e sem necessidade de desacoplamento, eu não começaria por esta arquitetura. O custo de operar Outbox, Inbox e Saga é real e precisa ser justificado por um problema de negócio ou integração.
📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog: BlogSamples/Messaging/EventDriven/
Entrega pelo menos uma vez e mensagens duplicadas
Uma entrega pelo menos uma vez significa que uma mensagem pode ser recebida mais de uma vez. Isso não é um bug; é o custo de se operar com retries e falhas temporárias. O que eu preciso fazer é tratar duplicidade como um caso normal. O Inbox é a ferramenta certa para isso, porque ele armazena o identificador da mensagem e o consumidor, impedindo reprocessamento indevido.
Quando eu digo “pelo menos uma vez”, eu quero dizer que o sistema pode receber a mesma mensagem mais de uma vez e, por isso, todo efeito colateral precisa ser idempotente. Isso vale para gravação em banco, atualização de status, envio de e-mail, criação de registro de auditoria e qualquer ação que não possa ser repetida sem cuidado. O broker pode confirmar a entrega e depois o processo local falhar antes de registrar o estado. Nesse caso, na próxima tentativa, eu vou receber a mesma mensagem outra vez. Sem um mecanismo de deduplicação, eu posso criar duas reservas, duas autorizações ou dois registros de pagamento para a mesma intenção original.
Esse é um dos motivos pelos quais eu nunca confio em “delivery exactly once” como uma promessa de implementação. O que existe, em geral, é uma combinação de pelo menos uma vez no transporte, idempotência no consumidor e uma camada de compensação para os casos em que a operação não pode seguir adiante.
Idempotência no consumidor com o padrão Inbox
O consumidor precisa ser idempotente por natureza. Se ele receber uma duplicata, ele deve reconhecer isso e retornar sucesso sem repetir efeitos colaterais. O estado do inbox é o registro de que a mensagem já foi processada, independentemente de retriadas ou replays do broker.
A forma mais simples de implementar isso é persistir, no mesmo contexto transacional do efeito de negócio, o identificador da mensagem e o nome do consumidor. Quando a mesma mensagem chega outra vez, eu faço uma verificação e corto o fluxo antes de qualquer ação adicional. Em sistemas reais, isso costuma ser feito com uma tabela de Inbox com constraint composta em (consumer, message_id), porque isso me dá uma garantia forte de que duas execuções concorrentes não irão realizar o mesmo efeito duas vezes.
Isso é importante porque o broker não substitui a responsabilidade da aplicação de ser segura. O Service Bus pode ter duplicate detection em algumas configurações, mas isso não elimina a necessidade de um controle local de deduplicação. O padrão Inbox é a camada que faz o consumidor reconhecer “eu já tratei isso” e não repetir a ação.
Outbox transacional no PostgreSQL e o problema do dual write
O dual write é o clássico problema de consistência entre banco e broker. O Outbox resolve esse cenário ao persistir a intenção de publicar na mesma transação do efeito local. Depois, um publisher separado lê o outbox, envia e marca o resultado com um update controlado. Isso cria uma janela de incerteza, mas não faz a aplicação mentir sobre o que foi confirmado.
A implementação prática costuma ser: eu começo uma transação no PostgreSQL, persisto o estado de negócio, insiro a mensagem no Outbox e só depois comito. Se o commit for bem-sucedido, a mensagem está garantida como intenção de publicação. Em seguida, um segundo processo — quase sempre um worker — lê as mensagens pendentes e as publica no broker. O ponto crucial é que eu não atualizo o status de “publicada” até que a publicação tenha realmente acontecido. Esse desenho me protege de ter um efeito local sem a notificação correspondente.
Esse padrão é muito mais confiável do que tentar publicar primeiro e depois registrar o estado local. Se eu fizer isso, eu corro o risco de perder a mensagem ou de criar um estado de negócio sem evento correspondente. O Outbox é um dos poucos padrões que realmente reduce o problema de consistência entre banco e mensageria sem exigir um protocolo distribuído complexo.
Consistência eventual sem esconder o estado do usuário
Consistência eventual não significa “deixar o usuário no escuro”. Eu preciso expor o estado atual do pedido, a etapa da saga e os timestamps. Um pedido pode estar em processamento, mas a experiência precisa dizer exatamente onde ele está e o que está acontecendo. O estado observável é parte do design, não um detalhe opcional.
Quando eu realmente preciso dessa arquitetura
Eu não implemento Outbox, Inbox e Saga porque “parece moderno”. Eu implemento porque existe um problema operacional real: uma operação de negócio precisa atravessar múltiplos passos, alguns deles assíncronos, e eu preciso manter a confiança mesmo quando uma parte falha. Se eu estiver lidando com um fluxo simples de criação e leitura, com uma única transação e sem compensação, eu provavelmente não preciso disso.
A decisão costuma ficar mais clara quando eu vejo sinais como esses:
- um passo depende de outro sistema que pode demorar ou falhar;
- eu preciso evitar que a operação inteira seja revertida por uma falha parcial;
- eu preciso manter rastreabilidade do fluxo entre domínios;
- a duplicidade de mensagens é uma possibilidade real e não um caso raro;
- o usuário precisa saber em que fase da operação ele está.
| Situação | Recomendação |
|---|---|
| CRUD simples, uma transação local e sem integração externa | manter o fluxo síncrono e simples |
| um pedido precisa reservar estoque, cobrar cartão e notificar o cliente | considerar Outbox + Saga + Inbox |
| vários serviços reagem a eventos e o fluxo precisa ser compensável | usar saga e estados explícitos |
| o problema é apenas performance e não consistência distribuída | não começar por eventos |
A regra prática é: se eu preciso de uma operação distribuída que possa falhar em um ponto intermediário e ainda assim manter uma experiência de negócio confiável, eu devo considerar essa arquitetura.
Como eu implementaria isso na prática
A implementação começa com uma decisão de desenho, não com uma ferramenta. Eu primeiro defino os passos de negócio e os estados deles. Depois eu separo duas coisas: o que precisa ser consistente localmente e o que pode ser processado de forma assíncrona.
- Eu começo com uma transação local no PostgreSQL para salvar o efeito de negócio e registrar uma intenção de publicação no Outbox.
- Eu adiciono uma tabela de Inbox para garantir que o mesmo evento não gere o mesmo efeito duas vezes.
- Eu crio um envelope versionado com
message_id,correlation_id,causation_id,event_type,schema_versione payload tipado. - Eu monto um worker responsável por ler mensagens pendentes do Outbox e publicá-las no tópico do Service Bus.
- Eu implementei consumidores com
PeekLock,AutoCompleteMessages=falsee confirmação apenas depois do efeito local estar salvo. - Eu mantenho um estado explícito da saga para decidir se avanço, compenso ou paro o fluxo.
- Eu registro logs e métricas com correlation ID para poder analisar o fluxo inteiro quando algo der errado.
Esse roteiro é simples de explicar, mas exige disciplina. A maior parte dos problemas em arquiteturas orientadas a eventos vem do fato de a equipe tratar o evento como um detalhe técnico e não como um contrato de negócio.
Saga por coreografia ou orquestração: qual escolher
Uma saga orquestrada é mais simples de rastrear quando eu tenho um fluxo de negócio conhecido e um orquestrador explícito. A coreografia se encaixa melhor quando cada peça reage localmente a eventos e o fluxo é amplamente distribuído. Para este exemplo, eu escolhi a orquestração porque ela deixa o estado e a compensação mais fáceis de explicar e testar.
Na prática, eu escolho a orquestração quando o fluxo é essencialmente uma máquina de estados: pedido criado, estoque reservado, pagamento processado, compensação em caso de falha. Nesse cenário, eu preciso de uma entidade que acompanhe o estado global do processo de negócio e decida o próximo passo. Já a coreografia faz mais sentido quando eu tenho muitos serviços reagindo a eventos e a complexidade é distribuída entre eles. A vantagem da coreografia é menos acoplamento; a desvantagem é que a compreensão da execução completa fica mais difícil e a rastreabilidade tende a piorar.
A saga não é uma solução para qualquer problema. Ela é a forma correta de modelar uma sequência de passos que precisa ser compensada quando uma peça falha. Se eu simplesmente tiver um processo linear e não precisarei de rollback parcial, eu provavelmente não preciso de Saga.
Ordenação por pedido com sessões do Azure Service Bus
As sessões do Service Bus preservam a ordem por chave de negócio, neste caso por pedido. Isso não equivale a uma ordem global entre todos os pedidos; o que eu ganho é uma ordenação local por sessão, o que é essencial em fluxos que precisam ser processados sequencialmente sem bloquear outros pedidos.
Retry, PeekLock e Dead Letter Queue
O PeekLock permite que eu desacople o processamento da confirmação. Se algo falhar, eu abandono a mensagem para retry. Se o erro for persistente ou inválido, eu envio para a DLQ com uma razão útil. Isso é muito mais seguro do que simplesmente perder a mensagem ou completar cedo demais.
Eu gosto de pensar em três categorias de falha quando estou implementando isso. A primeira é uma falha transitória: banco indisponível, timeout de rede, estouro de conexão. Nesse caso, eu abandono a mensagem e deixo o broker entregá-la de novo. A segunda é uma falha de contrato ou de validação: o payload está mal formatado, um campo inesperado chegou, o schema mudou de forma incompatível. Aqui eu não quero reprocessar indefinidamente. Eu prefiro registrar a causa e enviar para DLQ com um motivo claro. A terceira é uma falha não recuperável de negócio, como um evento que chega em um estado inconsistente do pedido. Nesse caso, a saga precisa decidir se ela descreve uma violação de contrato ou simplesmente uma inconsistência tolerável para ser observada.
Esse modelo faz com que meu sistema seja previsível. Eu não escondo a falha sob retries infinitos e também não perco a informação quando uma mensagem está definitivamente inválida.
Contratos de eventos e evolução de schema
Eventos precisam de contratos claros. Eu não posso depender só do payload sem versionamento. O exemplo usa envelopes versionados com IDs de correlação, causação e tipo de evento. Isso ajuda a evoluir o schema sem tornar a saga um caos de incompatibilidades.
Observabilidade com correlation e causation IDs
A observabilidade melhora muito quando cada mensagem carrega correlation e causation IDs. Eu consigo rastrear o caminho inteiro do pedido, correlacionar logs, métricas e traces e localizar exatamente onde a saga desviou do fluxo normal.
Quando não usar arquitetura orientada a eventos
Eu não uso eventos quando o problema é simples, com forte consistência imediata e sem necessidade de desacoplamento. Quando uma transação simples já resolve, uma arquitetura orientada a eventos pode introduzir custo e complexidade demais. A regra prática é: se eu não preciso de fluxo assíncrono, compensação e observabilidade distribuída, eu provavelmente não deveria começar por eventos.
Exemplo Prático: pedido, estoque e pagamento
O exemplo completo está disponível em BlogSamples/Messaging/EventDriven/. Ele implementa um fluxo simples em que um pedido cria um evento de saída, o estoque responde e, em seguida, o pagamento é processado. Em caso de rejeição, a saga emite compensação e cancela o pedido.
Caminho feliz e caminho triste
No caminho feliz, o pedido começa em Criado, emite um evento para reservar estoque, recebe confirmação, segue para o pagamento e termina com o pedido aprovado. No caminho triste, alguma etapa falha: estoque indisponível ou pagamento recusado. Nesse caso, a saga não tenta seguir adiante; ela muda o estado para uma fase de compensação e publica um evento de cancelamento ou reversão.
| |
🧠 Observação: o estado da saga é a fonte de verdade para decidir se eu avanço, compenso ou sigo com o fluxo.
Dicas e Boas Práticas
- Mantenha o estado da saga explícito e observável.
- Trate duplicidade como regra, não exceção.
- Semântica de retries e DLQ precisa estar documentada.
- Versione contratos e faça evolução controlada.
- Use sessões apenas quando a ordem por chave de negócio for importante.
- Não use eventos para tudo. Se a operação é local e síncrona, a complexidade de mensageria talvez não valha o custo.
- Defina claramente o que é compensável e o que não é. Nem toda falha tem rollback real.
- Faça o consumidor ser idempotente antes de pensar em throughput alto.
- Monitore correlation IDs e causation IDs desde o início, porque isso é o que tornará a depuração viável quando a cadeia de eventos crescer.
Resumo Objetivo
- Outbox — transforma a publicação em parte da transação local e reduz o problema do dual write.
- Inbox — absorve duplicidade e torna o consumidor idempotente.
- Saga — organiza compensação e estados de negócio em fluxos distribuídos.
- Azure Service Bus — oferece tópicos, subscriptions, sessões e DLQ para fluxos assíncronos.
- PostgreSQL — fornece um banco transacional para persistir o estado da aplicação e o outbox.
Leia Também
- Padrões GoF: Código à Nuvem, Monólito ao Microserviço
- Gargalo em Banco de Dados: Mensageria e Paginação
- Worker e BackgroundService: Alto Volume
- Full-Text Search em API REST: C#, SQL Server e PostgreSQL
Referências
- Entity Framework Core — documentação oficial do EF Core.
- Npgsql — driver PostgreSQL para .NET.
- Azure Service Bus Emulator — instalação e configuração do emulador.
- Azure Service Bus topics and subscriptions — tópicos e subscriptions.
- PostgreSQL SKIP LOCKED — locking e seleção de linhas com skip locked.

Ao comentar, você concorda com nossa Política de Privacidade, Termos de Uso e Política de Exclusão de Dados.