Introdução
O dual write problem aparece toda vez que uma aplicação precisa fazer duas escritas em sistemas diferentes — normalmente um banco de dados e um broker de mensagens — e tratar essas duas operações como se fossem uma coisa só. Se eu salvo o pedido no banco e, na sequência, publico um evento no Azure Service Bus como uma chamada separada, eu criei uma janela de inconsistência. Se a aplicação falhar ou a rede cair entre essas duas operações, o sistema termina em um estado que ninguém planejou: o dado existe mas o evento nunca saiu, ou o evento foi publicado mas o commit no banco nunca aconteceu.
Esse problema não é teórico. Eu já vi filas de eventos que anunciavam pedidos que na verdade não existiam no banco, porque a transação local falhou depois da publicação. E vi o oposto: pedidos criados sem nenhum evento de auditoria ou notificação, porque a aplicação caiu no meio do caminho antes de conseguir publicar. Este artigo é a segunda parte da série sobre arquitetura de eventos sem caos com C#, e aqui eu foco especificamente no Transactional Outbox Pattern: o que ele resolve, como implementá-lo em C# com PostgreSQL, e como isso se aplica a um caso real de auditoria transacional publicando no Azure Service Bus.
Pré-requisitos
Para acompanhar os exemplos, é útil ter conhecimento básico de transações ACID em banco relacional, Entity Framework Core ou Npgsql, e alguma familiaridade com filas ou tópicos de mensageria (Azure Service Bus, RabbitMQ ou Kafka seguem o mesmo raciocínio). O exemplo de código completo faz parte do mesmo laboratório de eventos usado no primeiro artigo da série, disponível em BlogSamples/Messaging/EventDriven/.
O problema da dupla escrita (dual write problem)
Toda vez que uma operação de negócio precisa persistir um estado local e notificar esse estado para outro sistema, existem duas escritas fisicamente separadas: um COMMIT no banco de dados e uma chamada de rede para o broker. Bancos de dados relacionais garantem atomicidade dentro de suas próprias transações, mas essa garantia não se estende a um sistema externo. Não existe uma transação distribuída de verdade entre PostgreSQL e Azure Service Bus sem um protocolo como two-phase commit, que na prática quase nenhuma mensageria moderna suporta e que introduz latência e acoplamento que a maioria dos times não quer pagar.
O resultado é que qualquer combinação “salvar e depois publicar” ou “publicar e depois salvar” tem uma janela onde uma das duas operações pode falhar sozinha:
- Se eu salvo no banco e a aplicação cai antes de publicar, o evento nunca existiu e nenhum consumidor será notificado.
- Se eu publico primeiro e o commit no banco falha depois, o consumidor recebe um evento sobre um dado que nunca foi persistido.
⚠️ Atenção: um retry ingênuo não resolve o dual write problem sozinho. Se eu simplesmente tentar de novo depois de uma falha, corro o risco de publicar o mesmo evento duas vezes ou de nunca reconciliar o estado quando a falha for definitiva (aplicação reiniciada, processo morto, deploy no meio do fluxo).
Por que transações de banco não resolvem sozinhas
A tentativa mais comum de correção é envolver as duas operações em uma transação e torcer para que tudo funcione. Isso não funciona porque a transação do banco de dados só controla recursos que o próprio banco gerencia. Uma chamada HTTP ou AMQP para o Azure Service Bus não participa do protocolo de commit do PostgreSQL ou do SQL Server. Se eu fizer INSERT no banco, chamar o SDK do Service Bus dentro do mesmo bloco try, e depois dar COMMIT, ainda existem cenários de falha:
- O
COMMITlocal pode ser bem-sucedido, mas a resposta de confirmação do broker nunca chega por timeout de rede — a aplicação não sabe se a mensagem foi ou não entregue. - O broker pode confirmar a entrega, mas o
COMMITsubsequente no banco falha por deadlock ou violação de constraint — o evento já foi publicado, mas o dado nunca ficou consistente.
Esse é exatamente o motivo pelo qual eu preciso transformar as duas operações em uma única transação local, e tratar a publicação de fato como uma etapa assíncrona e desacoplada.
A solução: Transactional Outbox Pattern
O Transactional Outbox Pattern resolve o dual write problem eliminando a necessidade de uma transação distribuída. Em vez de publicar diretamente no broker, a aplicação grava o dado de negócio e o evento pretendido em uma tabela de “caixa de saída” (outbox) dentro da mesma transação atômica do banco de dados. Como as duas gravações acontecem no mesmo banco e na mesma transação local, elas são atômicas por definição: ou as duas são persistidas, ou nenhuma é.
| |
📂 Código Fonte: O exemplo completo de Outbox, Inbox e Saga está disponível no repositório de exemplos do blog:
BlogSamples/Messaging/EventDriven/
Se o CommitAsync for bem-sucedido, eu tenho a garantia de que o pedido e a intenção de publicar o evento existem juntos no banco. Se ele falhar, nenhum dos dois foi persistido. Não existe mais o cenário em que um foi salvo e o outro não.
Consumo dos eventos: o publisher assíncrono
Salvar o evento na tabela outbox resolve a atomicidade, mas ainda falta o passo que efetivamente entrega a mensagem ao broker. Essa responsabilidade fica com um processo separado — geralmente um worker ou BackgroundService — que lê os registros pendentes da outbox e os publica no Azure Service Bus de forma assíncrona, marcando cada mensagem como publicada somente depois da confirmação do broker.
| |
Esse desenho cria uma janela de latência entre o COMMIT local e a publicação real — normalmente de milissegundos a poucos segundos —, mas nunca perde a garantia de atomicidade entre dado e evento. Se o worker cair depois do SendMessageAsync e antes do SaveChangesAsync, a mensagem pode ser publicada novamente na próxima execução. Isso é esperado e é resolvido do lado do consumidor, não do publisher.

