Principios SOLID

Introducción

Los principio SOLID son convenciones a nivel de diseño de software que ayudan a conseguir un código más mantenible, tolerante a cambios, y testeable.

Todos los desarrolladores de un equipo deberían tener nociones de diseño de software para fomentar la autonomía y agilidad del equipo

Huir de STUPID, el enemigo de SOLID

S → Singleton: Hay un objeto que lo contiene todo. No necesita inyección de dependencias. Y se encuentra por todo el programa. Tiene demasiadas resposabilidades.

T → Tight Coupling: Fuertemente acoplado. Conoces la implementación concreta del repositorio de usuario (un mysql por ejemplo), algo que dificulta el cambio de tipo de base de datos. El código no es tolerante a cambios.

U → Untestability: Código intesteable. Muy visto en los singleton. Código muy junto sin ningún tipo de inyección de dependencias que nos lleva a tener un código imposible de testear.

🤪

P → Premature Optimization: Realizar mucho más código del necesario atendiendo al futuro. Se debe pensar con vistas a futuro pero a raíz de las posibles necesidades, no de un simple «por si acaso».

I → Indescriptive Naming: Naming confuso que no refleja intencionalidad o significado alguno.

D → Duplication: Duplicación del mismo código en muchos lados que necesita de una abstracción o extracción a métodos o clases que solo tienen una responsabilidad y pueden ser parte de otra clase.

UML

Connotaciones negativas

Un modelo de diagramas que tiene una metodologia de trabajo en cascada:

Especificación de requisitos → Desarrollo → Testing

Una forma de trabajo muy lineal que no entiende de cambios durante el ciclo de desarrollo (especificación de requisitos), derivada de como se desarrollaba software hace tiempo.

Ventajas

Lenguaje de diagramas ilustrativo para nuestros diseños de software (clases e interacción entre ellas) Riguroso: Permite especificar hasta un nivel de detalle suficiente para identificar acoplamiento entre clases y sus relaciones sin ser verboso Agnóstico del lenguaje: No entra en detalles de implementación si quiera al nivel de qué lenguaje de programación se está usando

¿Que tipos hay?

  • Casos de uso: se busca definir todos los posibles casos de uso (acciones) que pueda hacer un usuario
  • Secuencia: Se trata de un diagrama que con el que podremos ver el flujo de nuestra aplicación, representando cómo interaccionan las clases (comunicación entre objetos)
  • Clases: Este tipo de diagramas son muy populares y nos permiten ver no sólo los atributos y métodos de cada clase, sino también las diferentes relaciones de herencia, interfaces e implementaciones de éstas. ¿Ventajas?:
    → Diagrama de clases con 4 garabatos para ponernos de acuerdo u obtener feedback de nuestro equipo antes de implementarlo de forma rápida
    → Documentar implementaciones ya existentes para facilitar la revisión de código. Por ejemplo, a la hora de hacer una nueva Pull Request (PR), generar el diagrama desde IntelliJ/PhpStorm con 2 clicks para adjuntar una imagen a la descripción de la PR.

UML con IntelliJ

Con esta herramienta podemos hacer diagramas de clases.

Seleccionamos las que queremos → Click derecho → Diagrams → Show Diagram

Nos encontraremos unas opciones tal que así:

  1. Campos
  2. Constructor
  3. Métodos
  4. Propiedades
  5. Inner clases

También podemos mediante click derecho sobre una clase abstracta añadir sus implementaciones al esquema:

Otro truco es añadir las clases usando Espacio


Un resumen rápido de lo que podemos hacer sobre diagramas en IntelliJ:

S (Single responsability principle – SRP)

¿Que es?

Una clase = un concepto = una responsabilidad

O lo que es lo mismo una sola razón para cambiar

¿Como?

Clases que funcionen como servicios con pequeños objetivos acotados, entendiéndose un servicio como un orquestador que conecta nuestros modelos con infraestructura (servicios externos).

¿Por qué?

Buscamos la alta cohesión entre la conexión entre componentes de nuestro sistema, robustez antes los cambios y evitamos la duplicidad de código ya que conseguiremos piezas más reutilizables.

¿Que tener en cuenta?

  • Los nombres → Un nombre muy general como «OrderProcessor» da lugar a querer reutilizarlo para muchas cosas y acaba teniendo demasiadas funcionalidades, en su lugar busca un nombre más concreto.

Ejemplo sencillo

//NO RESPETA SRP
//La clase "Book" tiene la responsabilidad de imprimir texto
final class Book
{
    public String getTitle()
    {
        return "A great book";
    }
    public String getAuthor()
    {
        return "John Doe";
    }
    public void printCurrentPage()
    { 
        System.out.println("current page content");
    }
}

