El patrón CQRS fue descrito por Greg Young en 2010 como una forma de separar los modelos de escritura (commands) y de lectura (queries) de nuestras aplicaciones, permitiendo de este modo un escalado asimétrico entre los mecanismos de lectura y escritura de nuestra aplicación.
La idea básica es que podemos utilizar un modelo para escribir, distinto al modelo que usamos para ejecutar lecturas, como contrapunto al habitual CRUD (Create, Read, Update, Delete). Cabe decir que esta aproximación, la de CQRS, puede añadir una mayor complejidad que un sistema sencillo basado en CRUD.
Implementación de CQRS
Separación de los modelos
Podemos implementar CQRS de distintas maneras en función de nuestras necesidades.
Por ejemplo, podemos optar por mantener una única base de datos y realizar solo una separación a nivel de conexión, es decir, usaremos una conexión diferente para las operaciones de lectura (queries) y las de escritura (commands). Esto nos permiten configurar distintos parámetros como timeouts, tamaño de peticiones y otros parámetros más ajustados a la naturaleza de la operación.
Esta aproximación tiene el inconveniente de que la separación solo existe a un nivel lógico, es decir, seguimos atacando la misma base de datos, por lo que sigue siendo el mismo sistema el que asume la carga de trabajo.
Otra aproximación sería utilizar dos bases de datos separadas para escrituras y lecturas:
De este modo, la carga de las operaciones de lectura y escritura están más repartidas y no solo a un nivel lógico, sino que son dos sistemas diferentes los que manejan esta carga. El inconveniente de esta aproximación es que se genera una consistencia eventual, es decir, los modelos de lectura y escritura no serán consistentes en todo momento, sino que eventualmente existirán diferencias entre ellos, lo cual puede generar un problema en función de la naturaleza de los datos.
Implementación de los modelos
Al implementar las operaciones de lectura y escritura vamos a tener 3 nuevas parejas de actores:
- Query y QueryHandler.
Son, respectivamente, el modelo de lectura y su manejador. La query no es más que un DTO con toda la información necesaria para realizar la lectura (parámetros, filtros, paginación…). Por su parte el handler o manejador es el servicio encargado de obtener esa información y devolvérsela al controlador.
Por norma general, las operaciones de lectura son siempre síncronas.
La correspondencia de queries y handlers es 1:1, es decir, cada query tiene solo un handler, no puede ser manejado por más de uno.
- Command y CommandHandler.
Son respectivamente el modelo de escritura y su manejador. El command no es más que un DTO con toda la información necesaria para realizar la escritura (identificador del registro(s) a editar, atributos a modificar…). Por su parte el handler o manejador es el servicio encargado de persistir esa información.
Las operaciones de escritura pueden ser síncronas o asíncronas, lo cual representa una ventaja en términos de eficiencia, ya que podemos simplemente lanzar la acción de actualización sin esperar a que ésta acabe: el frontend ya tiene los datos actualizados, ya que es él quien nos los ha mandado.
Debemos de tener, eso sí, mecanismos para reaccionar de cara a problemas de escritura de datos asíncronos (sistema de reintentos, notificaciones al frontend vía server push o sockets…).
La correspondencia de commands y handlers es 1:1, es decir, cada command tiene solo un handler, no puede ser manejado por más de uno.
- Event y EventHandler.
En este caso son, respectivamente, el modelo de algo que ha sucedido en nuestro sistema y su manejador. El event no es más que un DTO con la información de lo que ha sucedido, normalmente se compone de una fecha o timestamp de cuándo ha ocurrido, un nombre descriptivo, siempre en pasado, de lo sucedido (por ejemplo: user_deleted) y un cuerpo o payload con toda la información relevante para manejar el evento. Este payload puede componerse de datos necesarios para identificar el registro afectado, los atributos que se han modificado… etc.
Al igual que los commands, los eventos pueden manejarse de forma síncrona o asíncrona, asegurándonos de tomar las mismas precauciones para gestionar posibles errores.
Al contrario que en los casos anteriores, la correspondencia de eventos y handlers (o listeners) es 1:n, es decir, cada event tiene más de un handler o listener.
Un caso de uso muy común de los eventos es eliminar la consistencia eventual producida en sistemas con bases de datos separadas: tras realizarse una actualización, eliminación o inserción de datos en la base de datos de escritura, un evento avisa de los cambios para que estos se repliquen en la base de datos de lectura.
Manejo síncrono o asíncrono de los modelos
Para permitir el manejo síncrono o asíncrono de los modelos solemos hacer un uso de un sistema intermedio que se encarga de emparejar los commands, las queries y los events con sus respectivos handlers. Este sistema intermedio es lo que llamamos un bus. El bus es como una cola donde metemos los commands, queries o events y estos son ejecutados por sus handlers asociados. Este bus puede implementarse con sistemas como Message Queues Systems (RabbitMQ) o sistemas como Redis que permiten el encolado de tareas.
La ventaja del uso de buses en forma de colas de mensajes es que añadimos una capa de indirección entre la acción y la ejecución. Esto quiere decir que la tarea encolada puede ser consumida por nuestro propio sistema o por un sistema externo que se conecte al servicio de colas, permitiéndonos esto el tener sistemas externos encargados de manejar, por ejemplo, la consistencia eventual, o delegar tareas largas y pesadas a sistemas expertos en realizar estas mismas tareas, que puede que ni siquiera estén escritos en el mismo lenguaje que nuestro sistema principal.
Veamos un ejemplo de como nuestro sistema podría manejar la consistencia eventual (sin el uso de agentes externos):
En el ejemplo de arriba vemos como el command handler dispara un evento al bus de eventos. Este evento puede ser la notificación de que se ha escrito un nuevo registro, o que se ha editado o eliminado uno que ya existía previamente. Puede representar más cosas, como que se ha suscrito a un servicio, que ha hecho like a una foto… lo que sea. El tema es que este evento es capturado por el handler asociado y actúa en consonancia actualizando la base de datos de lectura, tratando de reducir al máximo el tiempo de consistencia eventual.
Ventajas de CQRS
- Escalado asimétrico.
En entornos de alta disponibilidad, donde se producen miles de interacciones por segundo y hay flujos con más tráfico que otros, el escalado asimétrico de nuestra plataforma es algo realmente deseable, ya que podemos centrar el esfuerzo económico en esa parte únicamente. En el caso de CQRS, el tener separados los dominios de escritura y los de lectura nos permite centrar nuestros esfuerzos económicos en el que requiera mayor inversión.
¿Tenemos un proyecto tipo red social donde la gente se pasa más tiempo leyendo contenido que produciéndolo? Incrementamos las instancias de lectura, escalamos las instancias… lo que sea necesario, pero sólo centrado en las de lectura.
¿Nuestro proyecto es como un API en la que sensores a lo largo de cientos de instalaciones escriben registros cada segundo o minuto? Nos centramos en escalar la escritura, ya que, seguramente, haya poca gente consultando el panel de lecturas si lo comparamos con el número de escrituras. - Separación de dominios más semántica.
Esta ventaja es más a nivel organizativo, pero el hecho de tener separados nuestros casos de uso entre lecturas y escrituras simplifica mucho el entendimiento del mismo. Por ejemplo, sabemos seguro que una lectura no va a tener side effects o que podemos ejecutarla tantas veces como queramos sin repercusiones. Luego, la separación de los side effects que puedan producir las escrituras, al manejarse con eventos nos permite centrar el caso de uso en su propósito único, y delegar estos side effects a otras porciones de código, quedando todo mucho más limpio y organizado.
Inconvenientes de CQRS
- Complejidad.
El hecho de tener que implementar CQRS incrementa notablemente la complejidad de nuestro sistema, tanto por tener el doble de configuraciones o instancias de base de datos, como por la necesidad del uso de buses (aunque esto es realmente opcional). Si no estamos acostumbrados a trabajar por capas puede ser un cambio realmente brusco, aunque a medio/largo plazo la situación mejora ostensiblemente.
Un problema derivado de la complejidad es que necesitamos un equipo senior formado para diseñar nuestra estrategia y montar un flow de trabajo que luego puedan seguir nuestros compañeros más juniors, pero un buen diseño puede derivar en un proceso casi mecánico para implantar nuevos casos de uso basados en commands y queries. - Consistencia eventual.
Éste es el verdadero gran inconveniente de CQRS, ya que, dependiendo de lo urgente que sea tener nuestros datos actualizados, puede ser una buena opción o no, debido a que tendremos que garantizar un tiempo mínimo para que sean consistentes ambas bases de datos. Luego derivado de esto pueden darse problemas con la entrega de datos de los buses, que pueden llegar repetidos o desordenados, y es algo que también tenemos que controlar.
A continuación veremos una técnica para facilitar la gestión de la consistencia eventual llamada Event Sourcing.
Consistencia eventual y Event Sourcing
Event Sourcing es un patrón o técnica que podemos utilizar para tratar de simplificar los problemas de consistencia eventual, así como añadir una serie de beneficios a nuestro sistema, tales como:
- Observabilidad. Sabemos porque un registro está en el estado que está.
- Tolerancia a fallos. Podemos reconstruir los registros a partir de los eventos guardados.
- Auditoria. Estrechamente relacionado con la observabilidad, nos permite auditar las acciones que tienen lugar en nuestro sistema y podemos determinar flujos de datos erróneos.
¿En qué consiste el Event Sourcing?
Es un patrón mediante el cual registramos en una base de datos separada o event store todos los eventos que se producen en nuestro sistema, de modo que formen un histórico de cambios asociado a un registro de nuestra base de datos. Esto nos permite reconstruir un registro a cualquier momento del flujo de vida de nuestro sistema.
Los eventos, por supuesto, deben de guardar la fecha en la que fueron lanzados, ya que esta es la que determinara su orden en la cadena. También es importante que guarden el identificador del registro de base de datos al que pertenecen, de modo que cuando queramos recrear un registro o desplazarnos hasta un momento en el tiempo, es necesario poder identificar el registro en cuestión.
El event sourcing solo se aplica en los modelos de escritura, ya que las lecturas no afectan al estado de los registros.
¿Qué nos aporta CQRS a nosotros?
Para nosotros CQRS ha sido todo un cambio para mejor, ya que nos ha permitido fijar un modo de trabajo común que simplifica mucho el entendimiento de los proyectos, y esto incluye cuando nos movemos entre proyectos en los que no hemos participado nunca.
Aunque de entrada se percibe como un sistema complejo (y lo es), las ventajas que nos aporta a nivel testabilidad, mantenibilidad y escalabilidad de nuestro código (así como la ya mencionada facilidad de rotación entre proyectos), compensan con creces los inconvenientes.
Cabe decir que en proyectos menores hemos tomado decisiones que simplifican todo o que anulan parte de los inconvenientes, como por ejemplo usar comandos síncronos o directamente eliminar los buses de queries y de commands, que borran de un plumazo gran parte de los problemas.
Adaptar los patrones a la necesidad de cada proyecto también es una decisión estratégica que hay que tomar, ya que debemos de estar seguros de sus implicaciones y su facilidad de revertir esas adaptaciones, ya que si un proyecto crece, puede que necesitemos esos extras que nos aporta el patrón original.