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 *