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

CQRS: dos modelos para dos trabajos

Escribir y leer son actividades con exigencias opuestas que la arquitectura tradicional obliga a compartir un mismo modelo de datos, y ese compromiso produce esquemas que no sirven bien a ninguna de las dos: normalizados hasta la incomodidad para proteger la consistencia de las escrituras, y luego rescatados con vistas, cachés y consultas de siete uniones para que las lecturas sean tolerables. CQRS —la segregación de responsabilidades entre comandos y consultas— rompe ese compromiso permitiendo que el modelo de escritura y el de lectura sean estructuras distintas, con esquemas distintos y hasta motores distintos, unidas por un flujo de propagación en una sola dirección. Esta lección distingue el principio original de Meyer sobre métodos que hacen o preguntan pero no ambas cosas, de su versión arquitectónica; recorre el camino completo de un comando desde su validación contra el agregado hasta la actualización de las vistas; y explica por qué event sourcing y CQRS aparecen casi siempre juntos sin ser lo mismo: el registro de eventos es el mecanismo de propagación que CQRS necesitaba, y las proyecciones son literalmente sus modelos de lectura.

⏱ 19 min

Piensa en las dos cosas que tu aplicación hace con los datos y en lo poco que se parecen. Cuando escribe, necesita proteger invariantes: comprobar que el saldo alcanza, que el usuario tiene permiso, que el pedido no estaba ya confirmado. Trabaja sobre una entidad cada vez, exige consistencia estricta y agradece un esquema normalizado que impida contradicciones. Cuando lee, necesita justo lo contrario: juntar datos de media docena de entidades, ordenarlos, agregarlos y devolverlos en la forma exacta que pide una pantalla, con la máxima velocidad y sin importarle demasiado que el número que muestra tenga doscientos milisegundos de retraso. Son dos oficios distintos, y durante décadas los hemos obligado a compartir una única tabla, un único esquema y un único motor. El resultado lo conoces: modelos normalizados que las consultas castigan con uniones interminables, desnormalizaciones defensivas que ponen en riesgo la consistencia, y una capa de cachés encima intentando reconciliar una tensión que era estructural. CQRS propone la solución obvia que casi nadie se atreve a considerar: si son dos trabajos, que haya dos modelos. Uno optimizado para decidir y otro para mostrar, conectados en un solo sentido. Y una vez planteado así, se entiende por qué event sourcing aparece siempre a su lado.

🎯 Al terminar esta lección sabrás
  • Separar el principio CQS de Meyer sobre métodos de su generalización arquitectónica en CQRS.
  • Describir el recorrido completo de un comando desde su validación hasta la actualización del modelo de lectura.
  • Justificar por qué el modelo de lectura puede desnormalizarse sin peligro y por qué puede haber varios.
  • Explicar la simbiosis entre event sourcing y CQRS sin confundir ambos patrones ni asumir que uno exige al otro.

De un principio sobre métodos a una decisión de arquitectura

La semilla es antigua y modesta, y merece contarse con su nombre porque explica de dónde viene la sigla. Bertrand Meyer formuló la separación entre comando y consulta como una regla de estilo: un método o cambia el estado y no devuelve nada, o devuelve algo y no cambia nada, pero nunca las dos cosas. La razón es que una función que responde y muta a la vez es imposible de razonar, porque preguntar tiene consecuencias. Esa regla es CQS, opera en la escala de un método y casi nadie discute su bondad. CQRS es lo que ocurre cuando alguien decide aplicarla no a un método sino a un sistema entero: si escribir y leer son responsabilidades distintas, quizá no deban compartir el mismo modelo de datos.

Vale la pena entender por qué el compromiso tradicional duele tanto, porque el dolor es estructural y no fruto de un mal diseño puntual. Un esquema normalizado optimiza para que no exista ningún estado contradictorio, y lo consigue repartiendo la información en muchas tablas pequeñas con una única copia de cada dato. Esa misma virtud es lo que castiga las lecturas, porque componer la pantalla exige volver a juntar lo que el modelo separó, y hacerlo en cada petición. Las soluciones habituales —vistas materializadas, columnas denormalizadas, cachés— son todas concesiones parciales al lado de la lectura hechas dentro de un modelo que fue diseñado para el otro lado. CQRS se limita a dejar de fingir que un solo modelo puede servir a dos amos.

