Acceder Registrarme

EVENT SOURCING: EL ESTADO COMO HISTORIA DE EVENTOS


La mayoría de los sistemas guardan el estado actual de un objeto: si un pedido cambia de estado, se hace un UPDATE en la base de datos y el estado anterior desaparece para siempre. Event Sourcing propone algo radicalmente diferente: nunca actualices, solo agrega eventos. El estado actual no se almacena directamente — se reconstruye reproduciendo la secuencia de eventos que lo generaron. Es un patrón que parece simple en la idea pero que cambia profundamente cómo piensas el diseño de tu sistema. Y cuando se combina con CQRS y DDD, da lugar a una de las arquitecturas más potentes y auditables que existen.

Autor: Kaled Ponce (Ver todos sus post)

CQRS Event Sourcing Arquitectura de Software Patrones de Diseño DotNet

Fecha de publicación: 2026-05-13 09:50:08
Ayúdanos con el arduo trabajo que realizamos.
[ARQUITECTURA DE SOFTWARE] EVENT SOURCING: EL ESTADO COMO HISTORIA DE EVENTOS

¿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.



...

INFORMACIÓN SOBRE EL AUTOR DEL ARTÍCULO
KALED AL FERNANDO PONCE ROBLES : Soy una persona proactiva y responsable con las actividades que tenga a mi cargo. El compromiso laboral que manejo se basa en garantizar un trabajo de calidad, realizado de forma eficiente y eficaz, ya que, poseo las habilidades y valores necesarios; así mismo, mi persona siempre está dispuesta a aprender y tomar en consideración las recomendaciones de mi entorno laboral.


  • Debes estar logueado para realizar comentarios