Caso real: auditoria transacional em um sistema de gestão de estoque
Um exemplo concreto desse padrão em produção é a outbox de auditoria de um sistema de gestão de estoque, que publica eventos no Azure Service Bus com semântica at-least-once. O publisher reivindica mensagens disponíveis por lease SQL — um mecanismo equivalente ao SKIP LOCKED do exemplo anterior, mas implementado como um lock otimista com timeout de lease —, envia o envelope versionado para o tópico configurado e só marca a mensagem como Published depois da confirmação do broker.
Esse fluxo reforça um ponto central do Transactional Outbox Pattern: a responsabilidade do publisher termina na confirmação de entrega. Ele nunca assume que uma mensagem enviada uma vez está garantida a chegar exatamente uma vez do outro lado. Por isso, as obrigações relevantes migram para o consumidor.
Obrigações do consumidor: idempotência e ordenação
Como a entrega é at-least-once, todo consumidor de uma outbox transacional precisa tratar duplicidade como parte normal do fluxo, não como exceção:
- Idempotência por
MessageId. O consumidor deve manter uma Inbox ou uma constraint equivalente por(consumer, messageId)antes de aplicar qualquer efeito colateral. Se a mesma mensagem chegar duas vezes, o segundo processamento precisa ser reconhecido e descartado sem repetir a ação de negócio. - PeekLock sem auto-complete. Processar em modo
PeekLockcomAutoCompleteMessagesdesabilitado é obrigatório. A mensagem só deve ser completada depois que a persistência local ou o efeito externo estiver confirmado — nunca antes. - Duplicatas são esperadas, não um bug. Se a API publicar no Service Bus e falhar antes de marcar a mensagem como
Published, a mesma mensagem pode ser reenviada com o mesmoMessageId. O consumidor precisa estar preparado para isso desde o design inicial. SessionIdpara ordenação por agregado. Quando a subscription exigir processamento sequencial, usarSessionIdcomo chave de ordenação por agregado — no formatoDomain:EntityType:EntityId— garante que eventos do mesmo pedido, por exemplo, sejam processados na ordem correta sem bloquear outros agregados.- DLQ apenas após esgotar tentativas. Enviar para Dead Letter Queue somente depois de esgotar as tentativas do consumidor, registrando o erro de forma sanitizada, sem payload sensível em log.
- Retries desacoplados entre outbox e consumidor. A outbox controla a publicação; o consumidor controla o próprio processamento. Misturar essas duas políticas de retry cria acoplamento desnecessário e dificulta o diagnóstico de qual lado está falhando.
| |
ℹ️ Informação: o par Outbox (produtor) + Inbox (consumidor) é o que efetivamente entrega consistência ponta a ponta. A outbox garante que o evento não se perde; a inbox garante que o efeito colateral não se repete.
Como saber se você precisa do Outbox Pattern
O Transactional Outbox Pattern resolve um problema específico: consistência entre uma escrita local e uma notificação externa. Ele não é necessário em todo lugar.
| Situação | Recomendação |
|---|---|
| Operação única, síncrona, sem sistema externo dependente | manter transação simples do banco |
| Persistir dado e notificar outro serviço/domínio de forma confiável | usar Transactional Outbox |
| Apenas performance de escrita, sem preocupação de consistência distribuída | não é o problema que o Outbox resolve |
| Auditoria, replicação de eventos ou integração assíncrona entre sistemas | Outbox é a base recomendada |
Dicas e Boas Práticas
- Grave o dado de negócio e o registro da outbox sempre na mesma transação local — nunca em chamadas separadas, mesmo que pareçam próximas no tempo.
- Marque a mensagem como publicada somente depois da confirmação do broker, nunca antes ou de forma otimista.
- Use
MessageIdestável (geralmente o ID do próprio registro da outbox) para permitir deduplicação no consumidor. - Trate duplicidade de mensagens como comportamento esperado do sistema, não como bug a ser eliminado.
- Separe a política de retry do publisher da política de retry do consumidor; cada um tem falhas e responsabilidades diferentes.
- Use
SKIP LOCKEDou lease otimista no publisher para permitir múltiplas instâncias do worker sem duplicar trabalho. - Nunca coloque payload sensível em logs de erro ou de DLQ; sanitize antes de registrar.
Resumo Objetivo
- Dual write problem — ocorre quando uma aplicação grava em banco e publica em broker como duas operações separadas, sem garantia de atomicidade entre elas.
- Transactional Outbox Pattern — grava o dado de negócio e o evento pretendido na mesma transação local, eliminando a inconsistência entre as duas escritas.
- Semântica at-least-once — o publisher garante que a mensagem não se perde, mas pode ser entregue mais de uma vez ao consumidor.
- MessageId — chave usada pelo consumidor para deduplicação via padrão Inbox, com constraint por
(consumer, messageId). - PeekLock sem auto-complete — modo de processamento que só confirma a mensagem depois do efeito colateral estar persistido.
- SessionId — chave de ordenação por agregado (
Domain:EntityType:EntityId) usada quando a subscription exige processamento sequencial. - Azure Service Bus — broker usado no caso real do sistema de gestão de estoque para publicar eventos de auditoria a partir da outbox transacional.
Leia Também
- Arquitetura de Eventos sem Caos com C#: Guia Prático
- Worker e BackgroundService: Alto Volume
- Gargalo em Banco de Dados: Mensageria e Paginação
- Padrões GoF: Código à Nuvem, Monólito ao Microserviço
Referências
- Transactional Outbox Pattern — microservices.io — descrição canônica do padrão e do problema de dual write.
- Azure Service Bus messaging documentation — documentação oficial de tópicos, subscriptions e sessões.
- Azure Service Bus PeekLock e AutoComplete — comportamento de locks e confirmação de mensagens.
- PostgreSQL SKIP LOCKED — locking otimista para publishers concorrentes.
- Entity Framework Core Transactions — documentação oficial de transações no EF Core.

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