Pedidos fantasmas na Black Friday: o caso que prova a necessidade do Outbox Pattern

2 meses atrás

9 min de leitura

Sumário Executivo

A sequência “commit no banco e depois publish no broker” é uma das principais fontes de inconsistência em sistemas orientados a eventos. Como banco e mensageria não compartilham a mesma transação, falhas reais (timeout, rede, restart) criam cenários perigosos: dado confirmado sem evento, ou evento emitido sem dado e a inconsistência aparece de forma intermitente e difícil de diagnosticar.

Este artigo descreve (1) por que esse padrão falha em produção, (2) como o Transactional Outbox garante atomicidade local ao persistir dado + intenção de publicar na mesma transação, e (3) como operar o dispatcher com entrega at-least-once, discutindo ordem correta (publicar antes de marcar como concluído), idempotência, concorrência entre workers e estratégias de retry.

Na construção de sistemas distribuídos e orientados a eventos, existe um momento crítico que decide se a arquitetura será confiável em produção: quando a mudança de negócio é confirmada no banco de um serviço e o evento correspondente é emitido para comunicar outras aplicações. 

É comum ver aplicações implementadas usando essa sequência: primeiro persiste o agregado (commit) e, logo em seguida, envia o evento/comando para o broker como RabbitMQ ou Kafka. Em ambiente de desenvolvimento isso costuma “funcionar”, mas essa sequência dá uma falsa sensação de segurança: banco e broker são recursos transacionais diferentes, não existe garantia automática de que os dois passos vão acontecer juntos. Na primeira falha real (timeout, queda de rede, restart do processo), você pode confirmar o commit e não publicar a mensagem, ou publicar a mensagem e falhar antes de confirmar o commit, criando um problema de consistência.

Considere o exemplo de código abaixo. O handler recebe uma request e executa uma sequência comum: cria a inscrição, salva no banco de dados e publica o evento.

public class Handler(
    EnrollmentRepository repository,
    IUnitOfWork unitOfWork,
    IServiceBus serviceBus,
    CancellationToken cancellationToken)
{
    public async Task<Result<Guid>> HandleAsync(Request request)
    {
        //Cria o agregado com validações de domínio.
        var enrollmentResult = Enrollment.Create(
            request.Name,
            request.Email,
            request.Phone,
            request.Document,
            request.BirthDate,
            request.Gender,
            request.Address
        );

        //Se falhou, retorna erro
        if (enrollmentResult.IsFailure)
            return Result.Failure<Guid>(enrollmentResult.Error);

        //Pega o objeto criado e adiciona ao repositório
        var enrollment = enrollmentResult.Value;
        await repository.AddAsync(enrollment, cancellationToken);

        //Persiste as alterações de negócio no banco de dados
        await unitOfWork.Commit(cancellationToken);

        //Envia o evento para o broker  
        await
            serviceBus.PublishAsync(
                new EnrollmentCreatedEvent(enrollment.Id));

        return Result.Success(enrollment.Id);
    }
}

Esse handler está implementando a lógica de “salva e depois publica”. Parece correto, mas tem um problema. Se o commit for bem-sucedido, mas a publicação falhar, o estado do agregado fica persistido e o evento não chega aos outros serviços, ou seja, você fica com dado persistido e evento não enviado.

Resultado: outros serviços não ficam sabendo do EnrollmentCreatedEvent e você cria inconsistência. Uma inconsistência geralmente intermitente e difícil de diagnosticar.

Também pode acontecer o inverso (dependendo de como o publish é feito): evento publicado e commit falhar, aí você avisa algo que “não existe” no banco.                                                                                                                                                 

Historicamente, esse cenário seria tratado com transações distribuídas, como o Two-Phase Commit (2PC). Em arquiteturas modernas de alta escala, porém, o 2PC é desencorajado por impor forte acoplamento, aumentar latência e introduzir fragilidade, já que recursos bloqueados podem propagar falhas em cascata. 

A abordagem prática para este cenário é trazer o problema para uma fronteira transacional única e local, utilizando o padrão Transactional Outbox Pattern.

A Mecânica da Atomicidade Local

A implementação do Outbox Pattern altera a mecânica da publicação. Em vez de publicar no broker durante a requisição, a aplicação serializa o evento. Em seguida, grava esse payload em uma tabela auxiliar, geralmente chamada de OutboxMessages, na mesma transação que persiste o agregado. Ao utilizar a mesma conexão do banco, a aplicação garante a atomicidade. Ou o agregado e o evento são ambos salvos, ou nenhum deles é.

