Buscando el Product Market Fit y Arquitectura Hexagonal

Hace un par de meses estuve en La Vertical by Mercadona Tech hablando sobre DDD estratégico. Al final del evento, pude conocer a algunas personas con quienes estuve compartiendo impresiones, ideas, proyectos…

En un momento dado, una persona me preguntó sobre qué opinaba de usar el estilo de arquitectura de Ports & Adapters, más popularmente conocida como Arquitectura Hexagonal, cuando el contexto es la fase de búsqueda o consolidación del Product Market Fit. Me pilló la pregunta algo a contrapié y creo que le respondí de forma un poco tibia; podríamos resumirlo con un clásico: “No lo sé, creo que depende del equipo y del contexto.

Esto es algo sobre lo que he compartido mi punto de vista en petit comité en bastantes ocasiones, así que quería extender un poco esa respuesta aterrizándolo en este artículo.

Dibujo que representa el product market fin, un hexágono dividido de driver y driven con un interrogante encima

¿Product Market Fit?

Este es el momento en el que un producto no está validado a nivel de negocio, o al menos no totalmente. Son momentos donde aún no es rentable y hay que probar a lanzar soluciones para cubrir los problemas de nuestros potenciales clientes e ir iterando (o descartando) esas soluciones. Más info sobre esto en este artículo de la lista de correo de Ignacio Arriaga: ¿Qué es el product market fit y cómo acelerarlo?.

Así que en una situación en la que no hemos consolidado el Product Market Fit, la capacidad de iterar un producto de software a una velocidad razonablemente alta es crítica para hacerlo. Más en situaciones en las que tenemos un presupuesto limitado, como suele ser el de la mayoría de startups.

¿Arquitectura Hexagonal?

Si seguimos el estilo de Hexagonal Architecture o Ports & Adapters o Clean Architecture, colocamos en el medio nuestra lógica de negocio y nos abstraemos de los detalles de implementación de infraestructura.

Esta infraestructura son tanto los driver ports que son el mecanismo de entrega para interactuar con la lógica de negocio, como podrían ser server-side rendering, REST, gRPC, GraphQL, CLI, tareas programadas… Como los driven ports que son los encargados de mantener el estado vía persistencia de datos y la comunicación hacia otros sistemas.

Las características principales de este estilo de arquitectura son la cambiabilidad de la infraestructura y, como consecuencia de eso, la testeabilidad. Ya que al utilizar inyección de dependencias nos abstraemos de las diferentes piezas de infraestructura y podemos testear la lógica de negocio de forma unitaria.

Frente a lo que me parece percibir a veces, no hay una forma única de implementar software con este estilo de arquitectura. En mi caso, ha evolucionado un poco mi forma de trabajar con este enfoque, pero como base siguen siendo bastante válidos los contenidos que compartimos en algunas charlas de hace algunos años con Coding Stones.

Lo malo

Este estilo de arquitectura se ha popularizado mucho en los últimos tiempos y ha crecido de la mano con el uso conjunto de los patrones tácticos de DDD. Tanto que para muchas personas creo que no hay diferencia, no se percibe que son cosas distintas que unas veces se complementan y otras no tiene demasiado sentido aplicar, al menos no de forma purista.

Así que esto en ocasiones lleva a sobreingeniería en el diseño, en forma de exceso de abstracciones y aumento de complejidad extrínseca, haciendo un código más difícil de modificar y extender funcionalmente. Lo que provoca que en esos casos de aplicaciones con una lógica de dominio no demasiado compleja, se ralentice la velocidad de iteración en el producto.

También, otras veces, se espera que al aplicar este estilo de arquitectura desaparezcan todos los problemas asociados a la deuda técnica. Como si ya vineran integrados mágicamente en ello principios como DRY, YAGNI, KISS, Separation of concerns, ley de Demeter, las 4 reglas del diseño simple… y nos fuera a evitar tener code smells.

Lo bueno

Para mí, lo que aporta este tipo de arquitectura en este tipo de contextos es principalmente la testeabilidad. Cuando se quiere iterar rápido, tener una batería de tests en la que puedas confiar y que se ejecuta rápido es una gran ayuda para mantener el foco en lo que estás desarrollando.

En segundo grado, la cambiabilidad nos aporta que podemos posponer decisiones, por ejemplo, usando soluciones de infraestructura a priori simplistas que nos permitan validar que se aporta valor a pequeña escala, sabiendo que de ser necesario, podemos cambiarla en el futuro con menos esfuerzo al no afectar al diseño.

Depende del equipo y del contexto

Sobre el contexto, empiezo desde una situación en la que se ha llegado a la conclusión de que no podemos servirnos de herramientas no-code ni de un prototipo o prueba de concepto que podamos tirar a la basura dentro de pocos meses. Así que no nos quedan más narices que desarrollar algo que pueda evolucionar, iterarse y adaptarse a lo que nos vayamos encontrando en el futuro.

