Event Sourcing .NET C# CQRS Arquitetura

Event Sourcing com .NET na prática: histórico imutável, projeções e reconstrução de estado

Aprenda a implementar Event Sourcing em projetos .NET: como armazenar eventos em vez de estado, construir projeções read-model.

N
Neryx Digital Architects
5 de novembro de 2025
14 min de leitura
230 profissionais leram
Categoria: Arquitetura Público: Times de engenharia e produto Etapa: Aprendizado

Em sistemas tradicionais, quando você atualiza um registro no banco, o estado anterior desaparece para sempre. O banco sabe o que é, mas não sabe o que foi. Event Sourcing inverte essa lógica: em vez de armazenar o estado atual, você armazena a sequência de eventos que levaram a esse estado. O estado atual é uma derivação dos eventos.

Isso resolve problemas que auditoria, histórico, debugging em produção e integração entre serviços — torna muito mais simples. E em .NET, a implementação é mais acessível do que parece.

O que muda na modelagem

Em CRUD padrão, um pedido confirmado tem coluna Status = "Confirmado". Em Event Sourcing, o mesmo pedido tem uma sequência de eventos:

PedidoCriado      { PedidoId, ClienteId, Timestamp }
ItemAdicionado    { PedidoId, ProdutoId, Quantidade, Preco }
ItemAdicionado    { PedidoId, ProdutoId, Quantidade, Preco }
PedidoConfirmado  { PedidoId, Total, Timestamp }

O estado atual é sempre reconstruído a partir desta sequência — nunca armazenado diretamente. Isso dá auditoria completa grátis, capacidade de "voltar no tempo" e rastreabilidade total.

Modelando o Event Store

// Contrato base de evento de domínio
public abstract record DomainEvent
{
    public Guid EventId { get; init; } = Guid.NewGuid();
    public DateTime OccurredAt { get; init; } = DateTime.UtcNow;
    public int Version { get; init; }
}

// Eventos do agregado Pedido
public record PedidoCriadoEvent(Guid PedidoId, Guid ClienteId) : DomainEvent;
public record ItemAdicionadoEvent(Guid PedidoId, Guid ProdutoId, int Quantidade, decimal Preco) : DomainEvent;
public record PedidoConfirmadoEvent(Guid PedidoId, decimal Total) : DomainEvent;
public record PedidoCanceladoEvent(Guid PedidoId, string Motivo) : DomainEvent;

// Tabela do Event Store (PostgreSQL)
public class StoredEvent
{
    public long Id { get; set; }           // auto-increment (sequência global)
    public Guid StreamId { get; set; }     // = PedidoId (identificador do agregado)
    public string StreamType { get; set; } = string.Empty;  // = "Pedido"
    public string EventType { get; set; } = string.Empty;   // = "PedidoCriadoEvent"
    public string Payload { get; set; } = string.Empty;     // JSON do evento
    public int Version { get; set; }       // versão dentro do stream
    public DateTime OccurredAt { get; set; }
}

O agregado com Event Sourcing

Em vez de aplicar mudanças diretamente ao estado, o agregado emite eventos e aplica esses eventos para atualizar seu próprio estado:

public class Pedido : AggregateRoot
{
    public Guid ClienteId { get; private set; }
    public StatusPedido Status { get; private set; }
    public List<ItemPedido> Itens { get; private set; } = new();
    public decimal Total { get; private set; }
// Construtor para reconstrução a partir de eventos
private Pedido() { }

// Factory method — emite o primeiro evento
public static Pedido Criar(Guid pedidoId, Guid clienteId)
{
    var pedido = new Pedido();
    pedido.Apply(new PedidoCriadoEvent(pedidoId, clienteId));
    return pedido;
}

public void AdicionarItem(Guid produtoId, int quantidade, decimal preco)
{
    if (Status != StatusPedido.Rascunho)
        throw new DomainException("Pedido não está em rascunho.");

    Apply(new ItemAdicionadoEvent(Id, produtoId, quantidade, preco));
}

public void Confirmar()
{
    if (!Itens.Any())
        throw new DomainException("Pedido sem itens não pode ser confirmado.");

    var total = Itens.Sum(i => i.Preco * i.Quantidade);
    Apply(new PedidoConfirmadoEvent(Id, total));
}

// Métodos When — aplicam o evento ao estado (puros, sem side-effects)
protected override void When(DomainEvent @event)
{
    switch (@event)
    {
        case PedidoCriadoEvent e:
            Id = e.PedidoId;
            ClienteId = e.ClienteId;
            Status = StatusPedido.Rascunho;
            break;

        case ItemAdicionadoEvent e:
            Itens.Add(new ItemPedido(e.ProdutoId, e.Quantidade, e.Preco));
            break;

        case PedidoConfirmadoEvent e:
            Status = StatusPedido.Confirmado;
            Total = e.Total;
            break;

        case PedidoCanceladoEvent e:
            Status = StatusPedido.Cancelado;
            break;
    }
}

}

