¿Puede el diseño surgir del Desarrollo Guiado por Pruebas (TDD, por sus siglas en inglés)? ¿Deberíamos utilizar siempre TDD para diseñar software? ¿Debemos diseñar por adelantado? ¿Cuánto diseño debemos hacer y cuándo?
Estas son preguntas que con frecuencia generan debate. Las personas que participan en estas discusiones suelen tener definiciones muy diferentes del diseño y también del ámbito en el que se produce. Es difícil mantener un debate sensato sin acordar primero algunas definiciones.
Definición de Diseño de Software
Comprobemos lo que dice Wikipedia:
El diseño de software es el proceso mediante el cual un agente crea una especificación de un artefacto de software, destinado a cumplir objetivos, utilizando un conjunto de componentes primitivos y sujeto a restricciones. El diseño de software puede referirse a "toda la actividad implicada en la conceptualización, el encuadre, la implementación, la puesta en marcha y, en última instancia, la modificación de sistemas complejos" o a "la actividad que sigue a la especificación de los requisitos y que precede a la programación, como ... [en] un proceso estilizado de ingeniería de software". - Wikipedia.
Esta definición me parece un poco engorrosa y de alguna manera me hace pensar en el proceso waterfall, con una gran fase de diseño antes de implementar cualquier código.
Una rápida búsqueda en Google puede mostrarnos muchas otras definiciones y la mayoría de ellas dicen cosas similares pero de diferentes maneras. Para ser breve, no las añadiré todas aquí. Utilizaré mi propia definición:
El diseño de software es el proceso continuo de especificación de los módulos de software responsables de proporcionar un comportamiento bien definido y cohesionado de forma que puedan ser fácilmente comprendidos, combinados, modificados y sustituidos a lo largo del tiempo.
El problema con esta definición es que no sólo define el diseño de software, sino que también hace referencia a una forma de trabajar, es decir, implica que el diseño debe hacerse de forma incremental como parte del proceso de desarrollo y no totalmente por adelantado. Para los que no les guste esa forma de trabajar, podemos dejarla como parte opcional de la descripción por ahora.
El diseño de software es el proceso [continuo] de especificar los módulos de software responsables de proporcionar un comportamiento bien definido y cohesivo de manera que puedan ser fácilmente comprendidos, combinados, modificados y sustituidos [a lo largo del tiempo].
Niveles e impactos del diseño de software
El diseño de software se produce en muchos niveles diferentes y no todas las técnicas de diseño son adecuadas para todos los niveles. Aunque los sistemas pueden presentar múltiples niveles de detalle, simplificaré los distintos niveles en los que puede producirse el diseño.
Arquitectura: El diseño en este nivel es el proceso de definir la responsabilidad de los diferentes sistemas y cómo van a interactuar entre sí. Aquí también podríamos definir la pila tecnológica, los mecanismos de persistencia, las estrategias generales de seguridad, el rendimiento, el registro, la supervisión y el despliegue. Las malas decisiones o los cambios en este nivel pueden afectar significativamente a todo el proyecto.
Macro diseño: El diseño a este nivel es el proceso de definición de la estructura general de un sistema único. Esta estructura puede estar relacionada con las capas, la separación del mecanismo de entrega del dominio central, la arquitectura hexagonal, los componentes, las capas de indirección, las abstracciones de alto nivel, los paquetes/espacios de nombres. También podríamos definir la pila tecnológica y algunas de las preocupaciones arquitectónicas descritas anteriormente, pero para un solo sistema. Las decisiones a este nivel deberían afectar al propio sistema, no al resto del ecosistema.
Micro diseño: El diseño a este nivel es el proceso de definir los detalles a nivel de código, centrándose en los aspectos internos de los conceptos de alto nivel definidos a nivel macro. El diseño a nivel micro significa definir clases, métodos, funciones, parámetros, tipos de retorno y algunos pequeños componentes. Las decisiones a este nivel suelen tener muy poco impacto a nivel de sistema y ningún impacto a nivel de arquitectura.
Enfoques de diseño
Además de los diferentes niveles en los que se produce el diseño, también hay diferentes formas de tomar decisiones de diseño. La mayoría están relacionadas con el momento en que tomamos estas decisiones.
Big Design Up Front (BDUF): Normalmente asociado al proceso waterfall, el BDUF es el proceso de diseño de todo un sistema por adelantado (antes del desarrollo), y puede abarcar desde conceptos de alto nivel (arquitectura, servicios, protocolos, base de datos) hasta conceptos de bajo nivel (clases, métodos).
Diseño estratégico: El proceso de definición de las principales partes del ecosistema y su arquitectura global. A diferencia del BDUF, el Diseño Estratégico no entra en el detalle. Su objetivo es proporcionar una visión técnica basada en los requisitos empresariales más importantes. Esta visión de alto nivel describe los servicios, sus responsabilidades, la forma en que se comunican, los componentes arquitectónicos, los principales flujos de negocio, la persistencia, las API, etc.
Diseño Just-in-Time: El proceso de definición de los módulos de software en el momento en que se van a construir. Este proceso de diseño puede utilizarse en el macro o micro diseño. El diseño Just-in-Time es bastante común en Outside-In TDD (London School), donde la colaboración entre módulos se define durante la fase Red de TDD. La fase de refactorización suele consistir en pequeños refinamientos de diseño cuando se utiliza Outside-In TDD.
Diseño emergente: Proceso de definición de módulos de software basados en el código que se acaba de escribir. Este proceso de diseño suele aplicarse a un nivel de micro diseño y se eleva gradualmente hasta el macro diseño. El diseño emergente se encuentra habitualmente en el TDD clasicista (Escuela de Chicago), en el que el diseño se realiza durante la fase de refactorización de TDD, como una forma de mejorar el código de trabajo existente escrito en la fase verde.
Niveles de complejidad
La complejidad es otra perspectiva que hay que tener en cuenta a la hora de decidir una estrategia de diseño. La complejidad puede adoptar muchas formas y está fuera del alcance de este artículo describir todos los tipos de complejidad que podemos encontrar en un proyecto de software. Pero, consideremos complejo todo aquello que no podemos entender inmediatamente o visualizar una solución. La complejidad puede estar presente en cualquier nivel: arquitectura, macro o micro diseño. Hay que utilizar diferentes enfoques de diseño según el grado de complejidad y el nivel en que se produzca.
En general, el TDD puede utilizarse a nivel micro para resolver problemas complejos de forma escalonada, principalmente cuando se conocen las entradas, salidas y/o efectos secundarios. Un ejemplo serían algoritmos como la kata de números romanos o el cálculo de una prima de seguro. Sin embargo, algunos problemas a nivel micro se beneficiarían sin duda de una reflexión previa, como la aplicación de un algoritmo genético.
Proceso de descubrimiento del diseño
El proceso de descubrimiento del diseño debería variar no sólo entre los distintos niveles, sino también en función de nuestra familiaridad con el dominio del problema. Deberíamos ser capaces de diseñar un problema conocido de inmediato, independientemente de si estamos en un nivel de diseño arquitectónico o micro. En casos así, no hay muchas ventajas en el diseño up-front. Yo preferiría hacer el diseño de forma incremental, mientras se escribe el código. Pero no siempre estamos familiarizados con el ámbito del problema y, para minimizar el riesgo, deberíamos hacer una investigación previa, creando prototipos, organizando talleres con la gente de negocios u otros equipos, y dibujando algunas cosas en una pizarra.
El último momento responsable vs coste de la demora
Siempre sabremos más mañana de lo que sabemos hoy. Tomar decisiones importantes demasiado pronto, cuando menos sabemos de un proyecto, puede perjudicar gravemente al proyecto. Recuerdo cuando el grupo de arquitectura de un banco de inversión decidió muy pronto que nuestro equipo tenía que utilizar una cuadrícula de datos en uno de los nuevos proyectos. Esa decisión fue muy perjudicial para el proyecto y dolorosa para nosotros, los desarrolladores, ya que durante los primeros meses de desarrollo descubrimos que podíamos construir el mismo sistema de una forma mucho más sencilla y rápida. Pero en ese momento era demasiado tarde para cambiar.
Un consejo muy sensato es tomar las decisiones arquitectónicas en el último momento responsable. Existe esa famosa historia de un equipo que retrasó su decisión sobre qué base de datos utilizar y construyó el núcleo de su sistema utilizando un repositorio en memoria. Cuando estuvieron satisfechos con las características, se dieron cuenta de que podían almacenar todo en archivos en lugar de utilizar una base de datos, lo que hacía que su sistema fuera mucho más sencillo de desplegar y utilizar. Esto es genial, pero es solo una parte de la historia. El otro lado es que tardaron un año en ponerse en marcha porque su sistema no podía persistir los datos. El retraso en la decisión sobre el mecanismo de persistencia también retrasó la fecha de puesta en marcha.
Retrasar las decisiones de diseño suele implicar un coste de retraso cada vez mayor. En lugar de retrasar una decisión de arquitectura, deberíamos centrarnos en reducir el coste de cambiar la decisión, diseñando nuestra arquitectura de forma que se minimice el acoplamiento entre los componentes arquitectónicos.
Posibilitar el trabajo en paralelo
Cuando se trabaja en un solo equipo, es fácil acordar cuándo tomar decisiones de diseño, ya que nadie más se ve afectado. Sin embargo, en los entornos en los que colaboran varios equipos, es necesario un diseño previo para que los equipos puedan trabajar en paralelo sin muchas interrupciones. Integrar el trabajo de varios equipos también es más fácil cuando trabajan detrás de interfaces o API bien definidas.
Diseño paralelo e incremental a todos los niveles
El despliegue continuo es un objetivo para muchas empresas y para ello necesitamos desarrollar características en rodajas verticales y evolucionar continuamente todas las partes de nuestro diseño, desde la arquitectura hasta el microdiseño. La diferencia entre niveles suele ser el ritmo de cambio. A nivel de arquitectura, definiríamos el mínimo necesario para soportar las funcionalidades más importantes (digamos, funcionalidades que formarían parte de un Producto Mínimo Viable (MVP) o milestone). De este modo, la arquitectura evolucionaría cada pocos meses, el macrodiseño cada mes aproximadamente y el microdiseño a diario.
Tamaño del diseño y pasos de prueba
La línea entre el macro y el micro diseño puede ser un poco borrosa a veces. A menudo, tengo que decidir entre utilizar el diseño Just-In-Time y el diseño emergente. La decisión está relacionada con el nivel de confianza que tengo. Cuanto más seguro esté de la solución que quiero dar a un problema, mayores serán mis pasos de TDD. En este caso, Outside-In TDD, centrándose en el comportamiento y la colaboración mientras se escriben las pruebas es mi estilo TDD preferido. Sin embargo, hay veces que no puedo visualizar fácilmente una solución o no estoy seguro de que la solución que tengo en mi cabeza sea una buena solución. En este caso, me paso al TDD clasicista y utilizo pasos muy pequeños en mis pruebas, sin hacer suposiciones de diseño en la fase roja, pasando a la verde tan pronto como sea posible con la implementación más sencilla que pueda encontrar, y luego utilizo la fase de refactorización para decidir si quiero mejorar el diseño y cómo. Four Rules of Simple Design y SOLID Principles son las directrices de diseño que utilizo durante la fase de refactorización.
Arquitectura de prueba
Algunas personas dicen que hacen pruebas de arquitectura. Eso puede ser cierto, pero creo que en realidad utilizan un enfoque de Test First, y no necesariamente se benefician de una secuencia rápida de iteraciones cortas de Red-Green-Refactoring para evolucionar la arquitectura como tendríamos en TDD. En el enfoque Test First la prueba está ahí para asegurarse de que se satisface un requisito funcional o no funcional específico y no como una ayuda para descubrir la arquitectura de forma incremental. TDDing la arquitectura puede ser una gran pérdida de tiempo. Yo prefiero usar el Doble Bucle de TDD, empezando con una prueba de aceptación que me dé la dirección y luego usar pruebas unitarias para hacer crecer la solución. También me gusta la idea de las Architectural Fitness Functions - las teníamos en UBS para asegurarnos de que el rendimiento y la latencia estaban en niveles aceptables - pero no son exactamente TDD.
¿Está bien escribir pruebas después?
En pocas ocasiones lo hago para las pruebas de arquitectura y caja negra ya que prefiero esperar a que se estabilicen las características de la interfaz y del negocio. Estas pruebas son normalmente un dolor para escribir y mantener (normalmente tienen una configuración compleja), así que prefiero escribirlas después de tener una idea clara de lo que necesito probar y cómo hacer que funcione.
Directrices que solemos seguir
Debido al impacto y la complejidad, normalmente diseñamos la arquitectura por adelantado, es decir, antes de escribir cualquier prueba o código. El grado de anticipación varía de un proyecto a otro. Somos partidarios de la arquitectura incremental y normalmente utilizamos las características del siguiente milestone como base de nuestras decisiones arquitectónicas. Aceptamos el riesgo de que las cosas cambien en el siguiente milestone, pero tratar de reducir este riesgo ha resultado ser bastante desgastante.
Al nivel del macro diseño, normalmente hacemos algunas sesiones rápidas de pizarra y discutimos los principales componentes de la aplicación. También acordamos si vamos a utilizar capas, arquitectura hexagonal y cómo se desacoplará el mecanismo de entrega y la infraestructura del dominio principal. Debido a nuestra familiaridad con las decisiones de macro diseño y a nuestra preferencia por desarrollar código en pequeñas rodajas verticales, Outside-In TDD es nuestro enfoque favorito.
Al nivel micro, la gran mayoría de nuestras decisiones de diseño provienen de TDD, utilizando una combinación de Outside-In y TDD clasicista. Aunque vienen de fuera, a menudo utilizamos pequeños pasos y triangulación en partes del flujo para sacar a la luz algoritmos, detalles de los componentes e incluso algo de código de infraestructura.
Conclusión
Para mí la cuestión no es SI debemos o no diseñar por adelantado, sino CUÁNDO. El diseño se produce en muchos niveles diferentes (arquitectura, macro y micro) y tiene distintos grados de complejidad. También tenemos que entender quiénes son los consumidores de ese diseño.
Hay diferentes estilos y técnicas de diseño, desde el BDUF hasta el TDD clasicista. No soy un gran fan de los extremos, así que prefiero estar en un punto intermedio, a veces utilizando el diseño emergente, pero rara vez BDUF.
Sé pragmático. No siga las prácticas a ciegas. A veces, un par de horas dibujando en una pizarra te puede ahorrar un par de meses de trabajo.