Principios SOLID con ejemplos

solid principles

Que es SOLID en el desarrollo de software:

SOLID es el acrónimo de cinco principios básicos de diseño que tienen como intención hacer que el diseño de software sea más simple y comprensible permitiendo reducir además los costes de mantenimiento.
SOLID apunta a lograr una alta cohesión y un bajo acoplamiento en nuestros programas.


SOLID Principios

Veamos con ejemplos de que se trata cada principio y como utilizarlo.

  • S — Single responsibility principle (Principio de responsabilidad única)
  • O — Open/closed principle (Principio abierto/cerrado)
  • L — Liskov substitution principle (Principio de sustitución de Liskov)
  • I — Interface segregation principle (Principio de segregación de interfaces)
  • D - Dependency inversion principle (Principio de inversión de dependencias)

Single responsibility principle (Principio de responsabilidad única)

Este principio se refiere a la responsabilidad Única que debiera tener cada programa con una tarea bien específica y acotada.
Se pretende entonces que nuestro programa haga algo determinado y concreto.

Algunos de los beneficios de este principio de responsabilidad única:

  • En relación al testing. Se simplifica porque una clase tiene una única responsabilidad.
  • Se disminuye el acoplamiento pirque menor funcionalidad en una clase hará que esta tenga menos dependencias.
  • La organización de las clases y los paquetes será mejor y más sencillo.

Veamos un ejemplo que no sigue este principio y luego lo modificaremos para que si lo siga.

MAL uso de este principio de responsabilidad única

Esta clase UserLogin tiene como responsabilidad realizar el proceso de login pero además le dimos la responsabilidad de de enviar mensajes al usuario.

Este código viola el principio de responsabilidad unica. Está haciendo dos cosas con objetivos diferentes.


package solid;

class UserLogin {

    private final DataBase db;

    UserLogin(DataBase db) {
        this.db = db;
    }

    void login(String userName, String password) {
        User user = db.findUserByUserName(userName);
        if (user == null) {
            // do something
        }
        // login process..
    }

    void sendEmail(User user, String msg) {
        // sendEmail email to user
    }

}

BUEN uso de este principio de responsabilidad única

¿Entonces qué deberíamos hacer?

Sería conveniente separar la clase en dos. Una para lo específico del login y otra para la funcionalidad de envío de mensajes.

Llevemos entonces el método sendMail a otra clase que tenga como responsabilidad el envío de mensajes.

package solid;

class EmailSender {

    void sendEmail(User user, String msg) {
        // send email to user
    }

}

Open/closed principle (Principio abierto/cerrado)

El principio de open/closed dice que toda clase, modulo, método, etc. debería estar abierto para extenderse pero debe estar cerrado para modificarse.
Lo que deseamos es evitar que nuestro código sea modificado sin intención causando errores o funcionalidades no esperadas.

Esta definición puede ser algo confusa. Veamos un ejemplo para aplicarlo.

MAL uso de este principio abierto/cerrado

Veamos esta clase Car con la funcionalidad básica para un vehículo.

package solid;

class Car {

    void accelerate() {
        // accelerate car
    }

    void stop() {
        // stop car
    }

}

Ahora si quisieramos tambien agregar funcionalidad para un vehículo de carrera RaceCar podríamos estar tentados a hacerlo en la misma clase.
Un vehículo RaceCar necesita este esta funcionalidad extra injectExtraGas

package solid;

class Car {

    void accelerate(boolean isCarRace) {
        if (isCarRace) {
            injectExtraGas();
        }
        // accelerate car
    }

    void stop() {
        // stop car
    }

    private void injectExtraGas() {
        // do.. 
    }

}

BUEN uso de este principio abierto/cerrado

Pero en vez de hacer esto, para cumplir con el principio de open/closed podemos extender nuestra clase creando otro tipo de clase derivada de la principal.

Creamos nuestra clase RaceCar que extiende Car agregando aquí la funcionalidad.
Extendiendo nuestra clase Car estamos seguros que nuestra aplicación que usa Car no se verá afectada de algún modo.

package solid;

public class RaceCar extends Car {

    @Override
    void accelerate() {
        injectExtraGas();
        super.accelerate();
    }

    private void injectExtraGas() {
        // do..
    }

}

Liskov substitution principle (Principio de sustitución de Liskov)

Hablemos ahora de este principio. Este es quizás el más complejo de los cinco principios.

El principio de sustitución de Liskov dice que si la clase A es de un subtipo de la clase B, entonces deberíamos poder reemplazar B con A sin afectar el comportamiento de nuestro programa.

Veamos algo de código para entender mejor este concepto.

MAL uso de este principio de sustitución de Liskov.

Utilicemos el ejemplo del Auto para demostrar un mal uso de este principio.
Creamos una interfaz y dos implementaciones a continuación.
La primera para un auto común en nuestra clase Car y la segunda para un auto eléctrico en nuestra clase ElectricCar .

package solid.l;

interface ICar {

    void accelerate();

    void stop();

}
package solid.l;

public class Car implements ICar {

    @Override
    public void accelerate() {
        System.out.println("accelerating the car");
    }

    @Override
    public void stop() {
        System.out.println("stopping the car");
    }
}

El auto eléctrico necesita verificar el estado de la batería cosa que hacemos en el método hasBattery


package solid.l;

public class ElectricCar implements ICar {

    private int battery;

    @Override
    public void accelerate() {
        System.out.println("accelerating the car");
    }

    @Override
    public void stop() {
        System.out.println("accelerating the car");
    }

    public boolean hasBattery() {
        System.out.println("checking battery");
        if (battery < 95) {
            System.out.println("the battery is very low :(");
            return false;
        } else {
            System.out.println("battery OK :)");
            return true;
        }
    }
}

Observa cómo en el caso de el auto eléctrico necesitamos invocar el método hasBattery() para luego poder acelerar.

Con este diseño de clases estamos rompiendo el principio de sustitución porque necesitamos explícitamente conocer el tipo de vehículo y no podemos reemplazar la clase ElectricCar con la interfaz ICar


package solid.l;
public class CarDrive {
    public static void main(String[] args) {

        String cardType = args[0];
        if ("car" == cardType) {
            Car car = new Car();
            car.accelerate();
        } else if ("electric" == cardType) {
            ElectricCarBad electricCar = new ElectricCarBad();
            if ((electricCar.hasBattery())) {
                electricCar.accelerate();
            }
        } else {
            throw new RuntimeException("Invalid car");
        }
    }
}

BUEN uso de este principio de sustitución de Liskov.

Vamos a arreglar esto para que el diseño de nuestras clases previas cumplan con este principio.

Arreglemos la clase ElectricCar

package solid.l;

public class ElectricCar implements ICar {

    private int battery;

    @Override
    public void accelerate() {
        if (hasBattery()) {
            System.out.println("accelerating the car");
        } else {
            System.out.println("I can not accelerate the car");
        }
    }

    @Override
    public void stop() {
        System.out.println("accelerating the car");
    }

    private boolean hasBattery() {
        System.out.println("checking battery");
        if (battery < 95) {
            System.out.println("the battery is very low :(");
            return false;
        } else {
            System.out.println("battery OK :)");
            return true;
        }
    }
}

Veamos como ahora podemos usar el principio de sustitución. Para acelerar el auto no necesitamos conocer el tipo de clase.

package solid.l;
public class CarDrive {
    public static void main(String[] args) {
        ICar car;
        String cardType = args[0];
        if ("car" == cardType) {
            car = new Car();
        } else if ("electric" == cardType) {
            car = new ElectricCar();
        } else {
            throw new RuntimeException("Invalid car");
        }
        car.accelerate();
    }
}

Interface segregation principle (Principio de segregación de interfaces)

Este principio es bastante fácil de comprender. Si habitualmente usas interfaces es muy probable que estés aplicando este principio.

El principio se segregacion de interfaz dice que ningún cliente debería estar obligado a depender de los métodos que no utiliza.

¿Cómo es esto?

En una interfaz existente no deberíamos agregar nuevos métodos que obliguen a implementar funcionalidad adicional.
En vez de agregar métodos a una interfaz existente, es mejor crear otra interfaz y que la clase que la necesite la implemente.
Veamos un ejemplo de cómo de un mal uso de interfases que viola este principio.

MAL uso de este principio de segregación de interfaces.

Creamos una interfaz para IProduct de la cual luego crearemos sus implementaciones.

package solid.i;
public interface IProduct {
    String getType();
}

Implementamos un producto Shoes desde la interfaz. IProduct

package solid.i;
class Shoes implements IProduct {
    @Override
    public String getType() {
        return "shoes";
    }
}

Implementamos otro producto Games desde la interfaz IProduct

package solid.i;
class Games implements IProduct {
    @Override
    public String getType() {
        return "game";
    }
}

Ahora necesitamos que nuestra clase Games también implemente un método getAge() para conocer para que edad son los juegos.
La forma simple para esto sería agregar a la interfaz IProduct el método de este modo.

package solid.i;
public interface IProduct {
    String getType();
    int getAge();
}

Pero esto nos obligará a implementar el método _getAge() también en todas las clases.
Lo implementamos en nuestra clase Games , en dónde si aplica este método.

package solid.i;
class Games implements IProduct {
    private int age;
    @Override
    public String getType() {
        return "game";
    }
    @Override
    public int getAge() {
        return age;
    }
    // get and set.. 
}

Pero nos vemos obligados también a usarlo en esta clase Shoes que no lo necesita.

package solid.i;
class Shoes implements IProduct {
    @Override
    public String getType() {
        return "shoes";
    }
    @Override
    public int getAge() {
        throw new UnsupportedOperationException();
    }
}

BUEN uso de este principio de segregación de interfaces.

Lo que podemos hacer para solucionar el problema anterior del mal uso de este principio es crear otra interfaz para el caso de productos que requieran la edad getAge() , así solo las clases que necesiten la restricción por edad lo implementarán.

De este modo NO obligamos a ninguna clase a implementar el método que no utilizará.

package solid.i;

public interface IProduct {

    String getType();

}
package solid.i;

public interface IRestrictedProduct {

    int getAge();

}

La clase Shoes solo implementa la interfaz IProduct

package solid.i;

class Shoes implements IProduct {

    @Override
    public String getType() {
        return "shoes";
    }

}

La clase Games ahora implementa las dos interfaces.
Resolvemos de este modo el principio de segregación de interfaces.

package solid.i;

class Games implements IProduct, IRestrictedProduct {

    private int age;

    @Override
    public String getType() {
        return "game";
    }

    @Override
    public int getAge() {
        return age;
    }

    // get and set..

}

Dependency inversion principle (Principio de inversión de dependencias)

El principio de inversión de dependencias nos permite desacoplar los distintos módulos de un software.
Los que nos dice este principio es que no deben existir dependencias entre los módulos, en especial entre módulos de bajo nivel y de alto nivel.
Dicho de otro modo nuestra software no debería depender, por ejemplo, de cómo esta implementados los frameworks para acceso a base de datos o conecciones con el servidor.

Para este fin debemos depender de interfaces sin conocer qué sucede exactamente en la implementación de dichas interfaces.

Para usar este principio necesitamos este patrón de inversión de dependencias que habitualmente en java se resuelve con la inyección de dependencias. Spring hace un buen uso de este principio.

Lo que deseamos es que:
Las clases de nuestro código no dependan directamente de las clases de bajo nivel. Debemos usar abstracciones a través de interfaces y solo depender de estas abstracciones.

MAL uso de este principio de inversión de dependencias.

Tenemos esta clase Cash que recibe un producto y un método de pago. Luego instancia la base de datos para persistir el producto con su respectivo método de pago.

Observa que estamos haciendo un uso directo de Database dependiendo directamente de su implementación. Estamos utilizando en nuestro código clases de bajo nivel, la clase MySqlDatabase .
Si el dia de mañana quisiéramos migrar a otra base de datos deberíamos modificar nuestro código directamente.

package solid.d;

class Cash {

   public void pay(Product product, PaymentType paymentType) {

        MySqlDatabaseBad persistence = new MySqlDatabaseBad();
        persistence.save(product, paymentType);

   }

}
package solid.d;

class MySqlDatabase{

    void save(Product product, PaymentType paymentType) {
        System.out.println("Save product " + product + " paymentType " + paymentType);
        // save into MySqlDatabase...
    }

}

BUEN uso de este principio de inversión de dependencias.

Lo primero que tenemos que hacer es dejar de depender directamente de la clase concreta MySqlDatabase por lo que vamos a crear una interfaz que nos desacople de la persistencia.
No tenemos porqué saber en nuestro código como o donde se guarda el producto.

Creamos nuestra interfaz para la la persistencia

package solid.d;

interface Persistence {

    void save(Product product, PaymentType paymentType);

}

Implementamos la interfaz en nuestra clase MySqlDatabase

package solid.d;

class MySqlDatabase implements Persistence {

    public void save(Product product, PaymentType paymentType) {
        System.out.println("Save product " + product + " paymentType " + paymentType);
        // save into MySqlDatabase...
    }

}

Nuestra clase cash ahora recibirá la interfaz Persistence en el constructor.

¿Se ve mejor así, no?

La clase ya no necesita saber quién o cómo implementa la persistencia.
La clase Cash utiliza la interfaz y desconoce su implementación.


package solid.d;

class Cash {

    Persistence persistence;

    public Cash(Persistence persistence) {
        this.persistence = persistence;
    }

    public void pay(Product product, PaymentType paymentType) {

        persistence.save(product, paymentType);

    }

}

Conclusión

Vimos para cada principio SOLID ejemplos de mal uso y luego como corregirlo para tener una idea clara de los beneficios que obtenemos utilizando esos principios.
Aplicando estos principios hacen que tu código sea reusable, mantenible, escalable. Ayudando además que que sea más fácil su testeo. No te olvides de usarlos, ganarás mucho en la legibilidad de tu código.

Puedes descargar este código en github o en gitlab

Deja un comentario

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

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.