Normalmente en estos casos seremos un equipo pequeño, con capacidad financiera limitada tanto para contratar como para subcontratar y donde querremos tener buena capacidad para entregar software. Porque, de lo contrario, difícilmente podremos iterar nada.

Cualquier decisión técnica para mí debería ir encaminada a usar soluciones conocidas siempre que sea posible. En general, creo que la línea a seguir es la de Choose Boring Technology, con más razón en el caso de estar consolidando el Product Market Fit.

Algunas preguntas que podemos hacernos para pensar sobre esto: ¿Quiénes formamos parte del equipo? ¿Cuánto hemos trabajado usando el estilo de arquitectura de Ports & Adapters? ¿Nuestro software tiene un dominio con cierta complejidad o se parece más a CRUD que encajaría bien acoplado con algún framework? ¿Tenemos algún framework de desarrollo con el que todas las personas del equipo seamos productivas? ¿Sabemos o hemos comprobado si estaremos peleando contra ese framework si lo combinamos con ese estilo de arquitectura? …

Alternativas

En algunas ocasiones, mi aproximación ha sido quedarme un poco a medio camino, utilizando alguno de los artefactos o prácticas que habitualmente se asocian a la Arquitectura Hexagonal, pero manteniendo el acoplamiento al framework en cierto puntos. Esto es: capa de use cases para representar lo que hace el producto de software, inyección de dependencias para tener piezas cambiables y hacer que la lógica sea más fácil de testear y, si encaja con las necesidades iniciales del producto, también modelar eventos.

Use Cases

Esta capa representa las acciones que se pueden hacer sobre el producto de software. A partir de aquí, se encapsula lógica y se orquestan las llamadas a infraestructura. En esta capa no se sabe si va a ser llamada desde un API rest, un comando desde CLI, un cron…

En mi caso, normalmente son clases con constructor y un solo método público para ejecutarlas, así que en un momento dado hasta podrían ser funciones.

En caso de que usemos un ORM (o un ODM o similar), utilizo esas mismas entidades para representar el dominio. Al menos hasta el momento donde se percibe que el modelo de dominio y el modelo de datos entran en conflicto como para tener que hacer una separación.

Inyección de dependencias

En los constructores de los use cases, hago uso intensivo de inyección de dependencias, más para ganar en testeabilidad que en cambiabilidad de la infraestructura. Esto es que en lenguajes de tipado estático, en los constructores normalmente se espera la inyección de una dependencia de una clase y no de una interfaz. El esfuerzo que sí trato de hacer al inyectar esas clases a los use cases, es no acoplarme a nivel de naming de clases y métodos sobre la implementación.

En algunas ocasiones, esta inyección la he mantenido manualmente y otras a través de algún framework de IoC, dependiendo principalmente de si el framework de desarrollo lo trae de serie o si la base de código es aún manejable sin ello.

Eventos (opcional)

En casos donde desde el inicio se observa que existen muchos side-effects en un producto tiendo a introducir eventos de dominio, seguramente no para todos los use cases, pero al menos sí los más relevantes. Pistas para introducirlos pueden ser necesidades relacionadas con: auditoría, distintos tipos de notificaciones, desacoplar la comunicación con otros módulos o sistemas, instrumentar behavioral analytics en el backend, etc.

Conclusiones

El mero hecho de usar o no Ports & Adapters no es relevante para que el producto tenga éxito, pero la capacidad de iterar rápido el producto es crítica.

Dentro de que nunca vamos a tener certezas, en momentos donde estamos buscando Product-Market fit a nivel técnico normalmente deberíamos ir hacia lo más seguro y conocido por parte del equipo. Bastantes riesgos existen en esos contextos como para asumir más.

Ya sea un estilo de arquitectura completamente acoplado a un framework, de Ports & Adapters o se quede en algún punto intermedio para desarrollar un producto de software, lo que considero innegociable para iterar rápido es acompañarlo de otras 3 prácticas técnicas:

  • Hacer testing automático, al menos unitario y de integración de forma bastante intensiva para tener confianza en los cambios y poder iterar más rápido.
  • Tener automatizado el pipeline de entrega y que lo pueda hacer cualquiera en el equipo, ya sea pulsando un botón o con un push en el sistema de control de versiones.
  • Preparar un mínimo de telemetría técnica y de producto, para saber de forma temprana si han surgido problemas en producción a partir de un cambio y poder analizar cómo es el comportamiento de quiénes están usando el producto.

Píldora. Precargar datos a bases de datos en Testcontainers

Una de las cosas en la que ando ayudando últimamente en Genially es en incorporar Testcontainers en algunas de nuestras aplicaciones. Una librería que conocía del mundo Java pero que ahora está disponible en otros ecosistemas, incluido el de Node.

