"El buen diseño es obvio. El gran diseño es transparente"
- Joe Sparano

Principios SOLID

7 minutos de lectura
Fecha: 30/10/2018

El término SOLID hace referencia a 5 principios fundamentales en el diseño orientado a objetos, es un acrónimo que nos sirve para recordarlos, cosa que nos viene bien porque desarrollar bajo estos principios es básico si quieres tener código limpio y mantenible.

Aunque lo interesante es analizarlos, antes de entrar en materia me gustaría hablaros un poco de quien los escribe y porque son útiles.

En primer lugar, el autor de SOLID no es otro que Robert Cecil Martin más conocido en el mundillo informático como Uncle Bob, no quiero ponerme dogmático pero si no te suena de nada comienza a buscar información sobre él porque es uno de los padres (por no decir el personaje mas representativo) del clean code, que aunque pienses que solo son principios o buenas prácticas realmente son la base de la que deben partir nuestros desarrollos.

De hecho este término SOLID se incluye en la que posiblemente sea su obra más conocida, el libro «Clean Code» del año 2008, aunque realmente ya por el año 2000 hablo de ellos en su artículo Design Principles and Design Patterns.

Aunque todo esto es historia, relamente da que pensar sobre lo buenos que deben ser estos principios, que casi dos décadas despues sigan siendo tan importantes. Algo que tampoco nos debe extrañar ya que el paradigma de la programación orientada a objetos entro en escena a principios de los 90 y hasta hoy sigue vigente y no ha cambiado.

Despues de esta chapa, el motivo más importante que pueda deciros para que os leais que son y los comenceis a usar es que se estima que, del coste total de una aplicación, entre el 70 y el 80% se va en el mantenimiento.

De manera que tener un buen diseño, y que sea mantenible (no solo por nosotros que igual solo participamos en el desarrollo inicial y no en las evoluciones ni el mantenimiento), es crucial, y estos principios lo garantizan.

Veamos cuales son los 5 principios de SOLID .

Single Responsibility Principle

Traducido quiere decir Principio de responsabilidad única, y como su nombre indica propone que nuestros objetos realicen una única cosa. Aplicarlo es tan sencillo como preguntarle a la lógica su funcionamiento, pero hay cosas que cantan bastante, por ejemplo:

  • Que nuestra clase abarque distintas capas, por ejemplo si nuestra arquitectura es MVC (tenemos capas de modelo, vista y controlador), una clase no debería nunca incluir código relacionado con una de esas capas
  • Otro indicador es que no podamos hacer bien un test unitario porque nuestra clase no esta lo suficientemente aislada
  • Que tengamos muchos métodos, y que haya bloques de métodos que podamos separar en función de los atributos que usa
  • Que la clase tenga muchisimas lineas o se entrelacen mucho entre ellas para fines concretos.

En definitiva hay muchas cosas que nos indican que nuestra clase puede necesitar de una refactorización y ser separada, aunque no hay recetas mágicas y es un poco subjetivo, de manera que es el propio programador el que decide como aplicarlo pudiendo haber pequeñas diferencias entre lo que algunos deciden y lo que decidimos otros.

Veamos un ejemplo.

class Book {
    getTitle() {
        return "A Great Book";
    }

    getAuthor() {
        return "John Doe";
    }

    turnPage() {
        // pointer to next page
    }

    printCurrentPage() {
        return "current page content";
    }
}

Esta clase libro es susceptible de ser dividida aplicando el Single Responsibility Principle, podemos observar que tenemos métodos para obtener el titulo y el autor, que son propiedades normales de un libro. Pero tambien tenemos un método para pasar de página, algo que ya no tiene que ver con el modelo tanto, sino mas bien con el controlador ya que es lógica de negocio que podemos aplicar al libro. Por último tendríamos un método para imprimir la página actual que será algo que usemos en las vistas.

Una manera de aplicar la división sería la siguiente.

class Book {
    getTitle() {
        return "A Great Book";
    }

    getAuthor() {
        return "John Doe";
    }
}
class Pager {
    gotoPrevPage() {
        // pointer to prev page
    }

    gotoNextPage() {
        // pointer to next page
    }

    gotoPageByPageNumber(pagerNumber: number) {
        // pointer to specific page
    }
}
class Printer { 
    printPageInHTML(pageContent: any) { 
        // your logic
    }

    printPageInJSON(pageContent: any) { 
        // your logic
    }

    printPageInXML(pageContent: any) { 
        // your logic
    }

    printPageUnformatted(pageContent: any) { 
        // your logic
    }
}

Open Closed Principle

Este es un principio que promulga que nuestras clases sean extensibles para poder modificarlas sin necesidad de ir directamente al código. O lo que es lo mismo las clases estan abiertas en el sentido de que las podemos extender y cerradas al mismo tiempo porque no las podemos modificar directamente.

Este principio Abierto/Cerrado es complejo de implementar porque la decisión de que dejamos abierto o no es exclusivamente nuestra y por lo tanto necesitamos tener una visión muy amplia de como va a ser la aplicación (algo que en fases tempranas podemos no tener claro o que podría variar si esta evoluciona durante el desarrollo).

Para poder llevar a cabo esto usamos la herencia, en programación orientada a objetos esto se define como la capacidad que tenemos de dotar a nuestras clases de atributos y métodos que tenemos en otras.

Tambien podemos implementar los métodos de una clase principal en otras clases que usaran una lógica concreta para su función, a esto se le llama poliformismo.

Volviendo al caso anterior donde teníamos la clase libro

class Book {
    getAuthor() {
        return "John Doe";
    }
}

Vemos que el autor nos devuelve su nombre, pero ¿qué sucede si además de ese dato tuvieramos su edad y su dirección?…

Con la clase tal como está arriba no soportamos ningun dato adicional, podriamos pensar en que tampoco es para tanto, se los metemos y a correr.

class Book {
    getAuthor() {
        return {
            name: 'John Doe',
            age: 27,
            address: 'Spain'
        };
    }
}

Pero al hacer esto podríamos estar rompiendo nuestro contrato, en un caso así podemos crear clases hijas que extiendan de la clase libro y donde cada una tenga un modelo distinto con más o menos parámetros adicionales. Quedaría así…

class Book {
    getAuthor() { }
}

class Book1 extends Book {
    getAuthor() {
        return {
            name: super.getAuthor(),
            age: ''
        }
    }
}

class Book2 extends Book {
    getAuthor() {
        return {
            name: super.getAuthor(),
            age: '',
            address: ''
        }
    }
}

Como vemos las clases que extienden de Libro implementan el método getAuthor de formas distintas.

Liskov Substitution Principle

Se llama así en honor a Barbara Liskov y trata sobre que las clases derivadas, como estas que acabamos de ver que extienden la clase libro, se puedan usar igual que la clase base.

Suena extraño pero es muy sencillo, nuestras clases hijas deben comportarse como la padre de manera que podríamos substituir unas por otras sin que la aplicación se rompa.

Esto también se detecta facil cuando vemos que los tests de una clase padre no se pueden aplicar en la hija o cuando nos sobran métodos, para verlo más claro pongamos un ejemplo.

Tenemos dos aves, el martín pescador y el avestruz, vamos a crear una clase para cada una de ellas y hacer que a su vez extiendan de una clase padre común a ambas, el código sería algo así…

class Bird {
    fly() {
        console.log('I can fly!');
    }
}

class Kingfisher extends Bird {
    constructor() {
        super()
    }
}

class Ostrich extends Bird {
    constructor() {
        super()
    }
}

let kingfisherBird: Bird = new Kingfisher();

let ostrichBird: Bird = new Ostrich();

kingfisherBird.fly(); // kingfisher can fly.

ostrichBird.fly()// ostrich can fly

Como vemos la salida dice que ambas pueden volar, pero eso no es cierto en el caso del avestruz. No es que tengamos ningun error de código, de hecho funciona, no rompe por ningun lado, pero si esto fuera una aplicación real sería un error en nuestra lógica de negocio que podría provocar un mal funcionamiento.

Esto lo arreglamos usando el principio de sustitución de Liskov de la siguiente manera.

class Bird {
    fly() {
        console.log('I can fly!');
    }
}

class Kingfisher extends Bird {
    constructor() {
        super()
    }
}

class Ostrich extends Bird {
    constructor() {
        super()
    }
    fly() { 
        throw new Error("I don't fly rather I run");
    }
}

let kingfisherBird: Bird = new Kingfisher();

let ostrichBird: Bird = new Ostrich();

kingfisherBird.fly(); // kingfisher can fly.

ostrichBird.fly()// I don't fly rather I run

Ahora la cosa cambia, el Martín Pescador dice que puede volar pero el avestruz dice que no puede, y para ello hemos implementado el metodo fly de la clase padre para que se comporte de forma correcta. La ejecución del comportamiento de la clase padre se puede sustituir de forma natural y tendrá el comportamiento esperado.

Interface Segregation Principle

Podemos decir que es como el primer punto solo que aplicado a los interfaces, establece que estos deben ser específicos para una finalidad concreta. Al igual que antes tener muchos métodos era indicio de que podíamos estar violando el principio, ahora tener muchos interfaces tambien es sospechoso (cuanto menos hay que revisar si realmente todos deberían estar ahí).

Tampoco sería correcto que nuestra clase haga uso de un interface pero no use alguno de sus métodos.

Al dividir nuestros interfaces conseguimos que sean coherentes con el comportamiento de las clases que los implementan y además son más reutilizables.

Volviendo al ejemplo de antes, pensemos en que queremos crear un interface con los métodos asociados al Martín Pescador y al Avestruz, el interface sería algo así.

interface IBird { 
    fly();
    run();
}

class Kingfisher implements IBird { 
    fly() { }
    run() { }
}

class Ostrich implements IBird { 
    fly() { }
    run() { }
}

El interface está mal, y debemos segregarlo (o separarlo) en dos, esto se debe a que el método fly no aplica al avestruz, ni el metodo run aplica al Martín Pescador, de manera que esos métodos no deberían ser implementados y por lo tanto el interface de arriba no respeta este principio para ninguno de los dos casos.

La forma correcta sería…

interface IKinshfisherBird { 
    fly();
}

interface IOstrichBird { 
    run();
}

class Kingfisher implements IKinshfisherBird { 
    fly() { }
}

class Ostrich implements IOstrichBird {
    run() { }
}

Y ahora sí, no estamos obligando a implementar a la clase ningún método que no use.

Dependency Inversion Principle

El principio de inversión de dependencias es vital y tal vez el que más impacto tenga en que nuestras apps sean mantenibles. Su objetivo es que las clases no esten acopladas entre ellas.

Para ello lo que persigue es que mediante abstracciones las clases interactuen unas con otras pero sin saber de los detalles de implementación que puedan tener.

El ejemplo para esto sería el de hacer login, pongamos que queremos usar el servidor OAUTH de Google para autenticar los usuarios de nuestra app, podríamos hacer esto…

class Login { 
    login(googleLogin: any) { 
        // some code which will be used for google login.
    }
}

Ahora pongamos que además de usar Google para autenticar quisiera usar Facebook, la clase ya no serviría porque esta acoplada, usando el principio de inversión de dependencias las clases solo dependen de las abstracciones de manera que podría quedar así…

interface ISocialLogin {
    login(options: any);
 }

class GoogleLogin implements ISocialLogin { 
    login(googleLogin: any) { 
        // some code which will be used for google login.
    }
}

class FBLogin implements ISocialLogin { 
    login(fbLogin: any) { 
        // some code which will be used for fb login.
    }
}

Todos los ejemplos que he usado los he sacado de este artículo, si quieres complementar este post puedes echarle un vistazo.