wandres.dev
EVENT SOURCING Y CQRS · el estado como log

Proyecciones: derivar el presente del pasado

Si el registro de eventos es la única fuente de verdad, el estado actual deja de ser algo que se guarda y pasa a ser algo que se calcula, y la operación que lo calcula es una proyección: un pliegue determinista que parte de un estado inicial y aplica en orden cada hecho del registro hasta llegar al ahora. Esta lección formaliza esa operación como el mismo reduce que ya conoces, muestra por qué el determinismo y la ausencia de efectos secundarios en el reductor son requisitos duros y no buenas prácticas, y explica la consecuencia liberadora del modelo: al ser el estado un derivado desechable, un mismo registro puede alimentar tantas proyecciones distintas como preguntas tenga el negocio, y cualquiera de ellas puede tirarse y recalcularse cuando cambia su forma. Después aborda el problema práctico inevitable —recorrer cien mil eventos para pintar una pantalla es inviable— y presenta los snapshots como la optimización canónica: un estado intermedio memorizado en una posición conocida del registro que permite arrancar el pliegue desde allí, con las precauciones de versionado que exige tratarlos como caché y nunca como verdad.

⏱ 19 min

La lección anterior dejó el estado actual sin hogar. Si lo único que se persiste son los hechos, alguien tiene que reconstruir el saldo, el carrito o el inventario cada vez que una pantalla los necesita, y esa reconstrucción tiene nombre propio: proyección. La buena noticia es que no vas a aprender un mecanismo nuevo, porque ya lo usas a diario sin llamarlo así. Una proyección es un pliegue, el mismo reduce que aplicas sobre un array: partes de un valor inicial, recorres la secuencia y aplicas una función que combina lo acumulado con el elemento siguiente. Lo asombroso es lo que esa banalidad desbloquea cuando la aplicas a un registro histórico completo. Como el estado ya no se guarda sino que se calcula, deja de haber un único estado legítimo: del mismo registro puedes derivar el saldo para la pantalla del cliente, el informe mensual para contabilidad y el modelo de riesgo para el departamento de fraude, tres formas distintas de leer los mismos hechos, ninguna más verdadera que las otras. Y como todas son desechables, cambiar la forma de una es tan barato como borrarla y volver a plegar. El precio, claro, es que plegar cien mil eventos para pintar un número es inviable, y de ahí nacen los snapshots. Esta lección construye las dos piezas y, sobre todo, la disciplina que las mantiene honestas.

🎯 Al terminar esta lección sabrás
  • Formalizar la proyección como un pliegue determinista y sin efectos secundarios sobre el registro de eventos.
  • Entender por qué un mismo registro admite múltiples proyecciones simultáneas y contradictorias en forma pero no en verdad.
  • Distinguir la reconstrucción a demanda de la proyección materializada y saber cuándo conviene cada una.
  • Aplicar snapshots como caché versionada y reconocer los fallos clásicos de tratarlos como fuente de verdad.

El pliegue: la operación que reconstruye el presente

Una proyección se define con dos ingredientes y ni uno más: un estado inicial, que es lo que la entidad era antes de que ocurriera nada, y un reductor, una función pura que recibe el estado acumulado y un evento, y devuelve el estado siguiente. Aplicar esa función en orden sobre todo el registro produce el estado actual. La formulación es tan compacta que resulta fácil subestimarla, pero de ella cuelgan dos exigencias que no son negociables. La primera es el determinismo: el reductor debe producir siempre la misma salida ante las mismas entradas, porque si no, reconstruir el estado hoy y mañana daría resultados distintos y la fuente de verdad dejaría de serlo. La segunda es la pureza: el reductor no puede llamar a la red, no puede leer el reloj, no puede consultar una base de datos ni generar identificadores aleatorios. Todo lo que necesita debe venir dentro del evento.

type Cuenta = { saldo: number; movimientos: number; abierta: boolean }

const inicial: Cuenta = { saldo: 0, movimientos: 0, abierta: false }

// Reductor puro: sin reloj, sin red, sin azar. Solo estado mas evento.
function aplicar(estado: Cuenta, e: Evento): Cuenta {
  switch (e.tipo) {
    case 'CuentaAbierta':
      return { saldo: e.saldoInicial, movimientos: 0, abierta: true }
    case 'DineroRetirado':
      return { ...estado, saldo: estado.saldo - e.cantidad, movimientos: estado.movimientos + 1 }
    case 'DineroIngresado':
      return { ...estado, saldo: estado.saldo + e.cantidad, movimientos: estado.movimientos + 1 }
  }
}

