Hasta ahora hemos usado los eventos como mecanismo de comunicación entre servicios. En esta lección damos un paso más y los convertimos en el mecanismo de almacenamiento. El Event Sourcing propone una idea radical: en lugar de guardar el estado actual de una entidad (la típica fila en una tabla que se sobrescribe), guardamos la secuencia completa de eventos que la han llevado hasta ese estado. El estado actual deja de ser el dato primario y pasa a ser una consecuencia que se deriva de los eventos.

Este patrón suele ir de la mano de CQRS (Command Query Responsibility Segregation), que separa el modelo que escribe (comandos) del modelo que lee (consultas). Juntos permiten auditoría total, escalado independiente de lecturas y escrituras, y la capacidad de "viajar en el tiempo". Es un enfoque potente pero exigente, así que también aprenderás cuándo no usarlo.

Contenido

  1. La idea central de Event Sourcing
  2. El Event Store (almacén de eventos)
  3. Reconstrucción del estado (replay)
  4. CQRS: separar lectura y escritura
  5. Proyecciones y modelos de lectura
  6. Ejemplo completo de eventos de una cuenta bancaria
  7. Errores comunes y consejos
  8. Ejercicios y soluciones
  9. Conclusión

  1. La idea central de Event Sourcing

En el modelo tradicional (CRUD) una transferencia bancaria se traduce en UPDATE cuentas SET saldo = 50 WHERE id = 1. El valor anterior (100) se pierde para siempre.

Con Event Sourcing guardamos los hechos:

1. CuentaAbierta(saldoInicial=0)
2. DineroIngresado(importe=100)
3. DineroRetirado(importe=50)

El saldo actual (50) no se almacena: se calcula sumando los eventos. Ventajas inmediatas:

  • Auditoría perfecta: tienes el historial íntegro de por qué el estado es el que es.
  • Depuración temporal: puedes reconstruir el estado en cualquier momento del pasado.
  • Nuevas vistas a posteriori: si mañana necesitas una nueva proyección, la generas reproduciendo los eventos ya existentes.
Aspecto CRUD tradicional Event Sourcing
Qué se guarda Estado actual (se sobrescribe) Todos los eventos (se añaden)
Historial Se pierde Completo e inmutable
Operación principal UPDATE / DELETE APPEND (solo añadir)
Auditoría Requiere tablas extra Intrínseca
Complejidad Baja Alta

  1. El Event Store (almacén de eventos)

El Event Store es la base de datos donde se persisten los eventos. Su característica fundamental es que es append-only: los eventos nunca se modifican ni se borran, solo se añaden al final. Cada evento se asocia a un stream (normalmente, uno por entidad/agregado).

-- Estructura mínima de un event store relacional
CREATE TABLE eventos (
    id            BIGSERIAL PRIMARY KEY,   -- orden global de inserción
    stream_id     VARCHAR(64) NOT NULL,    -- a qué entidad pertenece (p.ej. cuenta-42)
    version       INT NOT NULL,            -- nº de secuencia dentro del stream
    tipo          VARCHAR(100) NOT NULL,   -- "DineroIngresado", etc.
    datos         JSONB NOT NULL,          -- payload del evento
    ocurrido_en   TIMESTAMP NOT NULL,
    UNIQUE (stream_id, version)            -- control de concurrencia optimista
);

Explicación de cada columna:

  • stream_id agrupa todos los eventos de una misma entidad. Para reconstruir la cuenta 42, leemos todos los eventos con stream_id = 'cuenta-42' ordenados por version.
  • version es el número de orden dentro del stream. La restricción UNIQUE (stream_id, version) implementa concurrencia optimista: si dos procesos intentan escribir la versión 5 a la vez, uno fallará, evitando que se pisen.
  • datos (JSONB en PostgreSQL) guarda el contenido del evento de forma flexible.
  • Nunca hay UPDATE ni DELETE sobre esta tabla; solo INSERT.

  1. Reconstrucción del estado (replay)

Para obtener el estado actual de una entidad, leemos sus eventos en orden y los aplicamos uno a uno sobre un estado inicial vacío. A esto se le llama replay o rehidratación.

public class Cuenta {
    private String id;
    private BigDecimal saldo = BigDecimal.ZERO;
    private boolean abierta = false;

    // Reconstruye la cuenta reproduciendo su historial de eventos
    public static Cuenta rehidratar(List<EventoCuenta> historial) {
        Cuenta cuenta = new Cuenta();
        for (EventoCuenta e : historial) {
            cuenta.aplicar(e); // aplicamos cada evento en orden
        }
        return cuenta;
    }

    // Cada tipo de evento modifica el estado de forma determinista
    private void aplicar(EventoCuenta e) {
        switch (e) {
            case CuentaAbierta ca -> {
                this.id = ca.cuentaId();
                this.saldo = ca.saldoInicial();
                this.abierta = true;
            }
            case DineroIngresado di -> this.saldo = this.saldo.add(di.importe());
            case DineroRetirado dr  -> this.saldo = this.saldo.subtract(dr.importe());
        }
    }
}

Puntos clave:

  • rehidratar parte de una Cuenta vacía y aplica cada evento secuencialmente. El resultado es el estado actual.
  • El método aplicar usa pattern matching sobre tipos sellados (Java 21): cada evento sabe cómo muta el estado. Es totalmente determinista: los mismos eventos siempre producen el mismo estado.
  • Optimización (snapshots): reproducir miles de eventos en cada lectura es costoso. La solución son los snapshots: cada N eventos se guarda una "foto" del estado, y al rehidratar se parte del último snapshot y solo se reproducen los eventos posteriores.

  1. CQRS: separar lectura y escritura

CQRS separa las operaciones en dos modelos distintos:

  • Lado de escritura (Command): recibe comandos, valida reglas de negocio y genera eventos. Optimizado para la consistencia.
  • Lado de lectura (Query): sirve consultas desde modelos de datos preparados para leer rápido (desnormalizados). Optimizado para el rendimiento.
flowchart LR
    U[Usuario] -->|Comando| W[Modelo de Escritura]
    W -->|genera| ES[(Event Store)]
    ES -->|eventos| PR[Proyector]
    PR -->|actualiza| RM[(Modelo de Lectura<br/>desnormalizado)]
    U -->|Consulta| RM

Ventajas de separar ambos lados:

  • Escalado independiente: normalmente hay muchas más lecturas que escrituras; puedes replicar el modelo de lectura sin tocar el de escritura.
  • Modelos óptimos: el de escritura puede ser un agregado normalizado; el de lectura, una tabla plana lista para pintar una pantalla concreta.

Importante: CQRS no obliga a usar Event Sourcing, ni viceversa. Pero combinados encajan de forma natural: los eventos del write side alimentan las proyecciones del read side.

  1. Proyecciones y modelos de lectura

Una proyección es un consumidor de eventos que mantiene actualizado un modelo de lectura. Cada vez que llega un evento nuevo, la proyección actualiza su vista.

// Proyección que mantiene un resumen de saldos para listados rápidos
@Component
public class ProyeccionSaldos {

    private final JdbcTemplate jdbc;

    public ProyeccionSaldos(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    @EventListener
    public void on(DineroIngresado e) {
        // Actualiza la tabla de lectura desnormalizada
        jdbc.update("UPDATE saldos_vista SET saldo = saldo + ? WHERE cuenta_id = ?",
                e.importe(), e.cuentaId());
    }

    @EventListener
    public void on(DineroRetirado e) {
        jdbc.update("UPDATE saldos_vista SET saldo = saldo - ? WHERE cuenta_id = ?",
                e.importe(), e.cuentaId());
    }
}

Explicación:

  • saldos_vista es una tabla desnormalizada pensada solo para leer (por ejemplo, mostrar un listado de cuentas con su saldo al instante, sin reproducir eventos).
  • Cada método reacciona a un tipo de evento y actualiza la vista. El modelo de lectura es, en el fondo, una caché derivada del event store: si se corrompe, puede regenerarse reproduciendo todos los eventos desde cero.
  • Esto introduce consistencia eventual: tras una escritura, la vista puede tardar milisegundos en reflejarla. Hay que diseñar la experiencia de usuario contando con ello.

  1. Ejemplo completo de eventos de una cuenta bancaria

Veamos los eventos modelados como tipos sellados de Java:

// Interfaz sellada: solo estos tipos pueden ser eventos de cuenta
public sealed interface EventoCuenta
        permits CuentaAbierta, DineroIngresado, DineroRetirado {
    String cuentaId();
    Instant ocurridoEn();
}

public record CuentaAbierta(String cuentaId, BigDecimal saldoInicial,
                            Instant ocurridoEn) implements EventoCuenta {}

public record DineroIngresado(String cuentaId, BigDecimal importe,
                              Instant ocurridoEn) implements EventoCuenta {}

public record DineroRetirado(String cuentaId, BigDecimal importe,
                             Instant ocurridoEn) implements EventoCuenta {}

Y el lado de escritura validando una regla de negocio antes de emitir el evento:

public class Cuenta {
    // ... estado y rehidratación de antes ...

    // COMANDO: retirar dinero. Valida y DEVUELVE el evento resultante.
    public DineroRetirado retirar(BigDecimal importe) {
        if (!abierta) throw new IllegalStateException("Cuenta cerrada");
        if (importe.compareTo(saldo) > 0)
            throw new SaldoInsuficienteException(id, importe, saldo);
        // La regla se cumple: generamos el evento (aún no muta el estado)
        return new DineroRetirado(id, importe, Instant.now());
    }
}

Puntos clave:

