Qué son las abstracciones prematuras y cómo evitarlas con TDD
Por Diego Domínguez
Introducción
En ingeniería del software, la reutilización de componentes es fundamental para construir sistemas eficientes y escalables. Sin embargo, al intentar crear componentes reutilizables, a menudo se incurre en un fenómeno conocido como «abstracción prematura». Este concepto se refiere al hecho de generalizar o modularizar componentes en una etapa demasiado temprana del desarrollo, antes de comprender completamente el contexto o las necesidades específicas de la solución.
La abstracción prematura puede producir una complejidad innecesaria que afecta directamente a la mantenibilidad y la flexibilidad del sistema. Cuando se crea una arquitectura que busca anticiparse a múltiples casos de uso potenciales, se introduce una capa adicional de complejidad que, en muchos casos, termina poco aprovechada o es directamente innecesaria. Esto puede provocar que las soluciones se vuelvan rígidas y costosas de modificar, dificultando la adaptación del sistema a las necesidades reales que surjan durante el proceso de desarrollo.
Diversas corrientes sugieren que un enfoque más efectivo es construir componentes con la mínima abstracción necesaria, iterando y refinando solamente cuando haya evidencia de que un componente o flujo de trabajo vaya a ser reutilizado en múltiples contextos [1], [2]. De esta forma, se evita generar esa complejidad innecesaria y se maximiza la mantenibilidad y flexibilidad de la solución.
En conclusión, la reutilización de componentes es una técnica muy valiosa en la ingeniería del software, pero debe utilizarse de manera pragmática, no es un fin en sí misma.
TDD como solución al problema
Ahora que somos conscientes del problema que suponen las abstracciones prematuras, quisiera introducir una posible solución para hacer frente a este fenómeno: el TDD. El desarrollo guiado por pruebas (TDD, por sus siglas en inglés) es una técnica de programación que enfatiza la importancia de escribir pruebas antes del código funcional, con el objetivo de hacer explícito el ciclo de desarrollo. TDD consta de un ciclo iterativo de tres fases:
- Escribir una prueba: primero, se define un caso de prueba basado en un requerimiento específico. Dado que aún no existe código que lo satisfaga, la prueba ha de fallar inicialmente.
- Implementar el código: en esta fase, se escribe la mínima cantidad de código que logre pasar la prueba. Este paso impulsa la creación de soluciones simples y orientadas a los requisitos.
- Refactorizar: una vez que la prueba es superada, se optimiza el código sin modificar su comportamiento.
TDD es una técnica muy valiosa por muchas razones, pero para el caso que nos ocupa tenemos que hablar de la triangulación. La triangulación es una técnica que consiste en introducir múltiples pruebas para la misma funcionalidad, cada una diseñada para cubrir un caso o escenario concreto.
En lugar de generalizar desde el primer caso de prueba, se agregan casos adicionales que obligan a ir adaptando el código hasta llegar a una solución más general. Este enfoque ayuda a evitar la abstracción prematura, ya que el código evoluciona gradualmente y de forma natural a partir de las pruebas [3], [4]. A medida que más pruebas «empujan» el diseño, se va descubriendo si es necesario o no aplicar una abstracción, evitando la aparición de una abstracción prematura.
Cómo saber cuando abstraer: la Regla de Tres
Una forma pragmática de determinar cuándo es el momento adecuado para introducir una abstracción es aplicar la conocida Regla de Tres, formulada en el libro Refactoring: Improving the Design of Existing Code [5], en el que colaboran destacados autores como Kent Beck, creador del TDD. Esta regla establece que son necesarios al menos tres casos distintos antes de introducir una abstracción en el diseño del código: un solo caso resuelve un problema específico, dos casos podrían ser una coincidencia, pero el tercer caso revela un patrón claro que justifica la abstracción.
Caso práctico: sistema de reserva de libros
Vamos a tratar un ejemplo del mundo real aplicando esta metodología. Es conveniente tener en cuenta que no pretendo seguir estrictamente los pasos atómicos de los que habla TDD, sino que, en base a mi experiencia como desarrollador, voy a tomarme la libertad de dar unos pasos un poco más ambiciosos.
No obstante, sí que es recomendable que si en algún momento no se tuviera la certeza de que se está aplicando la solución más sencilla, se realicen pasos atomizados.
Iteración 1: reserva estándar
Supongamos, pues, que estamos desarrollando un caso de uso que nos permita reservar un libro en una biblioteca. En nuestra tarea se indican como criterios de aceptación que un usuario ha de poder reservar un libro por dos semanas, que la reserva se ha de “persistir” y que el caso de uso devolverá la propia reserva. En primer lugar, tal como indica TDD, escribiremos una prueba inicial que falle:
A continuación, escribiremos el código mínimo para que pase la prueba:
Ahora que el código cumple con las exigencias de la prueba iniciamos la tercera parte del flujo del TDD, la refactorización. El código que tenemos actualmente no parece ser candidato a ser refactorizado, así que daremos por concluida esta iteración.
Iteración 2: reserva para menores de edad
En el siguiente sprint llega una nueva tarea debido a que la gerencia de la biblioteca desea limitar el tiempo que puede un menor reservar un libro. Para resolver esta cuestión iniciaremos de nuevo el flujo de TDD escribiendo una prueba inicial que falle:
Siguiendo los preceptos de TDD, realizaremos el cambio mínimo para que nuestro código pase la prueba.
De nuevo, hemos obtenido un código que cumple con las exigencias de las pruebas. Como en el escenario anterior, antes de avanzar, cabe plantearse si es necesaria una refactorización en este punto. Con dos casos manejados en el método execute, podríamos sentirnos tentados de anticipar la necesidad de nuevos perfiles de usuario y adoptar patrones como Strategy o Factory Method para organizar el comportamiento. Sin embargo, atendiendo al enfoque que nos ofrece la Regla de Tres, dos casos no son suficientes para justificar una refactorización. Este principio nos recuerda que es preferible esperar hasta que al menos tres escenarios diferentes confirmen un patrón recurrente antes de abstraer el comportamiento. Teniendo esto en mente, terminamos la iteración actual.
4.3 Iteración 3: reserva para estudiantes universitarios
En otra ocasión, la gerencia expresó la necesidad de adaptar el sistema para satisfacer un requerimiento particular de los usuarios universitarios. Estos necesitan tener acceso a los libros durante un año completo, ya que este periodo les permite utilizarlos como material de apoyo en sus proyectos de tesis y trabajos de investigación. Además, se especificó que, al procesar este tipo de reservas, el sistema debería enviar un correo de confirmación para asegurar que el usuario esté al tanto de las condiciones especiales de su préstamo. Para ampliar la funcionalidad existente iniciamos de nuevo el ciclo de TDD con una nueva prueba:
Siguiendo la misma línea que en la anterior iteración, incluimos una nueva estructura if para incluir el nuevo comportamiento y poder pasar la prueba:
Con tres casos distintos claramente definidos para manejar las reservas de usuarios regulares, infantiles y universitarios, es el momento de plantear una refactorización del código.
La Regla de Tres respalda esta decisión, ya que al contar con tres escenarios se identifica un patrón común que justifica la implementación de una abstracción. En esta ocasión, parece acertado hacer uso del patrón Strategy, que permite encapsular los comportamientos específicos para cada tipo de usuario en estrategias independientes.
Esta decisión no solo mejora la organización del código, sino que también cumple con el principio Open/Closed de SOLID, permitiendo extender el sistema con nuevas estrategias para otros tipos de usuarios sin modificar las clases existentes.
Si hubiéramos implementado el patrón Strategy o una solución similar con solo dos casos, estaríamos anticipándonos a necesidades futuras sin evidencia suficiente para justificar dicha abstracción, ocasionando gastos innecesarios para el desarrollo de la funcionalidad y agregando una complejidad innecesaria al código en el caso de que no llegara a aparecer un tercer caso.
Conclusiones
Este ejercicio que hemos realizado nos recuerda que, como ingenieros de software, debemos ser conscientes de que no todas las herramientas y patrones son útiles en todas las situaciones. Es fundamental ofrecer una solución adecuada para cada problema sin pretender hacer un código grandilocuente cuando este puede resolverse con algo tan simple como un if. Aplicar principios como la Regla de Tres y enfoques como el TDD nos permite diseñar sistemas que sean eficientes y mantenibles, evitando la complejidad innecesaria y asegurando que nuestras decisiones estén justificadas por las necesidades reales del proyecto.
Referencias:
[1] Arend van Beelen, «Premature Abstraction is the Root of All Evil,» arendjr.nl, 2024. [Online]. Available: https://arendjr.nl/blog/2024/07/post-architecture-premature-abstraction-is-the-root-of-all-evil/
[2] This Dot Media, «The Cost of Premature Abstraction,» Medium, 2024. [Online]. Available: https://medium.com/@thisdotmedia/the-cost-of-premature-abstraction-b5d71ffd6400
[3] D. Pavlutin, «Triangulation in Test-Driven Development,» dmitripavlutin.com, 2024. [Online]. Available: https://dmitripavlutin.com/triangulation-test-driven-development/