const proyectar = (eventos: readonly Evento[]) => eventos.reduce(aplicar, inicial)

Conviene reparar en que el pliegue no impone ninguna forma al estado que produce. Puede ser un objeto plano con tres campos, un mapa de identificadores, un acumulador estadístico o incluso un simple número. Lo único que la proyección exige es que exista un valor inicial y una operación que combine estado y hecho, que es exactamente la estructura algebraica de un monoide, y por eso el mismo mecanismo sirve tanto para reconstruir una cuenta bancaria como para contar cuántas veces ocurrió algo. Esa generalidad es la razón de que no haya un tipo de proyección sino tantos como preguntas, y de que añadir uno nuevo no cueste más que escribir una función.

Esa exigencia de pureza tiene una consecuencia que sorprende a quien viene de la programación por objetos: la marca de tiempo de un evento no la pone el reductor al reproducirlo, sino quien lo escribió en su día. Si el reductor llamase a Date.now, reproducir el registro en 2027 daría un estado distinto al de 2026 y la reconstrucción sería una ficción. La regla se resume así: todo lo que el estado necesite saber tiene que estar capturado en el evento en el momento de escribirlo, porque el pasado no se puede volver a consultar. Ese es también el motivo de que los eventos bien diseñados sean más gordos de lo que un programador ahorrativo escribiría: guardan el precio aplicado, no la referencia a una tabla de precios que mañana cambiará.

Un registro, muchas lecturas

Cuando el estado se guarda, solo hay uno y todo el mundo negocia sobre su forma; cuando el estado se deriva, cada consumidor puede tener el suyo. Este es el rendimiento más infravalorado del modelo. Sobre el mismo flujo de eventos puedes plegar una proyección minúscula que solo lleva el saldo, otra que reconstruye el extracto mes a mes y otra que cuenta cuántas veces el cliente estuvo en números rojos. Las tres son correctas simultáneamente, ninguna compite con las demás y ninguna obliga a modificar el registro para existir. Añadir una cuarta el mes que viene no requiere migrar nada: requiere escribir un reductor y plegar la historia que ya estaba ahí.

🧮

Proyeccion a demanda

Se pliega el flujo entero en el momento de responder. Es la opción más simple y siempre está fresca, porque no hay caché que invalidar. Solo es viable con flujos cortos.

🗃️

Proyeccion materializada

Un proceso escucha el registro y va actualizando una tabla de lectura conforme llegan eventos nuevos. Leer es instantáneo, a cambio de aceptar un desfase entre escritura y lectura.

🔁

Reconstruccion completa

Se tira la proyección entera y se pliega desde el evento cero. Es la operación que hace desechable al estado: corregir un error de cálculo es cambiar el reductor y volver a correr.

🧭

Proyeccion temporal

Se pliega solo hasta una posición del registro para obtener el estado tal como era en ese instante. Es la base del viaje en el tiempo que estudiarás en la lección cuatro.

flowchart LR
L[registro de eventos append only] --> P1[proyeccion saldo]
L --> P2[proyeccion extracto mensual]
L --> P3[proyeccion riesgo de fraude]
P1 --> V1[pantalla del cliente]
P2 --> V2[informe contable]
P3 --> V3[panel antifraude]
style L fill:#cba6f7,color:#11111b

Esa multiplicidad tiene además una consecuencia organizativa que se aprecia poco. Cuando el estado es único y guardado, cualquier equipo que necesite una forma distinta de los datos tiene que negociar un cambio de esquema con todos los demás, y esas negociaciones son lentas porque el riesgo es compartido. Cuando el estado es derivado, cada equipo escribe su propia proyección sin pedir permiso ni poner en peligro las ajenas, porque lo único común es el registro y el registro no se modifica. La independencia entre consumidores deja de depender de la buena voluntad y pasa a estar garantizada por la estructura.

La reconstrucción completa merece detenerse un segundo, porque es la operación que distingue este modelo de cualquier otro. En una arquitectura clásica, descubrir que llevas seis meses calculando mal una métrica es una catástrofe: los datos correctos nunca se guardaron y solo cabe estimar. Aquí es un martes cualquiera: arreglas el reductor, borras la proyección y la vuelves a plegar desde el principio, y el resultado es el que habría salido si el código hubiera estado bien desde el primer día. El estado es barato porque es derivado, y es derivado porque los hechos siguen ahí.

💡
Empieza siempre por la proyeccion a demanda

La tentación de materializar desde el primer día es fuerte y casi siempre prematura. Plegar el flujo entero en cada petición es correcto por construcción, no tiene desfase, no necesita proceso alguno y en flujos de unos cientos de eventos es imperceptible. Materializar es una optimización, y como toda optimización merece medirse antes de adoptarse, porque el día que la introduces heredas de golpe la posición del consumidor, la idempotencia y la ventana de inconsistencia.

