Sunday, September 22, 2024

Desarrollando software: posponiendo decisiones y trabajando en pasos pequeños

En esta entrega de la serie sobre Lean Software Development, después de haber explorado prácticas para posponer decisiones en el producto, hoy hablaremos sobre cómo desarrollar software dando pasos muy pequeños, aplazando decisiones, y haciendo solo lo necesario en cada momento.

Este enfoque está alineado con los principios de Lean Software Development y eXtreme Programming (XP), siendo clave en el desarrollo ágil.

Por qué trabajar en pasos pequeños

Trabajar en pequeños pasos es esencial en entornos de incertidumbre. Tanto nosotros como el cliente no siempre sabemos con exactitud qué se necesita para lograr el impacto deseado. Al avanzar en incrementos reducidos, obtenemos feedback valioso tanto del sistema —sobre su funcionamiento y comportamiento— como del cliente. Este enfoque nos permite aprender y ajustarnos constantemente, evitando decisiones prematuras que podrían limitar nuestras opciones o ser difíciles de revertir.

Es un proceso de aprendizaje continuo donde evitamos el diseño especulativo y las funcionalidades innecesarias. Al avanzar poco a poco, aceptamos que no sabemos todo desde el principio y optamos por experimentar y validar de forma constante.

Beneficios de trabajar en pasos pequeños

Trabajar en pasos pequeños con feedback continuo ofrece numerosos beneficios. Geepaw Hill, en su artículo "MMMSS: The Intrinsic Benefit of Steps", describe de forma brillante los efectos de esta práctica en los equipos. A continuación, hago un resumen, aunque recomiendo leer el artículo completo o la serie "Many More Much Smaller Steps".

Geepaw menciona ocho beneficios de trabajar en pasos de menos de un par de horas:

Beneficios en la capacidad de respuesta:

  • Interruptibilidad: Puedes manejar una interrupción o cambiar de tema sin romper el flujo de trabajo.
  • Capacidad de maniobra (Steerability): Después de cada pequeño paso, puedes reflexionar, incorporar feedback y ajustar la dirección si es necesario.
  • Reversibilidad: Si un paso no cumple con las expectativas, revertirlo implica una pequeña pérdida de tiempo.
  • Paralelismo controlado (Target Parallelism): Al avanzar en pequeños pasos consistentes, es posible trabajar en diferentes áreas del sistema o para distintos stakeholders sin dejar tareas a medias.

Beneficios humanos:

  • Alcance: Obliga a reducir la carga cognitiva, limitando las combinaciones y casos que debemos considerar.
  • Ritmo: Establece un ritmo constante en el equipo, con ciclos de recompensas rápidas (tests exitosos, commits, despliegues, etc.).
  • Seguridad: Los cambios pequeños conllevan menos riesgo que los grandes. Con tests frecuentes y despliegues diarios, el riesgo máximo es revertir el último cambio.
  • Autonomía: Permite que el equipo tome decisiones continuas, lo que requiere un esfuerzo constante por comprender y empatizar con el usuario para abordar problemas o implementar mejoras.

Trabajando en pasos pequeños y posponiendo decisiones

Desde aproximadamente 2009-2010, he intentado aplicar la práctica de trabajar en pasos muy pequeños en todos los equipos con los que colaboro. Estos pasos suelen durar pocas horas, permitiendo lanzar a producción varias veces al día y lograr cambios visibles para el cliente en uno o dos días, como máximo. Este enfoque ágil minimiza el riesgo y maximiza la capacidad de respuesta, pero requiere disciplina y la aplicación rigurosa de las prácticas de desarrollo ágil que propone eXtreme Programming (XP).

Prácticas y tácticas para trabajar en pasos pequeños

A continuación, presento algunas prácticas y estrategias que nos permiten trabajar de esta manera. A veces es difícil separarlas, ya que están estrechamente interrelacionadas y se complementan entre sí.

Desarrollo Iterativo e Incremental

La técnica más importante que usamos es también la más sencilla y, al mismo tiempo, la menos común. En lugar de partir de una solución completa y dividirla para implementarla en pasos, hacemos crecer la solución progresivamente hasta que sea suficientemente buena y podamos pasar a invertir en otro problema. Es decir, nos centramos en, teniendo la solución y el problema en mente, poner en producción (al cliente final) incrementos que estén alineados con la idea de la solución que buscamos. Usamos el feedback para asegurarnos de que vamos en la dirección correcta. Además, no tener miedo a iterar en base a este feedback nos permite trabajar en pasos pequeños y de bajo riesgo.


Por ejemplo, en este caso, partiendo de un problema inicial con una potencial solución, vamos generando los incrementos (Inc 1, Inc 2, etc.) de menos de un día. Cada incremento se entrega al usuario para obtener feedback, lo que nos ayuda a decidir el siguiente paso y si la solución ya es suficientemente buena. Así, evitamos desperdicio (zona gris) al no hacer tareas innecesarias, lo que reduce el coste basal del sistema.



Segmentación vertical (Vertical Slicing)

La segmentación vertical consiste en dividir las funcionalidades y soluciones de manera que podamos tener una aproximación incremental al desarrollo y que cada pequeño incremento aporte valor por sí mismo. Este valor puede reflejarse en mejoras para el usuario, aprendizaje para el equipo, reducción de la incertidumbre, entre otros. En lugar de dividir las historias por capas técnicas (infraestructura, backend, frontend), las dividimos por incrementos que aportan valor y, por lo general, requieren trabajo en todas las capas.

En mis equipos, aplicamos esta segmentación de manera rigurosa, procurando que ningún incremento tome más de dos días y, preferiblemente, menos de un día. Utilizamos diversas heurísticas y procesos para realizar el vertical slicing (https://www.humanizingwork.com/the-humanizing-work-guide-to-splitting-user-stories/), como el método de la hamburguesa de Gojko Adzic, que describiré más adelante.

Aunque usemos esta segmentación vertical para dividir en incrementos lo que queremos implementar, esto no implica que siempre implementemos todos los incrementos identificados. Al contrario, el objetivo es siempre hacer crecer la solución lo mínimo posible para conseguir el impacto deseado.



Abraham Vallez describe muy bien como hacer crecer progresivamente una solución en esta serie de posts 

Segmentación técnica

Como complemento a la segmentación vertical (Vertical Slicing), en mis equipos también dividimos esos incrementos que aportan valor al usuario en tareas más pequeñas que igualmente ponemos en producción. Estas tareas tienen un enfoque más técnico y suelen durar menos de dos o tres horas.

Desplegar estos incrementos técnicos nos permite obtener feedback principalmente del sistema: ¿sigue funcionando bien nuestro pipeline de CI?, ¿genera algún problema evidente el código que hemos desplegado?, ¿afecta de alguna manera al rendimiento?

Esta práctica nos obliga a mantener un coste de despliegue bajo (en tiempo y esfuerzo) y nos permite garantizar en todo momento que el flujo de trabajo sigue funcionando correctamente. Es posible porque contamos con un sistema de pruebas automatizado sólido, pipelines de CI rápidos y trabajamos con Integración Continua/Trunk-Based Development, como explicaremos posteriormente.

Poder aplicar esta segmentación técnica también es esencial para hacer cambios en paralelo, realizar modificaciones importantes en pasos pequeños y seguros, y así reducir significativamente el riesgo.

Generación de opciones

Generar opciones es fundamental para tomar decisiones bien fundamentadas. Cada decisión debe considerar múltiples alternativas; nosotros solemos intentar tener al menos tres o cuatro. Para facilitar la generación de opciones, podemos plantearnos preguntas como:

  • ¿Qué otras opciones podrías considerar si tuvieras la mitad del tiempo?
  • ¿Qué opciones requieren nuevas dependencias?
  • ¿Qué soluciones has implementado en problemas similares en el pasado?
  • ¿Cuál es el mínimo grado de sofisticación necesario para la solución?
  • ¿Quiénes podrían beneficiarse del cambio? ¿Podríamos entregarlo a cada grupo de usuarios de forma independiente?

Estas preguntas nos ayudan a generar opciones que luego el equipo puede evaluar, intentando siempre seleccionar aquellas que aporten valor rápidamente (aprendizaje, capacidad, eliminación de incertidumbre, etc.) comprometiéndonos lo mínimo posible.

Esta forma de trabajar nos permite avanzar en pequeños pasos, teniendo siempre visibilidad sobre distintas opciones que podemos tomar para continuar con el problema o redirigirlo si los pasos dados no están logrando el impacto esperado. Como ves, todo converge en trabajar con pequeños avances, aprendiendo, tomando decisiones lo más tarde posible e intentando que las soluciones sean lo más simples posibles.

Una herramienta que usamos mucho para generar opciones y realizar la segmentación vertical (vertical slicing) es el método de la hamburguesa (https://gojko.net/2012/01/23/splitting-user-stories-the-hamburger-method/) de Gojko Adzic (https://gojko.net/).

Con este método, intentamos dividir una funcionalidad o solución en los pasos necesarios para aportar valor al usuario. Esos pasos los visualizamos como “capas” de la hamburguesa, y para cada una de ellas nos forzamos a generar al menos tres o cuatro opciones. Luego seleccionamos al menos una opción de cada capa para decidir cuál será el primer incremento a implementar. Una vez implementado y entregado ese primer incremento, y con el feedback del usuario en mano, repetimos el proceso para implementar alguna de las otras opciones.

Este proceso continuo no termina cuando implementamos todas las opciones identificadas, sino cuando la funcionalidad es lo suficientemente buena o hay otra funcionalidad o problema más prioritario en el que invertir. Es decir, invertimos en lo más importante hasta que el usuario está satisfecho o hasta que surge una nueva prioridad.


Simplicidad

La simplicidad es uno de los valores fundamentales de XP y, por lo tanto, de la agilidad bien entendida. Un mantra del desarrollo ágil es “haz la cosa más simple que pueda funcionar”. Esto significa empezar con la solución más sencilla y mínima que funcione, iterando y mejorando en base al feedback.

No siempre la solución más simple es la más fácil de implementar. A veces, evitar la complejidad innecesaria requiere un esfuerzo significativo. La verdadera simplicidad es el resultado de un diseño consciente que evoluciona gradualmente.

Desarrollo en dos pasos

Kent Beck nos aconseja hacer “la cosa más simple que pueda funcionar”, pero esto a menudo se confunde con “lo primero que se me ocurra” o “lo único que sé hacer”. Una forma efectiva de asegurarnos de que estamos eligiendo la opción más simple posible es dividir cualquier cambio o incremento en dos partes:

  1. Preparación: Ajustar la base de código actual para que la nueva funcionalidad sea fácil de introducir.
  2. Implementación: Introducir el cambio real.

https://x.com/eferro/status/1810067147726508033

Esta separación evita el diseño especulativo y garantiza que solo se realicen los cambios mínimos necesarios para integrar la nueva funcionalidad, siguiendo el principio de Kent Beck: “Haz que el cambio sea fácil, y luego haz el cambio fácil”.


Principio YAGNI (You Aren't Gonna Need It / No lo vas a necesitar)

Relacionado con el punto anterior, el principio YAGNI nos recuerda que muchas ideas que se nos ocurren probablemente no serán necesarias, y que debemos enfocarnos solo en lo que necesitamos ahora. Nos ayuda a evitar el diseño especulativo y a centrarnos en lo que es realmente relevante en el momento. Incluso si identificamos algo que podríamos necesitar en el futuro cercano, YAGNI nos insta a cuestionarnos si es realmente relevante para las necesidades actuales, recordándonos que deberíamos posponerlo. Si el sistema es sencillo y fácil de evolucionar, más adelante será fácil introducir esos cambios.

Desarrollo Dirigido por Pruebas (TDD) y Outside-In TDD

El Desarrollo Dirigido por Pruebas (TDD) es una práctica que consiste en escribir primero una prueba que defina el comportamiento deseado de una funcionalidad, antes de escribir el código que lo implemente. A partir de ahí, el desarrollador escribe el código mínimo necesario para que la prueba pase, seguido de un proceso de refactorización para mejorar el diseño del código sin cambiar su comportamiento. Este ciclo se repite continuamente, lo que garantiza que cada línea de código tiene un propósito claro y definido, evitando el código innecesario o superfluo.

Outside-In TDD es una variante de TDD que comienza desde los casos de uso de negocio más amplios y se adentra en la implementación del sistema. Al partir de las necesidades del negocio y escribir únicamente el código necesario para pasar cada prueba en cada nivel (desde el nivel funcional más alto hasta las piezas individuales de código), se asegura que solo se crea el código esencial. Este enfoque previene la creación de código innecesario o la introducción de características que no son requeridas en el momento, evitando así el diseño especulativo y asegurando que se sigue siempre el principio YAGNI (You Aren't Gonna Need It).

En nuestro equipo, usamos Outside-In TDD como la forma de trabajo predeterminada para todo el código nuevo, excepto en aquellos casos donde este flujo no resulta beneficioso (spikes, algoritmos complejos, etc.). Esto implica que aproximadamente un 5-10% del código puede ser experimental para aprender, el cual es desechado posteriormente y no suele tener pruebas. Otro 10% del código corresponde a tareas donde las pruebas se escriben después (por ejemplo, integración de librerías o algoritmos complejos). El resto, la mayoría del código, se desarrolla con TDD Outside-In.

Este enfoque minimiza el desperdicio y, por defecto, sigue el principio YAGNI, ya que no se puede crear código ni diseño que no corresponda al incremento actual. Dado que el incremento actual está definido mediante una segmentación vertical radical, trabajamos en pasos pequeños, con poco desperdicio y tomando decisiones lo más tarde posible.

Como ventaja adicional, este proceso facilita la resolución rápida de errores, tanto en el código como en el diseño, ya que se verifican constantemente los avances paso a paso. Cuando se detecta un error, lo más probable es que esté en la última prueba o en el último cambio realizado, lo que permite una recuperación rápida y sin estrés.

Integración Continua, también conocida como Trunk-Based Development

Si hay una práctica técnica que obliga y ayuda a trabajar en pasos pequeños, con feedback constante, permitiendo decidir lo más tarde posible mientras aprendemos y nos adaptamos a la máxima velocidad, esa es la Integración Continua.

Primero, es importante aclarar que la Integración Continua es una práctica de XP (eXtreme Programming) que consiste en que todos los miembros del equipo integren su código en una rama principal de forma frecuente (al menos una vez al día). En otras palabras, esta práctica es equivalente a hacer Trunk-Based Development, donde solo existe una rama principal sobre la que todos los desarrolladores realizan cambios (normalmente en pareja o en equipo).

Esta práctica no tiene nada que ver con ejecutar tests automáticos en feature branches. De hecho, diría que es directamente incompatible con trabajar en ramas separadas para cada funcionalidad.

Desafortunadamente, este enfoque no es el más común en la industria, pero puedo asegurar que, junto con el TDD, es una de las prácticas que más impacto tiene en los equipos. En todos los equipos en los que he trabajado, la introducción de Integración Continua/TBD ha provocado un cambio espectacular. Nos ha obligado a trabajar en pasos muy pequeños (pero seguros), dándonos la agilidad y adaptabilidad que buscábamos.

Evidentemente, como cualquier práctica, requiere esfuerzo y el aprendizaje de una serie de tácticas para poder desplegar a producción de forma muy frecuente sin mostrar funcionalidades incompletas al usuario. Es necesario dominar estrategias que separen el deployment (decisión técnica) de la release al usuario (decisión de negocio). Las más comunes son:

  • Feature toggles: Permiten activar o desactivar funcionalidades, realizar pruebas A/B o mostrar nuevas características solo a ciertos clientes (internos, beta testers, etc.).
  • Despliegue gradual: Métodos como Canary releases o Ring deployments, que permiten realizar un despliegue progresivo del cambio.
  • Dark launches: Lanzar una funcionalidad sin que esté visible al cliente, solo para realizar pruebas de rendimiento o compatibilidad.
  • Shadow launches: Ejecutar un nuevo algoritmo o proceso en paralelo al anterior, pero sin mostrar los resultados al cliente final.

Diseño evolutivo

Esta práctica central de eXtreme Programming (XP) nos permite desarrollar software de manera incremental, refactorizando continuamente el diseño para hacerlo evolucionar conforme a las necesidades del negocio. En la práctica, consiste en crear el diseño más simple posible que cumpla con los requisitos actuales, y luego evolucionarlo en pequeños pasos a medida que aprendemos y agregamos nuevas funcionalidades.

Dentro del diseño evolutivo se pueden aplicar tácticas como:

  • Desarrollo en dos pasos.
  • Refactorización continua en el ciclo de TDD.
  • Refactorización oportunista.
  • No crear abstracciones demasiado pronto (Ver https://www.eferro.net/2015/05/aplicacion-del-principio-dry.html).
  • Cambios paralelos para mantener los tests en verde todo el tiempo mientras efectuamos cambios en varios pasos.
  • Ramificación por abstracción (Branch by abstraction) y el patrón Expandir/Contraer para facilitar cambios en paralelo.

Es importante destacar que, más allá de las tácticas que utilices para guiar el diseño en pequeños pasos, es fundamental desarrollar una sensibilidad por el diseño en el equipo. Ninguna de estas prácticas, por sí sola, enseña diseño orientado a objetos. Por lo tanto, el equipo no solo debe aprender a realizar cambios incrementales en el diseño, sino también adquirir un conocimiento profundo de los principios del diseño orientado a objetos.

Diseño evolutivo diferenciado

En general, en mis equipos intentamos siempre trabajar en pasos pequeños, enfocándonos en lo que necesitamos en ese momento y dejando que las nuevas necesidades guíen los cambios de arquitectura y diseño. Al mismo tiempo, reconocemos que la facilidad de evolución y la fricción generada ante el cambio dependen mucho del tipo de código afectado. Sabemos que no es lo mismo modificar código que implementa reglas de negocio, un API interno entre equipos o un API para el cliente final.



Cada uno de estos casos tiene más o menos fricción al cambio (es decir, distinta facilidad de evolución). Por tanto, aplicamos un diseño evolutivo diferenciado según el tipo de código.

Para el código con mayor fricción al cambio, como un API destinado al cliente final, dedicamos más tiempo a un diseño robusto que permita evolucionar sin necesidad de cambios frecuentes. En cambio, para el código interno de lógica de negocio, que solo se usa en casos específicos, aplicamos una aproximación evolutiva más flexible, permitiendo que el diseño emerja del propio proceso de desarrollo.


Otras tácticas y prácticas

Por supuesto, estas no son las únicas tácticas y prácticas a tener en cuenta, pero sí considero que son las que más nos ayudan. Existen algunos trucos y heurísticas que, aunque no los considero prácticas en sí mismas, contribuyen a la toma de decisiones y, en general, facilitan trabajar en pequeños pasos, posponiendo decisiones lo más tarde posible:

  • Priorizar librerías antes que frameworks, para no cerrar opciones y mantener mayor flexibilidad.
  • Enfocarse en hacer el código usable (y entendible) antes que en hacerlo reusable (a menos que tu negocio sea vender librerías o componentes para otros desarrolladores).
  • Usar tecnología aburrida y sólida, que esté ampliamente aceptada por la comunidad.
  • Crear wrappers ligeros sobre componentes/librerías externas para definir claramente qué parte de ese componente usamos y facilitar el testing. Puedes consultar más sobre este enfoque en https://www.eferro.net/2023/04/thin-infrastructure-wrappers.html.
  • Separar la infraestructura del código de negocio mediante Puertos y Adaptadores u otra arquitectura que permita diferenciarlos claramente.
  • Aplicar Arquitectura evolutiva para partiendo de una arquitectura mínima ir adaptandola a las necesidades de negocio, posponiendo al máximo posible las decisiones difíciles de revertir.


Conclusiones

En el desarrollo de software, la clave está en adoptar un enfoque consciente respecto a nuestras decisiones, trabajando en pasos pequeños y de bajo riesgo, y centrándonos únicamente en lo que necesitamos ahora. La simplicidad y la claridad deben ser prioridades para maximizar la eficiencia y minimizar el desperdicio.

Las prácticas de eXtreme Programming (XP), junto con los principios de Lean Software Development, nos proporcionan una guía clara para evitar el desperdicio y la sobreingeniería. Al entender que no podemos predecir el futuro con certeza, nos enfocamos en construir sistemas que sean fáciles de entender y de evolucionar, evitando la complejidad innecesaria. Trabajar de esta manera implica huir de soluciones sobredimensionadas o altamente configurables, que a menudo se convierten en obstáculos para la evolución del sistema.

Finalmente, se trata de ser humildes: asumir que no tenemos todas las respuestas y que la única forma de encontrar la solución correcta es mediante la experimentación y el aprendizaje continuo. En resumen, la simplicidad, la agilidad y la capacidad de respuesta son fundamentales para desarrollar software de manera efectiva en un entorno siempre cambiante.

Si tuviera que elegir las técnicas y prácticas que más impacto tienen en mis equipos para trabajar en pasos pequeños, seguros y posponiendo decisiones, diría que son:

  • Segmentación vertical
  • Integración Continua / Trunk-Based Development
  • TDD

Todo ello con un foco constante en la simplicidad.

Cada una de las prácticas y tácticas mencionadas en este artículo es amplia y podría explorarse en mayor profundidad. Me encantaría saber si hay interés en conocer más detalles sobre alguna de ellas, ya que sería muy enriquecedor profundizar en aquellas que resulten de mayor utilidad o curiosidad para los lectores.


Referencias y notas


No comments: