DEV Community

José Marcos
José Marcos

Posted on

Outbox Pattern na Prática: Mensageria Confiável em um Banco Digital (Portuguese)

Quando falamos de sistemas financeiros, confiabilidade não é um luxo, é um requisito.
Uma transferência bancária não pode simplesmente "sumir" porque o sistema caiu antes de publicar o evento no broker.

Neste artigo, quero mostrar como resolver esse tipo de problema implementando o Outbox Pattern e Domain Events usando um exemplo prático inspirado em um banco digital.

Problema

Imagine o fluxo de uma transferência entre contas:

  1. O serviço registra a transação no banco de dados.
  2. Em seguida, publica um evento no broker (RabbitMQ, Kafka, etc.)

Um fluxo simples de caminho feliz. Até o momento em que o servidor cai ou uma falha de rede acontece. A transação foi salva, mas o evento nunca chegou ao broker.
Resultado: inconsistência entre os sistemas.

Solução: Outbox Pattern

A ideia central do Outbox Pattern é armazenar os eventos junto com a transação do banco de dados, garantindo atomicidade.
Ou seja, ou tudo é persistido (entidade + evento), ou nada é.

Um processo em background lê periodicamente esses eventos na tabela OutboxMessages e os publica com segurança, marcando-os como processados.

Dessa forma, mesmo que o sistema caia, o evento continua guardado e será reenviado depois.

Cenário: Banco Digital

Nesta POC, o contexto é o de um banco digital com operações de transferência entre contas.

A estrutura da solução é a seguinte:

BancoDigital
├── Application
│   ├── Interfaces
│   └── UseCases
├── Domain
│   ├── Entities
│   ├── Events
│   └── Abstractions
├── Infrastructure
│   ├── DbContext (EF Core)
│   ├── Interceptors (Outbox)
│   ├── Outbox (OutboxDispatcher)
│   └── Serializers
└── BancoDigital.Api
    ├── DTOs
    └── Program.cs
Enter fullscreen mode Exit fullscreen mode

Entidades Principais

 public class Conta : IHasDomainEvents
 {
     private readonly List<DomainEvent> _domainEvents = new();
     public IReadOnlyCollection<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();

     public Guid Id { get; private set; }
     public decimal Saldo { get; private set; }

     public Conta(Guid id, decimal saldoInicial = 0m)
     {
         Id = id;
         Saldo = saldoInicial;
     }

     public void Creditar(decimal valor)
     {
         if (valor <= 0) throw new ArgumentException("Valor deve ser positivo", nameof(valor));
         Saldo += valor;            
     }

     public void Debitar(decimal valor)
     {
         if (valor <= 0) throw new ArgumentException("Valor deve ser positivo", nameof(valor));
         if (Saldo < valor) throw new InvalidOperationException("Saldo insuficiente");
         Saldo -= valor;            
     }

     public void TransferirPara(Conta contaDestino, decimal valor)
     {
         Debitar(valor);
         contaDestino.Creditar(valor);

         _domainEvents.Add(new TransferenciaRealizadaDomainEvent(
             ContaOrigemId: this.Id,
             ContaDestinoId: contaDestino.Id,
             Valor: valor
         ));
     }

     public void ClearDomainEvents() => _domainEvents.Clear();
 }
Enter fullscreen mode Exit fullscreen mode

A entidade de Conta dispara um Domain Event automaticamente:

 _domainEvents.Add(new TransferenciaRealizadaDomainEvent(
             ContaOrigemId: this.Id,
             ContaDestinoId: contaDestino.Id,
             Valor: valor
         ));
Enter fullscreen mode Exit fullscreen mode

Tabela Outbox

Cada evento de domínio é serializado e armazenado em uma tabela auxiliar:

 public class OutboxMessage
 {
     public long Id { get; set; }
     public DateTime OccurredOnUtc { get; set; }
     public string Type { get; set; } = null!;
     public string Payload { get; set; } = null!;
     public DateTime? ProcessedOnUtc { get; set; }
     public string? Error { get; set; }
 }
