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
- La idea central de Event Sourcing
- El Event Store (almacén de eventos)
- Reconstrucción del estado (replay)
- CQRS: separar lectura y escritura
- Proyecciones y modelos de lectura
- Ejemplo completo de eventos de una cuenta bancaria
- Errores comunes y consejos
- Ejercicios y soluciones
- Conclusión
- 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:
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 |
- 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_idagrupa todos los eventos de una misma entidad. Para reconstruir la cuenta 42, leemos todos los eventos constream_id = 'cuenta-42'ordenados porversion.versiones el número de orden dentro del stream. La restricciónUNIQUE (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
UPDATEniDELETEsobre esta tabla; soloINSERT.
- 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:
rehidratarparte de unaCuentavacía y aplica cada evento secuencialmente. El resultado es el estado actual.- El método
aplicarusa 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.
- 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| RMVentajas 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.
- 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_vistaes 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.
- 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
sealedgarantiza que elswitchdeaplicar(sección 3) cubra todos los casos posibles; si añades un evento nuevo, el compilador te obliga a tratarlo. - El método
retirarvalida 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
- Tienes una cuenta con los eventos:
CuentaAbierta(0),DineroIngresado(200),DineroRetirado(70),DineroIngresado(30). ¿Cuál es el saldo tras rehidratar? Explica el proceso. - 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?
- Diseña la tabla de lectura desnormalizada
movimientos_vistaque permita mostrar el extracto de una cuenta (fecha, tipo, importe, saldo resultante) sin reproducir eventos en cada consulta.
Soluciones
- Saldo = 0 + 200 − 70 + 30 = 160. Se parte de una cuenta vacía y se aplican los eventos en orden:
CuentaAbiertafija 0,DineroIngresado(200)suma a 200,DineroRetirado(70)baja a 130,DineroIngresado(30)sube a 160. - 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.
- 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
- ¿Qué es la Arquitectura de Aplicaciones?
- El Rol del Arquitecto de Software
- Atributos de Calidad y Requisitos No Funcionales
- Decisiones Arquitectónicas y Compromisos (Trade-offs)
- Documentación de Arquitectura: Vistas y el Modelo C4
Módulo 2: Principios y Tácticas de Diseño
- Acoplamiento, Cohesión y Separación de Responsabilidades
- Principios SOLID Aplicados a la Arquitectura
- DRY, KISS, YAGNI y Otros Principios de Diseño
- Tácticas Arquitectónicas para los Atributos de Calidad
- Gestión de la Deuda Técnica
Módulo 3: Estilos y Patrones Arquitectónicos
- Arquitectura Monolítica
- Arquitectura en Capas (N-Tier)
- Arquitectura Cliente-Servidor
- Arquitectura Hexagonal (Puertos y Adaptadores)
- Arquitectura Limpia y Cebolla (Clean & Onion)
Módulo 4: Arquitecturas Distribuidas y Microservicios
- Introducción a los Sistemas Distribuidos
- Arquitectura de Microservicios
- Descomposición de Servicios y Bounded Contexts
- API Gateway, Service Discovery y Comunicación entre Servicios
- Patrones de Resiliencia: Circuit Breaker, Retry y Bulkhead
- El Teorema CAP y la Consistencia de Datos
Módulo 5: Arquitecturas Dirigidas por Eventos y Mensajería
- Fundamentos de la Arquitectura Orientada a Eventos
- Mensajería Asíncrona: Colas y Brokers
- Patrones de Eventos: Event Sourcing y CQRS
- Gestión de Transacciones Distribuidas: Patrón Saga
- Streaming de Datos en Tiempo Real
Módulo 6: Diseño Dirigido por el Dominio (DDD)
- Conceptos Fundamentales del DDD
- Diseño Estratégico: Bounded Contexts y Lenguaje Ubicuo
- Diseño Táctico: Entidades, Agregados y Repositorios
- Mapeo de Contextos (Context Mapping)
Módulo 7: Datos y Persistencia
- Estrategias de Persistencia: SQL vs NoSQL
- Patrones de Acceso a Datos: Repository, Unit of Work y DAO
- Base de Datos por Servicio y Gestión de Datos Distribuidos
- Caché y Estrategias de Invalidación
Módulo 8: Arquitectura en la Nube y Despliegue
- Fundamentos del Cloud Computing (IaaS, PaaS, SaaS)
- Contenedores y Orquestación con Docker y Kubernetes
- Arquitectura Serverless
- Patrones de Diseño Cloud-Native
- Infraestructura como Código (IaC)
Módulo 9: Calidad, Seguridad y Observabilidad
- Escalabilidad: Horizontal vs Vertical y Balanceo de Carga
- Alta Disponibilidad y Tolerancia a Fallos
- Seguridad por Diseño y Autenticación/Autorización
- Observabilidad: Logging, Métricas y Trazabilidad
- Rendimiento y Pruebas de Carga
