Principios SOLID en JavaScript

SOLID es el acrónimo que introdujo Robert C. Martin a principios de los años 2000 que representan los cinco principios que debes de considerar en la programación orientada a objetos. Estos principios no son más que guías que puedes o no aplicar en el desarrollo de software, pero que te permiten crear sistemas extensibles, flexibles, legibles y con un código limpio (spoiler: en futuras entradas hablaremos sobre código limpio). Podemos concluir que los principios SOLID nos permiten alto grado de cohesión y bajo acoplamiento.

¿Qué es la cohesión?

La cohesión en términos de informática se refiere al grado en que diferentes elementos de un mismo sistema permanecen unidos generando un elemento mayor. Podríamos verlo como una clase que integra varios métodos y cada uno de estos métodos está relacionado entre sí, teniendo una “temática” común.

¿Qué es el acoplamiento?

El acoplamiento es el grado en que todos estos elementos se relacionan entre sí. Entre mayor sean las relaciones o dependencias mayor grado de acoplamiento tendremos.

Cómo aplicar los principios SOLID en JavaScript

Ya vimos un poco de teoría y ahora nos vamos a enfocar en la práctica. En esta parte de este artículo buscaremos cómo aplicar cada uno de los principios en éste maravilloso lenguaje.

Por cierto, si estás buscando como convertirte en un mejor desarrollador de software, te dejo esta guía que fue escrita en Laserants.

Los cinco principios SOLID son:

  • S – Principio de responsabilidad única (Single responsibility principle)
  • O – Principio de abierto/cerrado (Open/Close principle)
  • L – Principio de sustitución de Liskov (Liskov substitution principle)
  • I – Principio de segregación de la interfaz (Interface segregation principle)
  • D - Principio de inversión de la dependencia (Dependency inversion principle)

Principio de Responsabilidad Única

Nos dice que una clase o función debe centrarse en una única responsabilidad, que debe existir una única razón para cambiar; en resumen podemos decir que éste principio nos pide que todos los métodos o sub-funciones tengan alta cohesión.

class Auto {
  constructor(marca, modelo) {
    this.marca = marca;
    this.modelo = modelo;
  }

  obtenerMarca() {
    return this.marca;
  }

  obtenerModelo() {
    return this.modelo;
  }

  guardarMarca(marca) {
    this.marca = marca;
  }

  guardarModelo(modelo) {
    this.modelo = modelo;
  }
}

En este ejemplo podemos ver como la clase Auto tiene métodos específicos para leer y escribir información, pero no hace nada adicional como guardar en una base de datos, llamar a otras funciones ajenas.

Principio de Abierto/Cerrado

Nos dice que debemos ser capaces de extender el comportamiento de una clase/función sin modificar.

class ProductosEnAlacena {
  productos = ["Piña", "Manzanas", "Harina"];

  existeProducto(producto) {
    // indexOf nos devuelve la posición del producto en el array,
    // si la posición es -1 significa que no existe el producto
    return this.productos.indexOf(producto) !== -1;
  }
}

Si quisiéramos a la clase ProductosEnAlacena añadirle la posibilidad de ingresar más productos entonces haríamos lo siguiente:

class ProductosEnAlacena {
  productos = ["Piña", "Manzanas", "Harina"];

  existeProducto(producto) {
    // indexOf nos devuelve la posición del producto en el array,
    // si la posición es -1 significa que no existe el producto
    return this.productos.indexOf(producto) !== -1;
  }

  agregarProducto(producto) {
    this.productos.push(producto);
  }
}

Como se puede observar, le hemos hecho modificaciones a la clase, sin alterar la funcionalidad previa, cumpliendo así con el principio.

Principio de sustitución de Liskov

El principio nos indica que si estás usando una clase Rectangulo y luego creas otra clase llamada Cuadrado que extiende de Rectangulo entonces cualquier objeto creado a partir de la clase Rectangulo puede ser cambiado por Cuadrado, obligándonos así a que cualquier clase hija no altere el comportamiento de la clase padre.

Entonces tendríamos un rectángulo:

class Rectangulo {
  ancho;
  alto;

  establecerAncho(ancho) {
    this.ancho = ancho;
  }

  establecerAlto(alto) {
    this.alto = alto;
  }

  calcularArea() {
    return ancho * alto;
  }
}

Y tenemos una prueba escrita en mocha para comprobar el área:

describe("Validar área de un rectángulo ", function () {
  it("El área debe ser igual a alto * ancho ", function () {
    const rectangulo = new Rectangulo();
    rectangulo.establecerAncho(8);
    rectangulo.establecerAlto(2);
    const area = rectangulo.calcularArea();
    assert.equal(area, 16);
  });
});

Si ejecutamos la prueba nos encontramos que el área debe ser equivalente a 16, resultado de multiplicar ancho (8) por alto (2).

Ahora creamos una clase Cuadrado que extiende de Rectangulo.

class Cuadrado extends Rectangulo {
  establecerAncho(ancho) {
    super.establecerAncho(ancho);
    super.establecerAlto(ancho);
  }

  establecerAlto(alto) {
    super.establecerAncho(alto);
    super.establecerAlto(alto);
  }
}

Para validar que no rompimos el funcionamiento del padre, correremos la prueba sobre un objeto creado con la clase Cuadrado. Al correr la prueba nos daremos cuenta que ha fallado, pues ahora un cuadrado escribe el ancho y alto como el mismo valor imposibilitando tener el área de un rectángulo con lados diferentes.

Hasta éste punto te estarás preguntando como solucionarlo, y creo que has de estar pensando en diferentes posibilidades. La primera y más sencilla puede ser abstraer la lógica a una clase superior quedando el código de la siguiente manera:

class Paralelogramo {
  constructor(ancho, alto) {
    this.establecerAncho(ancho);
    this.establecerAlto(alto);
  }

  establecerAncho(ancho) {
    this.ancho = ancho;
  }

  establecerAlto(alto) {
    this.alto = alto;
  }

  calcularArea() {
    return this.ancho * this.alto;
  }
}

class Rectangulo extends Paralelogramo {
  constructor(ancho, alto) {
    super(ancho, alto);
  }
}

class Cuadrado extends Paralelogramo {
  constructor(lado) {
    super(lado, lado);
  }
}

Principio de segregación de la interfaz

El principio nos indica que una clase debe de implementar únicamente las interfaces que necesita, es decir, que no necesite tener que implementar métodos que no utilice. El propósito de este principio es obligarnos a escribir interfaces pequeñas buscando aplicar el principio de cohesión en cada interfaz.

Imaginemos que tenemos un negocio de venta de computadoras de escritorio, sabemos que todas las computadoras deberían de extender de la clase Computadora y tendríamos algo como esto:

class Computadora {
  marca;
  modelo;

  constructor(marca, modelo) {
    this.marca = marca;
    this.modelo = modelo;
  }

  obtenerMarca() {
    return this.marca;
  }

  obtenerModelo() {
    return this.modelo;
  }

  guardarMarca(marca) {
    this.marca = marca;
  }

  guardarModelo(modelo) {
    this.modelo = modelo;
  }
}

class ComputadoraDell extends Computadora {
   ...
}

En nuestro negocio todo va de maravilla y ahora queremos extender un poco más nuestro catalogo de productos, así que decidimos optar por empezar a vender computadoras portátiles. Un atributo útil de una portátil es el tamaño de la pantalla integrada, pero como bien sabemos esto solo esta presente en las portátiles y no computadoras de escritorio (generalizando), al inicio podemos pensar que una implementación podría ser:

class Computadora {
  ...
  constructor() {
    ...
  }
  ...
  guardarTamanioPantalla(tamanio) {
    this.tamanio = tamanio;
  } 
  obtenerTamanioPantalla() {
    return this.tamanio;
  }
}
class PortatilHP extends Computadora {
   ...
}

El problema que tenemos con esta implementación es que no todas las clases, por ejemplo la EscritorioDell, requieren los métodos para leer y escribir el tamaño de la pantalla integrada, entonces deberíamos de pensar en separar ambas lógicas en dos interfaces quedando nuestro código así:

class Computadora {
  marca;
  modelo;

  constructor(marca, modelo) {
    this.marca = marca;
    this.modelo = modelo;
  }

  obtenerMarca() {
    return this.marca;
  }

  obtenerModelo() {
    return this.modelo;
  }

  guardarMarca(marca) {
    this.marca = marca;
  }

  guardarModelo(modelo) {
    this.modelo = modelo;
  }
}

class TamanioPantallaIntegrada {
  tamanio;
  constructor(tamanio) {
    this.tamanio = tamanio;
  }

  guardarTamanioPantalla(tamanio) {
    this.tamanio = tamanio;
  }

  obtenerTamanioPantalla() {
    return this.tamanio;
  }
}

class PortatilAsus implements <TamanioPantallaIntegrada, Computadora> {
  ...
}

Todo suena perfecto, pero ¿te has dado cuenta del problema?, y es que JavaScript solo soporta una clase padre, entonces la solución sería aplicar un mixin, este sería el código utilizando un mixin:

class Computadora {
  marca;
  modelo;

  constructor(marca, modelo) {
    this.marca = marca;
    this.modelo = modelo;
  }

  obtenerMarca() {
    return this.marca;
  }

  obtenerModelo() {
    return this.modelo;
  }

  guardarMarca(marca) {
    this.marca = marca;
  }

  guardarModelo(modelo) {
    this.modelo = modelo;
  }
}

const Portatil = (clasePadre) => {
  return (
    class extends clasePadre {
      constructor(marca, modelo){
        super(marca, modelo);
      }

      guardarTamanioPantalla(tamanio) {
        this.tamanio = tamanio;
      }

      obtenerTamanioPantalla() {
        return this.tamanio;
      }

    }
  )
}

class PortatilAsus extends Portatil(Computadora) {
  ...
}

Principio de inversión de dependencia

En este principio se establecen que las dependencias deben de estar en las abstracciones y no en las concreciones, en otras palabras, nos piden que las clases nunca dependan de otras clases y que toda esta relación debe estar en una abstracción. Este principio tiene dos reglas:

  1. Los módulos de alto nivel no deben de depender de módulos de bajo nivel. Esta lógica debe de estar en una abstracción.
  2. Las abstracciones no deben de depender de detalles. Los detalles deberían depender de abstracciones.

Imagina que tenemos una clase que nos permite enviar un correo:

class Correo {
  provider;

  constructor() {
    // Levantar una instancia de google mail, este código es con fin de demostración.
    this.provider = gmail.api.createService();
  }

  enviar(mensaje) {
    this.provider.send(mensaje);
  }
}

var correo = new Correo();
correo.enviar('hola!');

En este ejemplo se puede ver que se está rompiendo la regla, puesto que la clase correo depende del proveedor de servicio, ¿qué pasaría si después queremos usar Yahoo y no Gmail?

Para solucionar esto debemos eliminar esa dependencia y añadirla como una abstracción.

class GmailProveedor {
  constructor() {
    // Levantar una instancia de google mail, este código es con fin de demostración.
    this.provider = gmail.api.createService();
  }
  enviar(mensaje) {
    this.provider.sendAsText(mensaje);
  }
}
class Correo {
  constructor(proveedor) {
    this.proveedor = proveedor;
  }
  enviar(mensaje) {
    this.proveedor.send(mensaje);
  }
}
var gmail = new GmailProveedor();
var correo = new Correo(gmail);
correo.enviar('hola!');

De esta forma ya no nos importa el proveedor ni la forma en que implementa el envío de correos el proveedor, la clase de Correo solo se ocupa de una única cosa, pedirle al proveedor que envíe un correo.

Hasta acá hemos terminado con esta publicación sobre los principios SOLID en Javascript, agradecería que me dejes comentarios y sugerencias sobre que otros temas te interesaría que repasaramos.