final class Client
{
    public Client() {
        Book book = new Book(…);
        book.printCurrentPage(); :(
    }
}
// RESPETA SRP
//La clases "Book" ahora simplemente devuelve su contenido, para que otro servicio lo reciba e imprima dicho texto.
final class Book
{
    public String getTitle()
    {
        return "A great book";
    }
    public String getAuthor()
    {
        return "John Doe";
    }
    public String getCurrentPage()
    {
        return "current page content";
    }
}

final class StandardOutputPrinter
{
    public void printPage(String page)
    {
        System.out.println(page);
    }
}

final class Client
{
    public Client() {
        Book book = new Book(…);
        String currentPage = book.getCurrentPage();
        StandardOutputPrinter printer = new StandardOutputPrinter();
        printer.printPage(currentPage); :)
    }
}

Cuando respetamos el principio de responsabilidad única, es más fácil introducir modularidad. Entiéndase modularidad como la propiedad que permite subdividir una aplicación en partes más pequeñas (llamadas módulos), cada una de las cuales debe ser tan independiente como sea posible de la aplicación en sí y de las restantes partes.

Con el anterior ejemplo, el servicio que imprime páginas a la consola puede implementar una interfaz que tenga métodos para imprimir, y así poder reemplazar en el código fácilmente la implementación que define el tipo de impreso, como por ejemplo por HTML.

//INTERFAZ PARA IMPRIMIR
interface Printer
{
    public void printPage(String page);
}
//IMPLEMENTACIÓN PARA IMPRIMIR POR CONSOLA UN STRING
final class StandardOutputPrinter implements Printer
{
    public void printPage(String page)
    {
        System.out.println(page);
    }
}
//IMPLEMENTACIÓN PARA IMPRIMIR POR CONSOLA UN HTML
final class StandardOutputHtmlPrinter implements Printer
{
    public void printPage(String page)
    {
        System.out.println("<div>" + page + "</div>");
    }
}

Y para mirar como hemos montado esta modularidad que mejor forma de hacerlo que con los diagramas que podemos auto-generar con IntelliJ.

Otros ejemplos

Todo empieza en el controller

Con una aplicación basada en una API todo empieza con una petición a un endpoint. Es por ello que tenemos que empezar a cuidar los detalles desde ahí. Un controlador no necesita entender de contruir sentencias SQL, ni mucho menos de interactuar directamente con la base de datos. Un servicio es el encargado de realizar esto de ejecutar lógica de negocio usando infraestructura. Por ello, haz que tu controlador solo reciba llamadas y la redirecione a un servicio dedicado a la causa.

Cuando un servicio y cuando no

Cuando la lógica de negocio no tiene dependencias externas puede ir acoplada al modelo de dominio. Cuando ya existen esas dependencias es mejor tener un servicio externo que se encargue de inyectar por constructor las dependencias que necesite. Esto favorece las testabilidad y la cohesión.

O (Open-Closed Principle OCP)

El software debería estar abierto a extensión y cerrado a modificación

Aquí vemos un ejemplo de como trabajar objetos «medibles». Vamos a poder llamar a la interfaz y especificar que tipo de objeto «medible» es cuando se necesite.

Nos acoplamos a la interfaz, no a la implementación específica, por lo que podremos cambiar en cualquier momento a que objeto «medible» nos referimos.

Lo mismo podríamos hacer con una clase abstracta que desde un principio defina como se calcula el porcentaje, y sus implementaciones extiendan de alguna manera dicho calculo.

Una clase abstracta es útil cuando las implementaciones van a tener una parte común que siempre se repite, como un sistema de bonificaciones que tienen una general y otras específicas. Si esto no ocurre usaremos interfaces, que permiten desacoplar entre capas, el detalle aparecerá en las implementaciones.

Otros ejemplos

En este dibujo, hay un ejemplo de un sistema abierto a extensión basado en colas para eventos de dominio (acciones). Un productor ejecutará un evento de dominio que terminará mandando información a una cola, que espera ser consumida por un cliente y que reaccionará realizando otra acción con el contenido consumido.

Un sistema de colas para este fin nos evitaría tener que modificar la acción principal cada vez que queramos añadir una acción derivada de ésta, respetando el SRP y OCP

L (Liskov Substitution Principle – LSP)

Cualquier clase hija de otra, debería poder reemplazada sin alterar el sistema por otra clase hija de la misma clase

Ejemplo sencillo

En este ejemplo el cuadrado extiende del rectángulo, porque a groso modo son lo mismo, pero a la hora de la verdad tienen comportamientos diferentes, por lo que cuando vamos a utilizar los métodos de la clase rectángulo en la clase cuadrado, tenemos que hacer unos apaños que aunque funcionen y el código actúe correctamente, no estamos respetando el LSP, ya que la clase hija (cuadrado) necesita una serie de modificaciones a nivel de comportamiento para poder extender y así no será posible reemplazar fácilmente un cuadrado por otra figura geométrica que extienda del rectángulo.

class Rectangle {

    private Integer length;      
    private Integer width;

    Rectangle(Integer length, Integer width) {  
        this.length = length;
        this.width = width;
    }

    void setLength(Integer length) {
        this.length = length;
    }

    void setWidth(Integer width) {
        this.width = width;
    }

    Integer getArea() {
        return this.length * this.width;
    }
}

final class Square extends Rectangle {
    Square(Integer lengthAndWidth) {
        super(lengthAndWidth, lengthAndWidth);
    }

    @Override
    public void setLength(Integer length) {
      super.setLength(length);
      super.setWidth(length);
    }
    @Override
    public void setWidth(Integer width) {
      super.setLength(width);
      super.setWidth(width);
    }
}

final class SquareShould {
    @Test
    void not_respect_the_liskov_substitution_principle_breaking_the_rectangle_laws_while_modifying_its_length() {
        Integer squareLengthAndWidth = 2;
        Square square = new Square(squareLengthAndWidth);

        Integer newSquareLength = 4;
        square.setLength(newSquareLength);

        Integer expectedAreaTakingIntoAccountRectangleLaws = 8;

        assertNotEquals(expectedAreaTakingIntoAccountRectangleLaws, square.getArea());
	  }
}

Que las subclases respeten el contrato definido en la clase padre es justamente lo que nos permite cumplir con este principio para mantener una correctitud funcional

I (Interface Segregation Principle – ISP)

Ningún cliente debería verse forzado a depender de métodos que no usa

Las interfaces se debe de desarrollar acorde a las necesidades del cliente que las usa, y no de sus implementaciones.

Header Interfaces → Una interfaz que ha sido extraida o basada de una clase (por lo que ahora la interfaz es padre de la clase), y que se crea con todos los métodos que dicha clase tenía o necesitaba.

Role Interface → Una interfaz que ha sido creada a partir de la definición previa de un caso de uso del cliente.

Conseguiremos un código con bajo acoplamiento estructural

Ejemplo sencillo

¿Que firma debería tener una interface que nos permita implementarla y enviar notifaciones por slack, email o fichero.txt?

  • a) $notifier($content) ✅
  • b) $notifier($slackChannel, $messageTitle, $messageContent, $messageStatus) ❌
  • c) $notifier($recieverEmail, $emailSubject, $emailContent) ❌
  • d) $notifier($destination, $subject, $content) ❌
  • e) $notifier($filename, $tag, $description) ❌

En la opción A, sólo estaríamos enviando el contenido, por lo que las particularidades de cada uno de los tipos de notificación tendrían que venir dados en el constructor

Las opciones B,C,E son un claro ejemplo de Header Interface.

D (Dependency inversion principle – DIP)

Módulos de alto nivel no deberían depender de los de bajo nivel. Ambos deberían depender de abstracciones.

Módulo → Clases

Ej: Un caso de uso no debe depender de una implementación si no que debería hacerlo de una abstracción como sería la interfaz.

Este principio busca mucho la inyección de dependencias, que sería el acto de recibir parámetros en constructor.

La finalidad es la substitución de implementaciones y mejorar la testabilidad de las clases.

Ejemplo sencillo

Etapa 1 – Instanciación desde los clientes 🔒

Clase UserSearcher:

final class UserSearcher {
    private HardcodedInMemoryUsersRepository usersRepository = new HardcodedInMemoryUsersRepository();

    public Optional<User> search(Integer id) {
        return usersRepository.search(id);
    }
  }

Clase HardcodedInMemoryUsersRepository:

final class HardcodedInMemoryUsersRepository {
    private Map<Integer, User> users = Collections.unmodifiableMap(new HashMap<Integer, User>() {
        {
            put(1, new User(1, "Rafa"));
            put(2, new User(2, "Javi"));
        }
    });

    public Optional<User> search(Integer id) {
        return Optional.ofNullable(users.get(id));
    }
}

En esta primera fase, estaríamos instanciando en la propia clase el repositorio que vamos a utilizar en el método search, es decir, cuando instanciemos nuestro UserSearcher, esta clase internamente estaría haciendo un new de HardcodedInMemoryUsersRepository, lo cual nos lleva inevitablemente a estar fuertemente acoplados a dicho repositorio.

Test UserSearcherShould:

final class UserSearcherShould {
    @Test
    void find_existing_users() {
        UserSearcher userSearcher = new UserSearcher();

        Integer existingUserId = 1;
        Optional<User> expectedUser = Optional.of(new User(1, "Rafa"));

        assertEquals(expectedUser, userSearcher.search(existingUserId));
    }

    @Test
    void not_find_non_existing_users() {
        UserSearcher userSearcher = new UserSearcher();

        // We would be coupled to the actual HardcodedInMemoryUsersRepository implementation.
        // We don't have the option to set test users as we would have to do if we had a real database repository.
        Integer nonExistingUserId = 5;
        Optional<User> expectedEmptyResult = Optional.empty();

        assertEquals(expectedEmptyResult, userSearcher.search(nonExistingUserId));
    }
}

Desde el propio Test ya se observa este acoplamiento, obligando a saber, en este caso, que el usuario tiene que existir en el HashMap (caso de find_existing_users) o que no va a existir un usuario con un id concreto (caso de not_find_non_existing_users).

Etapa 2.0 Inyección de Dependencias 💉

Clase UserSearcher:

final class UserSearcher {
    private HardcodedInMemoryUsersRepository usersRepository;

    public UserSearcher(HardcodedInMemoryUsersRepository usersRepository) {
        this.usersRepository = usersRepository;
    }

    public Optional<User> search(Integer id) {
        return usersRepository.search(id);
    }
}

Vamos un paso más allá en para reducir el acoplamiento en nuestra UserSearcher, para ello inyectaremos la dependencia que nuestra clase tiene respecto a HardcodedInMemoryUsersRepository en el propio constructor. De este modo, el punto de nuestro aplicación que instancie a nuestro UserSearcher será el responsable de saber cómo debe hacerlo y que otras dependencias puede haber detrás.

Test UserSearcherShould:

final class UserSearcherShould {
    @Test
    void find_existing_users() {
        // Now we're injecting the HardcodedInMemoryUsersRepository instance through the UserSearcher constructor.
        // 👍 Win: We've moved away from the UserSearcher the instantiation logic of the HardcodedInMemoryUsersRepository class allowing us to centralize it.
        // 👍 Win: We're exposing the couplings of the UserSearcher class.
        HardcodedInMemoryUsersRepository usersRepository = new HardcodedInMemoryUsersRepository();
        UserSearcher userSearcher = new UserSearcher(usersRepository);

        Integer existingUserId = 1;
        Optional<User> expectedUser = Optional.of(new User(1, "Rafa"));

        assertEquals(expectedUser, userSearcher.search(existingUserId));
    }

    @Test
    void not_find_non_existing_users() {
        HardcodedInMemoryUsersRepository usersRepository = new HardcodedInMemoryUsersRepository();
        UserSearcher userSearcher = new UserSearcher(usersRepository);

        Integer nonExistingUserId = 5;
        Optional<User> expectedEmptyResult = Optional.empty();

        assertEquals(expectedEmptyResult, userSearcher.search(nonExistingUserId));
    }
}

A nivel de Test observamos que, aunque no hemos ganado mucho en términos de acoplamiento, si que conseguimos exponer el acoplamiento de nuestras clases.

Etapa 2.1 Inyección de Dependencias de Parámetros 💉

Clase UserSearcher:

final class UserSearcher {
    private HardcodedInMemoryUsersRepository usersRepository;

    public UserSearcher(HardcodedInMemoryUsersRepository usersRepository) {
        this.usersRepository = usersRepository;
    }

    public Optional<User> search(Integer id) {
        return usersRepository.search(id);
    }
}

Clase HardcodedInMemoryUsersRepository:

final class HardcodedInMemoryUsersRepository {
    private Map<Integer, User> users;

    public HardcodedInMemoryUsersRepository(Map<Integer, User> users) {
        this.users = users;
    }

    public Optional<User> search(Integer id) {
        return Optional.ofNullable(users.get(id));
    }
}

Aunque la clase UserSearcher no ha cambiado, hemos dado un paso más al realizar la inyección de dependencias de forma recursiva con el HardcodedInMemoryUsersRepository, que ahora recibiría como argumento en el constructor su atributo de clase users.

Test UserSearcherShould:

final class UserSearcherShould {
    @Test
    void find_existing_users() {
        // Now we're also injecting the constant parameters needed by the HardcodedInMemoryUsersRepository through its constructor.
        // 👍 Win: We can send different parameters depending on the environment.
        // That is, in our production environment we would have actual users,
        // while in our tests cases we will set only the needed ones to run our test cases
        int rafaId = 1;
        User rafa = new User(rafaId, "Rafa");

        Map<Integer, User> users = Collections.unmodifiableMap(new HashMap<Integer, User>() {
            {
                put(rafaId, rafa);
            }
        });
        HardcodedInMemoryUsersRepository usersRepository = new HardcodedInMemoryUsersRepository(users);
        UserSearcher userSearcher = new UserSearcher(usersRepository);

        Optional<User> expectedUser = Optional.of(rafa);

        assertEquals(expectedUser, userSearcher.search(rafaId));
    }

    @Test
    void not_find_non_existing_users() {
        Map<Integer, User> users = Collections.emptyMap();
        HardcodedInMemoryUsersRepository usersRepository = new HardcodedInMemoryUsersRepository(users);
        UserSearcher userSearcher = new UserSearcher(usersRepository);

        // 👍 Win: Now we don't have to be coupled of the actual HardcodedInMemoryUsersRepository users.
        // We can send a random user ID in order to force an empty result because we've set an empty map as the system users.
        Integer nonExistingUserId = 1;
        Optional<User> expectedEmptyResult = Optional.empty();

        assertEquals(expectedEmptyResult, userSearcher.search(nonExistingUserId));
    }

Si echamos un vistazo a los Test, vemos cómo ya no tenemos por qué saber qué usuarios existen en nuestro repositorio, por lo que conseguimos aislar nuestros Test sin que dependan de la infraestructura

Etapa 3 – Inversión de Dependencias 🤹‍♀️

Clase UserSearcher:

final class UserSearcher {
    private UsersRepository usersRepository;

    public UserSearcher(UsersRepository usersRepository) {
        this.usersRepository = usersRepository;
    }

    public Optional<User> search(Integer id) {
        return usersRepository.search(id);
    }
}

Interface UsersRepository:

public interface UsersRepository {
    Optional<User> search(Integer id);
}

Vemos como ahora la clase UserSearcher lo que recibe por argumento en el constructor no es una implementación de UserRepository, sino una interface que define únicamente el contrato de un método search.

Test UserSearcherShould:

final class UserSearcherShould {
    @Test
    void find_existing_users() {
        // Now we're injecting to the UserSearcher use case different implementation of the new UserRepository interface.
        // 👍 Win: We can replace the actual implementation of the UsersRepository used by the UserSearcher.
        // That is, we'll not have to modify a single line of the UserSearcher class despite of changing our whole infrastructure.
        // This is a big win in terms of being more tolerant to changes.
        // 👍 Win: It also make it easier for us to test the UserSearcher without using the actual implementation of the repository used in production.
        // This is another big win because this way we can have test such as the following ones which doesn't actually go to the database in order to retrieve the system users.
        // This has a huge impact in terms of the time to wait until all of our test suite is being executed (quicker feedback loop for developers 💪).
        // 👍 Win: We can reuse the test environment repository using test doubles. See CodelyTvStaffUsersRepository for its particularities
        UsersRepository codelyTvStaffUsersRepository = new CodelyTvStaffUsersRepository();
        UserSearcher userSearcher = new UserSearcher(codelyTvStaffUsersRepository);

        Optional<User> expectedUser = Optional.of(UserMother.rafa());

        assertEquals(expectedUser, userSearcher.search(UserMother.RAFA_ID));
    }

    @Test
    void not_find_non_existing_users() {
        // 👍 Win: Our test are far more readable because they doesn't have to deal with the internal implementation of the UserRepository.
        // The test is 100% focused on orchestrating the Arrange/Act/Assert or Given/When/Then flow.
        // More info: <http://wiki.c2.com/?ArrangeActAssert> and <https://www.martinfowler.com/bliki/GivenWhenThen.html>
        UsersRepository emptyUsersRepository = new EmptyUsersRepository();
        UserSearcher userSearcher = new UserSearcher(emptyUsersRepository);

        Integer nonExistingUserId = 1;
        Optional<User> expectedEmptyResult = Optional.empty();

        assertEquals(expectedEmptyResult, userSearcher.search(nonExistingUserId));
    }
}

A nivel de Test ya vemos cómo podemos cambiar la implementación de UserRepository sin necesidad de tocar nuestro UserSearcher, es decir, podemos pasarle como argumento cualquier clase que implemente la interface.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *