1

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 COMMIT local 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 COMMIT subsequente 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 PeekLock com AutoCompleteMessages desabilitado é 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 mesmo MessageId. O consumidor precisa estar preparado para isso desde o design inicial.
  • SessionId para ordenação por agregado. Quando a subscription exigir processamento sequencial, usar SessionId como chave de ordenação por agregado — no formato Domain: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çãoRecomendação
Operação única, síncrona, sem sistema externo dependentemanter transação simples do banco
Persistir dado e notificar outro serviço/domínio de forma confiávelusar Transactional Outbox
Apenas performance de escrita, sem preocupação de consistência distribuídanão é o problema que o Outbox resolve
Auditoria, replicação de eventos ou integração assíncrona entre sistemasOutbox é 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 MessageId está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 LOCKED ou 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

Carregando publicação patrocinada...