El salto de escala cambia la naturaleza de la idea. En la escala del método, separar es gratis. En la escala de la arquitectura, separar significa aceptar dos representaciones del mismo dominio que pueden diferir en forma, en esquema, en tecnología y —esto es lo incómodo— en instante. El modelo de escritura es la autoridad: valida, decide y produce los cambios. El modelo de lectura es un derivado: recibe esos cambios y se acomoda para responder rápido a preguntas concretas. El flujo va siempre en un solo sentido, de la escritura a la lectura, y jamás al revés. Una consulta que corrigiera el modelo de escritura sería exactamente el tipo de acoplamiento que el patrón existe para prohibir.

flowchart LR
U[usuario] -->|comando| W[modelo de escritura]
W -->|valida invariantes| E[eventos o cambios]
E -->|propagacion en un solo sentido| R1[modelo de lectura pantalla]
E --> R2[modelo de lectura informes]
U -->|consulta| R1
style W fill:#f9e2af,color:#11111b
style R1 fill:#a6e3a1,color:#11111b

El recorrido de un comando

Conviene además nombrar bien las piezas antes de recorrerlas, porque el vocabulario impreciso es la causa de la mitad de las discusiones sobre este patrón. Un comando es una petición con destinatario único que puede fracasar. Una consulta es una pregunta que no altera nada y que puede responderse con datos algo antiguos. Un evento es la constancia de que un comando prosperó. Cuando alguien dice que envía un evento para pedir algo, está mezclando las tres cosas, y esa mezcla acaba produciendo sistemas donde nadie sabe quién tiene la autoridad para decir que no.

Seguir un comando de punta a punta es la mejor forma de fijar el patrón, porque cada etapa tiene una responsabilidad que no comparte con ninguna otra. Llega una intención del usuario, expresada como comando y nombrada en imperativo. El lado de escritura carga el estado de la entidad afectada —el agregado, en el vocabulario del diseño dirigido por el dominio— y comprueba sus invariantes contra ese estado y no contra la pantalla del usuario, que puede estar desfasada. Si la comprobación pasa, se producen uno o más hechos. Esos hechos se propagan al lado de lectura, que actualiza las vistas que le competen. Y las consultas posteriores leen esas vistas sin tocar jamás el modelo de escritura.

type Comando = { tipo: 'RetirarDinero'; cuenta: string; cantidad: number }

// Lado escritura: decide contra el estado autoritativo, no contra la vista
function decidir(estado: Cuenta, c: Comando): Evento[] {
  if (!estado.abierta) throw new Error('cuenta cerrada')
  if (c.cantidad > estado.saldo) throw new Error('saldo insuficiente')
  return [{ tipo: 'DineroRetirado', cantidad: c.cantidad, en: new Date().toISOString() }]
}

// Lado lectura: tabla plana, desnormalizada, hecha para una pantalla concreta
type VistaCuenta = { cuenta: string; saldo: number; titular: string; ultimoMovimiento: string }

Hay un detalle de esa secuencia que suele pasarse por alto y que define la calidad de una implementación: el lado de escritura valida contra el estado que él mismo carga, nunca contra los datos que el cliente le envía. El navegador puede mandar el saldo que cree recordar, y ese número es una opinión desfasada, no una premisa. Esta desconfianza deliberada es lo que permite que la interfaz sea rápida y optimista sin comprometer la corrección: la pantalla adelanta lo que probablemente pasará, y el lado de escritura decide lo que realmente pasa. Cuando ambos discrepan, gana el segundo y la interfaz se corrige, que es exactamente el reparto de responsabilidades que uno querría.

Observa la asimetría del código. La función que decide es diminuta, densa en reglas y no sabe nada de pantallas. La estructura de lectura es ancha, plana y contiene el nombre del titular duplicado, un dato que en el modelo de escritura vive en otra entidad. Esa duplicación, que en un esquema normalizado sería un defecto grave, aquí es correcta por construcción: nadie escribe sobre la vista salvo el proceso de propagación, de modo que no puede haber dos verdades en conflicto. Solo hay una verdad —la del lado de escritura— y copias suyas moldeadas para responder rápido. La normalización protege contra actualizaciones descoordinadas; si nada actualiza la vista salvo un único flujo, la protección deja de hacer falta.

🛡️

Modelo de escritura

Normalizado, pequeño, orientado a invariantes. Carga una entidad cada vez, valida y emite cambios. Su métrica de éxito es no permitir jamás un estado imposible.

Modelo de lectura

Desnormalizado y a menudo uno por pantalla. Sin lógica de negocio, sin validaciones, sin uniones costosas. Su métrica de éxito es responder en un acceso.

➡️

Propagacion unidireccional

Los cambios viajan de la escritura a la lectura y nunca al revés. Ese sentido único es lo que hace el sistema razonable y lo que impide el acoplamiento circular.

🧰

Motores distintos

Nada obliga a que ambos lados usen la misma tecnología. Es habitual una base relacional para escribir y un índice de búsqueda o un almacén de documentos para leer.

Esa asimetría también reordena dónde vive la complejidad del sistema, y el cambio es saludable. En una arquitectura de modelo único la lógica se dispersa: hay reglas en el servicio, reglas en los disparadores de la base de datos, reglas escondidas en consultas que filtran lo que no debería mostrarse y reglas replicadas en el cliente para evitar un viaje al servidor. Al separar, todo lo que decide se concentra en un lugar pequeño y bien delimitado, y todo lo que muestra queda sin lógica alguna. Cuando alguien pregunta dónde se comprueba que un pedido no se puede confirmar dos veces, hay exactamente un sitio donde mirar, y esa propiedad vale más en el mantenimiento a cinco años que cualquier ganancia de rendimiento.

💡
Una vista por pantalla no es despilfarro

El instinto formado en modelos normalizados se resiste a crear una tabla de lectura por cada pantalla, porque parece duplicación gratuita. Merece la pena vencerlo. Esas tablas no contienen información propia, solo copias derivadas que un proceso mantiene, así que no pueden desincronizarse entre sí más allá del desfase común. Y a cambio, cada pantalla se sirve con un acceso directo, cambiar el diseño de una no afecta a las demás y borrar una función del producto es borrar su vista sin tocar nada más.

⚠️
CQRS no significa dos bases de datos desde el primer dia

La versión más común del patrón en la práctica es también la más sobria: una sola base de datos, dos conjuntos de modelos en el código y la disciplina de que las consultas no atraviesen el modelo de escritura. Separar los almacenes es una decisión posterior que se toma cuando las cargas de lectura y escritura divergen de verdad, y trae consigo despliegue, monitorización y desfase. Adoptar el patrón entero con dos infraestructuras el primer día es la forma más rápida de pagar todo su coste sin haber ganado aún ninguno de sus beneficios.

Por qué viajan juntos y por qué no son lo mismo

La independencia tecnológica que menciona la última tarjeta suele malinterpretarse como una invitación a la exuberancia, y conviene acotarla. Que se pueda usar un motor distinto para leer no significa que convenga hacerlo desde el principio, porque cada almacén añadido trae despliegue, copias de seguridad, monitorización y un modo de fallo nuevo. La secuencia sensata es adoptar primero la separación conceptual dentro de un mismo almacén, medir dónde duele de verdad y solo entonces, si una carga de lectura concreta lo exige, mover esa vista a un motor especializado. La arquitectura se gana por acumulación de decisiones justificadas, no por adoptar de golpe el diagrama completo que aparece en las presentaciones.

flowchart TD
A[una sola base y modelos mezclados] --> B[modelos separados en el codigo]
B --> C[vistas materializadas en el mismo almacen]
C --> D[almacen de lectura dedicado]
D --> E[varios almacenes por tipo de consulta]
style B fill:#a6e3a1,color:#11111b
style E fill:#f38ba8,color:#11111b

Es fácil confundirlos porque casi siempre aparecen en la misma frase, así que conviene decirlo sin rodeos: son patrones independientes. Puedes hacer CQRS sin event sourcing, guardando el estado actual en el lado de escritura y propagando notificaciones de cambio a las vistas. Puedes hacer event sourcing sin CQRS, plegando el registro para responder a cualquier consulta sin construir modelos de lectura separados. Ambas combinaciones existen y funcionan. Lo interesante es entender la afinidad que hace que la mayoría de sistemas serios acaben adoptando los dos.

Esa independencia tiene además valor pedagógico, porque permite adoptar los patrones por separado y en el orden que convenga. Un equipo que sufre sobre todo por consultas lentas y esquemas peleados empezará por CQRS y quizá nunca necesite el registro. Un equipo que sufre por falta de trazabilidad empezará por event sourcing en un agregado concreto y proyectará a demanda sin construir modelos de lectura. Plantearlo como un paquete indivisible es lo que hace que muchos equipos rechacen ambos a la vez, cuando probablemente les habría bastado con la mitad.

La afinidad es que cada uno resuelve el punto débil del otro. CQRS necesita un mecanismo fiable, ordenado y completo para propagar los cambios del lado de escritura al de lectura; sin él, tienes dos modelos y ninguna garantía de que el segundo refleje al primero. El registro de eventos es exactamente ese mecanismo, y trae la ordenación y la reproducibilidad de serie. En la dirección contraria, event sourcing plantea un problema de consulta: plegar el registro entero cada vez que alguien abre una pantalla no escala, y algunas preguntas cruzan varios flujos y no se contestan plegando uno solo. Los modelos de lectura de CQRS son la respuesta, y no son otra cosa que las proyecciones materializadas de la lección anterior. Ahí está la coincidencia que cierra el círculo: la pieza que CQRS llama modelo de lectura y la que event sourcing llama proyección son el mismo objeto visto desde dos tradiciones distintas.

Vale la pena señalar también qué se rompe si se rompe el sentido único de la flecha, porque es el error de principiante más caro. Basta con que una validación del lado de escritura consulte el modelo de lectura para que todo el edificio se venga abajo. La razón es que la vista está por definición desfasada, de modo que estarías decidiendo si el saldo alcanza con un número que pudo quedar obsoleto hace medio segundo, y en cuanto haya concurrencia real ese hueco produce estados imposibles. La regla es tajante y sin excepciones prácticas: el lado de escritura solo confía en lo que él mismo ha cargado y validado, y trata la vista como lo que es, una copia con retraso destinada a ojos humanos.

El agregado: dónde se dibuja la frontera

Dibujar bien esa frontera es probablemente la decisión más difícil y más consecuente de todo el diseño, y es también la que ninguna herramienta puede tomar por ti. Si la consistencia estricta solo se aplica en el lado de escritura, la pregunta inmediata es hasta dónde llega ese lado, y la respuesta es el concepto más útil que el diseño dirigido por el dominio ha aportado a esta conversación: el agregado. Un agregado es el conjunto mínimo de datos que debe estar consistente en el mismo instante porque comparten una invariante que no puede violarse jamás. Una cuenta y su saldo forman un agregado, porque la regla de saldo suficiente los une. Un pedido y sus líneas también, si el total debe cuadrar con la suma. Pero una cuenta y el perfil de su titular no lo forman, porque ninguna regla de negocio se rompe si el nombre tarda un segundo en propagarse.

// Un comando toca un unico agregado y se serializa contra su version
async function ejecutar(c: Comando) {
  const eventos = await almacen.leer(c.cuenta)
  const estado = eventos.reduce(aplicar, inicial)
  const nuevos = decidir(estado, c)
  // Version esperada: si otro escribio entretanto, se rechaza y se reintenta
  await almacen.anexar(c.cuenta, nuevos, { versionEsperada: eventos.length })
}

Conviene notar que ese esquema resuelve la concurrencia sin necesidad de bloqueos porque el registro es de solo anexado: dos escritores que compiten no pueden pisarse mutuamente, solo pueden llegar tarde. El que llega tarde recibe un rechazo limpio, vuelve a cargar el flujo, vuelve a decidir con la información actualizada y reintenta. Es el mismo control de versión optimista que usa cualquier interfaz web bien construida, aplicado aquí con una ventaja añadida: como el estado se deriva del flujo, recargar y volver a decidir es barato y siempre correcto.