  • La interfaz sealed garantiza que el switch de aplicar (sección 3) cubra todos los casos posibles; si añades un evento nuevo, el compilador te obliga a tratarlo.
  • El método retirar valida la regla "no puedes retirar más de tu saldo" y, si pasa, genera el evento. El estado se actualizará cuando ese evento se aplique y persista. Así, escritura y validación quedan en el agregado.

Errores Comunes y Consejos

  • Aplicar Event Sourcing a todo el sistema. Es complejo. Resérvalo para los dominios donde la auditoría y el historial aporten valor real (finanzas, seguros, logística). Para un CRUD de configuración, es exagerado.
  • Cambiar el significado de un evento ya guardado. Los eventos son inmutables y eternos; debes versionarlos (PedidoCreadoV2) y mantener compatibilidad, no editarlos.
  • Olvidar los snapshots. Sin ellos, las entidades con miles de eventos se vuelven lentas de rehidratar.
  • Esperar consistencia inmediata en las lecturas. CQRS es eventualmente consistente; informa al usuario o usa estrategias de lectura tras escritura cuando sea crítico.
  • Consejo: empieza solo con CQRS (sin Event Sourcing) si únicamente necesitas escalar lecturas; añade Event Sourcing más adelante si necesitas el historial.

Ejercicios

  1. Tienes una cuenta con los eventos: CuentaAbierta(0), DineroIngresado(200), DineroRetirado(70), DineroIngresado(30). ¿Cuál es el saldo tras rehidratar? Explica el proceso.
  2. Explica por qué un evento debe expresar un hecho ya validado y no una orden. ¿Qué pasaría si guardáramos comandos en el event store en lugar de eventos?
  3. Diseña la tabla de lectura desnormalizada movimientos_vista que permita mostrar el extracto de una cuenta (fecha, tipo, importe, saldo resultante) sin reproducir eventos en cada consulta.

Soluciones

  1. Saldo = 0 + 200 − 70 + 30 = 160. Se parte de una cuenta vacía y se aplican los eventos en orden: CuentaAbierta fija 0, DineroIngresado(200) suma a 200, DineroRetirado(70) baja a 130, DineroIngresado(30) sube a 160.
  2. Un evento es un hecho consumado: ya pasó por las validaciones, así que reproducirlo nunca debe fallar. Si guardáramos comandos, al reproducir el historial tendríamos que volver a validar reglas que podrían haber cambiado, y un comando podría rechazarse al reproducirlo, rompiendo la reconstrucción determinista del estado.
  3. Por ejemplo:
CREATE TABLE movimientos_vista (
    id            BIGSERIAL PRIMARY KEY,
    cuenta_id     VARCHAR(64) NOT NULL,
    fecha         TIMESTAMP NOT NULL,
    tipo          VARCHAR(20) NOT NULL,   -- INGRESO / RETIRADA
    importe       NUMERIC(15,2) NOT NULL,
    saldo_resultante NUMERIC(15,2) NOT NULL
);
CREATE INDEX idx_mov_cuenta ON movimientos_vista (cuenta_id, fecha);

La proyección rellena saldo_resultante en cada evento, de modo que el extracto se consulta con un simple SELECT ... WHERE cuenta_id = ? ORDER BY fecha.

Conclusión

Event Sourcing convierte la secuencia de eventos en la fuente de verdad, ofreciendo auditoría total y la capacidad de reconstruir cualquier estado pasado mediante replay, optimizado con snapshots. CQRS separa el modelo de escritura del de lectura, permitiendo escalarlos por separado y alimentar el lado de lectura con proyecciones. Vimos que son potentes pero exigentes y que conviene aplicarlos con criterio.

En la siguiente lección, "Gestión de Transacciones Distribuidas: Patrón Saga", abordaremos un problema que aparece de forma natural en estos sistemas: cómo mantener la coherencia de una operación que abarca varios servicios cuando no podemos usar una transacción ACID tradicional.

Curso de Arquitectura de Aplicaciones

Módulo 1: Fundamentos de la Arquitectura de Aplicaciones

Módulo 2: Principios y Tácticas de Diseño

Módulo 3: Estilos y Patrones Arquitectónicos

Módulo 4: Arquitecturas Distribuidas y Microservicios

Módulo 5: Arquitecturas Dirigidas por Eventos y Mensajería

Módulo 6: Diseño Dirigido por el Dominio (DDD)

Módulo 7: Datos y Persistencia

Módulo 8: Arquitectura en la Nube y Despliegue

Módulo 9: Calidad, Seguridad y Observabilidad

Módulo 10: Evolución, Gobernanza y Casos Prácticos

© Copyright 2026. Todos los derechos reservados