SOLID describe un grupo de pautas que los desarrolladores pueden usar para simplificar y aclarar su código. Si bien ciertamente no son leyes, comprender estos conceptos lo convertirá en un mejor desarrollador. En resumen, los cinco principios SOLID son:

  1. Principio de responsabilidad única (single responsability); Una clase solo debe tener una responsabilidad única, es decir, solo los cambios en una parte de la especificación del software deben poder afectar la especificación de la clase.
  2. Principio abierto-cerrado (open closed principle); Sus clases deben estar abiertas para extensión pero cerradas para modificaciones.
  3. Principio de sustitución de Liskov (Liskov subsitution); Los objetos de un programa deben ser reemplazables con instancias de sus subtipos sin alterar la corrección de ese programa.
  4. Principio de segregación de interfaz (Interface segregation); Muchas interfaces específicas del cliente son mejores que una interfaz de propósito general.
  5. Principio de inversión de dependencias (Dependecy Injection); Uno debería depender de abstracciones, no de concreciones.

No se preocupe si no comprende completamente el significado de estos por primera vez de lectura, yo no lo hice. Ahora repasaré cada principio uno por uno y trataré de explicar no solo cómo funcionan, sino cómo lo beneficiarán a largo plazo.

Single-responsibility

El más común de los principios de diseño SOLID, el principio de responsabilidad única, establece que una clase debe tener solo una razón para cambiar. Cuando una clase maneja más de una responsabilidad, cualquier cambio realizado en las funcionalidades puede propagarse por toda la aplicación de formas inesperadas. Este comportamiento inesperado puede ser perjudicial si tiene una aplicación más pequeña, pero puede empeorar aún más cuando trabaja con un software grande de nivel empresarial. Al asegurarse de que cada función solo encapsula una sola responsabilidad, puede ahorrar mucho tiempo de prueba y crear una arquitectura más fácil de mantener.

Dejame mostrarte un ejemplo. Usaré PHP pero también puedes aplicar principios de diseño SOLID a cualquier otro lenguaje de programación orientada a objetos.

Imaginemos que tenemos una clase que representa un documento de texto, donde dicho documento tiene título y contenido. Este documento debe poder exportarse a HTML y PDF.

class Documento
{
    protected $titulo;
    protected $contenido;

    public function __construct(string $titulo, string $contenido)
    {
        $this->titulo = $titulo;
        $this->contenido = $contenido;
    }

    public function getTitulo(): string
    {
        return $this->titulo;
    }

    public function getContenido(): string
    {
        return $this->contenido;
    }

    public function exportHtml() {
        echo "DOCUMENTO EXPORTADO A HTML".PHP_EOL;
        echo "Titulo: ".$this->getTitulo().PHP_EOL;
        echo "Contenido: ".$this->getContenido().PHP_EOL.PHP_EOL;
    }

    public function exportPdf() {
        echo "DOCUMENTO EXPORTADO A PDF".PHP_EOL;
        echo "Titulo: ".$this->getTitulo().PHP_EOL;
        echo "Contenido: ".$this->getContenido().PHP_EOL.PHP_EOL;
    }
}

En este caso, no es responsabilidad del documento exportarse a un formato particular, el documento solo debe ser una representación de sí mismo.

La clave para resolver esto es mover cada uno de los métodos de exportación a sus propias clases, que implementarán una interfaz “Exportable”.

interface InterfaceDocumentoExportable
{
    public function export(Documento $documento);
}

Lo siguiente que tenemos que hacer es extraer la lógica que no se aplica a la clase.

public function exportHtml() {
        echo "DOCUMENTO EXPORTADO A HTML".PHP_EOL;
        echo "Titulo: ".$this->getTitulo().PHP_EOL;
        echo "Contenido: ".$this->getContenido().PHP_EOL.PHP_EOL;
    }

    public function exportPdf() {
        echo "DOCUMENTO EXPORTADO A PDF".PHP_EOL;
        echo "Titulo: ".$this->getTitulo().PHP_EOL;
        echo "Contenido: ".$this->getContenido().PHP_EOL.PHP_EOL;
    }

Dejando la clase de documentos algo como esto

class Documento
{
    protected $titulo;
    protected $contenido;

    public function __construct(string $titulo, string $contenido)
    {
        $this->titulo = $titulo;
        $this->contenido = $contenido;
    }

    public function getTitulo(): string
    {
        return $this->titulo;
    }

    public function getContenido(): string
    {
        return $this->contenido;
    }
}

Open–closed

El principio de abierto-cerrado establece que los objetos o entidades deben estar abiertos para extensión, pero cerrados para modificación. Este es uno de los principios que los desarrolladores suelen omitir, pero intentan no hacerlo. Estas técnicas son fundamentales para el diseño maduro.

Por lo tanto, debería poder extender su código existente utilizando características como la herencia a través de subclases e interfaces. Sin embargo, nunca debe modificar clases, interfaces y otras unidades de código que ya existan, ya que puede provocar un comportamiento inesperado. Si agrega una nueva característica extendiendo su código en lugar de modificarlo, reduce el riesgo de falla tanto como sea posible.

Imaginemos que necesitamos lograr un sistema de inicio de sesión. Para autenticar a nuestro usuario, necesitamos un nombre de usuario y una contraseña, hasta ahora todo bien. Entonces, ¿qué sucede un año después si queremos que un usuario pueda autenticarse a través de Twitter o Facebook? Es importante comprender que lo que se nos ha pedido no es un cambio en una función actual, sino más bien la creación de una nueva función.

Digamos que nuestra clase de autenticación se ve así, donde llama a un método de autenticación para su usuario.

class LoginService
{
    public function login($usuario)
    {
        $this->autenticar($usuario);
    }
}

Cuando se trata de implementar autenticación a través de terceros, es posible que deseemos probar algo como esto, donde verificamos qué tipo de usuario tenemos usando una declaración if y ejecutando el código en consecuencia.

class LoginService
{
    public function login($usuario)
    {
        if ($usuario instanceof Usuario) {
            $this->autenticar($usuario);
        } else if ($usuario instanceOf UsuarioExterno) {
            $this->autenticarExterno($usuario);
        }
    }
}

Esto no es bueno porque estamos modificando código que ya está en su lugar. Puede parecer bueno ahora, pero ¿qué sucede cuando se admiten cinco o seis tipos de autenticación? En su lugar, debe abstraerse y trabajar en una interfaz. Lo primero que debemos hacer es construir una interfaz que cumpla con lo que queremos hacer para el caso de uso específico.

interface LoginInterface
{
    public function autenticar($usuario);
}

Ahora podemos desacoplar la lógica que ya habíamos creado para nuestro caso de uso, luego implementar una clase usando nuestra nueva interfaz.

class AutenticacionSimple implements LoginInterface
{
    public function autenticar($usuario)
    {
        // TODO: Implementar autenticar().
    }
}

class AutenticacionExterna implements LoginInterface
{
    public function autenticar($usuario)
    {
        // TODO: Implementar autenticar().
    }
}

Ahora a nuestra clase LoginService no le importa qué tipo de usuario tengamos, simplemente interactúa con una ‘LoginInterface’.

class LoginService
{
    public function login(LoginInterface $usuario)
    {
        $user->autenticar($usuario);
    }
}

Liskov Substitution

Acuñado por Barbara Liskov, este principio establece que cualquier implementación de una abstracción (interfaz) debe ser sustituible en cualquier lugar donde se acepte la abstracción.

En términos sencillos, establece que un objeto de una clase principal debe ser reemplazable por objetos de su clase secundaria sin causar problemas en la aplicación. Por lo tanto, una clase secundaria nunca debe cambiar las características de su clase principal (como la lista de argumentos y los tipos de retorno). Puede implementar el principio de sustitución de Liskov prestando atención a la jerarquía de herencia correcta.

Digamos que tenemos una clase de envío que va a calcular el costo de envío de un producto dado su peso y destino.

class Shipping
{
    public function calcularCosto($peso, $destino)
    {
        if ($peso <= 0) {
            throw new \Exception('El peso no puede ser 0');
        }

        $costo = rand(5, 15);

        if ($costo <= 0) {
            throw new \Exception('El costo no puede ser 0');
        }

        return $costo;
    }
}

Pero para el envío mundial, queremos que estas reglas sean ligeramente diferentes, por lo que creamos una clase secundaria que amplía la clase Envío.

class ShippingInternacional extends Shipping
{
    public function calcularCosto($peso, $destino)
    {
        if ($peso <= 0) {
            throw new \Exception('El peso no puede ser 0');
        }

        if (empty($destino)) {
            throw new \Exception('Debe especificar un destino');
        }

        $costo = rand(5, 15);

        if ('España' === $destino) {
            $costo = 0;
        }

        return $costo;
    }
}

El problema aquí es que el método de envío mundial no proporciona la misma implementación, como lo ve $destino ahora lanza una excepción si está vacío.

La mejor manera de no romper LSP es utilizando interfaces. En lugar de extender nuestras clases secundarias de una clase principal.

interface CostoDeEnvio
{
    public function calcular($peso, $destino);
}
class ShippingInternacional implements CostoDeEnvio
{
    public function calcular($peso, $destino)
    {
        // Implementacion
    }
}

Mediante el uso de interfaces, puede implementar métodos que diferentes clases tienen en común, pero cada método tendrá su propia implementación, sus propias condiciones previas y posteriores, etc. No estamos atados a una clase padre.

Interface Segregation

El Principio de Segregación de Interfaces establece que un cliente nunca debe verse obligado a implementar una interfaz que no utilice. Como verá, todo esto se reduce al conocimiento.

La violación del principio de segregación de interfaces daña la legibilidad del código y requiere que los programadores escriban métodos de código auxiliar vacíos que no hacen nada. En una aplicación bien diseñada, debe evitar la contaminación de la interfaz (también llamada interfaces fat). La solución es crear interfaces más pequeñas que pueda implementar de manera más flexible.

Digamos que tenemos una clase que representa un libro de tapa dura y otra clase que representa un audiolibro. Queremos crear una interfaz que represente las acciones que un usuario puede realizar con este libro.

interface Interacciones {

    public function verResenas();

    public function buscarUsados();

    public function escucharMuestra();

}

Ahora bien, si tuviéramos que agregar esta implementación a nuestras clases, ambas clases ahora tienen que contener métodos que no son relevantes para ellas. El HardcoverBook no puede tener una muestra para escuchar, por ejemplo. Del mismo modo, los audiolibros no tienen copias de segunda mano, por lo que la clase de audiolibros tampoco las necesita.

Sin embargo, como la interfaz Interacciones incluye estos métodos, todas sus clases dependientes deben implementarlos. En otras palabras, BookAction es una interfaz contaminada que debemos segregar. Vamos a ampliarlo con dos interfaces más específicas: Libro y AudioLibro.

    interface Interacciones {
        public function verResenas();
    }

    interface Libro extends Interacciones {
        public function buscarUsados();
    }

    interface AudioLibro extends Interacciones {
        public function escucharMuestra();
    }

Ahora la clase Libro puede implementar la interfaz Libro y la clase AudioLibro puede implementar la interfaz AudioLibro. De esta manera, ambas clases pueden implementar el método verResenas() de la superinterfaz Libro. Sin embargo, Libro no tiene que implementar el método escucharMuestra() irrelevante y AudioLibro tampoco tiene que implementar buscarUsados().

Dependency Inversion

El principio de inversión de dependencia establece que los módulos de alto nivel nunca deben depender de módulos de bajo nivel, sino que el módulo de alto nivel puede depender de una abstracción y el módulo de bajo nivel depende de esa misma abstracción. No es la declaración más simple con la que nos hemos encontrado. En palabras muy simples … no, una declaración tan compleja no se puede simplificar.

Veamos un ejemplo, tome esta clase PasswordReminder. Pasamos una MySQLConnection al constructor. Esto puede parecer legítimo, pero está rompiendo el principio de inversión de dependencia. La clase de alto nivel (PasswordReminder) ahora se basa en la clase de bajo nivel (MySQLConnection).

class PasswordReminder {

    protected $dbConnection;

    public function __construct(MySQLConnection $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }
}

Entonces, ¿qué hacemos para solucionar esto? Bueno, codificamos a una interfaz. Es posible que ya haya notado que las interfaces son herramientas muy útiles para seguir los principios SOLID. Entonces, si configuramos una ConnectionInterface, puede tener un método de recopilación.

interface ConnectionInterface()
{
    public function connect();
}

Ahora, si tuviéramos que seguir el principio, deberíamos cambiar la clase PasswordReminder para usar esta interfaz en lugar de la implementación de la interfaz.

class PasswordReminder {

    protected $dbConnection;

    public function __construct(ConnectionInterface $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }
}

Conclusión

El propósito de aplicar los principios en proyectos de software es aprovechar los beneficios de utilizar correctamente el paradigma orientado a objetos, evitando problemas como la falta de estandarización de código y la duplicación de código. Y si podemos seguir todos estos consejos, tendremos un código fácil de mantener, probar, reutilizar y ampliar. Como siguiente paso, comience a capacitarse en proyectos personales, pequeños y más simples. Puede comenzar haciendo cambios en clases específicas. Pronto, comenzará a entrenar su cerebro para pensar con mayor madurez cuando se enfrente a situaciones de desarrollo más complejas.