Los principios SOLID de la programación orientada a objetos ayudan a que los diseños orientados a objetos sean más comprensibles, flexibles y mantenibles.

También facilitan la creación de código legible y comprobable con el que muchos desarrolladores pueden trabajar en colaboración en cualquier momento y lugar. Y te hacen consciente de la mejor manera de escribir código 💪.

SOLID es un acrónimo mnemotécnico de los cinco principios de diseño de clases orientadas a objetos. Estos principios son:

S - Principio de responsabilidad única
O - Principio abierto-cerrado
L - Principio de sustitución de Liskov
I - Principio de segregación de interfaces
D - Principio de inversión de dependencia


En este artículo, aprenderás qué significan estos principios y cómo funcionan utilizando ejemplos de JavaScript. Los ejemplos le servirán aunque no conozca bien JavaScript, ya que también se aplican a otros lenguajes de programación.

¿Qué es el principio de responsabilidad única?


El Principio de Responsabilidad Única, o SRP, establece que una clase sólo debe tener una razón para cambiar. Esto significa que una clase debe tener un solo trabajo y hacer una sola cosa.

Veamos un ejemplo apropiado. Siempre tendrás la tentación de juntar clases similares pero desafortunadamente, esto va en contra del Principio de Responsabilidad Única. ¿Por qué?

El objeto ValidatePerson que se muestra a continuación tiene tres métodos: dos métodos de validación, (ValidateName() y ValidateAge()), y un método Display().

class ValidatePerson {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    ValidateName(name) {
        if (name.length > 3) {
            return true;
        } else {
            return false;
        }
    }

    ValidateAge(age) {
        if (age > 18) {
            return true;
        } else {
            return false;
        }
    }

    Display() {
        if (this.ValidateName(this.name) && this.ValidateAge(this.age)) {
            console.log(`Name: ${this.name} and Age: ${this.age}`);
        } else {
            console.log('Invalid');
        }
    }
}

El método Display() va en contra del SRP porque el objetivo es que una clase tenga un solo trabajo y haga una sola cosa. La clase ValidatePerson hace dos trabajos  valida el nombre y la edad de la persona y luego muestra alguna información.

La forma de evitar este problema es separar el código que soporta diferentes acciones y trabajos para que cada clase sólo realice un trabajo y tenga una razón para cambiar.

Esto significa que la clase ValidatePerson sólo será responsable de validar a un usuario, como se ve a continuación:

class ValidatePerson {
    constructor(name, age) {
        this.name = name;
        this.age = age;
    }

    ValidateName(name) {
        if (name.length > 3) {
            return true;
        } else {
            return false;
        }
    }

    ValidateAge(age) {
        if (age > 18) {
            return true;
        } else {
            return false;
        }
    }
}

Mientras que la nueva clase DisplayPerson será ahora la responsable de mostrar una persona, como puedes ver en el siguiente bloque de código:

class DisplayPerson {
    constructor(name, age) {
        this.name = name;
        this.age = age;
        this.validate = new ValidatePerson(this.name, this.age);
    }

    Display() {
        if (
            this.validate.ValidateName(this.name) &&
            this.validate.ValidateAge(this.age)
        ) {
            console.log(`Name: ${this.name} and Age: ${this.age}`);
        } else {
            console.log('Invalid');
        }
    }
}

Con esto, habrás cumplido el principio de responsabilidad única, lo que significa que nuestras clases tienen ahora una sola razón para cambiar. Si quieres cambiar la clase DisplayPerson, no afectará a la clase ValidatePerson.

¿Qué es el principio abierto-cerrado?


El principio abierto-cerrado puede resultar confuso porque es un principio bidireccional. Según la definición de Bertrand Meyer en Wikipedia, el principio abierto-cerrado (OCP) establece que las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas a la ampliación, pero cerradas a la modificación.

Esta definición puede resultar confusa, pero un ejemplo y más aclaraciones te ayudarán a entenderlo.

Hay dos atributos principales en el OCP:

Está abierto a extensiones: Esto significa que puedes extender lo que el módulo puede hacer.
Está cerrado a modificaciones: Esto significa que no puedes cambiar el código fuente, aunque puedas extender el comportamiento de un módulo o entidad.
OCP significa que una clase, módulo, función y otras entidades pueden extender su comportamiento sin modificar su código fuente. En otras palabras, una entidad debe ser extensible sin modificar la propia entidad. ¿Cómo?

Por ejemplo, supongamos que tenemos un array de sabores de helado, que contiene una lista de posibles sabores. En la clase makeIceCream, un método make() comprobará si existe un determinado sabor y registrará un mensaje.

const iceCreamFlavors = ['chocolate', 'vanilla'];

class makeIceCream {
    constructor(flavor) {
        this.flavor = flavor;
    }

    make() {
        if (iceCreamFlavors.indexOf(this.flavor) > -1) {
            console.log('Great success. You now have ice cream.');
        } else {
            console.log('Epic fail. No ice cream for you.');
        }
    }
}

El código anterior incumple el principio OCP. ¿Por qué? Bueno, porque el código de arriba no está abierto a una extensión, lo que significa que para añadir nuevos sabores, tendría que editar directamente la matriz iceCreamFlavors. Esto significa que el código ya no está cerrado a modificaciones. Jaja (eso es mucho).

Para arreglar esto, necesitarías una clase o entidad extra para manejar la adición, así ya no necesitarías modificar el código directamente para hacer cualquier extensión.

const iceCreamFlavors = ['chocolate', 'vanilla'];

class makeIceCream {
    constructor(flavor) {
        this.flavor = flavor;
    }
    make() {
        if (iceCreamFlavors.indexOf(this.flavor) > -1) {
            console.log('Great success. You now have ice cream.');
        } else {
            console.log('Epic fail. No ice cream for you.');
        }
    }
}

class addIceCream {
    constructor(flavor) {
        this.flavor = flavor;
    }
    add() {
        iceCreamFlavors.push(this.flavor);
    }
}

Aquí, hemos añadido una nueva clase  addIceCream  para manejar la adición a la matriz iceCreamFlavors utilizando el método add().

Esto significa que tu código está cerrado a modificaciones pero abierto a una extensión porque puedes añadir nuevos sabores sin afectar directamente al array.

let addStrawberryFlavor = new addIceCream('strawberry');
addStrawberryFlavor.add();
makeStrawberryIceCream.make();

Además, observa que SRP está en su lugar porque ha creado una nueva clase 😊.

¿Qué es el principio de sustitución de Liskov?


En 1987, el Principio de Sustitución de Liskov (LSP) fue introducido por Barbara Liskov en su conferencia magistral "Abstracción de datos". Unos años más tarde, definió el principio de la siguiente manera:

"Sea Φ(x) una propiedad demostrable sobre objetos x del tipo T. Entonces Φ(y) debe ser cierta para objetos y del tipo S donde S es un subtipo de T.

Para ser honesto, esa definición no es lo que muchos desarrolladores de software quieren ver 😂  así que permítanme desglosarlo en una definición relacionada con la programación orientada a objetos.

El principio define que en una herencia, los objetos de una superclase (o clase padre) deben ser sustituibles por objetos de sus subclases (o clase hija) sin romper la aplicación ni causar ningún error.

En términos muy sencillos, usted quiere que los objetos de sus subclases se comporten de la misma manera que los objetos de su superclase.

Un ejemplo muy común es el escenario rectángulo, cuadrado. Está claro que todos los cuadrados son rectángulos porque son cuadriláteros con los cuatro ángulos rectos. Pero no todos los rectángulos son cuadrados. Para ser un cuadrado, sus lados deben tener la misma longitud.

Teniendo esto en cuenta, supongamos que tenemos una clase rectángulo para calcular el área de un rectángulo y realizar otras operaciones como establecer el color:

class Rectangle {
    setWidth(width) {
        this.width = width;
    }

    setHeight(height) {
        this.height = height;
    }

    setColor(color) {
        // ...
    }

    getArea() {
        return this.width * this.height;
    }
}

Sabiendo perfectamente que todos los cuadrados son rectángulos, puedes heredar las propiedades del rectángulo. Dado que la anchura y la altura tiene que ser el mismo, entonces usted puede ajustar:

class Square extends Rectangle {
    setWidth(width) {
        this.width = width;
        this.height = width;
    }
    setHeight(height) {
        this.width = height;
        this.height = height;
    }
}

Echando un vistazo al ejemplo, debería funcionar correctamente:

let rectangle = new Rectangle();
rectangle.setWidth(10);
rectangle.setHeight(5);
console.log(rectangle.getArea()); // 50

En la imagen anterior, observará que se crea un rectángulo y se establecen la anchura y la altura. Entonces puedes calcular el área correcta.

Pero de acuerdo con la LSP, usted quiere que los objetos de sus subclases se comporten de la misma manera que los objetos de su superclase. Es decir, si sustituyes Rectangle por Square, todo debería seguir funcionando bien:

let square = new Square();
square.setWidth(10);
square.setHeight(5);

Deberías obtener 100, porque setWidth(10) se supone que establece tanto la anchura como la altura a 10. Pero debido a setHeight(5), esto devolverá 25. Pero debido al setHeight(5), esto devolverá 25.

let square = new Square();
square.setWidth(10);
square.setHeight(5);
console.log(square.getArea()); // 25

Esto rompe la LSP. Para solucionar esto, debe haber una clase general para todas las formas que contendrá todos los métodos genéricos que desea que los objetos de sus subclases para tener acceso a.

A continuación, para los métodos individuales, se crea una clase individual para rectángulo y cuadrado.

class Shape {
    setColor(color) {
        this.color = color;
    }
    getColor() {
        return this.color;
    }
}

class Rectangle extends Shape {
    setWidth(width) {
        this.width = width;
    }
    setHeight(height) {
        this.height = height;
    }
    getArea() {
        return this.width * this.height;
    }
}

class Square extends Shape {
    setSide(side) {
        this.side = side;
    }
    getArea() {
        return this.side * this.side;
    }
}

De esta forma, puedes establecer el color y obtener el color utilizando la superclase o las subclases:

// superclass
let shape = new Shape();
shape.setColor('red');
console.log(shape.getColor()); // red

// subclass
let rectangle = new Rectangle();
rectangle.setColor('red');
console.log(rectangle.getColor()); // red

// subclass
let square = new Square();
square.setColor('red');
console.log(square.getColor()); // red

¿Qué es el principio de segregación de interfaces?


El Principio de Segregación de Interfaces (PSI) establece que "nunca se debe obligar a un cliente a implementar una interfaz que no utiliza, ni se debe obligar a los clientes a depender de métodos que no utilizan". ¿Qué significa esto?

Lo mismo que el término segregación: se trata de mantener las cosas separadas, es decir, separar las interfaces.

Nota: Por defecto, JavaScript no tiene interfaces, pero este principio se sigue aplicando. Así que vamos a explorar esto como si la interfaz existiera, para que sepas cómo funciona para otros lenguajes de programación como Java.

Una interfaz típica contendrá métodos y propiedades. Cuando implementas esta interfaz en cualquier clase, entonces la clase necesita definir todos sus métodos. Por ejemplo, supongamos que tienes una interfaz que define métodos para dibujar formas específicas.

interface ShapeInterface {
    calculateArea();
    calculateVolume();
}

Cuando una clase implementa esta interfaz, todos los métodos deben definirse aunque no se vayan a utilizar o no se apliquen a esa clase.

class Square implements ShapeInterface {
    calculateArea(){
        //...
    }
    calculateVolume(){
        //...
    }  
}

class Cuboid implements ShapeInterface {
    calculateArea(){
        //...
    }
    calculateVolume(){
        //...
    }    
}

class Rectangle implements ShapeInterface {
    calculateArea(){
        //...
    }
    calculateVolume(){
        //...
    }   
}

Si observas el ejemplo anterior, te darás cuenta de que no puedes calcular el volumen de un cuadrado o un rectángulo. Como la clase implementa la interfaz, tienes que definir todos los métodos, incluso los que no vas a usar o necesitar.

Para solucionar esto, tendrías que segregar la interfaz.

interface ShapeInterface {
    calculateArea();
}

interface ThreeDimensionalShapeInterface {
    calculateArea();
    calculateVolume();
}

Ahora puede implementar la interfaz específica que funciona con cada clase.

class Square implements ShapeInterface {
    calculateArea(){
        //...
    } 
}

class Cuboid implements ThreeDimensionalShapeInterface {
    calculateArea(){
        //...
    }
    calculateVolume(){
        //...
    }    
}

class Rectangle implements ShapeInterface {
    calculateArea(){
        //...
    }  
}

¿Qué es el principio de inversión de dependencia?


Este principio está orientado al acoplamiento flexible de módulos de software, de modo que los módulos de alto nivel (que proporcionan lógica compleja) sean fácilmente reutilizables y no se vean afectados por cambios en los módulos de bajo nivel (que proporcionan características de utilidad).

Según Wikipedia, este principio establece que:

  • Los módulos de alto nivel no deben importar nada de los módulos de bajo nivel. Ambos deben depender de abstracciones (por ejemplo, interfaces).
  • Las abstracciones deben ser independientes de los detalles. Los detalles (implementaciones concretas) deben depender de las abstracciones.
  • En términos sencillos, este principio establece que tus clases deben depender de interfaces o clases abstractas en lugar de clases y funciones concretas. Esto hace que tus clases estén abiertas a la extensión, siguiendo el principio abierto-cerrado.

Veamos un ejemplo. Cuando construyes una tienda, quieres que tu tienda haga uso de una pasarela de pago como stripe o cualquier otro método de pago preferido. Podrías escribir tu código estrechamente acoplado a esa API sin pensar en el futuro.

Pero entonces, ¿qué pasa si descubres otra pasarela de pago que ofrece un servicio mucho mejor, digamos PayPal? Entonces se convierte en una lucha para cambiar de Stripe a Paypal, lo que no debería ser un problema en la programación y el diseño de software.

class Store {
    constructor(user) {
        this.stripe = new Stripe(user);
    }

    purchaseBook(quantity, price) {
        this.stripe.makePayment(price * quantity);
    }

    purchaseCourse(quantity, price) {
        this.stripe.makePayment(price * quantity);
    }
}

class Stripe {
    constructor(user) {
        this.user = user;
    }

    makePayment(amountInDollars) {
        console.log(`${this.user} made payment of ${amountInDollars}`);
    }
}

Teniendo en cuenta el ejemplo anterior, se dará cuenta de que si cambia la pasarela de pago, no sólo tendrá que añadir la clase también tendrá que hacer cambios en la clase Store. Esto no sólo va en contra del Principio de Inversión de Dependencia, sino también en contra del principio abierto-cerrado.

Para solucionarlo, debes asegurarte de que tus clases dependen de interfaces o clases abstractas en lugar de clases y funciones concretas. Para este ejemplo, esta interfaz contendrá todo el comportamiento que quieres que tenga tu API y no depende de nada. Sirve de intermediario entre los módulos de alto nivel y los de bajo nivel.

class Store {
    constructor(paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }

    purchaseBook(quantity, price) {
        this.paymentProcessor.pay(quantity * price);
    }

    purchaseCourse(quantity, price) {
        this.paymentProcessor.pay(quantity * price);
    }
}

class StripePaymentProcessor {
    constructor(user) {
        this.stripe = new Stripe(user);
    }

    pay(amountInDollars) {
        this.stripe.makePayment(amountInDollars);
    }
}

class Stripe {
    constructor(user) {
        this.user = user;
    }

    makePayment(amountInDollars) {
        console.log(`${this.user} made payment of ${amountInDollars}`);
    }
}

let store = new Store(new StripePaymentProcessor('John Doe'));
store.purchaseBook(2, 10);
store.purchaseCourse(1, 15);

En el código anterior, se dará cuenta de que la clase StripePaymentProcessor es una interfaz entre la clase Store y la clase Stripe. En una situación en la que necesite hacer uso de PayPal, todo lo que tiene que hacer es crear un PayPalPaymentProcessor que funcionaría con la clase PayPal, y todo funcionará sin afectar a la clase Store.

class Store {
    constructor(paymentProcessor) {
        this.paymentProcessor = paymentProcessor;
    }

    purchaseBook(quantity, price) {
        this.paymentProcessor.pay(quantity * price);
    }

    purchaseCourse(quantity, price) {
        this.paymentProcessor.pay(quantity * price);
    }
}

class PayPalPaymentProcessor {
    constructor(user) {
        this.user = user;
        this.paypal = new PayPal();
    }

    pay(amountInDollars) {
        this.paypal.makePayment(this.user, amountInDollars);
    }
}

class PayPal {
    makePayment(user, amountInDollars) {
        console.log(`${user} made payment of ${amountInDollars}`);
    }
}

let store = new Store(new PayPalPaymentProcessor('John Doe'));
store.purchaseBook(2, 10);
store.purchaseCourse(1, 15);

También notarás que esto sigue el Principio de Sustitución de Liskov porque puedes reemplazarlo por otras implementaciones de la misma interfaz sin romper tu aplicación.

Ta-Da 😇
Ha sido una aventura🙃. Espero que te hayas dado cuenta de que cada uno de estos principios está relacionado con los demás de alguna manera.

En un intento de corregir un principio, digamos el principio de inversión de dependencias, te aseguras indirectamente de que tus clases estén abiertas a la extensión pero cerradas a la modificación, por ejemplo.

Debes tener en cuenta estos principios cuando escribas código, porque facilitan la colaboración de muchas personas en tu proyecto. Simplifican el proceso de ampliación, modificación, prueba y refactorización del código.

Así que asegúrate de que entiendes sus definiciones, lo que hacen y por qué los necesitas más allá de la programación orientada a objetos.

¡Diviértete codificando!

Fuente

Plataforma de cursos gratis sobre programación