Esse segundo código visa resolver exatamente esse problema do “commit no banco + publish no brokertornando o evento parte da mesma transação do banco (a ideia do Transactional Outbox). 

public class Handler(
    EnrollmentRepository repository, 
    IUnitOfWork unitOfWork
    CancellationToken cancellationToken)
{
    public async Task<Result<Guid>> HandleAsync(Request request)
    {
        // Criar o agregado de enrollment
        var enrollmentResult = Enrollment.Create(
            request.Name,
            request.Email,
            request.Phone,
            request.Document,
            request.BirthDate,
            request.Gender,
            request.Address
        );

        if (enrollmentResult.IsFailure)
            return Result.Failure<Guid>(enrollmentResult.Error);

        var enrollment = enrollmentResult.Value;

        await repository.AddAsync(enrollment, cancellationToken);
        
        // Adiciona o evento no repositorio
        await
        repository.AddAsync(
            new EnrollmentCreatedEvent(enrollment.Id));

        // Persiste as alterações de negócio e evento no BD
        await unitOfWork.Commit(cancellationToken);
        
        return Result.Success(enrollment.Id);
    }
}

Dessa forma, você elimina o caso mais perigoso do modelo anterior: commit confirmado e evento perdido por falha na publicação. Agora, se o commit aconteceu, o evento também ficou gravado no banco, porque ambos fazem parte da mesma transação.

Isso é o coração do outbox: não publicar no broker no mesmo fluxo, e sim registrar a intenção de publicar de forma transacional e publicar depois (por um dispatcher/worker).

O Dispatcher: Polling e Garantias de Entrega

Uma vez que o dado (negócio + evento) está salvo no banco de dados, o envio é desacoplado. Um componente separado, tipicamente um Background Service, assume a responsabilidade de processar essa tabela. Uma estratégia mais comum é o Polling, onde o worker consulta a tabela de  OutboxMessages periodicamente em busca de mensagens a serem enviadas. Nesse ponto, a ordem das operações define a confiabilidade do sistema.

public sealed class OutboxPublisherHostedService(
    IServiceScopeFactory scopeFactory,
    ILogger<OutboxPublisherHostedService> logger)
    : BackgroundService
{
    private const int PollingIntervalSeconds = 5;
    private const int BatchSize = 50;

    private static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        PropertyNameCaseInsensitive = true
    };

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        logger.LogInformation("Outbox publisher started");

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                await using var scope = scopeFactory.CreateAsyncScope();
                var context = scope.ServiceProvider.GetRequiredService<FinanceDbContext>();
                var dbContextFactory = scope.ServiceProvider.GetRequiredService<IEfDbContextFactory<SubscriptionsDbContext>>();
                var serviceBus = scope.ServiceProvider.GetRequiredService<IServiceBus>();
 
                try
                {
                    var messages = await context.OutboxMessages
                        .OrderBy(m => m.CreatedAt)
                        .Take(BatchSize)
                        .ToListAsync(stoppingToken);

                    foreach (var message in messages)
                    {
                        try
                        {
                            var type = Type.GetType(message.Type);
                            if (type == null)
                            {
                                logger.LogWarning("Outbox message type not found: {Type}", message.Type);
                                continue;
                            }

                            var payload = JsonSerializer.Deserialize(message.Payload, type, JsonOptions);
                            if (payload == null)
                            {
                                logger.LogWarning("Failed to deserialize outbox message {Id}", message.Id);
                                continue;
                            }

                            // Envia evento para o broker
                            await serviceBus.PublishAsync(payload);
                            
                            // Garante entrega do evento removendo do outbox
                            context.OutboxMessages.Remove(message);
                            await context.SaveChangesAsync(stoppingToken);
                        }
                        catch (Exception ex)
                        {
                            logger.LogError(ex, "Error publishing outbox message {Id}, will retry next cycle", message.Id);
                        }
                    }
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "Error processing outbox for tenant {TenantName}", tenant.Name);
                }
        
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Error in outbox publisher cycle");
            }

            await Task.Delay(TimeSpan.FromSeconds(PollingIntervalSeconds), stoppingToken);
        }

        logger.LogInformation("Outbox publisher stopped");
    }
}

 

Para garantir que nenhuma mensagem seja perdida, utilizamos At-Least-Once (pelo menos uma vez). A regra é: o worker só considera a mensagem concluída depois de publicar no broker e receber o acknowledgment (Ack). Até lá, ela segue como pendente no banco.

Se houver falha após a publicação e antes do banco registrar o sucesso, o worker publica na próxima execução. Isso pode gerar duplicidade, mas evita perda de mensagem. Por isso, os serviços consumidores devem ser idempotentes.