// Base class que gerencia eventos não commitados public abstract class AggregateRoot { public Guid Id { get; protected set; } public int Version { get; private set; } = -1;

private readonly List&lt;DomainEvent&gt; _uncommittedEvents = new();
public IReadOnlyList&lt;DomainEvent&gt; UncommittedEvents => _uncommittedEvents.AsReadOnly();

protected void Apply(DomainEvent @event)
{
    When(@event);
    _uncommittedEvents.Add(@event with { Version = Version + 1 });
    Version++;
}

// Reconstrói o estado a partir de eventos históricos (não adiciona aos uncommitted)
public void LoadFromHistory(IEnumerable&lt;DomainEvent&gt; events)
{
    foreach (var @event in events)
    {
        When(@event);
        Version = @event.Version;
    }
}

protected abstract void When(DomainEvent @event);

public void ClearUncommittedEvents() => _uncommittedEvents.Clear();

}

Implementando o Event Store com PostgreSQL

public class EventStore : IEventStore
{
    private readonly AppDbContext _context;
    private readonly JsonSerializerOptions _jsonOptions;
// Mapa de tipos para deserialização
private static readonly Dictionary&lt;string, Type&gt; _eventTypes = new()
{
    ["PedidoCriadoEvent"] = typeof(PedidoCriadoEvent),
    ["ItemAdicionadoEvent"] = typeof(ItemAdicionadoEvent),
    ["PedidoConfirmadoEvent"] = typeof(PedidoConfirmadoEvent),
    ["PedidoCanceladoEvent"] = typeof(PedidoCanceladoEvent),
};

public async Task SaveEventsAsync(Guid streamId, string streamType,
    IEnumerable&lt;DomainEvent&gt; events, int expectedVersion, CancellationToken ct)
{
    // Otimistic concurrency: verifica se a versão esperada bate com a atual
    var currentVersion = await _context.StoredEvents
        .Where(e => e.StreamId == streamId)
        .MaxAsync(e => (int?)e.Version, ct) ?? -1;

    if (currentVersion != expectedVersion)
        throw new ConcurrencyException(
            $"Conflito de versão no stream {streamId}. " +
            $"Esperado: {expectedVersion}, Atual: {currentVersion}");

    var storedEvents = events.Select(e => new StoredEvent
    {
        StreamId = streamId,
        StreamType = streamType,
        EventType = e.GetType().Name,
        Payload = JsonSerializer.Serialize(e, e.GetType(), _jsonOptions),
        Version = e.Version,
        OccurredAt = e.OccurredAt
    });

    _context.StoredEvents.AddRange(storedEvents);
    await _context.SaveChangesAsync(ct);
}

public async Task&lt;IReadOnlyList&lt;DomainEvent&gt;&gt; LoadEventsAsync(
    Guid streamId, CancellationToken ct, int fromVersion = 0)
{
    var stored = await _context.StoredEvents
        .Where(e => e.StreamId == streamId && e.Version >= fromVersion)
        .OrderBy(e => e.Version)
        .ToListAsync(ct);

    return stored
        .Select(e => Deserialize(e))
        .Where(e => e != null)
        .Cast&lt;DomainEvent&gt;()
        .ToList();
}

private DomainEvent? Deserialize(StoredEvent stored)
{
    if (!_eventTypes.TryGetValue(stored.EventType, out var type)) return null;
    return (DomainEvent?)JsonSerializer.Deserialize(stored.Payload, type, _jsonOptions);
}

}

Repositório com Event Sourcing

public class PedidoRepository : IPedidoRepository
{
    private readonly IEventStore _eventStore;
public PedidoRepository(IEventStore eventStore) => _eventStore = eventStore;

public async Task&lt;Pedido?&gt; GetByIdAsync(Guid id, CancellationToken ct)
{
    var events = await _eventStore.LoadEventsAsync(id, ct);
    if (!events.Any()) return null;

    var pedido = new Pedido(); // usa construtor privado via reflection ou factory
    pedido.LoadFromHistory(events);
    return pedido;
}

public async Task SaveAsync(Pedido pedido, CancellationToken ct)
{
    var events = pedido.UncommittedEvents;
    if (!events.Any()) return;

    // expectedVersion = versão antes dos novos eventos
    var expectedVersion = pedido.Version - events.Count;

    await _eventStore.SaveEventsAsync(
        pedido.Id, "Pedido", events, expectedVersion, ct);

    pedido.ClearUncommittedEvents();
}

}

Projeções: construindo read models a partir de eventos

O Event Store é a fonte da verdade, mas consultar eventos toda hora para montar uma tela é lento. Projeções consomem eventos e constroem read models otimizados para leitura — tabelas desnormalizadas que respondem queries rápidas.