Testcontainers utiliza Docker por debajo y nos ofrece la posibilidad de tener instancias de usar y tirar para nuestras suites de test, así que de esta manera podemos tener tests de integración autocontenidos. Esto facilita el trabajo en local y simplifica enormemente la automatización en los pipelines de CI al no tener que ir montando infraestructura específica.

Normalmente nos interesa tener instancias limpias con las que trabajar y que cada test prepare en el arrange el estado que espera. Pero me encontré en la necesidad de tener que cargar varios scripts a una base de datos MySql y vi que las imágenes de Docker de Postgres, MySql, Mongo… soportan inicializar datos la primera vez que se instancia un contenedor, esto se hace ejecutando los scripts que se encuentren en el directorio /docker-entrypoint-initdb.d

Así, que lo que tenemos que hacer es copiar esos scripts programáticamente antes de inicializar el contenedor usando withCopyFilesToContainer del api de Testcontainers.

Este sería un ejemplo:

const container = await new GenericContainer('mysql:8.0')
   .withExposedPorts(3306)
   .withEnvironment({
     MYSQL_DATABASE: 'test_db',
     MYSQL_ROOT_PASSWORD: 'a_root_password',
     MYSQL_USER: 'an_user',
     MYSQL_PASSWORD: 'a_password',
   })
   .withCopyFilesToContainer([
     {
       source: './dump/foo.sql',
       target: '/docker-entrypoint-initdb.d/1.sql',
     },
     {
       source: './dump/bar.sql',
       target: '/docker-entrypoint-initdb.d/2.sql',
     }
   ])
   .start();

Por cierto, hay que tener en cuenta que si hay varios ficheros de scripts el orden de ejecución será por orden alfabético.

Responsabilidades de la Arquitectura de Software

La mayor parte de las veces cuando discuto sobre cuestiones relacionadas con Arquitectura de Software suele ser para referirme a prácticas (cómo diagramar y comunicar, tipos de workshops, ADRs, ASRs, prototipado…) o a enfoques de soluciones (monolitos modulares, microservicios, DDD, servicios de cloud en particular, contenedores, hexagonal, event-driven…).

Pero cuando hace unos meses desde el departamento de tecnología de Grupo Carreras (con quienes llevo un tiempo colaborando de vez en cuando) me pidieron que preparase una charla para un evento interno sobre la importancia de la arquitectura de software. Tenía claro que no podía enfocarme demasiado en prácticas y soluciones, porque iba a haber combinación de público técnico y no técnico.

Finalmente opté por ser más abstracto y hablar acerca de las responsabilidades asociadas a la arquitectura de software, ya que podía conectarlo con referencias a decisiones que se han tomado en la propia empresa y sus razones, que me servían como ejemplo.

Conociendo mi desmemoria, este es un contenido que me daba un poco de rabia perder, así que decidí transformarlo en un post.

Diagrama de una posible arquitectura dibujada en una pizarra

¿Qué es Arquitectura de Software?

Explicaciones de terceros de las que caben en un tuit:

  • Según la entrada en la Wikipedia: “La arquitectura de software se refiere a las estructuras fundamentales de un sistema de software y la disciplina de crear tales estructuras y sistemas”
  • Según Michael Keeling en Design It: “La arquitectura de software de un sistema es el conjunto de decisiones significativas sobre cómo se organiza el software para promover los atributos de calidad y otras propiedades”
  • Según Ralph Johnson: “La arquitectura se trata de las cosas importantes. Sea lo que sea”

Por intentar aterrizarlo un poco más, son las decisiones que afectan a:

  • Cómo se diseña el software y su calidad interna.
  • Cómo encaja el software con los objetivos de negocio.
  • Cuándo y cómo se entrega el software.
  • Cuál es el impacto organizativo.

No es una torre de marfil

Que las decisiones de arquitectura se tomen exclusivamente por personas que no están en la realidad del desarrollo diario es un anti-patrón. Se corre el peligro de desconexión con la realidad y/o con las personas que lo llevan a cabo.

Esto es porque la arquitectura de software es una actividad de grupo. Hay personas que aportan diferentes puntos de vista: conocimiento del dominio, visión del impacto organizacional, conocimiento de diferentes tecnologías disponibles, habilidades de diseño de software, consciencia sobre el legacy existente…

Además debemos tener en cuenta que las decisiones del día a día pueden impactar en la evolución de la arquitectura. Algunos casos pueden ser: introducir una nueva librería que nos abra nuevas posibilidades, modificar parte del software para facilitar su delivery, refactorizar (o reescribir) una parte del sistema para mejorar su testeabilidad, introducir una convención de equipo respecto a la organización de directorios y ficheros…

La arquitectura no es estática, se itera y evoluciona

Antaño con el enfoque waterfall, el trabajo de arquitectura se hacía antes de arrancar el desarrollo de un sistema de software. Normalmente tras una fase de análisis y toma de requisitos en las que se definía hasta el aburrimiento los últimos detalles de la funcionalidad a construir. Así que con ese enfoque había que pensarlo muy fuerte todo, para no dejarse ningún punto ciego y que la gente que desarrollaba “solo tuviera que centrarse en ejecutar” como code monkeys. Y… luego pasaba lo que pasaba.

Por suerte, se va percibiendo que en el sector nos vamos adaptando poco a poco a enfoques más evolutivos en cuanto a la definición y construcción de lo que debe hacer un sistema de software. De forma paralela va ocurriendo lo mismo con la arquitectura de software.

No es lo mismo arrancar un MVP para validar/invalidar rápido una hipótesis, que estar buscando generar volumen tras ver que hay encaje en el mercado y ya hay clientes, o tener que soportar el crecimiento del negocio teniendo en cuenta el legacy, etc. Los problemas van cambiando con el tiempo, y hay que ir evolucionando las soluciones de arquitectura.

No perder de vista la Big Picture

Los sistemas de software son sistemas socio-técnicos, no viven aislados del resto del mundo, así que debemos introducir una visión más de alto nivel y tratar que el trabajo del desarrollo del día a día esté alineado con esa visión. Para esto debemos tener en cuenta a las personas, el negocio y la tecnología.

Respecto a las personas de la propia organización: ¿Qué habilidades tenemos dentro de la organización? ¿Necesitamos fichar personas con habilidades que no tenemos? ¿Cómo y qué tipos de equipos tenemos? ¿Cómo interactúan entre ellos? ¿Deberíamos cambiar su estructura?

Cuestiones relacionadas con el negocio y los procesos: ¿Nos dirigimos a un mercado concreto? ¿Queremos expandir a más mercados a corto/medio plazo? ¿Tenemos que adaptarnos a un cambio en el negocio o del mercado? ¿Queremos automatizar un proceso existente? ¿Queremos cambiarlo para ser más eficientes?

Factores puramente técnicos: ¿Tenemos ya soluciones que conocemos en la compañía? ¿Creemos que podemos sacarle ventaja competitiva al uso de una nueva herramienta o tecnología? ¿Un proveedor nos avisa que va discontinuar una herramienta que usamos o su nuevo pricing lo hace inasumible?

Algo que nos puede resultar útil para cualquier tema relacionado con arquitectura de software, pero especialmente para no perder de vista este alto nivel, es tener en cuenta la Ley de Conway y enfrentarla con nuestro contexto.

Definir el problema desde una perspectiva de ingeniería

Para cumplir los objetivos del negocio, además de hacer el trabajo de producto para ir descubriendo, definiendo e implementando la funcionalidades de un software; debemos tener razonablemente claros cuáles son los atributos de calidad que esperamos que tenga nuestro software para que nos facilite cumplirlos, o como mínimo que no entorpezca alcanzarlos.

Estos atributos de calidad se venían conociendo más como requisitos no funcionales. Que es algo que suena ya a viejuno, ya que no son requisitos que usuarios o stakeholders suelan pedir, sino atributos que como equipo se han detectado como facilitadores para la consecución de los objetivos. Algunos atributos de calidad serían escalabilidad, seguridad, operabilidad, testeabilidad, rendimiento, disponibilidad…

Debemos tener en cuenta que tal como construimos software seguimos aprendiendo tanto acerca del negocio como de lo apropiada que puede ser o no nuestra arquitectura, así que debemos dar seguimiento a las restricciones del diseño y a las funcionalidades que pueden impactar en la arquitectura. ¿Nuestra arquitectura dificulta o limita añadir nuevas funcionalidades? ¿Tenemos que evolucionar el diseño manteniendo compatibilidad con otro software existente? ¿Necesitamos pivotar el producto porque han cambiado los objetivos de negocio?

También debemos tener cuidado porque a veces pecamos de hablar de estos atributos de calidad en genérico, cuando lo normal es que no todos los componentes de un sistema deban tener el mismo nivel de calidad en un atributo. Una práctica que podemos utilizar son los Architecturally Significant Requirements para ayudarnos a discutir y definir los diferentes niveles de atributos de calidad que necesitamos.

Dividir el sistema y asignar responsabilidades

Debemos ser estratégicos en cómo dividir un sistema en distintos componentes dependiendo de nuestro contexto. Normalmente la forma de dividirlo estará relacionada con cuestiones técnicas o cuestiones organizativas.

Las razones técnicas para dividir un sistema están relacionadas con la identificación de funcionalidades que requieren atributos de calidad muy diferentes (escalabilidad, rendimiento, testeabilidad…). Por ejemplo, no son las mismas necesidades las de guardar datos personales de menores, donde legalmente debemos tener en cuenta muchas cuestiones de seguridad y auditoría; con tener que identificar anomalías en fotos utilizando computer vision, donde requerimos uso intensivo de GPU.

Las razones de divisiones organizativas suelen tener que ver con que existan distintos objetivos del negocio que repetidamente entran en conflicto, además de que haya muchas personas involucradas en una base de código y se haga inmanejable. No es lo mismo tener un equipo de 3 personas en una pequeña startup que una compañía mucho más establecida con 3000 personas y múltiples equipos.

Una vez hechas esas divisiones no debemos olvidarnos de que se deberá delimitar las responsabilidades y definir cómo van a interactuar los distintos componentes del sistema. Prácticas interesantes para ambas cosas pueden ser Context Map, y entrando más en detalle se puede utilizar una aproximación API-first para especificar la forma de interactuar de los componentes.

Decidir Trade-Offs sobre los atributos de calidad y la división del sistema

Tenemos que ser conscientes de que no existe una división del sistema perfecto, así que debemos asumir compromisos intermedios, para ello debemos trabajar con los stakeholders. Podemos tener limitaciones presupuestarias, de conocimiento en la compañía, etc.

Suena bien lo de tener un nivel de servicio por encima del 99% durante un año, pero tal vez el negocio no lo necesite si el sobrecoste conlleva comprar o alquilar el doble de servidores para tener alta disponibilidad. Tal vez haya partes legacy del sistema que no podemos re-escribir por cuestiones de presupuesto y tiempo. Puede que haya alguna tecnología en el mercado que nos resulte prometedora, pero para la que todavía no haya un soporte que nos parezca fiable. Quizás queramos validar o invalidar una hipótesis rápidamente frente a que soporte mucha carga en caso de que acertemos.

Es posible que al decidir los trade-offs nos podamos equivocar, o incluso aunque a corto plazo veamos que resultan acertados con el paso del tiempo puede que no lo resulten tanto. Así que terminará emergiendo deuda técnica relacionada.

Prácticas útiles en este caso pueden ser registrar ADRs o registrar los caminos no tomados.

Gestionar la Deuda Técnica

Es inevitable que lleguen momentos en los que los objetivos del negocio y las decisiones técnicas tomadas en un sistema de software no encajen, hay diferencias entre el diseño actual del sistema y el ideal hacia el que se quiere ir. Esas diferencias son la deuda técnica que tenemos adquirida.

Puede haber más o menos, la podemos haber asumido conscientemente o inconscientemente, pero al final siempre va a surgir deuda técnica. Así que en cuanto la identifiquemos debemos darle visibilidad y analizar cuándo es el momento de pagarla, ya que tenemos que equilibrar la capacidad de entrega de valor actual con la futura.

Hay tipos de deuda que pueden ser baratas de pagar y tener alto retorno en forma de refactors progresivos de forma continuada. Pero en las situaciones que requieren de re-escrituras que impactan en la división del sistema o la interacción de los distintos componentes, debemos implicar a los stakeholders para alinearnos y decidir cuándo y cómo vamos a pagar esa deuda.

Resumiendo

  • La arquitectura no son los diagramas hechos por un comité de sabios, se refleja en lo que está en desarrollo y que finalmente termina en producción.
  • Debemos tener la mentalidad de que la arquitectura de software es evolutiva y actuar en consecuencia.
  • A veces el día a día nos pierde. Nos ayudará el tener momentos en los que levantemos la vista más a largo plazo y veamos si seguimos alineados con la visión y necesidades del negocio.
  • Definamos los atributos de calidad, evitando caer en ser falsamente específicos.
  • Pensemos en una división del sistema de una forma estratégica, analicemos el impacto de las dependencias entre los distintos componentes.
  • No vivimos en un mundo ideal, tengamos en cuenta las limitaciones de nuestro contexto y elijamos a qué renunciamos.
  • No nos obsesionemos con no tener deuda técnica, es inevitable. Pero no la perdamos de vista, si no la tenemos controlada puede poner en peligro la sostenibilidad del sistema o incluso el éxito del negocio.

Agradecimientos a Alberto Gualis, Vanessa Rubio, Rubén Salado y Javi Rubio por su tiempo revisando y sugiriendo mejoras para este post.

Referencias:

Píldora. Desplegar un monorepo en Heroku

Por defecto cuando queremos desplegar en Heroku, nos encontramos una relación 1:1 entre repositorio de git y aplicación, ya que se espera tener un Procfile en el directorio raíz de tu proyecto con el tipo de procesos y comandos que arrancan la aplicación.

Pero con Heroku Multi Procfile buildpack tenemos la posibilidad de cambiar ese comportamiento, y poder tener varias aplicaciones en un sólo repositorio. Las instrucciones para ello:

Antes asumiremos que tenemos ya una aplicación en heroku en un git remote con nombre heroku, que hemos movido la primera aplicación del directorio raíz a my-application y la nueva la hemos creado en my-new-application.

Instalamos el buildpack en local

$ heroku create --buildpack https://github.com/heroku/heroku-buildpack-multi-procfile.git

En la aplicación de heroku existente añadimos el buildpack

$ heroku buildpacks:add -a my-application heroku-community/multi-procfile

Le asignamos el path del fichero Procfile que queramos asignar a la variable de entorno PROCFILE

$ heroku config:set -a my-application PROCFILE=my-application/Procfile

Lo pusheamos a heroku

$ git push heroku

Para la otra aplicación, la creamos en heroku

$ heroku create -a my-new-application

Añadimos el buildpack en la aplicación de heroku recién creada

$ heroku buildpacks:add -a my-new-application heroku-community/multi-procfile

Le asignamos el path del fichero Procfile que queramos asignar a la variable de entorno PROCFILE

$ heroku config:set -a my-new-application PROCFILE=my-new-application/Procfile

Añadimos el repositorio git remoto heroku-new

$ git remote add heroku-new https://git.heroku.com/my-new-application.git

Y lo pusheamos a heroku

$ git push heroku-new

Cambio en paralelo de un Event Bus

Una de las cosas en las que estamos trabajando desde hace unas semanas en el equipo de producto de Sigma Rail es en cambiar el bus de eventos, algo con lo que aún no hemos terminado porque está siendo un cambio en paralelo que vamos haciendo progresivamente.

Originalmente empezamos con una implementación de Event Bus en memoria por simplicidad, lo cual nos sirvió para tener un diseño que nos permitía desacoplar fácilmente lógica de los casos de uso con los side-effects que deben desencadenar.

Uno de esos side-effects es la generación de thumbnails de las imágenes que suben los usuarios a nuestra plataforma, ahora mismo se generan 4 thumbnails distintos que luego terminan persistiéndose en Amazon S3. Teniendo en cuenta que las imágenes de los usuarios pesan bastante y que lo normal es que se suban varios gigas de imágenes de golpe, resultaba en que se empeoraba la experiencia de usuario al tardar más en finalizar la subida de ficheros y se exigía usar instancias con más recursos, y obviamente más caras. De modo que se hacía patente que había que empezar a resolver ese tema con una solución realmente eventual.

Qué es un Event Bus

El bus de eventos es como solemos llamar al intermediario responsable de hacer llegar los eventos que genera un componente publisher para que otros componentes subscribers los reciban, con algún tipo de implementación basada en el patrón Pub-Sub.

Ese patrón lo utilizamos cuando queremos evitar que un componente que genera eventos esté acoplado a quienes deben consumirlo.Hacemos que ese componente envíe los eventos que genera al bus de eventos de dominio, y este bus informa de esos eventos a los componentes que se hayan suscrito al respectivo tipo de evento.

Esquema representando un event bus

Qué es hacer un cambio en paralelo

Según Danilo Sato en la web de (san) Martin Fowler:

Parallel change, also known as expand and contract, is a pattern to implement backward-incompatible changes to an interface in a safe manner, by breaking the change into three distinct phases: expand, migrate, and contract.

Lo que viene siendo introducir cambios retro compatibles haciendo baby steps que no rompan una interface existente hasta que todos los clientes de esa interface no se hayan adaptado.

En este ejemplo vamos a romper la interface existente porque vamos a pasar a una ejecución en el mismo proceso a en distintos workers de forma distribuida. Además como parte de cada uno de los cambios integramos con la línea principal de desarrollo y desplegamos a producción (obviamente antes en desarrollo, stage, etc).

Esto es porque queremos evitar hacer grandes reescrituras y despliegues a modo big-bang, ir haciendo cambios razonablemente pequeños y hacer una entrega continua que nos ayude a reducir riesgos y detectar posibles problemas pronto.

Personalmente me gustó mucho el taller que preparó Eduardo Ferro sobre el tema, muy recomendable para practicar en un entorno controlado.

La interface EventBus

Como lenguaje de programación que usamos es Python y tenemos la convención de equipo de usar type hints siempre que sea posible; para definir la interface usamos una clase base EventBus que define métodos para añadir un subscriber (que a su vez utilizan una clase base Subscriber). En otros lenguajes hubiéramos usado interfaces frente a clases abstractas.

La interface de EventBus es sencilla: Expone la posibilidad de publicar eventos, añadir suscriptores al bus y limpiar todos los suscriptores del bus. Mientras que de los suscriptores se espera una tupla con los nombres de los eventos que le interesan y se espera que sean callable.

La clase abstracta EventBus queda algo así:

class EventBus(ABC):
   @abstractmethod
   def publish(self, events: List[DomainEvent]):
       pass
 
   @abstractmethod
   def add_subscriber(self, subscriber: Subscriber):
       pass
 
   @abstractmethod
   def clean_subscribers(self):
       pass