await serviceBus.PublishAsync(payload);
context.OutboxMessages.Remove(message);                       
await context.SaveChangesAsync(stoppingToken);

Existe a estratégia inversa, chamada At-Most-Once (no máximo uma vez), onde a aplicação remove a mensagem do banco antes de tentar publicar. Se a publicação falhar, a mensagem é perdida. Essa abordagem é pouco utilizada em sistemas de negócio com requisitos altos de confiabilidade, pois prioriza a não-duplicação em detrimento da garantia de entrega.

context.OutboxMessages.Remove(message);                                
await context.SaveChangesAsync(stoppingToken);
await serviceBus.PublishAsync(payload);

A sequência “publicar depois confirmar” sustenta a semântica At-Least-Once. Só que a resiliência aparece quando você controla o ciclo de vida da mensagem. Em produção, com múltiplas instâncias de workers, confiar apenas na existência do registro cria risco de race conditions (condições de corrida). Dois workers podem tentar processar a mesma linha ao mesmo tempo, gerando reprocessamento sem necessidade.

Uma forma comum de resolver isso é transformar a tabela de outbox em uma máquina de estados: Pending, Processing, Published e Failed. Com campos como RetryCount e a data da próxima execução, você aplica Exponential Backoff e isola poison messages que falham sempre. Esse controle impede que erros transientes virem gargalos de performance ou retries infinitos, e torna o fluxo auditável em escala.

Outro ponto de decisão arquitetural é o destino das mensagens processadas na tabela de outbox. No Hard Delete, a mensagem é removida fisicamente assim que o broker confirma o recebimento. Isso mantém a tabela leve e deixa o polling mais rápido, mas você perde o histórico de envio. 

No Soft Delete (update), a mensagem não é removida. Em vez disso, a aplicação atualiza uma coluna de status ou a data de envio. Isso ajuda na rastreabilidade e no debug, mas faz a tabela crescer indefinidamente e pode degradar a performance do banco ao longo do tempo. Se essa estratégia for escolhida, é importante ter um processo de limpeza (cleanup) para arquivar ou deletar registros antigos.

Quando devemos adotar o Outbox Pattern?

Embora o Outbox Pattern resolva o problema de consistência, ele não é uma “bala de prata” e traz complexidade operacional. Você passa a gerenciar mais uma tabela, workers em background e uma latência natural entre persistir o dado e publicar a mensagem. Por isso, a adoção precisa ser criteriosa.

Use Outbox quando a integridade do negócio depender da entrega do evento. Exemplos clássicos são processamento de pagamentos, movimentações de estoque, emissão de notas fiscais e qualquer cenário em que perder um evento gere prejuízo financeiro ou inconsistência entre serviços.

Por outro lado, evite essa complexidade em cenários de fire-and-forget ou dados não críticos. Se a mensagem é só uma notificação de “usuário logado” para estatística, logs de auditoria não regulatórios ou métricas de telemetria, a perda eventual pode ser aceitável em troca de uma arquitetura mais simples. Avalie o custo da complexidade versus o risco de perda de dados.

Estudo de Caso: O Incidente dos “Pedidos Fantasmas” na Black Friday

Para ilustrar a necessidade real desse padrão, considere um e-commerce de médio porte durante sua maior campanha anual. A arquitetura original usava a sequência direta de “Salvar Pedido” e depois “Publicar evento no RabbitMQ”. No pico de tráfego, o throughput do broker chegou ao limite e as conexões começaram a sofrer timeout.

O resultado foi grave: a aplicação salvava o pedido no SQL Server, mas falhava ao publicar o evento OrderCreated. Como o tratamento de erro apenas registrava a falha em log para não prejudicar a experiência do usuário, milhares de pedidos ficaram “presos” no banco. Os serviços de pagamento e logística, que dependiam desse evento, não foram notificados. O estoque foi reservado, o cliente viu a tela de “Sucesso”, mas o pedido não seguiu no fluxo.

A solução pós-incidente foi implementar o Outbox Pattern. Na campanha seguinte, mesmo com oscilações do broker sob carga, a tabela de outbox funcionou como um buffer persistente. Os pedidos passaram a ser salvos atomicamente com seus eventos e, quando o broker ficava estável, o worker drenava as mensagens pendentes. Assim, a entrega era retomada sem perda de vendas, mesmo com instabilidade momentânea na infraestrutura de mensageria.

