Aclarando CQRS [traducción]

Artículo original de Udi Dahan, 2009. Traducido por Andrea Magnorsky, Carlos Peix y Pablo Núñez.

Después de ver cómo la comunidad ha interpretado CQRS (Separación de Responsabilidades con Comandos y Consultas) creo que ha llegado la hora de hacer algunas aclaraciones. Algunos lo han relacionado con Event Sourcing. La mayoría han superpuesto sus ideas previas de  arquitecturas de capas sobre el nuevo patrón. Aquí espero identificar CQRS en sí mismo, y describir en qué puntos puede conectarse con otros patrones.

¿Por qué CQRS?

Antes de describir los detalles de CQRS necesitamos entender sus dos principales potencias conductoras: la colaboración y la obsolencia.

Colaboración se refiere a circunstancias bajo las cuales múltiples actores usarán o modificarán los mismos datos (tengan la intención de colaborar entre ellos o no). A menudo hay reglas que indican qué usuario puede realizar qué tipo de modificación y modificaciones que podrían ser aceptables en un caso pueden no serlo en otros. Expondremos algunos ejemplos en breve. Los actores pueden ser humanos, como los usuarios normales, o automáticos, como otro software.

Obsolencia se refiere al hecho de que en un entorno colaborativo, una vez que los datos han sido mostrados al usuario esos mismos datos pueden haber sido modificados por otro actor, en otras palabras la información puede potencialmente ser obsoleta. Cualquier sistema que haga uso de una caché está sirviendo datos obsoletos, a menudo por una cuestión de eficiencia. Lo que esto significa es que no podemos confiar completamente en las decisiones de nuestros usuarios, ya que podrían estar tomadas en base a información desactualizada . O sea, el hecho que la información es obsoleta no es completamente obvia.

Las arquitecturas de capas estándar no tratan explícitamente con ninguno de estos problemas (colaboración y obsolencia ). Mientras que compartirlo todo en una misma base de datos podría ser un paso en la dirección correcta para tratar la Colaboración,  la obsolescencia es normalmente más acusado en esas arquitecturas por el uso de cachés cuya intención es mejorar el rendimiento.

Una imagen como referencia

He dado algunas charlas sobre CQRS usando este diagrama para explicarlo:

Las cajas llamadas AC son Componentes Autónomos. Describiremos qué los hace autónomos cuando discutamos los comandos. Pero antes de entrar en lo complicado, vamos a empezar por las consultas (Query en el gráfico).

Consultas

Si los datos que vamos a mostrar a los usuarios estarán obsoletos de todas maneras, ¿es realmente necesario ir a la base de datos maestra para obtenerlos de allí? ¿Por qué transformar esas estructuras en 3ª forma normal a objetos del dominio si nosotros sólo queremos datos, y no comportamientos para cumplir las reglas? ¿Por qué transformar esos objetos del dominio en DTOs (Objetos de Transferencia de Datos) para enviarlos a través de una red, y quién dice que: esa red va a estar exactamente ahí? ¿Por qué transformar esos DTOs en modelos para la vista (view models).

Resumiendo, parece que estuviéramos haciendo demasiado trabajo innecesario basado en el supuesto de que reutilizar código que ya ha sido escrito será más fácil que simplemente resolver el problema que tenemos entre manos. Así que vamos a probar otra alternativa.

¿Qué tal si creamos un almacén de datos adicional cuyos datos puedan estar un poco desactualizados respecto a la base de datos maestra? Es decir, los datos que mostramos al usuario van a ser obsoletos de una forma u otra, así que por qué no reflejar esta obsolescencia en el propio almacén de datos. Más adelante veremos una aproximación para mantener este almacén de datos más o menos sincronizado.

Ahora bien, ¿cuál debería ser la estructura correcta para este almacén de datos? ¿Qué tal el propio modelo de la vista? Una tabla para cada vista. Entonces nuestro cliente podría hacer simplemente SELECT * FROM MiTablaDeVista (o posiblemente pasarle un ID en una cláusula Where), y enlazar el resultado con la pantalla. Podría ser así de simple. Podrías envolverlo con una fina fachada si sientes la necesidad, o con procedimientos almacenados(Stored Procedures) , o usar AutoMapper para que mapee desde un DataReader a tu clase modelo de la vista. El hecho es que la estructura modelo de la vista ya es buena para transmitir, así que no necesitas transformarla en nada más.

Podrías incluso considerar mover ese almacén de datos a tu capa web. Así tendría tanta seguridad como una caché de memoria en la capa web. Con darle permisos al servidor web para que sólo haga SELECT en esas tablas será suficiente.

Almacén de datos de las consultas

Pero usar una base de datos convencional como almacén de datos al que consultar no es la única opción. Considera que el esquema de las consultas es básicamente idéntico al modelo de la vista. No hay relaciones entre las diferentes clases modelo de las vistas, así que no se debería necesitar ninguna relación entre las tablas en ese almacén de datos.

En ese caso ¿necesitas realmente una base de datos relacional?

La respuesta es no, pero por razones prácticas y debido a la inercia de las organizaciones, probablemente sea tu mejor opción (por ahora).

Escalando las consultas

Ahora que las consultas son ejecutadas desde un almacén de datos distinto de la base de datos maestra, y no podemos asumir que los datos servidos estén actualizados al 100%, puedes fácilmente añadir más instancias a esos almacenes sin preocuparte de que no contengan exactamente los mismos datos. El mismo mecanismo que actualiza una instancia puede actualizar muchas instancias, como veremos más adelante.

Esto te ofrece una escalabilidad horizontal barata para tus consultas. Y al no estar haciendo casi ninguna transformación, la latencia por consulta disminuye también. El código sencillo es código rápido.

Modificaciones de datos

Como nuestros usuarios están tomando decisiones basadas en datos obsoletos, necesitamos ser más exigentes respecto a qué dejamos pasar. Pongamos un escenario para explicar por qué:

Supongamos que tenemos un servicio de atención telefónica donde un operador está al teléfono con un cliente. Este usuario está viendo los detalles del cliente en su pantalla y quiere hacerlo un cliente ‘preferido’, además de modificar su dirección, cambiar su título Srta. por Sra., cambiar su apellido, e indicar que ahora está casada. Lo que el usuario no sabe es que tras abrir la pantalla, un evento procedente del departamento de facturación indica que este mismo cliente no paga sus facturas: es moroso. En este punto, nuestro usuario confirma sus cambios.

¿Deberíamos aceptar esos cambios?

Bien, nosotros deberíamos aceptar algunos de ellos, pero no el cambio a ‘preferido’ ya que el cliente es moroso. Pero escribir este tipo de comprobaciones no es fácil: necesitamos comprobar las diferencias entre los datos, inferir qué significan los cambios, cuales están relacionados entre sí (cambio de apellido y título) y cuales no, identificar contra qué datos comprobar (no sólo comparando con los datos que el usuario leyó, sino con el estado actual de la base de datos) y entonces decidir si rechazar o aceptar.

Desafortunadamente para nuestros usuarios, tendemos a rechazarlo todo si alguna parte está mal. Así que nuestros usuarios tendrán que actualizar sus pantallas para obtener los datos actualizados, y volver a escribir todos los cambios anteriores, esperando que esta vez no les gritemos por culpa de un conflicto de concurrencia optimista(Optimistic concurrency).

Conforme nuestras entidades se hacen más grandes, con más campos, también hay más actores trabajando con esas mismas entidades, y mayor es la probabilidad de que alguien toque algún atributo de ellas en un momento dado, incrementando el número de conflictos de concurrencia.

Si solo hubiera una forma de que nuestros usuarios nos proporcionen sus modificaciones de datos con un correcto nivel de granularidad y clara intención. En eso precisamente consisten los comandos.

Comandos

Un elemento clave de CQRS es repensar el diseño de la interfaz de usuario para permitirnos capturar la intención de nuestros usuarios, por ejemplo marcar un cliente como preferido es para el usuario una unidad de trabajo distinta de indicar que el cliente se ha mudado o que se ha casado. Usando un interfaz de usuario tipo Excel para modificar los datos no captura la intención, como vimos antes.

Podríamos incluso considerar que el usuario pudiera enviar un nuevo comando aun antes de haber recibido confirmación del anterior. Podríamos tener un pequeño asistente en el lateral mostrando al usuario sus comandos pendientes, retirándolos asíncronamente al recibir confirmación del servidor, o marcándolos con una X si fallan. El usuario podría entonces hacer doble clic sobre la tarea fallida para encontrar información sobre qué ha sucedido.

Noten que el cliente envía comandos al servidor, no los publica. La publicación está reservada para eventos que establecen un hecho: indicar que algo ha sucedido; y que el publicador no se preocupa sobre qué harán los receptores de ese evento al respecto.

Comandos y Validación

Pensando en qué podría hacer que un comando fallara, un aspecto que surge es la validación. La validación es diferente de las reglas de negocio ya que establece una afirmación independiente del contexto acerca del comando. Un comando es válido o no lo es. Las reglas de negocio, por otro lado, son dependientes del contexto.

En el ejemplo que vimos antes, los datos que nuestro operador guardaba eran válidos, sólo el evento recibido previamente desde facturación provocaba que el comando fuera rechazado. Si ese evento de facturación no hubiese llegado, los datos se habrían aceptado.

Incluso siendo un comando válido, todavía puede haber razones para rechazarlo.

De hecho, la validación puede ser realizada en el cliente, comprobando que se han rellenado todos los campos requeridos para ese comando, que los rangos de números y fecha se cumplen y ese tipo de cosas. El servidor todavía debería validar todos los comandos recibidos y no confiar en que los clientes hagan la validación.

Replanteo de UI (Interfaz de Usuario) y comandos a la luz de la validación

El cliente puede usar el almacén de datos para consultas al validar comandos. Por ejemplo, antes de enviar el comando de que el cliente se ha mudado, podría comprobar que el nombre de la calle existe en el almacén para consultas.

En este punto, podríamos replantear el interfaz de usuario (UI) y poner una caja de texto con autocompletado para el nombre de la calle, asegurando así que la calle que pasaremos en el comando será válida. Pero podríamos ir aún más allá. ¿Por qué no pasar el ID de la calle en lugar de su nombre? Hagamos que el comando represente la calle no como una cadena sino como un ID (int, guid, lo que se quiera).

En el servidor, la única razón de que tal comando falle sería debido a un problema de concurrencia: que alguien haya eliminado esa calle y que todavía no se haya aplicado esa eliminación en el almacén para consultas, un conjunto bastante excepcional de circunstancias.

Razones por las que comandos válidos fallan y qué hacer al respecto

Así tenemos un cliente que se porta bien, que está enviando comandos válidos, y un servidor que todavía puede decidir si rechazarlos. A menudo las circunstancias de rechazo tienen que ver con los cambios de estado realizados por otros actores, que resultan relevantes en el procesamento de ese comando.

En el ejemplo anterior de CRM, se debía sólo a que el evento de facturación llegara primero. Pero “primero” podría ser un milisegundo antes que nuestro comando. ¿Qué pasaría si nuestro usuario presionara el botón un milisegundo antes? ¿Debería eso realmente cambiar los resultados desde el punto de vista del negocio? ¿No esperaríamos que nuestro sistema se comportase de la misma forma cuando lo observamos desde afuera?

Así que, si el evento de facturación llegara el segundo, ¿no se debería volver a poner el cliente preferido como normal? Y más aún: ¿no se debería notificar al cliente, por ejemplo enviándole un email? En ese caso, ¿por qué no establecer ese comportamiento incluso cuando el evento de facturación llegue primero? Y ya que tenemos configurado un modelo de notificaciones, ¿necesitamos realmente devolver un error al operador? Es decir, dado que él no puede hacer otra cosa más que notificarlo al cliente.

Por lo cual si no estamos devolviendo errores al cliente (quien ya nos está enviando comandos válidos), quizá todo lo que necesitamos hacer en el cliente cuando enviamos un comando es decir al usuario “gracias, recibirá confirmación vía email en breve”. Ni siquiera necesitamos un asistente en el UI para mostrarnos los comandos pendientes.

Comandos y Autonomía

Podemos ver que en este modelo los comandos no necesitan ser procesados inmediatamente, pueden ser encolados. Cuando se ejecuten realmente es cuestión de un acuerdo de servicio (Service-Level Agreement, SLA) y no es significativo desde el punto de vista de la arquitectura. Esta es una de las cosas que hace que el nodo que procesa los comandos sea autónomo desde la perspectiva del tiempo de ejecución: no se necesita una conexión siempre activa con el cliente.

Es más, no necesitaríamos acceder al almacén para consultas para procesar comandos, cualquier estado necesario sería gestionado por el componente autónomo, eso es parte del significado de autonomía.

Otra cuestión es el caso de fallos en el procesamiento de mensajes por culpa de caídas de la base de datos o por conflictos bloqueantes. No hay razón para devolver esos errores al cliente, podemos deshacer (rollback) e intentarlo de nuevo. Cuando el administrador recupere la base de datos, todos los mensajes en cola de espera serán procesados con éxito y nuestros usuarios recibirán confirmación.

El sistema como un todo es bastante más robusto ante las condiciones de error.

Dado que ya no se lanzan consultas contra esta base de datos, es capaz de mantener más filas/páginas en memoria para servir comandos, mejorando el rendimiento. Cuando tanto los comandos como las consultas eran servidos por las mismas tablas, el servidor de base de datos estaba siempre haciendo malabares con las filas de ambos.

Componentes Autónomos

En la figura superior vemos todos los comandos dirigidos hacia el mismo AC (componente autónomo), pero podríamos lógicamente procesar cada comando en un AC separado, con su propia cola. Esto nos permite visualizar qué cola es más larga, lo cual nos muestra de una forma muy obvia qué parte del sistema es un cuello de botella. Esta información, que es interesante para el desarrollador, es indispensable para los administradores de sistemas.

Dado que los comandos esperan en colas, podemos agregar más nodos procesadores a esas colas (si estamos usando NServiceBus, podemos emplear el distributor), para de esta manera escalar sólo la parte del sistema que es lenta. No hay necesidad de gastar servidores en las otras peticiones.

Capas de servicio

Nuestros objetos de procesamiento de comandos en los distintos componentes autónomos realmente conforman nuestra capa de servicio. La razón de que no se represente explícitamente esta capa en CQRS es porque realmente no está ahí, al menos no como una colección lógica identificable de objetos relacionados, y he aquí porque:

En la aproximación de la arquitectura en n capas (o 3-capas), no hay reglas en cuanto a las dependencias entre los objetos dentro de una capa, o más bien se supone que están permitidas. Sin embargo, al mirar a la capa de servicio a través del prisma de la orientación a comandos, lo que vemos son objetos manejando diferentes tipos de comandos. Cada comando es independiente de los otros, así que ¿por qué deberíamos permitir que los objetos que los manejan dependan unos de otros?

Las dependencias son cosas que deberían ser evitadas, a menos que haya una buena razón para ellas.

Mantener independientes entre sí estos objetos de manejo de comandos nos permitirá evolucionar nuestro sistema más fácilmente, un comando cada vez, no necesitando ni siquiera detener el sistema completo, dado que la nueva versión es compatible hacia atrás con la anterior.

Por lo tanto, hay que mantener cada manejador de comando en su propio proyecto VS, o quizás incluso en su propia solución, alejando así a los desarrolladores de la tentación de introducir dependencias en nombre de la reutilización (es una falacia). Si después decidieras, como un aspecto del despliegue, que quieres poner todos juntos en el mismo proceso alimentándose de la misma cola, puedes combinar con ILMerge esos ensamblados y alojarlos juntos, pero entendiendo que puedes estar perdiendo muchos de los beneficios de tus componentes autónomos.

¿Qué hay del modelo del dominio?

Aunque en el diagrama anterior podías ver el modelo del dominio bajo los componentes autónomos de procesamiento de comandos, realmente sólo es un detalle de implementación. No hay nada que establezca que todos los comandos deben ser procesados por el mismo modelo del dominio. Podría decirse que puedes tener algunos comandos procesados con secuencias transaccionales (transaction scripts), otros usando modulo de tabla (table module o active record), así como otros usando el modelo del dominio. Event sourcing sería otra posible implementación.

Otra cosa a entender referente al modelo del dominio es que ya no se usa para dar servicio a las consultas. Así que la cuestión es: ¿por qué necesitas tener tantas relaciones entre entidades en tu modelo del dominio?

(Es posible que desee disponer de un momento para asimilar esto.)

¿Realmente necesitamos una colección de órdenes en la entidad cliente? ¿En qué comando necesitaríamos navegar por esa colección? De hecho, ¿qué tipo de comando necesitaría una relación uno-a-muchos? Y si es así para las uno-a-muchos, las muchos-a-muchos definitivamente estarían fuera también. Es decir, la mayoría de los comandos sólo contienen uno o dos ID en el mejor de los casos.

Cualquier operación de agregación, que podría haber sido calculada iterando por las entidades hijas, podrá ser precalculada y almacenada como propiedades en la entidad padre. Siguiendo este proceso a través de todas las entidades en nuestro dominio dará lugar a entidades aisladas necesitando nada más que un par de propiedades para los ID de sus entidades relacionadas (hijos conteniendo el ID del padre, como en las bases de datos).

De esta forma, los comandos podrían ser completamente procesados por una sola entidad: viola, una entidad raíz que es un límite de consistencia.