Snapshots: no volver a contar desde el principio

El problema práctico llega solo. Un flujo con cien mil eventos no se pliega en cada petición sin que la latencia se note, y hay entidades longevas —una cuenta bancaria activa durante veinte años— cuyo registro solo crece. La solución canónica es el snapshot: memorizar el estado proyectado en una posición conocida del registro y, la próxima vez, arrancar el pliegue desde ese estado aplicando únicamente los eventos posteriores. Es una memorización clásica, y su corrección descansa por completo en el determinismo del reductor: si plegar es determinista, el estado guardado en la posición mil es indistinguible del que se obtendría plegando esos mil eventos otra vez.

type Snapshot = { estado: Cuenta; version: number; esquema: number }

const ESQUEMA_ACTUAL = 3

function proyectarDesde(snap: Snapshot | null, eventos: readonly Evento[]) {
  // Un snapshot de un esquema viejo se descarta: es cache, no verdad
  const usable = snap && snap.esquema === ESQUEMA_ACTUAL ? snap : null
  const base = usable ? usable.estado : inicial
  const desde = usable ? usable.version : 0
  return eventos.slice(desde).reduce(aplicar, base)
}

Hay un matiz de diseño que conviene fijar antes de seguir: el snapshot no debe guardarse dentro del propio registro de eventos, aunque técnicamente sea posible y aunque algunos motores lo faciliten. Mezclar hechos y resúmenes en la misma secuencia rompe la propiedad que sostiene todo el modelo, la de que cada entrada es algo que ocurrió, y abre la puerta a que un consumidor futuro confunda un resumen con un acontecimiento. Los hechos y sus resúmenes pertenecen a categorías distintas y merecen almacenes distintos, aunque compartan la misma base de datos.

La línea que descarta el snapshot obsoleto es la más importante del fragmento, y su ausencia es el error que más caro sale en producción. Un snapshot no es un hecho: es el resultado de haber ejecutado una versión concreta de tu reductor. En cuanto cambias la lógica de proyección —añades un campo, corriges un cálculo, reinterpretas un evento antiguo—, todos los snapshots existentes contienen el resultado del código viejo y son mentira. Por eso se versionan con el número de esquema del reductor y se descartan sin contemplaciones cuando no coincide. La regla mental que evita todos los desastres de esta familia es una sola: si borrases todos los snapshots del sistema ahora mismo, este debería seguir siendo correcto y solo volverse más lento. Si además se vuelve incorrecto, es que los estabas usando como fuente de verdad.

⚠️
El snapshot es cache y la cache miente por diseno

Guardar snapshots en el mismo almacén que los eventos, sin distinguirlos, invita a que alguien los lea como si fueran hechos. Manténlos aparte, márcalos con la versión del reductor, no los repliques a otros sistemas y prográmate la disciplina de borrarlos todos al menos una vez en un entorno de prueba para verificar que el sistema se recupera. Un snapshot que no puedes tirar sin miedo ha dejado de ser una optimización y se ha convertido en deuda.

Queda por decidir cada cuánto tomarlos, y aquí no hay número mágico sino un compromiso claro. Un snapshot muy frecuente hace las lecturas rapidísimas y multiplica el almacenamiento y el trabajo de escritura; uno muy espaciado ahorra espacio y deja colas largas de eventos que plegar. La heurística habitual es tomar uno cada cierto número fijo de eventos por flujo, con un umbral que se ajusta midiendo, y guardar solo el más reciente de cada entidad, porque los anteriores no aportan nada que el registro no pueda regenerar. Si además necesitas snapshots intermedios para viajar en el tiempo con rapidez, eso ya no es una caché de lectura sino una función del producto, y conviene tratarla y presupuestarla como tal.

La disciplina del consumidor: posición e idempotencia

Cuando la proyección se materializa aparece una pieza que las versiones a demanda no tienen: un proceso que consume el registro de forma continua y va escribiendo en la tabla de lectura. Ese proceso necesita recordar por dónde iba, y esa memoria —la posición del consumidor, su checkpoint— es tan importante como el propio reductor. Si la posición se guarda antes de aplicar el efecto, un fallo entre ambos pasos se salta un evento y la proyección queda incompleta para siempre sin que nadie lo note. Si se guarda después, un fallo repite el evento cuando el proceso reinicia. Como no existe una tercera opción sin transacciones distribuidas, la elección práctica es la segunda, y con ella viene una obligación: la aplicación de un evento sobre la proyección debe ser idempotente.

