← Volver

S.O.L.I.D.

Un recorrido por los principios S.O.L.I.D. y la inversión de control para diseñar software desacoplado, extensible, reutilizable y más fácil de mantener y probar.

PrincipiosS.O.L.I.D.Buenas Prácticas

¿Cómo conseguir que un proyecto software sea mantenible y que se pueda modificar con sencillez?

Esta pregunta tiene muchas respuestas, según la naturaleza del mismo proyecto, claro. No obstante, existen una serie de principios que, de seguirlos, la vida (y estructura) de un proyecto puede cambiar de una forma radical.

Demasiado a menudo veo proyectos —sin ir más lejos, muchos repositorios en GitHub— en los que es imposible discernir cómo se han implementado los principios de diseño más básicos en ingeniería del software. Y no es que esté mal, claro: todo depende de la vida que vaya a tener ese proyecto.

No hace falta que conozcamos al detalle todos y cada uno de los patrones de diseño más o menos conocidos y aceptados por la industria, y muy bien explicados en libros clásicos como este. Pero, desde luego, si lo que queremos es que nuestro proyecto pueda mejorarse continuamente sin demasiada dificultad, entonces sí que hay que seguir unas mínimas normas de desarrollo.

Aquí es donde entran los principios S.O.L.I.D.

Si tu proyecto tiene clases o funciones extraordinariamente largas, del orden de cientos de líneas de código; si hay métodos con innumerables if o con muchos bloques anidados; o si hay métodos o funciones con muchos parámetros, entonces tu aplicación camina hacia una solución espagueti difícil de mantener y evolucionar.

Si es así, sigue leyendo…

¿Qué diferencia un principio de diseño de un patrón?

Mientras que un patrón es una solución técnica y clara a un problema particular, un principio es una guía que debe cumplir tu código a nivel de diseño. Los mejores frameworks de desarrollo son los que mejor cumplen con estos principios. Pienso, sin ir más lejos, en Seneca, un framework que me gusta mucho para construir microservicios en Node.js, aunque hay muchos otros ejemplos.

A grandes rasgos, los principios S.O.L.I.D. permiten diseñar una aplicación de modo que:

  • Tenga un alto grado de desacoplamiento entre sus partes funcionales.
  • Se pueda extender en funcionalidad con sencillez, sin grandes cambios en la aplicación.
  • Sea testeable.
  • Mejore la reutilización del código.

Aunque su implementación es más natural en programación orientada a objetos, se pueden aplicar en lenguajes funcionales. En el contexto de este artículo, hablaremos de entidades software para referirnos a clases, módulos, métodos, funciones, etc.

A continuación indico un breve resumen de estos cinco principios, junto con una técnica igualmente relevante: la inversión de control.

Principio de responsabilidad única (SRP)

Una clase se debe dedicar a resolver un único problema; esto es, no se debe implementar una clase cuya funcionalidad mezcle ámbitos diferentes de la aplicación.

Si se utiliza bien este principio, se podrán crear clases más o menos pequeñas y, por tanto, más reutilizables y fáciles de modificar y probar.

En el contexto de este principio, se entiende por responsabilidad la razón para cambiar: si en una clase existe más de un motivo por el que puede cambiar, entonces no sigue este principio.

Quizá no sea fácil verlo al comienzo, pero en este ejemplo de código indico un ejemplo sencillo (y algo académico), escrito en JavaScript para Node.js.

Principio abierto/cerrado (OCP)

Según este principio, una clase se debe diseñar de modo que sea fácilmente extensible sin modificar su funcionalidad actual. Es fácil de describir, pero para implementarlo hace falta cierto grado de abstracción. Según la característica de la aplicación en la que se esté trabajando, este principio se puede aplicar o no.

No niego que este principio sea algo sutil, pero, si se entiende bien, en realidad indica que la entidad software debe abstraer al máximo el núcleo de la funcionalidad que implementa.

Lo que persigue OCP es extender la funcionalidad de una entidad software sin modificar su código y, para ello, como digo, hace falta cierta abstracción.

En este otro ejemplo de código indico cómo aplicar este principio en el clásico ejemplo de validación de entradas de usuario.

Principio de sustitución de Liskov (LSP)

Este es quizá el principio más sutil de entender, pero igualmente importante. Con LSP lo que se pretende es asegurarnos de que se implementa correctamente la herencia entre clases.

Entendemos como clase hija o extendida aquella que deriva de otra.

La definición es un tanto académica, de modo que haré una descripción más sencilla: LSP viene a decir que, si un programa utiliza una clase, deberá seguir siendo válido (es decir, funcionará bien) si se la sustituye por una nueva clase que derive de ella. Si un programa utiliza la clase P y, en su lugar, sustituimos P por O (donde O deriva de P), el programa deberá seguir funcionando correctamente.

Y aquí está la definición de la autora, Barbara Liskov: si o1 es un objeto de tipo S y o2 es un objeto de tipo T, y el comportamiento de un programa (función, método, etc.) basado en objetos T no cambia al usar o1 y o2, entonces necesariamente S es un subtipo de T.

Puff… En realidad, está indicando cómo hay que implementar la herencia entre clases.

Es sencillo de decir, no tan obvio de implementar, aunque con este principio nos garantizamos que la herencia se hace bien y, además, las clases base (padres) y sus derivadas (hijas) son más fáciles de probar.

LSP está muy relacionado con el principio OCP, ya que establece también que una clase hija debe extender el comportamiento de la clase padre, no modificarlo.

Para implementar LSP hace falta un alto grado de abstracción de la solución que pretendemos resolver. Si se rompe este principio en nuestro diseño, entonces no se está abstrayendo lo suficientemente bien.

Aquí dejo un ejemplo ilustrativo que cumple LSP.

Por último, una forma de forzar que se cumpla este principio es usar diseño por contratos, en el que se especifican interfaces y las clases base las implementan.

Principio de segregación de interfaces (ISP)

Este principio pretende evitar la creación de interfaces extensas con métodos que no tienen nada que ver unos con otros, de modo que una clase que derive de ellas tenga que dejar algunos métodos sin implementar porque no los necesita.

Con ISP lo que se sugiere es dividir esta interfaz (fat interface) en interfaces más pequeñas y coherentes, de modo que las clases que deriven de ellas lo hagan porque necesitan todos y cada uno de sus métodos.

La consecuencia de este principio es que necesariamente las interfaces definidas en el proyecto tienen un alto grado de abstracción y granularidad.

En este otro ejemplo se puede ver cómo aplicar este principio.

Principio de inversión de dependencias (DIP)

Este principio es, quizá, el más popular de todos y el más fácil de implementar; en ocasiones se confunde con la inyección de dependencias.

Lo que viene a decir es que nuestro código (en un módulo, una clase, etc.) debe depender de abstracciones, no de instancias concretas de las mismas.

Entendemos por abstracción la definición que debe implementar una clase concreta, lo que viene a ser lo mismo que las clases abstractas y las interfaces en la programación orientada a objetos.

Con DIP conseguimos que el código esté más desacoplado y que las unidades de funcionalidad sean más pequeñas (y, por tanto, reutilizables y fáciles de probar).

Inversión de control (IoC)

La inversión de control no está dentro del conjunto S.O.L.I.D., pero está en la línea de conseguir código más desacoplado y mantenible. Sin embargo, no es aplicable a cualquier tipo de proyecto.

Tradicionalmente, podemos decir —muy simplificadamente— que la implementación de un programa consiste en ejecutar una llamada a un método o función tras otro. Sin embargo, a medida que el programa crece, es necesario estructurar la aplicación en módulos, entran en juego diversas arquitecturas, etc.

Es decir, el flujo habitual de una aplicación consiste en llamar a procedimientos (funciones o métodos) de una biblioteca.

Se implementa la inversión de control cuando se invierte ese flujo; esto es, cuando es la misma biblioteca la que llama al código del usuario, que es el que implementa realmente la aplicación.

Parece enrevesado, pero hay multitud de ejemplos que implementan inversión de control y, además, es el enfoque natural cuando necesitamos que nuestra aplicación sea fácilmente extensible. Sin embargo, insisto en que no siempre es necesario implementar este mecanismo: depende de la naturaleza del proyecto.

IoC y DIP (inversión de control e inversión de dependencias) suelen ir de la mano, ya que es sencillo implementar IoC utilizando inyección de dependencias (DI):

  • Service Locator: es un patrón de diseño bien conocido que, en realidad, implementa inversión de control.
  • En el gestor de contenidos Drupal, el usuario puede implementar módulos que el núcleo de la aplicación invoca en el momento oportuno ante diferentes eventos (inversión de control). Este es también el mecanismo habitual para extender funcionalidad en WordPress, Dolibarr y aplicaciones de este tipo.
  • En Express, un popular framework para Node.js con el que implementar aplicaciones web, existe un mecanismo denominado middlewares, por el que se inyectan módulos para que sean invocados en cada petición (inversión de control).

Sin ir más lejos, uno de los productos que he diseñado —la Plataforma de Telegestión IRIS— implementa inversión de control en la gestión de tareas: existe un engine que procesa las tareas que se van generando, y se pueden implementar otras nuevas con la funcionalidad necesaria para cada cliente sin modificar el núcleo del producto.

Conclusión

Merece la pena dedicar un tiempo a estudiar estos principios y leer ejemplos de cómo implementarlos en el entorno que cada uno utiliza habitualmente.

La estructura y el diseño de una aplicación que los sigue de un modo u otro no tienen nada que ver con los de la misma aplicación que ignora este tipo de principios, aunque funcione correctamente.

Entonces, ¿para qué molestarnos? Pues por la sencilla razón de que la aplicación será infinitamente más mantenible, reutilizable y fácil de probar.