Mientras que la clase abstracta Subscribers es algo como:

class Subscriber(ABC):
   @abstractmethod
   def __call__(self, event: dict):
       pass
 
   @abstractmethod
   def subscribed_to(self) -> Tuple[str, ...]:
       pass

Punto de partida, implementación en memoria

Una implementación de Event Bus en memoria es muy naive, pero funciona a pequeña escala o cuando tienes side-effects muy ligeros.

Lo bueno es que como comentaba al inicio, nos habilita a diseñar nuestro software de forma eventual aunque realmente no lo sea, sin tener que introducir nada a nivel de infraestructura y añadir complejidad operacional. Lo malo es que todo ocurre en la misma máquina y petición, así que nos perdemos el habitual beneficio de este tipo de diseños de tener mayor facilidad de escalabilidad horizontal y mejorar el rendimiento/UX.

En nuestro caso, esta implementación InMemoryEventBus utiliza un diccionario para guardar todos los suscriptores que se le vayan añadiendo a add_subscriber, mientras que a la hora de publicar con publish lo que ocurre es que se recorre los eventos recibidos y se comprueba si los suscriptores están subscribed_to a cada unos de los type_name de los eventos para llamar a su __call__ si es así. Y tenemos una serie de tests automáticos con escenarios que comprueban cuando sí o no se recibe la llamada a un doble que actúa como suscriptor.

A tener en cuenta que, aunque todo ocurra en memoria, simulamos el serializar y deserializar los eventos para que el día que se cambie a otro adapter que requiera de infraestructura estar seguros de que los suscriptores no necesiten modificar su código. Por eso en nuestro caso el publish transforma el evento en un diccionario que es lo que los Subscriber esperan. De un modo similar en una aplicación web/http recoge todo el contenido del cuerpo de la request. También os digo que posiblemente podríamos haber envuelto el contenido del diccionario en algún objeto propio para esconder ese detalle de implementación.

Este sería el punto de partida antes de empezar con el cambio en paralelo.

Implementando el adapter para SQS

Al necesitar que ocurran cosas de forma realmente eventual nos decidimos por usar SQS, manteniendo todo en el mismo repositorio y buscando maneras de desplegar workers que ejecuten los distintos subscribers para poder escalar de forma independiente unos de otros.

El primer paso fue implementar el adapter de SQSEventBus usando los mismos escenarios de tests que los de memoria. Aunque tuvimos que introducir algún pequeño cambio en la implementación de esos tests, ya que había que hacer una llamada explícita al consumo de mensajes de la cola SQS.

La implementación es más elaborada en este caso. En add_subscriber se crea una cola específica en SQS por subscriber y se mantiene también la referencia de cada suscriptor en un diccionario. En este caso el publish elige a qué cola SQS publica el evento con la misma comprobación sobre el subscribed_to, esto es porque queremos añadir sólo los eventos en cada cola está interesada en consumir.

Todo esto lo he resumido como un paso, pero podemos haberlo ido integrando en la línea de desarrollo principal y desplegado en varios. De momento con que pase los tests nos vale que de momento no se va a ejecutar nada de este código en producción.

Implementando un adapter para publicar eventos simultáneament en memoria y SQS

El siguiente paso que queremos dar, es ver que seamos capaces de que ese adapter que hemos implementado publica los eventos en mensajes SQS. Sólo verlo, sin afectar al resto del comportamiento.

Para ello decidimos crear un HybridEventBus, que es un nuevo adapter que combina ambas implementaciones recibiéndolas en el constructor, en este paso simplemente llama a las implementaciones que compone. Con esto podría habernos valido para validar la publicación, pero no queremos crear demasiadas colas SQS por entorno de momento y decidimos utilizar sólo el suscriptor ThumbnailCreator que es el primero que queremos migrar.

Quedando el add_subscriber algo como

def add_subscriber(self, subscriber: BaseSubscriber):
   self.__in_memory.add_subscriber(subscriber)
   if isinstance(subscriber, ThumbnailCreator):
       self.__sqs.add_subscriber(subscriber)

Para testear automáticamente nos basamos en los tests anteriores comprobando que cuando se recibe un evento esperado por un doble que herede de ThumbnailCreator, éste se llama 2 veces.

De nuevo podemos integrarlo y desplegarlo, aún no se ejecuta nada de SQS en producción. En el siguiente paso es cuando empieza.

Publicación de eventos simultánea en memoria y SQS

En nuestro caso, tenemos centralizada la inyección de dependencias, de modo que cambiando de ahí la implementación todos los que reciben esa dependencia pasan a utilizar la implementación híbrida.

De:

def event_bus(self):
   return InMemoryEventBus()

A:

def event_bus(self):
   return HybridEventBus(self.in_memory_event_bus(), self.sqs_event_bus())

Eso ya sí impacta en el código de producción, así que tras integrarlo y desplegarlo se que comprueba que todo sigue funcionando correctamente y que, aunque aún no consumimos los eventos de SQS, podemos validar que se crean las colas esperadas y que están llegando los tipos de eventos que esperamos en ellas.

Consumir eventos de memoria y de colas SQS

Una vez viendo que hay mensajes que representan nuestros eventos en las colas, el siguiente paso es empezar a consumirlos. Así que se abre por fin el melón de los workers, que en nuestro caso desplegamos en Amazon Elastic Beanstalk.

Resumen rápido de cómo funciona el happy path:

  • A Elastic Beanstalk le configuras la url de la cola que debe escuchar y un endpoint http, ese endpoint recibirá vía POST el contenido de los mensajes de esa cola
  • El propio entorno provee de un SQS Dameon que se encarga de leer mensajes de la cola y mandarlos a ese endpoint
  • Si va todo ok y devolvemos en ese endpoint un status 200, SQS Dameon borra el mensaje de la cola

Para entender mejor cómo funcionaba me resultó bastante aclarador el post AWS Elastic Beanstalk Worker environment deep dive.

Así que lo que tenemos es un único endpoint para recibir esos mensajes como puerta de entrada de los mensajes, a partir de ahí buscamos qué suscriptor es el que debe consumir el evento que contiene el mensaje por una variable de entorno SUBSCRIBER_NAME, esa información también la configuramos para que aparezca en los logs. Ese endpoint podemos dejarlo testeado automáticamente y de nuevo integrar y desplegar, aún no se ejecutará en producción.

Aprovisionamiento de workers como suscriptores

El siguiente paso es el aprovisionamiento de esos entornos, que lo que hemos terminado haciendo a nivel de AWS Elastic Beanstalk es crear un entorno tipo worker nuevo por cada subscriber.

Sin entrar en detalles por no desviarme mucho de tema, en nuestro caso optamos por provisionar esos entornos y desplegar programáticamente cuando arranca el entorno principal usando el SDK oficial en python de AWS. Tiene limitaciones pero de momento es la mejor opción que hemos encontrado.

Así que una vez implementada la parte de aprovisionamiento al arrancar la aplicación ya tenemos workers consumiendo los mensajes de las colas SQS.

Por la implementación de HybridEventBus se va a llamar a ThumbnailCreator una vez por estar suscrito en memoria y otra por estarlo en SQS, como es una operación idempotente no hay gran problema con que de momento se genere 2 veces. Así que tras integrar y desplegar, podemos validar a través de los logs que se está ejecutando correctamente el subscriber dentro del worker.

Pasando a consumir sólo de colas SQS

Ahora ya que tenemos validado que se generan los thumbnails vía los nuevos workers toca empezar a hacer limpieza. Tenemos que quitar ese subscriber de la ejecución en memoria para que sea realmente eventual.

Modificando ligeramente HybridEventBus (y sus tests), podemos hacemos que no se añada el subscriber para el bus de eventos, sólo en el de SQS:

def add_subscriber(self, subscriber: BaseSubscriber):
   if isinstance(subscriber, ThumbnailCreator):
       self.__sqs.add_subscriber(subscriber)
   else:
       self.__in_memory.add_subscriber(subscriber)

Una vez más se vuelve integrar y desplegar, comprobando que todo continua correcto.

En este último paso tenemos que tener en cuenta que hemos cambiado el comportamiento del sistema:

  • Lo más evidente es que las peticiones de subida responden más rápido
  • Se ha ganado en resiliencia, si la generación del thumnbail falla la petición de subida ya no lo hace. El mensaje quedará en la cola de nuevo y se reintentará generar el thumbnail en otro momento
  • Precisamente aunque la acción de subida se haya ejecutado con éxito el thumbnail no estará disponible inmediatamente, cosa que en este caso puede llegar a impactar en la interfaz de usuario, debemos contar con ello

Este es un trabajo que todavía no hemos finalizado, tenemos algunos suscriptores más que iremos migrando poco a poco hasta que podamos borrar tanto HybridEventBus como InMemoryEventBus como pasos finales.


Aunque seguir esta práctica pueda dar la sensación de ir más lentos es algo que reduce el riesgo, podemos equivocarnos también pero sin tener que desechar tanto trabajo y puede ayudarnos a darnos cuenta antes si estamos en un callejón sin salida. No tenemos ramas sin integrar durante muchos días o incluso semanas que luego generan conflictos dolorosos, eso también facilita que en caso de aparcarlo por otras prioridades podamos retomarlo con mucha más facilidad.

La parte negativa es que, hasta que el cambio no está hecho, tenemos más complejidad en el código y deuda técnica. Así que cuidado con que esos cambios se eternicen.