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:

  1. uma API para aceitar o comando de negócio e persistir o efeito local;
  2. um banco PostgreSQL como fonte de verdade do estado do pedido, da saga e das mensagens de integração;
  3. um broker local, neste caso o Azure Service Bus Emulator, para transporte, roteamento, sessões e DLQ;
  4. 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/ com DbContext, 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.yml e README.md para 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.

Comparação entre o happy path e o path de compensação de uma saga

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çãoRecomendação
CRUD simples, uma transação local e sem integração externamanter o fluxo síncrono e simples
um pedido precisa reservar estoque, cobrar cartão e notificar o clienteconsiderar Outbox + Saga + Inbox
vários serviços reagem a eventos e o fluxo precisa ser compensávelusar saga e estados explícitos
o problema é apenas performance e não consistência distribuídanã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.

  1. 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.
  2. Eu adiciono uma tabela de Inbox para garantir que o mesmo evento não gere o mesmo efeito duas vezes.
  3. Eu crio um envelope versionado com message_id, correlation_id, causation_id, event_type, schema_version e payload tipado.
  4. Eu monto um worker responsável por ler mensagens pendentes do Outbox e publicá-las no tópico do Service Bus.
  5. Eu implementei consumidores com PeekLock, AutoCompleteMessages=false e confirmação apenas depois do efeito local estar salvo.
  6. Eu mantenho um estado explícito da saga para decidir se avanço, compenso ou paro o fluxo.
  7. 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public sealed class PedidoSagaState
{
    public Guid PedidoId { get; set; }
    public SagaStatus Status { get; set; }
    public SagaEtapa Etapa { get; set; }
    public List<string> Processados { get; set; } = [];
    public List<SagaOutboxMessage> Outbox { get; set; } = [];
}

public static class PedidoSagaOrquestrador
{
    public static PedidoSagaState HandlePedidoCriado(PedidoSagaState saga)
    {
        saga.Status = SagaStatus.EmAndamento;
        saga.Etapa = SagaEtapa.AguardandoReserva;
        saga.Outbox.Add(new SagaOutboxMessage
        {
            TipoEvento = "ReservarEstoque",
            CorrelationId = saga.PedidoId.ToString()
        });

        return saga;
    }

    public static PedidoSagaState HandleReservaConfirmada(PedidoSagaState saga)
    {
        saga.Etapa = SagaEtapa.AguardandoPagamento;
        saga.Processados.Add("estoque-reservado");
        return saga;
    }

    public static PedidoSagaState HandlePagamentoRecusado(PedidoSagaState saga)
    {
        saga.Status = SagaStatus.Compensando;
        saga.Etapa = SagaEtapa.Cancelado;
        saga.Outbox.Add(new SagaOutboxMessage
        {
            TipoEvento = "CancelarPedido",
            CorrelationId = saga.PedidoId.ToString()
        });

        return saga;
    }
}

🧠 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

Referências