Enter fullscreen mode Exit fullscreen mode

Um interceptor do EF Core detecta os eventos e os grava nessa tabela dentro da mesma transação do banco.

public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
    DbContextEventData eventData,
    InterceptionResult<int> result,
    CancellationToken cancellationToken = default)
{
    var context = eventData.Context;
    if (context == null) return result;

    var aggregates = context.ChangeTracker
        .Entries()
        .Where(e => e.Entity is IHasDomainEvents hasEvents && hasEvents.DomainEvents.Any())
        .Select(e => (IHasDomainEvents)e.Entity)
        .ToList();

    if (!aggregates.Any()) return result;

    var outbox = context.Set<OutboxMessage>();

    foreach (var aggregate in aggregates)
    {
        foreach (var @event in aggregate.DomainEvents)
        {
            outbox.Add(new OutboxMessage
            {
                OccurredOnUtc = @event.OccurredOnUtc,
                Type = @event.GetType().AssemblyQualifiedName!,
                Payload = SystemTextJsonEventSerializer.Serialize(@event)
            });
            _logger.LogInformation("Outbox message {Type} stored ", @event.GetType().Name);
        }
        aggregate.ClearDomainEvents();
    }

    return result;
}
Enter fullscreen mode Exit fullscreen mode

Processamento Assíncrono

O serviço OutboxDispatcher é executado em background. Ele busca eventos não processados e os "envia" para um broker:

 protected override async Task ExecuteAsync(CancellationToken stoppingToken)
 {
     while (!stoppingToken.IsCancellationRequested)
     {
         using var scope = _sp.CreateScope();
         var db = scope.ServiceProvider.GetRequiredService<BancoDigitalDbContext>();

         var pendentes = await db.OutboxMessages
             .Where(o => o.ProcessedOnUtc == null)
             .OrderBy(o => o.Id)
             .Take(50)
             .ToListAsync(stoppingToken);

         foreach (var msg in pendentes)
         {
             try
             {
                 var evt = SystemTextJsonEventSerializer.Deserialize(msg.Payload, msg.Type) as DomainEvent;
                 if (evt != null)
                 {                     
                     await SimulatedPublish(evt);  
                     msg.ProcessedOnUtc = DateTime.UtcNow;
                 }
             }
             catch (Exception ex)
             {
                 msg.Error = ex.Message;                 
             }
         }

         if (pendentes.Any())
         {
             await db.SaveChangesAsync(stoppingToken);
         }

         await Task.Delay(_pollInterval, stoppingToken);
     }
 } 
Enter fullscreen mode Exit fullscreen mode

Resultados

Com essa abordagem, a consistência é garantida:

O evento nunca se perde.
Falhas momentâneas no broker não impactam a transação.
O sistema continua eventual e previsivelmente consistente.

Exemplo de logs:

[INF] Outbox message TransferenciaRealizadaDomainEvent armazenada 
[INF] Evento TransferenciaRealizada publicado: d2ae0477-6d9b-44a2-bc10-849b6d11fef0 -> 8d425533-c790-4ec8-8bcb-79537dd318d1 : Valor 10 
Enter fullscreen mode Exit fullscreen mode

Próximos Passos

Integrar o Outbox com RabbitMQ ou Kafka.
Implementar retry policies com Polly.
Adicionar métricas e monitoramento da Outbox.

Referências

Artigo original: Reliable Messaging in .NET: Domain Events and the Outbox Pattern with EF Core Interceptors
Microsoft Docs – Interceptors in EF Core
Microsoft Docs – Worker services in .NET

Conclusão

O Outbox Pattern é um exemplo elegante de como simples decisões de arquitetura evitam grandes dores de cabeça em produção.

Em um sistema bancário, onde cada evento importa, ele se torna uma camada essencial de segurança transacional.

O código completo da POC está disponível no repositório:
https://github.com/josemarcosit/bancodigital-api

Top comments (0)