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 broker” tornando 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.