Insights & Takeaways

  • Atomicidade via banco: utilize o banco de dados como fonte única da verdade. Se o dado de negócio foi salvo, o evento também deve estar lá.
  • A ordem importa: uara garantir At-Least-Once, a publicação no broker deve ocorrer estritamente antes da confirmação (commit/delete) no banco de dados do Outbox.
  • Consequência da resiliência: a garantia de entrega robusta traz consigo a duplicidade de mensagens em cenários de falha. Seus consumidores precisam ser idempotentes.
  • Performance do polling: índices adequados na tabela de Outbox são essenciais para evitar table scans recorrentes que podem travar o banco de produção.
  • Gerenciamento de volume: se optar por apenas atualizar o status das mensagens enviadas, planeje uma rotina de limpeza desde o primeiro dia para evitar degradação de performance.

 

Você também pode gostar

Explicando a Arquitetura do OpenClaw, na prática

Sumário Executivo

A transição da interface conversacional para a interface agêntica muda o jogo: em vez de apenas responder, o sistema passa a agir, lembrar, orquestrar e executar, tornando-se infraestrutura operacional e não só uma “IA para conversa”.

Este texto descreve (1) uma arquitetura de referência para AI Agents baseada em três blocos com fluxos claros (Interaction, Core e Resources), (2) como esses blocos ganham forma concreta demonstrando a utilização em uma assistente virtual construída sobre o OpenClaw e (3) como maximizar o resultado preservando salvaguardas práticas importante, cobrindo riscos como prompt injection, data exfiltration e excessive agency, além de e práticas como least privilege, isolamento e human-in-the-loop.

Microfrontends como estratégia arquitetural de modernização

Modernização de frontend legado sem reescrita total utilizando microfrontends como estratégia de arquitetura incremental para migração gradual e convivência com legado.

FinOps e governança de custos para inteligência artificial

Guia prático de FinOps para IA/GenAI: pare de olhar custo de GPU e meça custo por resposta. Entenda como aplicar guardrails para controlar consumo sem perder qualidade.

DDD (Domain-Driven Design) faz sentido no frontend?

Organização do frontend com DDD (Domain Driven Design) ao desenvolver uma aplicação faz sentido? Como estruturar o frontend?

Aplicação Node.js em produção sem telemetria é operar no escuro

Guia prático de observabilidade em Node.js com OpenTelemetry e Grafana: una logs, métricas e traces, comece com auto-instrumentação e evolua para diagnóstico rápido usando OTel Collector, Tempo, Loki e Prometheus com correlação por traceId.

A Importância do Refinamento de Dados para Modelos de IA: Por que algoritmos bilionários continuam falhando com dados de centavos

Recomendações práticas de técnicas de refinamento de dados para garantir resultados precisos em modelos de Inteligência Artificial.

Por que usar mensageria se posso chamar o outro serviço via HTTP?

Quando HTTP síncrono vira o caminho crítico, falhas e latência se propagam em cascata. Veja quando a mensageria deixa de ser opcional, como ela desacopla serviços e absorve picos, e quais disciplinas (idempotência, DLQ e rastreabilidade) evitam colapsos em sistemas distribuídos.

Exemplo completo de implementação de Open Telemetry aplicação Node.js

Exemplo pronto de OpenTelemetry em Node.js para reduzir tempo de investigação e aumentar previsibilidade. Inclui instrumentação, OTel Collector e visualização no Grafana (Tempo/Loki/Prometheus). Ideal para usar como referência e acelerar a adoção no seu time.

Modelo de Governança para tratar itens urgentes

Aprenda a governar a urgência e evitar o colapso da engenharia. Descubra como repriorizações sem critérios destroem a produtividade e o fluxo técnico.

Guia técnico: Comunicação Síncrona ou Assíncrona

HTTP ou Mensageria? Entenda os impactos do acoplamento temporal e saiba quando o modelo síncrono se torna um gargalo para sistemas distribuídos.

O mito do rewrite na modernização de legado

Descubra por que a modernização incremental é mais segura que o rewrite total. Evite armadilhas técnicas e preserve o conhecimento do seu negócio.

Você Não Quer Desenvolvedores Cuidadosos. Você Quer Fly-by-Wire

Substitua a dependência do erro humano pela Engenharia Fly-by-Wire: crie envelopes operacionais para garantir entregas rápidas, seguras e escaláveis.

7 Controles de FinOps que Cortam Gastos na Nuvem: Estratégias AWS e Multicloud

Recomendações das estratégias FinOps mais eficientes para reduzir até 40% dos custos de nuvem em 90 dias, em cenários reais multicloud, com governança e otimização sem sacrificar performance.

Assine nossa newsletter.

Assine nossa newsletter para ficar por dentro de todas as novidades de tecnologia.