// Projeção: mantém uma tabela resumo de pedidos por cliente
public class PedidoResumoProjection
{
    public Guid PedidoId { get; set; }
    public Guid ClienteId { get; set; }
    public string Status { get; set; } = string.Empty;
    public decimal Total { get; set; }
    public int TotalItens { get; set; }
    public DateTime CriadoEm { get; set; }
    public DateTime? ConfirmadoEm { get; set; }
}

// Projetor — consome eventos e atualiza a projeção public class PedidoResumoProjector : IEventHandler<PedidoCriadoEvent>, IEventHandler<ItemAdicionadoEvent>, IEventHandler<PedidoConfirmadoEvent> { private readonly ProjectionDbContext _db;

public async Task HandleAsync(PedidoCriadoEvent @event, CancellationToken ct)
{
    _db.PedidoResumos.Add(new PedidoResumoProjection
    {
        PedidoId = @event.PedidoId,
        ClienteId = @event.ClienteId,
        Status = "Rascunho",
        CriadoEm = @event.OccurredAt
    });
    await _db.SaveChangesAsync(ct);
}

public async Task HandleAsync(ItemAdicionadoEvent @event, CancellationToken ct)
{
    var resumo = await _db.PedidoResumos.FindAsync(@event.PedidoId);
    if (resumo == null) return;
    resumo.TotalItens++;
    await _db.SaveChangesAsync(ct);
}

public async Task HandleAsync(PedidoConfirmadoEvent @event, CancellationToken ct)
{
    var resumo = await _db.PedidoResumos.FindAsync(@event.PedidoId);
    if (resumo == null) return;
    resumo.Status = "Confirmado";
    resumo.Total = @event.Total;
    resumo.ConfirmadoEm = @event.OccurredAt;
    await _db.SaveChangesAsync(ct);
}

}

Snapshots: evitando recarregar histórico longo

Quando um agregado tem centenas de eventos, reconstruir o estado do zero a cada operação fica lento. Snapshots salvam o estado a cada N eventos e o repositório parte do snapshot mais recente em vez de recomeçar do zero:

public async Task<Pedido?> GetByIdAsync(Guid id, CancellationToken ct)
{
    var snapshot = await _snapshotStore.GetLatestAsync<Pedido>(id, ct);
int fromVersion = 0;
Pedido? pedido;

if (snapshot != null)
{
    pedido = snapshot.State;
    fromVersion = snapshot.Version + 1; // carrega apenas eventos após o snapshot
}
else
{
    pedido = new Pedido();
}

var events = await _eventStore.LoadEventsAsync(id, ct, fromVersion);
if (!events.Any() && snapshot == null) return null;

pedido.LoadFromHistory(events);

// Cria snapshot a cada 50 eventos
if (events.Count >= 50)
    await _snapshotStore.SaveAsync(id, pedido, pedido.Version, ct);

return pedido;

}

Event Sourcing + CQRS: a combinação natural

Event Sourcing e CQRS se complementam perfeitamente. Os Commands modificam o Event Store via agregados. As Queries leem projeções — nunca o Event Store diretamente. Isso dá máxima flexibilidade: você pode adicionar novas projeções a qualquer momento e reconstruí-las do zero a partir do histórico completo de eventos.

Quando usar Event Sourcing

Event Sourcing tem custo de complexidade significativo. Vale a pena quando: auditoria completa é requisito de negócio ou regulatório; você precisa depurar o que exatamente aconteceu em produção; o sistema integra múltiplos microsserviços via eventos (os eventos já existem, faz sentido persistí-los); ou quando "voltar no tempo" é um caso de uso real (simulações, correções retroativas).

Não vale a pena para CRUDs simples, sistemas com baixa complexidade de negócio ou equipes pequenas sem familiaridade com o padrão.

Conclusão

Event Sourcing transforma o banco de dados de um repositório de estado para um repositório de fatos. Você ganha auditoria completa, depurabilidade, integração via eventos e flexibilidade para criar novas visões dos dados sem migrar nada. O custo é complexidade adicional na camada de persistência — que o código acima ajuda a estruturar de forma clara.

Se você está desenhando a arquitetura de um sistema que precisa de histórico completo, ou quer migrar de CRUD para Event Sourcing de forma incremental, a Neryx tem experiência com esses padrões em produção. Consultoria inicial gratuita.

Leitura complementar:

Precisa desenhar a próxima fase com menos retrabalho?

Fazemos discovery técnico para mapear riscos, arquitetura-alvo e sequência de execução antes de investir pesado.

Solicitar Discovery

Newsletter

Receba artigos como este no seu e-mail

Conteúdo técnico sobre arquitetura de software, .NET, IA e gestão de produto. Sem spam.