
Lincoln ZocateliEntenda o dual write problem e como o Transactional Outbox Pattern garante atomicidade entre banco de dados e broker de mensagens em C#.
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.
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/.
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:
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:
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.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.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.
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.
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.
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:
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 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.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.// 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.
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 |
MessageId estável (geralmente o ID do próprio registro da outbox) para permitir deduplicação no consumidor.SKIP LOCKED ou lease otimista no publisher para permitir múltiplas instâncias do worker sem duplicar trabalho.(consumer, messageId).Domain:EntityType:EntityId) usada quando a subscription exige processamento sequencial.👉 Artigo completo com todos os exemplos de código: Transactional Outbox Pattern: Resolvendo o Dual Write