Transactional Outbox Pattern: Resolvendo o Dual Write
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 é.
// Ambos os inserts fazem parte da mesma transação local no PostgreSQL
await using var transaction = await dbContext.Database.BeginTransactionAsync();
var pedido = new Pedido { Id = Guid.NewGuid(), Status = "Criado" };
dbContext.Pedidos.Add(pedido);
// O evento pretendido é gravado como um registro comum na mesma tabela/transação
dbContext.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.NewGuid(),
TipoEvento = "PedidoCriado",
Payload = JsonSerializer.Serialize(pedido),
CriadoEm = DateTimeOffset.UtcNow,
Status = OutboxStatus.Pendente
});
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
📂 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.
// Worker que publica mensagens pendentes da outbox, uma de cada vez
public sealed class OutboxPublisherWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ServiceBusSender _sender;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// SKIP LOCKED evita que múltiplas instâncias do worker peguem a mesma linha
var pendentes = await db.OutboxMessages
.Where(m => m.Status == OutboxStatus.Pendente)
.OrderBy(m => m.CriadoEm)
.Take(20)
.ToListAsync(stoppingToken);
foreach (var mensagem in pendentes)
{
var envelope = new ServiceBusMessage(mensagem.Payload)
{
MessageId = mensagem.Id.ToString(),
SessionId = mensagem.CorrelationId
};
await _sender.SendMessageAsync(envelope, stoppingToken);
// Só marca como publicada depois da confirmação do broker
mensagem.Status = OutboxStatus.Publicada;
}
await db.SaveChangesAsync(stoppingToken);
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
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.
// Consumidor idempotente com Inbox e PeekLock
public async Task ProcessarMensagemAsync(ProcessMessageEventArgs args)
{
var messageId = args.Message.MessageId;
// Verifica se essa combinação (consumer, messageId) já foi processada
var jaProcessada = await _db.InboxMessages
.AnyAsync(m => m.Consumer == ConsumerName && m.MessageId == messageId);
if (jaProcessada)
{
// Duplicata esperada: reconhece e sai sem repetir efeito colateral
await args.CompleteMessageAsync(args.Message);
return;
}
// Aplica o efeito de negócio e registra o inbox na mesma transação
await using var transaction = await _db.Database.BeginTransactionAsync();
await AplicarEfeitoDeNegocioAsync(args.Message);
_db.InboxMessages.Add(new InboxMessage { Consumer = ConsumerName, MessageId = messageId });
await _db.SaveChangesAsync();
await transaction.CommitAsync();
await args.CompleteMessageAsync(args.Message);
}
ℹ️ 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.
📬
📖 Artigo completo com exemplos de código: Transactional Outbox Pattern: Resolvendo o Dual Write