Esa versión esperada es el mecanismo entero de concurrencia del modelo, y su sencillez es notable: no hay bloqueos, solo un control optimista que rechaza la escritura si el flujo cambió mientras decidías. Diseñar bien los agregados consiste en hacerlos lo más pequeños posible sin partir una invariante en dos, y ese equilibrio tiene consecuencias directas que se notan en producción y no en el diagrama. Un agregado demasiado grande convierte cualquier operación en un cuello de botella, porque todas las escrituras de esa zona compiten por la misma versión. Un agregado demasiado pequeño deja una invariante repartida entre dos flujos que ya no se pueden validar juntos, y esa regla dejará de cumplirse tarde o temprano.

📝
Todo lo que cruza agregados es eventualmente consistente

La frontera del agregado es también la frontera de la transacción. Cualquier regla que necesite coordinar dos agregados —transferir dinero entre cuentas, reservar inventario para un pedido— no se resuelve con una transacción que los abarque, sino con un proceso que reacciona a los eventos del primero y emite comandos al segundo, con compensación si algo falla. Ese patrón se llama saga, y es la forma de admitir que la atomicidad tiene un límite y que más allá de él solo cabe la coordinación explícita.

La consistencia no es un valor absoluto: es un presupuesto que se asigna

Lo que CQRS descubre, y lo que cuesta aceptar, es que la consistencia estricta que tu sistema aplica uniformemente a todo no es una virtud sino un gasto, y que gastarlo por igual en todas partes es mala administración. Hay lugares donde la consistencia es innegociable porque su violación produce un estado imposible: dos reservas del mismo asiento, un saldo negativo donde no puede haberlo, un pedido confirmado dos veces. Ese lugar es el lado de escritura, es pequeño, y ahí se paga lo que haga falta —bloqueos, transacciones, control de versión optimista— porque la alternativa es que el negocio quede en un estado que ninguna regla permite. Y hay muchísimos otros lugares donde la consistencia estricta es un lujo sin retorno: el contador de notificaciones, el listado de resultados, el panel de métricas, el nombre del titular junto al saldo. Que esa información tenga trescientos milisegundos de retraso no rompe absolutamente nada, salvo la expectativa de un ingeniero que nunca se preguntó cuánto costaba esa expectativa. CQRS es, mirado de frente, la infraestructura que te permite asignar tu presupuesto de consistencia con criterio en lugar de por defecto: máxima donde el dominio la exige, mínima donde el dominio no la necesita. Y esa decisión no es técnica sino de negocio, lo cual explica que sea imposible tomarla bien desde un diagrama de tablas y que solo pueda tomarse hablando con quien conoce las reglas. La arquitectura, aquí, no es una elección de herramientas: es la traducción a código de una conversación sobre qué cosas de tu dominio no pueden estar mal ni un instante y cuáles pueden esperar.

⚔️ Parte en dos un modelo que hoy es uno solo
  1. Toma una pantalla real de un proyecto tuyo y escribe la consulta que la alimenta. Cuenta las uniones y estima qué forma tendría una vista plana hecha a su medida.
  2. Enumera los comandos de ese mismo dominio y, para cada uno, escribe la invariante que el lado de escritura debe proteger. Marca cuáles no pueden violarse ni un instante.
  3. Clasifica cinco datos que tu interfaz muestra según cuánto desfase toleran: cero, un segundo o un minuto. Justifica cada respuesta con el negocio y no con la intuición.
  4. Implementa decidir con dos comandos y comprueba que valida contra el estado cargado y no contra los datos que envía el cliente.
  5. Escribe el proceso de propagación que actualiza VistaCuenta a partir de los eventos emitidos y verifica que la vista nunca escribe hacia atrás.
  6. Argumenta en un párrafo si tu proyecto necesita separar los almacenes o si le basta con separar los modelos dentro del mismo.
  7. Dibuja los agregados de tu dominio buscando el tamaño mínimo que no parta ninguna invariante, y señala cuál sería el cuello de botella de cada uno.
  8. Encuentra una regla de tu negocio que cruce dos agregados y diseña la saga que la coordina, incluyendo qué compensa cada paso si falla el siguiente.