// Guardar la posicion DESPUES del efecto, y hacer el efecto repetible
async function consumir(evento: Evento, pos: number) {
  await vista.aplicarIdempotente(evento, pos) // ignora pos ya vistas
  await checkpoint.guardar(pos)               // si falla aqui, se repite el paso
}

Esa obligación se resume en una frase que conviene memorizar: el consumidor garantiza al menos una entrega, y el efecto garantiza que repetir no duele. Repartida así, la responsabilidad es asumible por ambas partes; concentrada en una sola —pretender que la infraestructura entregue exactamente una vez— exige coordinación distribuida y acaba siendo más frágil que el problema que resolvía.

La forma más simple de conseguir esa idempotencia es guardar en la propia fila de la vista la última posición aplicada y descartar cualquier evento cuya posición sea igual o anterior. Es una comprobación trivial que ahorra clases enteras de errores irreproducibles, esos en los que un contador aparece inflado sin que nadie sepa explicar cuándo. Y hay un caso donde importa especialmente: cuando quieras reconstruir una proyección sin parar el sistema, la técnica habitual es levantar una segunda copia en paralelo, plegarla desde el evento cero mientras la vieja sigue sirviendo, y conmutar el tráfico cuando la nueva alcanza a la primera. Ese baile solo es seguro si repetir eventos es inofensivo.

📝
La proyeccion no decide nada

Una tentación frecuente es meter lógica de negocio en el reductor de una proyección: rechazar un evento, corregir un importe, decidir que un pedido no debería estar en ese estado. No lo hagas. Una proyección interpreta hechos, no los juzga, y su única obligación es ser un reflejo fiel del registro bajo una forma concreta. Si al proyectar descubres que un hecho no debería haber ocurrido, el problema está en la validación del lado que lo escribió, y ahí es donde hay que arreglarlo.

Cuando el estado es derivado, el estado deja de dar miedo

Hay una ansiedad silenciosa en toda arquitectura que persiste el estado actual, y es la de la corrupción irreversible. Si la fila está mal, está mal para siempre: hay que adivinar el valor correcto, escribir una migración quirúrgica y rezar. Esa ansiedad explica media industria del software —los procedimientos de copia de seguridad, el pánico a las migraciones, la resistencia a cambiar un cálculo que lleva años funcionando mal— y desaparece por completo en cuanto el estado pasa a ser una proyección. Un derivado no puede corromperse de forma irreversible, porque no contiene información: la contiene el registro. Si la proyección está mal, la tiras. Si su forma ya no sirve, la cambias y vuelves a plegar. Si necesitas una nueva, la escribes hoy y plegas la historia de los últimos cinco años esta noche, obteniendo respuestas sobre un pasado en el que nadie había imaginado la pregunta. Eso convierte el pliegue en algo más que una función de utilidad: lo convierte en el mecanismo que separa la información de su interpretación. Los eventos son información y son sagrados. Las proyecciones son interpretación y son desechables. Casi todo el dolor que has sufrido con esquemas y migraciones venía de haber mezclado ambas cosas en la misma tabla, de haber tratado una interpretación concreta —la que se te ocurrió el día que diseñaste el modelo— como si fuera el dato mismo. Separarlas es lo que compra la libertad de cambiar de opinión sobre el pasado sin haberlo perdido.

⚔️ Pliega la historia y luego cambia de opinion sobre ella
  1. Implementa aplicar y proyectar del ejemplo y comprueba con una secuencia de veinte eventos que el saldo derivado coincide con el esperado.
  2. Introduce deliberadamente una llamada a Date.now dentro del reductor y demuestra con dos ejecuciones separadas por qué eso rompe la reconstrucción.
  3. Escribe una segunda proyección sobre el mismo registro —por ejemplo, el número de veces que el saldo quedó en negativo— sin tocar ni un evento.
  4. Añade snapshots con número de esquema. Cambia luego el reductor, sube el esquema y verifica que los snapshots viejos se descartan solos.
  5. Borra todas las proyecciones y snapshots y reconstruye desde cero. Mide el tiempo y decide a partir de qué tamaño de flujo el snapshot deja de ser opcional.
  6. Corrige un error de cálculo en el reductor y demuestra que la reconstrucción produce el estado que habría existido si el código hubiera sido correcto desde el principio.
  7. Escribe un consumidor con posición persistida, provoca un fallo entre el efecto y el guardado del checkpoint y comprueba que la repetición no altera el resultado.
  8. Reconstruye una proyección en paralelo mientras la antigua sigue sirviendo y describe el instante exacto en que conmutarías el tráfico.