Equipos multidisciplinares de producto en Mosaic

Desde hace bastante tiempo que de vez en cuando algunas personas me habían ido dejando caer lo de que me animara a publicar algo o preparar alguna charla relacionada en cómo colaborar mejor entre diseño y desarrollo en esto del desarrollo de software o producto digital. Para charla la verdad que nunca me había motivado mucho, pero en un par de ocasiones arranqué borradores con enfoques diferentes, pero al final los terminé dejando aparcados por no terminar de encontrar un hilo conductor.

Pero cuando hace unos meses me llegó la propuesta de César de escribir sobre equipos multidisciplinares de producto, con foco principalmente en quiénes tienen perfiles de desarrollo y diseño, para la revista Mosaic de la Universitat Oberta de Cataluña, me decidí a hacer un nuevo intento.

Esquema representando un flujo desde descubrimiento a despliegue y aprendizaje

Para mi era un reto combinar el darle un enfoque algo generalista, dado la variedad de audiencia que tiene la revista, con intentar que aportase algo a profesionales del sector que lleguen a leerlo. Por eso evité usar demasiados tecnicismos más allá de algunas referencias relacionadas con Lean Software Development, Design Systems/Tokens y Hot Potato Process que me servían como puntos de apoyo para desarrollar el artículo y llegar a la conclusión final:

“En el mundo de creación de proyectos o productos digitales, los equipos multidisciplinares van a seguir siendo tendencia por su mejor adaptación al actual entorno cambiante. Para que estos funcionen bien, debemos tratar de trabajarlos a nivel personal, con el resto del equipo y a nivel organizacional.

Y, en esencia, en cualquiera de los niveles, debemos buscar tres factores:

  • Tratar de que las personas que formen el equipo estén alineadas hacia una meta común. Así como que su meta esté dentro de los objetivos de la organización.
  • Tratar de que el conjunto de capacidades individuales haga viable alcanzar esa meta, o que al menos puedan llegar a capacitarse para hacerlo.
  • Tratar de que siempre haya respeto, y de conseguir que exista empatía entre las personas del equipo y del resto de la organización con las que se interactúe.

Sí, factores infinitamente más fáciles de verbalizar que de conseguir.”

Lo puedes leer en castellano: Equipos multidisciplinares de desarrollo de producto digital

També ho pots llegir en catalá: Equips multidisciplinaris de desenvolupament de producte digital

And also in english: Multidisciplinary digital product development teams

El lado estratégico de Domain-Driven Design. CommitConf 2024

Hace unas semanas estuve en Commit Conf en Madrid, evento al que no iba desde hace mucho. Estuve compartiendo la charla El lado estratégico de Domain-Driven Design, iterando ligeramente la que hice hace unos meses en La Vertical, a su vez basada en una charla más larga (y espesa) que he impartido in-company en varias ocasiones.

Foto de la sala de conferencias del track 1 del evento

En los últimos años ha crecido el interés y la adopción de Domain-Driven Design en la comunidad de desarrollo de software. Ahora es bastante habitual oír hablar del lado táctico de DDD, la mayoría de las veces acompañado del uso de ports & adapters (aka Hexagonal Architecture). Pero, al menos en castellano, no he visto hablar apenas de DDD estratégico y lo que nos puede aportar a nivel de Sociotechnical Architecture.

Así que de ahí vino buena parte de mi motivación de proponer repetirla en un evento mucho más masivo como es la Commit Conf.

La presentación está dividida en 4 bloques:

  • Introducción a DDD y específicamente a la parte estratégica
  • Resumen de las actividades estratégicas basado en el Domain-Driven Design Starter Modelling Process de DDD Crew
  • Un ejemplo práctico de DDD estratégico basado en un caso real, mostrando su división y conexión entre dominios visibilizándolo con un Context Map un tanto enriquecido con su clasificación desde diferentes puntos de vista
  • Otras consideraciones a tener a nivel de la organización de equipos

Aquí os dejo el vídeo de la charla

Y el genially que usé para la presentación

Reduciendo riesgos con tests de carga

Hace varias semanas estuve involucrado en realizar algunos tests de carga en Genially, algo que no había tenido necesidad de hacer desde que trabajé en Inditex lanzando un nuevo servicio interno.

Esto venía dado por unos cambios en los que estuvimos trabajando un par de equipos para mejorar la experiencia de uso de una parte del producto, lo cual implicó un cambio bastante importante a nivel de arquitectura.

Con estos cambios teníamos 2 riesgos:

  • Que aunque la experiencia de uso de la funcionalidad mejorase esto pudiera impactar negativamente en un funnel de conversión.
  • Que la nueva solución que habíamos implementado pudiera causar problemas dependiendo de la carga y tuviéramos incidencias.

El primer riesgo lo minimizamos realizando un rollout incremental, que es como lanzamos la mayoría de cambios relevantes en Genially. Esto, en este caso, significó lanzar los cambios internamente bajo una feature flag para obtener feedback cualitativo y luego abrirlo a un porcentaje del tráfico para observar las métricas de producto.

El segundo riesgo, como mencionaba al principio, lo minimizamos realizando algunos tests de carga.

¿Pero qué es un test de carga?

Es un tipo de prueba en la que se genera tráfico de forma artificial para evaluar la respuesta o capacidad de un sistema ante una carga determinada de trabajo o de personas usuarias, por lo que puede servir para comprobar tanto el rendimiento como el escalado de un sistema.

Para esto, antes de ejecutar la prueba, necesitamos tener definido previamente qué y cómo lo vamos a observar para poder consultarlo tras su ejecución (tiempos de respuesta, consumo de recursos, etc). Así que el entorno sobre el que vayamos a probar tiene que ser observable; en este caso, lo que más nos va a interesar son las métricas y, en caso de que empiece a degradarse el servicio, también las trazas pueden ayudar a identificar el origen del problema con mayor facilidad.

En ocasiones, este tipo de pruebas se tienden a hacer con personas reales de manera algo informal, en plan “entrad aquí X personas a hacer Y metiéndole caña y vamos a ver cómo van las métricas Z”. Eso puede ser perfectamente válido para tener una idea general de cómo responde el sistema con una carga un tanto aleatoria, pero tiene el problema de que no es repetible ni controlado, por lo que de ese modo no podemos dar seguimiento a los resultados obtenidos de forma consistente.

Para tener consistencia en este tipo de pruebas, hay herramientas que nos permiten automatizarlas, de ese modo obtenemos escenarios controlados y repetibles a los que sí podemos dar seguimiento. Con estas herramientas, podremos definir distintos escenarios en los que queremos probar el sistema y observar si se mejora o empeora comparando los resultados de antes y después de un cambio.

En cuanto a herramientas concretas, en el pasado usé Apache HTTP Server Benchmarking Tool y JMeter, pero en la última ocasión lo hice con k6 por recomendación de mi compañero Manu Franco. La verdad es que me pareció una herramienta fácil de empezar a usar, y viendo su documentación también muy potente, así que de momento se ha convertido en mi preferencia.

Tipos de tests de carga

Dentro de los tests de carga, se pueden clasificar en subtipologías dependiendo del objetivo de la prueba y del patrón de generación de tráfico utilizado. Me gusta mucho la gráfica y la explicación de la propia documentación de k6.

Esquema representando los distintos tipos de tests

  • Smoke tests: son pruebas sobre el sistema de corta duración (segundos o pocos minutos) con una carga baja, con el objetivo de comprobar que todo funciona razonablemente bien sin consumir muchos recursos. De primeras, no los hubiera incluido como test de carga, pero dada la aproximación de esta herramienta de generar tráfico concurrente, les compro el incluirlo. En este caso, se podrían lanzar de forma bastante recurrente para detectar errores de configuración a nivel de aplicación o anomalías en las métricas de forma temprana.
  • Average-load test: son pruebas sobre el sistema de duración media (minutos-hora) con una carga similar a la habitual, con el objetivo de asegurar que los cambios introducidos no impactan negativamente en el contexto habitual del sistema. Esto podría hacerse de forma periódica para encontrar potenciales problemas que se hayan podido introducir.
  • Stress test: son pruebas sobre el sistema de duración media (minutos-hora) con una carga por encima de la habitual, con el objetivo de comprobar el comportamiento del sistema con un tráfico bastante superior al habitual. Esto nos puede ser útil, por ejemplo, para prepararnos para campañas como navidad o rebajas en el mundo del comercio electrónico.
  • Spike test: de duración corta (unos pocos minutos) con una carga que sobrepase mucho la habitual del sistema. Su objetivo es ver cómo se comporta con un pico de tráfico masivo durante un tiempo más limitado. Escenarios para los que esto puede ser útil pueden ser prepararse para las primeras horas del Black Friday, si se va a lanzar un anuncio en prime time en televisión, etc.
  • Breakpoint test: de duración indeterminada y una carga incremental hasta llegar a que el sistema se rompa o llegue al límite que hayamos definido. En este caso, el objetivo es llevar el sistema al extremo máximo para conocer en qué momento nuestro sistema no da más de sí o hasta dónde permitimos escalarlo si la infraestructura del sistema puede ir hacia “infinito”. Los escenarios podrían ser comprobar optimizaciones de partes del sistema o trabajar en un plan de contingencia si en algún momento el sistema se acerca a su límite.
  • Soak tests: de larga duración (varias horas) y una carga similar a la habitual. Su objetivo es detectar problemas surgidos a partir de un uso extendido del sistema, como el aumento del consumo de infraestructura o la degradación de los tiempos de respuesta. Esto nos puede interesar especialmente cuando no somos los dueños de la infraestructura en la que corre nuestro sistema y queramos comprobar que quienes lo vayan a operar no se encuentren sorpresas posteriormente.

¿Cómo lanzar los tests de carga?

En un mundo ideal lo probaríamos en algún entorno aislado que se asemeje mucho a producción a nivel de infraestructura, pero no siempre podremos contar con esa posibilidad. Y la frencuencia de ejecución dependerá de cada contexto.

Por ejemplo, cuando trabajaba en Inditex, disponíamos de un entorno específico para este tipo de pruebas. Y dado que no era posible realizar llamadas entre entornos distintos debido a que estaba limitado a nivel de red, sabíamos que podíamos probar nuestros servicios de forma aislada sin necesidad de coordinarnos con equipos no involucrados en estas pruebas.

Por otro lado, para llevar a cabo pruebas preliminares del cambio de arquitectura al que me refería en Genially, las estuvimos realizando en un entorno efímero. A nivel de infraestructura, estos entornos efímeros son bastante limitados en comparación con el de producción, pero nos permitía realizar algunas validaciones en un entorno aislado también sin necesidad de coordinación. Utilizamos este entorno para ejecutar una serie de smoke tests y un mini average-load test para obtener las métricas base. Luego introdujimos los cambios relevantes y comprobamos si surgía alguna anomalía para ver si había que iterar algo, una vez visto que no había nada raro podíamos ir a producción con mayor confianza y darle seguimiento al uso real de las primeras horas.

En los casos que describo lanzábamos las pruebas de forma manual y luego analizábamos los resultados. Pero también existen contextos donde estas pruebas se lanzan automáticamente incluso en pipelines de continuous delivery. Así que se puede echar para atrás una release si un test falla dado el límite marcado como aceptable en una métrica. Por ejemplo si dada una carga se supera el máximo de latencia de peticiones, no se consigue ingestar un mínimo de peticiones por segundo, etc.

Concluyendo

Hay lugares donde este tipo de pruebas son muy relevantes por su contexto y forman parte del camino de entrega del software. En mi caso no han formado nunca parte de mi flujo habitual de trabajo, pero han habido ocasiones en las que me han resultado muy útiles para lanzar nuevos servicios, nuevas funcionalidades o para introducir cambios relevantes en la arquitectura con una mayor confianza.

Aunque nunca hay que olvidar que, como cualquier prueba automática, estas pruebas pueden ayudar a minimizar el riesgo pero no garantizan la ausencia total de problemas de degradación o errores. Ya que el tráfico artificial nunca será igual al generado a partir del comportamiento real de las personas que utilizan nuestro software, así que es importante invertir primero en observabilidad y en comprender cómo se comportan nuestros sistemas de software en producción.

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.