¿Qué es Event Sourcing?
En un modelo CRUD tradicional, la base de datos almacena el estado actual de cada entidad. Si una orden cambia de 'pendiente' a 'pagada', haces un UPDATE. Perdiste el historial.
En Event Sourcing, en cambio, la base de datos almacena únicamente eventos inmutables y ordenados cronológicamente. Cada evento representa algo que ocurrió en el dominio: OrderPlaced, PaymentProcessed, ItemShipped. El estado actual es simplemente el resultado de aplicar todos esos eventos en orden.
En lugar de guardar dónde estás, guardas cómo llegaste ahí. Esa diferencia lo cambia todo.
Esto convierte al Event Store en la única fuente de verdad del sistema. Las proyecciones (vistas de lectura) son derivadas de esos eventos y pueden ser reconstruidas en cualquier momento.
El flujo completo
El flujo tiene cinco pasos claros. Un Comando llega al sistema expresando una intención: 'quiero colocar un pedido'. El Aggregate de dominio valida ese comando contra sus invariantes y, si es válido, genera uno o más Domain Events que representan lo que ocurrió. Esos eventos se persisten en el Event Store de forma append-only. Finalmente, los Event Handlers escuchan esos eventos y actualizan las proyecciones de lectura.
La reconstitución del estado
Cuando el sistema necesita el estado actual de un Aggregate para procesar un nuevo comando, no hace un SELECT * del estado. Lee todos los eventos de ese Aggregate desde el Event Store y los aplica en orden sobre un estado inicial vacío. El resultado es exactamente el mismo estado actual, pero con toda la historia preservada.
Para Aggregates con historiales muy largos, se usa el patrón Snapshot: un estado serializado en un punto determinado del tiempo, que permite arrancar el replay desde ese punto en lugar del inicio.
CRUD tradicional vs Event Sourcing
|
Aspecto |
CRUD Tradicional |
Event Sourcing |
|
Modelo de persistencia |
Último estado (UPDATE) |
Secuencia de eventos (APPEND) |
|
Historial de cambios |
Se pierde al actualizar |
Completo e inmutable |
|
Auditoría |
Requiere tablas extra |
Nativa — los eventos son el log |
|
Rollback temporal |
Difícil / imposible |
Replay hasta cualquier punto en el tiempo |
|
Debugging |
Snapshot sin contexto |
Reproducir exactamente qué pasó y cuándo |
|
Complejidad inicial |
Baja |
Media-alta (curva de aprendizaje) |
|
Combina bien con |
Repositorios simples |
CQRS · DDD · Proyecciones |
La ventaja menos obvia
Con Event Sourcing puedes reconstruir cualquier proyección del pasado. ¿Necesitas saber exactamente cuál era el estado de todos los pedidos el 1 de enero a las 10:23am? Con una DB tradicional eso es imposible. Con Event Sourcing es solo un replay filtrado por timestamp.
Implementación en C# con .NET 10
Domain Events — la base de todo
// IDomainEvent.cs + Eventos concretos
// Interfaz base — todos los eventos la implementan
public interface IDomainEvent
{
Guid EventId { get; }
Guid StreamId { get; } // Id del Aggregate
int Version { get; } // Número secuencial
DateTimeOffset OccurredAt { get; }
}
// Evento concreto — inmutable, sin setters
public sealed record OrderPlacedEvent(
Guid EventId,
Guid StreamId,
int Version,
DateTimeOffset OccurredAt,
Guid CustomerId,
IReadOnlyList Items) : IDomainEvent;
Aggregate Base — aplica y registra eventos
// AggregateRoot.cs
public abstract class AggregateRoot
{
private readonly List _uncommitted = [];
public Guid Id { get; protected set; }
public int Version { get; private set; } = -1;
// Eventos pendientes de persistir
public IReadOnlyList UncommittedEvents => _uncommitted;
// Aplica y registra un nuevo evento
protected void RaiseEvent(IDomainEvent evt)
{
Apply(evt);
_uncommitted.Add(evt);
Version++;
}
// Reconstruye el estado desde el Event Store
public void Rehydrate(IEnumerable history)
{
foreach (var evt in history)
{
Apply(evt);
Version = evt.Version;
}
}
protected abstract void Apply(IDomainEvent evt);
public void ClearUncommitted() => _uncommitted.Clear();
}
Order Aggregate — lógica de dominio
// Order.cs
public sealed class Order : AggregateRoot
{
public Guid CustomerId { get; private set; }
public string Status { get; private set; } = string.Empty;
public IReadOnlyList Items { get; private set; } = [];
// Factory — genera el primer evento
public static Order Place(Guid customerId, List items)
{
var order = new Order();
order.RaiseEvent(new OrderPlacedEvent(
EventId: Guid.NewGuid(),
StreamId: Guid.NewGuid(),
Version: 0,
OccurredAt: DateTimeOffset.UtcNow,
CustomerId: customerId,
Items: items));
return order;
}
// Aplica cada tipo de evento
protected override void Apply(IDomainEvent evt) => _ = evt switch
{
OrderPlacedEvent e => Apply(e),
PaymentProcessedEvent e => Apply(e),
_ => false
};
private bool Apply(OrderPlacedEvent e)
{
Id = e.StreamId;
CustomerId = e.CustomerId;
Items = e.Items;
Status = "Placed";
return true;
}
private bool Apply(PaymentProcessedEvent e)
=> (Status = "Paid") is not null;
}
Event Store — interfaz y implementación
// IEventStore.cs
public interface IEventStore
{
// Guarda eventos de un stream (Aggregate)
Task AppendAsync(
Guid streamId,
IEnumerable events,
int expectedVersion, // optimistic concurrency
CancellationToken ct = default);
// Lee todos los eventos de un stream
Task> ReadStreamAsync(
Guid streamId,
CancellationToken ct = default);
// Lee desde una versión específica (útil con snapshots)
Task> ReadStreamFromAsync(
Guid streamId,
int fromVersion,
CancellationToken ct = default);
}
Command Handler — integración con Cortex.Mediator
// PlaceOrderHandler.cs
public sealed class PlaceOrderHandler(IEventStore store, IEventBus bus) : ICommandHandler
{
public async Task HandleAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
// 1. Crear el aggregate → genera el evento
var order = Order.Place(cmd.CustomerId, cmd.Items);
// 2. Persistir eventos en el Event Store (append-only)
await store.AppendAsync(
order.Id,
order.UncommittedEvents,
expectedVersion: -1, // -1 = stream nuevo
ct);
// 3. Publicar para que los proyectores actualicen el Read Model
foreach (var evt in order.UncommittedEvents)
await bus.PublishAsync(evt, ct);
order.ClearUncommitted();
return new PlaceOrderResult(order.Id);
}
}
Projector — actualiza el Read Model
// OrderProjector.cs — escucha eventos y actualiza la vista de lectura
public sealed class OrderProjector(IReadDbContext readDb) : IEventHandler, IEventHandler
{
public async Task HandleAsync(OrderPlacedEvent evt, CancellationToken ct)
{
readDb.OrderViews.Add(new OrderView
{
Id = evt.StreamId,
CustomerId = evt.CustomerId,
Status = "Placed",
CreatedAt = evt.OccurredAt,
});
await readDb.SaveChangesAsync(ct);
}
public async Task HandleAsync(PaymentProcessedEvent evt, CancellationToken ct)
{
var view = await readDb.OrderViews.FindAsync([evt.StreamId], ct);
if (view is not null) { view.Status = "Paid"; }
await readDb.SaveChangesAsync(ct);
}
}
Concurrencia optimista
El parámetro expectedVersion en AppendAsync protege contra condiciones de carrera. Si dos comandos intentan modificar el mismo Aggregate simultáneamente, el segundo fallará porque la versión actual ya no coincide con la esperada. Esto evita inconsistencias sin necesidad de locks a nivel de base de datos.
Event Sourcing + CQRS: la combinación natural
Event Sourcing y CQRS son independientes pero se complementan de forma excepcional. En esta arquitectura combinada el Event Store reemplaza completamente al Write Store de CQRS: los Command Handlers persisten eventos en lugar de estados. Los Event Handlers (proyectores) escuchan esos eventos y actualizan el Read Model, que puede ser Redis, una tabla desnormalizada en PostgreSQL, o cualquier proyección optimizada para consultas.
El resultado es un sistema donde la escritura es siempre append-only (rápida y sin contención), la lectura es de modelos optimizados (sin joins complejos), y el historial completo de cambios está disponible para auditoría, debugging o reconstrucción de proyecciones.
¿Cuándo tiene sentido?
-
Auditoría obligatoria: si el negocio necesita saber quién hizo qué y cuándo (finanzas, salud, legal), Event Sourcing lo da de forma nativa.
-
Debugging en producción: poder reproducir exactamente el estado del sistema en cualquier momento pasado es invaluable para diagnosticar bugs en producción.
-
Sistemas con lógica de negocio compleja: cuando los Aggregates tienen muchos estados y transiciones, modelarlos como eventos es más claro que columnas de estado.
-
Integración con microservicios: los eventos son el contrato natural entre servicios. El Event Store se convierte en un log distribuido que otros servicios pueden consumir.
Cuándo NO usarlo
Event Sourcing agrega complejidad real: hay que diseñar versioning de eventos, manejar consistencia eventual entre el Event Store y las proyecciones, y gestionar migraciones de esquemas de eventos. En sistemas CRUD sin lógica de negocio compleja, sin necesidades de auditoría, o en equipos sin experiencia previa en el patrón, el costo supera ampliamente el beneficio.
CONCLUSION
Event Sourcing no es una base de datos diferente ni una librería. Es una forma distinta de modelar el tiempo en tu sistema: en lugar de guardar dónde estás, guardas cómo llegaste ahí. Eso habilita capacidades que con un modelo CRUD son simplemente imposibles: replay, time travel queries, auditoría nativa y reconstrucción de proyecciones.
Combinado con CQRS y DDD en .NET 10, Event Sourcing forma la base de los sistemas de más alto rendimiento y mantenibilidad del ecosistema. Pero como todo patrón avanzado, la pregunta antes de adoptarlo no es '¿puedo?' sino '¿tengo el problema que esto resuelve?'.
Guardar el último estado es conveniente. Guardar la historia es poder.
- Debes estar logueado para realizar comentarios