Persistencia para el procesamiento de comandos

Ya que la base de datos usada para procesamiento de comandos no se usa para consultas, y que la mayoría de (si no todos) los comandos contienen el ID de las filas a las que van a afectar, ¿realmente necesitamos tener una columna para cada propiedad de objetos del dominio? ¿No podríamos serializar la entidad del dominio y ponerla en una sola columna, y tener otra columna conteniendo el ID? Esto suena bastante parecido a un almacenamiento basado en claves tal como está disponible en los varios proveedores en la nube. En tal caso, ¿necesitas realmente un mapeador objeto-relacional (ORM) para persistir en este tipo de almacenamiento?

También podrías extraer una propiedad adicional para cada dato del que quieras que la base de datos asegure la unicidad.

No estoy sugiriendo que hagas esto en todos los casos, más bien trato de hacer que te replantees algunas presunciones básicas.

Permíteme repetirlo

La forma en que se procesan los comandos es un detalle de la implementación de CQRS.

Manteniendo el almacén para consultas actualizado

Después de que el componente autónomo de procesamiento de comandos ha decidido aceptar uno, y tras modificar su almacén de persistencia en consecuencia, publicará un evento notificándolo al resto del mundo.Este evento a menudo es la “forma pasada” del comando:

HacerClientePreferidoCommand -> ClienteHaSidoHechoPreferidoEvent

La publicación del evento se hace en la misma transacción en que se procesa el comando y se hacen los cambios en la base de datos. De este modo, cualquier tipo de error al confirmar la transacción (commit) dará lugar a que el evento no sea enviado. Esto es algo que debería ser gestionado por defecto por el bus de mensajes, y si se utiliza MSMQ como transporte subyacente, requiere del uso de colas transaccionales.

El componente autónomo que recibe esos eventos y actualiza el almacén de datos para consultas es bastante simple, traduciendo de la estructura del evento a la estructura de persistencia del modelo de la vista. Sugiero tener un manejador de eventos por cada clase modelo de la vista (es decir, por tabla).

Aquí tenemos la imagen con todas las piezas de nuevo:

Contextos limitados

Aunque CQRS afecta a muchas piezas de la arquitectura del software, todavía no está en la cima de la cadena alimenticia. CQRS, si se usa, será aplicado dentro de un contexto limitado (DDD) o de un componente de negocio (SOA): una pieza cohesiva del dominio del problema. Los eventos publicados por un BC serán escuchados por otros BC, y cada uno de ellos actualizará sus almacenes (de comandos y de consultas) como sea necesario.

Los interfaces de usuario de CQRS que encontramos en cada BC pueden ser “unificados” en una única aplicación, ofreciendo a los usuarios una única vista compuesta con todas las partes del dominio del problema. Las librerías de composición de UI serán de gran ayuda para estos casos.

Resumen

CQRS versa sobre cómo alcanzar una arquitectura apropiada para aplicaciones multiusuario colaborativas. Explícitamente considera factores como la obsolescencia de datos y la volatilidad, y explota esas características para crear construcciones más simples y escalables.

Uno no puede disfrutar verdaderamente los beneficios de CQRS sin considerar el interfaz de usuario, sin hacer que este capture explícitamente la intención del usuario. Cuando se considera la validación en el lado del cliente, la estructura de comandos debe ser de alguna forma reajustada. Pensando más profundamente en el orden en que los comandos y eventos son procesados puede conducir a patrones de notificación que hagan innecesaria la devolución de errores.

Mientras que el resultado de aplicar CQRS a un proyecto dado es una base de código más facil de mantener y eficiente, esta simplicidad y escalabilidad requieren de la comprensión detallada de los requisitos del negocio y no son el resultado de ninguna “buena práctica” de carácter técnico. Si acaso, podemos ver una plétora de enfoques a problemas aparentemente similares siendo empleados conjuntamente: lectura con cursores frente a modelos de dominio, mensajes unidireccionales frente a llamadas asíncronas.

Aunque este artículo supera las 3000 palabras (un récord para este blog), reconozco que no profundiza lo suficiente en la materia (toma unos 3 días de los 5 de mi curso Advanced Distributed Systems Design cubrirlo todo en suficiente detalle). En cualquier caso, espero que te ofrezca un entendimiento de por qué CQRS es como es y quizá te abra los ojos a otras formas de mirar al diseño de sistemas distribuidos.

Las preguntas y los comentarios serán muy bienvenidos.

[Udi Dahan, 2009]

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *