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<DomainEvent> _uncommittedEvents = new(); public IReadOnlyList<DomainEvent> 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<DomainEvent> 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<string, Type> _eventTypes = new() { ["PedidoCriadoEvent"] = typeof(PedidoCriadoEvent), ["ItemAdicionadoEvent"] = typeof(ItemAdicionadoEvent), ["PedidoConfirmadoEvent"] = typeof(PedidoConfirmadoEvent), ["PedidoCanceladoEvent"] = typeof(PedidoCanceladoEvent), }; public async Task SaveEventsAsync(Guid streamId, string streamType, IEnumerable<DomainEvent> 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<IReadOnlyList<DomainEvent>> 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<DomainEvent>() .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<Pedido?> 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: