A finales del siglo XX, la industria de desarrollo de software estaba experimentando una creciente complejidad en los sistemas de software, y los desarrolladores estaban luchando para mantener y mejorar esos sistemas que tenían alta complejidad ciclomática y objetos todopoderosos.

En el año 2000, Robert C. Martin (también conocido como Uncle Bob) habla sobre los principios y patrones de diseño para resolver estos problemas en su paper “Design Principles and Design Patterns”. Este artículo se convirtió en una referencia importante en la industria de desarrollo de software y ha sido ampliamente citado y utilizado por desarrolladores y arquitectos de software desde entonces.

Estos conceptos fueron compilados más tarde por Michael Feathers (autor del libro “Working Effectively with Legacy Code”), quien los presentó con el acrónimo SOLID. Desde entonces, estos cinco principios han revolucionado el mundo de la programación orientada a objetos, cambiando la forma en que escribimos software para que sea más fácil de mantener, leer y modificar.

Como indica el propio Uncle Bob en su artículo “Getting a SOLID start”, no se trata de reglas, ni leyes, ni verdades absolutas, sino más bien soluciones de sentido común a problemas comunes, basados en la experiencia.

“Se ha observado que funcionan en muchos casos; pero no hay pruebas de que siempre funcionen, ni de que siempre se deban seguir.”

Vivimos en unos tiempos que son dinámicos y si alguna regla de negocio es válida hoy, mañana puede que no sea válida. Todo depende del mercado y si el mercado (nuestros usuarios) cambian sus requisitos, nuestras reglas de negocio deben cambiar. Si queremos estar en el juego, debemos ser flexibles, por esta razón nuestro objetivo principal debe ser escribir código limpio y fácil de modificar.

El código se escribe una vez y se lee muchas veces, por esto a mí me gusta escribir código verboso que se puede leer y entender fácilmente sin necesidad de comentarios. Un código con comentarios es como un chiste que se debe explicar, no tiene gracia.

Los principios SOLID son:

Vamos a verlos en detalle.

1 Single Responsibility Principle (SRP).

Según el principio de responsabilidad única, “una clase debería tener una, y solo una, razón para cambiar por rol”. La responsabilidad se entiende como la razón para cambiar.

La regla es: “Reúne las cosas que cambian por las mismas razones. Separa aquellas que cambian por razones diferentes”.

Un ejemplo puede ser una navaja suiza: es muy chula, tiene muchas utilidades, pero no es cómodo manejarla. Y si se rompe alguna parte, hay que cambiarla entera. En algunos casos, por ejemplo, si me voy a la montaña, no voy a llevar conmigo el maletín con todas las herramientas, me llevaré la navaja suiza. Pero en mi casa prefiero usar herramientas separadas para cada cosa, me resulta más cómodo manejarlas.

Con el código es lo mismo, cada clase debe tener su responsabilidad. Vamos a verlo con un ejemplo: en el software del nuestro eBook Reader, tenemos una clase Book que contiene la información de un libro y los métodos para cambiar de página y visualizar la página actual. Simplemente explicando el contenido de la clase, ya me “huele” a code smell.

public class Book {

 private final String title;
 private final String author;
 private final List<String> pages;
 private int currentPageIndex = 0;

 public Book(String title, String author, List<String> pages) {
   this.title = title;
   this.author = author;
   this.pages = pages;
 }

 public String getTitle() {
   return title;
 }

 public String getAuthor() {
   return author;
 }

 public void turnPage() {
   if (currentPageIndex < pages.size() - 1) {
     currentPageIndex++;
   }
 }

 public String printCurrentPage(String displayType) {
   if (displayType.equals("plainText")) {
     return pages.get(currentPageIndex);
   } else if (displayType.equals("html")) {
     return "<div class='page'>" + pages.get(currentPageIndex) + "</div>";
   }
   return "Unknown type";
 }

 public int getCurrentPage() {
   return currentPageIndex + 1;
 }
}

¿Cuáles son las responsabilidades de esta clase? ¿Cuáles son las razones de cambiar? Si los atributos del libro cambian, por ejemplo si queremos añadir ISBN a los libros o queremos cambiar el autor a una lista de autores (es habitual que un libro tenga varios autores), ya tenemos la primera razón para cambiar.

En el código vemos que solo soporta libros de texto y de tipo HTML… ¿Qué pasará si queremos añadir soporte para libros de tipo PDF? Aquí tenemos la segunda razón para cambiar el código de nuestra clase.

Entonces, la responsabilidad de mostrar el contenido de la página la moveremos a otras clases que implementen la interfaz Printer.

public interface Printer {

 String printPage(String page);
}

Y tendremos implementación por cada tipo de libro, por ejemplo para mostrar un libro en el formato HTML, tendremos la clase HtmlPrinter que implementa a Printer.

public class HtmlPrinter implements Printer {

 public String printPage(String page) {
   return "<div class='page'>" + page + "</div>";
 }

}

De esta manera, para añadir soporte para poder visualizar libros en el formato PDF, simplemente debemos crear una clase PdfPrinter que implemente a Printer.

Nuestro código ha quedado supersimple y fácil de mantener, separando las responsabilidades.

2 Open / closed principle (OCP)

El principio de abierto/cerrado quiere decir que una clase debe ser abierta para extender, pero cerrada para modificar. Esto significa que una clase debe diseñarse de tal manera que se pueda agregar nueva funcionalidad sin cambiar su código fuente, solo extendiéndola.

En este ejemplo tenemos una clase Greeter que salud dependiendo de la formalidad.

public class Greeter {

 String formality;

 public String greet() {
   if (this.formality.equals("formal")) {
     return "Good evening, sir.";
   } else if (this.formality.equals("casual")) {
     return "Sup bro?";
   } else if (this.formality.equals("intimate")) {
     return "Hello Darling!";
   } else {
     return "Hello.";
   }
 }

 public void setFormality(String formality) {
   this.formality = formality;
 }
}

El problema aquí es que tenemos un código muy acoplado que rompe SRP y si queremos añadir un nuevo tipo de saludo, debemos modificar la clase Greeter incrementando la complejidad de leer esta “escalera if-else-if”.

En este caso podemos hacer que Greeter recibe un Personality, interfaz implementada por los diferentes tipos de formalidades:

public class Greeter {

 private final Personality personality;

 public Greeter(Personality personality) {
   this.personality = personality;
 }

 public String greet() {
   return this.personality.greet();
 }
}
public interface Personality {

 String greet();
}
public class FormalPersonality implements Personality {

 public String greet() {
   return "Good evening, sir.";
 }
}

3 Liskov substitution principle (LSP)

El principio de sustitución de Liskov es una definición particular de una relación de subtipificación, llamada subtipificación conductual, que fue introducida inicialmente por Barbara Liskov en 1987.

Este es el principio que es más difícil de entender, pero yo lo voy a explicar de la manera más sencilla: los hijos deben respetar los contratos de sus padres. Esto significa que los objetos deben poder ser reemplazados por instancias de sus subtipos sin alterar el correcto funcionamiento del sistema o, lo que es lo mismo, si en un programa utilizamos cierta clase, deberíamos poder usar cualquiera de sus subclases sin interferir en la funcionalidad del programa.

En este ejemplo veremos dos patios: uno verde que es silvestre y otro de amarillo que es eléctrico que heredan de la clase pato. El pato silvestre respeta el contrato de su clase padre, pero el pato eléctrico puede nadar solo si está en ON (encendido).

¿Cómo podemos garantizar que el pato eléctrico no rompa el contrato del padre? Muy fácil, comprobando si está encendido, en caso de que esté apagado, lo encendemos. Este es el código con la solución:

public class ElectricalDuck extends DuckImpl {

 private boolean isPowerOn;

 public boolean isPowerOn() {
   return isPowerOn;
 }

 public void turnPowerOn() {
   isPowerOn = true;
 }

 @Override
 public void swim() {
   if (!this.isPowerOn()) {
     this.turnPowerOn();
   }
   System.out.println("The electrical duck is swimming");
 }
}

4 Interface segregation principle (ISP)

El principio de segregación de interfaz va muy mano a mano con el principio de la responsabilidad única y dicho en un lenguaje habitual se puede decir de la siguiente manera: no debemos obligar a los clientes a consumir algo que no pueden hacer.

Vamos con el ejemplo. Tenemos una interfaz:

public interface Bird {

 void fly();

 void swim();

 void eat();
}

En este caso tenemos dos aves, pero ninguna de las dos puede hacer todas las tres acciones, por esta razón no es necesario obligarlas a que implementen la interfaz Bird.

Es mejor crear interfaces más pequeñas que agrupen acciones que son usables de un grupo de clientes, por ejemplo crear tres diferentes interfaces: CreatureThatFeeds, FlyingCreature y SwimmingCreature, de esta manera el Pingüino implementará las interfaces CreatureThatFeeds y SwimmingCreature y el Pájaro azul implementará las interfaces CreatureThatFeeds y FlyingCreature.

Aquí está la solución final:

public interface CreatureThatFeeds {

 void eat();
}
public interface FlyingCreature {

 void fly();
}
public interface SwimmingCreature {

 void swim();
}
public class Eagle implements FlyingCreature, CreatureThatFeeds {

 String currentLocation;

 public void fly() {
   this.currentLocation = "in the air";
 }

 public void eat() {
   System.out.println("The eagle is eating");
 }
}
public class Penguin implements SwimmingCreature, CreatureThatFeeds {

 String currentLocation;

 public void swim() {
   this.currentLocation = "in the water";
 }

 public void eat() {
   System.out.println("The penguin is eating");
 }
}

Dicho de otra manera: “Haz interfaces que sean específicas para un tipo de cliente”, es decir, para una finalidad concreta. Es preferible contar con muchas interfaces que definen pocos métodos que tener una interfaz forzada a implementar muchos métodos a los que no dará uso.

5 Dependency inversion principle (DIP)

Según el principio de la inversión de las dependencias, los módulos de alto nivel no deben depender de los módulos de bajo nivel, ambos deben depender de abstracciones. Las abstracciones no deben depender de los detalles, pero los detalles sí que pueden depender de las abstracciones.

Vamos a verlo con un ejemplo. Nosotros desarrollamos software para eBook Readers y por ahora solo ofrecemos la funcionalidad de leer documentos PDF.

public class EBookReader {

 private final PDFBook book;

 public EBookReader(PDFBook pdfBook) {
   this.book = pdfBook;
 }

 public String read() {
   return book.read();
 }

}

Para nuestro negocio, el módulo de alto nivel sería el EBookReader que estará en nuestra capa de dominio si hablamos de Arquitectura hexagonal y el PDFBook estaría en la capa de la infraestructura, porque los formatos de los libros no los decidimos nosotros y debemos poder implementar adaptadores para los diferentes formatos.

Para nosotros es importante que los libros se puedan leer por el Reader. Pero en este caso vemos que depende directamente del PDFBook y no de una abstracción. En este caso tenemos un acoplamiento muy alto entre el EBookReader y PDFBook y esto dificultará la implementación de los nuevos adaptadores. Por esta razón, el EBookReader debe depender de un abstracción (en este caso crearemos un interfaz llamada EBook) del cual dependerá el EBookReader y que será implementada por la clase PDFBook.

De esta manera, siempre cuando queremos añadir nuevo formato a nuestro EBookReader, solo necesitaremos crear un adaptador que implemente el puerto EBook.

Adaptador que implemente el puerto EBook.

De esta manera hemos reducido el acoplamiento entre el EBookReader y PDFBook.

Esto nos ayudará en nuestros tests, podemos probar la funcionalidad del EBookReader usando mocks.

Conclusión

Siempre cuando empezamos un proyecto, lo desarrollamos de la mejor manera. Pero con el paso del tiempo debemos implementar más y más funcionalidades, y nuestras clases pueden ir creciendo con cada commit.

Cuando terminemos un desarrollo y tengamos una buena cobertura de test, debemos hacer un refactor y revisar si no “rompemos” alguno de los principios SOLID y tener un código con alta cohesión y bajo acoplamiento que es muy fácil de testear, mantener y modificar. Algo que es muy importante hoy en día, cuando los requisitos del negocio cambian muy rápido y debemos estar preparados para esto.

El código de los ejemplos se puede ver en este repositorio de GitHub. Podéis usar la rama main como punto de partida, en la rama solution podéis ver los pasos de la solución.

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete