Inicio / Artículos / Principios SOLID con código: infracciones y correcciones para cada regla

Principios SOLID con código: infracciones y correcciones para cada regla

Un repaso práctico de los cinco principios SOLID en diseño de bajo nivel, con un ejemplo de código malo y otro bueno para SRP, OCP, LSP, ISP y DIP, más un resumen rápido.

2125 palabras

El diseño de bajo nivel es la etapa en la que los requisitos se convierten en clases, interfaces y relaciones. Entre las ideas que lo guían, los principios SOLID aparecen más que ninguna otra.

SOLID designa cinco reglas prácticas que mantienen el código orientado a objetos limpio, flexible y fácil de mantener:

  1. S: principio de responsabilidad única (Single Responsibility Principle, SRP)
  2. O: principio abierto/cerrado (Open/Closed Principle, OCP)
  3. L: principio de sustitución de Liskov (Liskov Substitution Principle, LSP)
  4. I: principio de segregación de interfaces (Interface Segregation Principle, ISP)
  5. D: principio de inversión de dependencias (Dependency Inversion Principle, DIP)

Cada principio viene con dos versiones del mismo código, una que rompe la regla y otra que la sigue, para que la diferencia sea fácil de ver.

S: responsabilidad única

Una clase debería tener un solo motivo para cambiar. Dicho de otro modo, debería hacer un solo trabajo.

Tomemos una clase Car. Todo lo relativo a los coches pertenece a ella, y una funcionalidad que no tiene nada que ver con coches pertenece a otro sitio.

Primero, la versión que rompe la regla:

package SingleResponsibilityWithViolation;
// Violates SRP: This class has multiple responsibilities
// (salary calculation & report generation)
class Employee {
    private String name;
    private double salary;    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }    public double calculateSalary() {
        return salary * 1.2; // Example salary calculation
    }    public void generateReport() {
        System.out.println("Generating employee report...");
    }
}public class SRPViolation {
    public static void main(String[] args) {
        Employee emp = new Employee("John", 50000);
        System.out.println("Salary: " + emp.calculateSalary());
        emp.generateReport();
    }
}

Aquí la clase Employee hace malabares con tres tareas: guarda los datos del empleado, calcula el salario y genera informes. Eso le da tres motivos para cambiar. Una nueva fórmula salarial, un nuevo formato de informe o un cambio en los campos del empleado obligan a editar la misma clase. Con el tiempo, la clase se hincha y se vuelve arriesgado tocarla, ya que un ajuste en los informes podría romper sin avisar la lógica salarial.

Ahora la versión más limpia:

package SingleResponsibilityWithoutViolation;
// Now, each class has a single responsibility// Responsible only for holding employee details
class Employee {
    private String name;
    private double salary;    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }    public String getName() {
        return name;
    }    public double getSalary() {
        return salary;
    }
}// Responsible only for salary calculation
class SalaryCalculator {
    public double calculateSalary(Employee employee) {
        return employee.getSalary() * 1.2;
    }
}// Responsible only for report generation
class ReportGenerator {
    public void generateReport(Employee employee) {
        System.out.println("Generating employee report for " + employee.getName() + "...");
    }
}public class SRPExample {
    public static void main(String[] args) {
        Employee emp = new Employee("John", 10000);
        SalaryCalculator calculator = new SalaryCalculator();
        ReportGenerator reportGenerator = new ReportGenerator();        reportGenerator.generateReport(emp);
        System.out.println("Salary: " + calculator.calculateSalary(emp));
    }
}

Cada clase tiene ahora un único motivo para cambiar. Employee solo almacena datos, SalaryCalculator solo calcula la paga y ReportGenerator solo construye informes. Si mañana cambia la fórmula del salario, solo se toca SalaryCalculator y nada más corre peligro. Además, cada clase es mucho más fácil de probar por separado.

O: abierto/cerrado

Las entidades de software deberían estar abiertas a la extensión pero cerradas a la modificación. En la práctica, el comportamiento nuevo debería llegar sin editar código que ya funciona. Cada edición de código estable corre el riesgo de romper algo que iba bien, así que las nuevas funciones deberían venir de extender lo que existe.

La infracción:

package OpenClosedWithViolation;
// Violates OCP: Every time a new shape is added,
// we need to modify the existing class.
class AreaCalculator {
    public double calculateArea(String shape, double radius, double length, double breadth) {
        if (shape.equals("circle")) {
            return Math.PI * radius * radius;
        } else if (shape.equals("rectangle")) {
            return length * breadth;
        }
        return 0;
    }
}public class OCPViolation {
    public static void main(String[] args) {
        AreaCalculator calculator = new AreaCalculator();
        System.out.println("Circle Area: " + calculator.calculateArea("circle", 5, 0, 0));
    }
}

Para admitir otra forma, sea un triángulo, un cuadrado o un hexágono, alguien tiene que volver a AreaCalculator y añadir otra rama else-if. La clase nunca está terminada. La firma del método también es incómoda: radius, length y breadth deben pasarse siempre, aunque la mayoría no importen, y por eso la llamada contiene esos ceros. Cada forma nueva hace el método más largo, más difícil de leer y de probar.

La corrección:

package OpenClosedWithoutViolation;
// Now, new shapes can be added without modifying the existing code
interface Shape {
    double calculateArea();
}class Circle implements Shape {
    private double radius;    public Circle(double radius) {
        this.radius = radius;
    }    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}class Rectangle implements Shape {
    private double length, breadth;    public Rectangle(double length, double breadth) {
        this.length = length;
        this.breadth = breadth;
    }    public double calculateArea() {
        return length * breadth;
    }
}class AreaCalculator {
    public double calculateArea(Shape shape) {
        return shape.calculateArea();
    }
}public class OCPExample {
    public static void main(String[] args) {
        Shape circle = new Circle(5);
        Shape rectangle = new Rectangle(4, 6);
        AreaCalculator calculator = new AreaCalculator();        System.out.println("Circle Area: " + calculator.calculateArea(circle));
        System.out.println("Rectangle Area: " + calculator.calculateArea(rectangle));
    }
}

Ahora cada forma sabe calcular su propia área, y a AreaCalculator le da igual qué forma recibe. Simplemente llama a calculateArea(). Añadir un Triangle significa escribir una clase nueva que implemente Shape, sin cambios en AreaCalculator, Circle ni Rectangle. El diseño está abierto a la extensión, porque se pueden añadir formas libremente, y cerrado a la modificación, porque las clases existentes se quedan como están.

L: sustitución de Liskov

Los objetos de una superclase deberían poder sustituirse por objetos de una subclase sin romper la corrección del programa.

Este depende de la herencia. Una clase padre tiene varias clases hijas que heredan sus características. El principio dice que allí donde el código espera el tipo padre, cualquier tipo hijo debería funcionar correctamente en su lugar.

Un ejemplo que lo infringe:

package LiskovSubstitutionWithViolation;
// Violates LSP: Penguin cannot fly, but it inherits a fly() method.
class Bird {
    public void fly() {
        System.out.println("Flying...");
    }
}class Sparrow extends Bird {
    @Override
    public void fly() {
        System.out.println("Sparrow can fly");
    }
}class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("Penguins can't fly!");
    }
}public class LSPViolation {
    public static void main(String[] args) {
        Bird penguin = new Penguin();
        Bird sparrow = new Sparrow();
        sparrow.fly();
        penguin.fly(); // This will break at runtime
    }
}

La clase base Bird da por hecho que todas las aves vuelan, así que fly() está en ella. Los pingüinos no pueden volar, lo que deja a Penguin sobrescribiendo fly() solo para lanzar una excepción. Cualquier código que reciba un Bird y llame a fly(), como hace aquí main(), falla en tiempo de ejecución al recibir un Penguin. La subclase ya no puede sustituir con seguridad a su padre, y eso es precisamente lo que prohíbe LSP. La causa de fondo es un mal diseño de la herencia: volar se metió en la clase base como si todas las aves lo hicieran.

El diseño corregido:

package LiskovSubstitutionWithoutViolation;
abstract class Bird {
    abstract void makeSound();
}interface FlyingBird {
    void fly();
}class Sparrow extends Bird implements FlyingBird {
    public void makeSound() {
        System.out.println("Chirp Chirp!");
    }    public void fly() {
        System.out.println("Sparrow is flying...");
    }
}class Penguin extends Bird {
    public void makeSound() {
        System.out.println("Penguin sound...");
    }
}public class LSPExample {
    public static void main(String[] args) {
        Bird sparrow = new Sparrow();
        Bird penguin = new Penguin();        sparrow.makeSound();
        penguin.makeSound();        FlyingBird flyingBird = new Sparrow();
        flyingBird.fly();  // Works without breaking substitution
    }
}

Volar salió de la clase base y pasó a su propia interfaz FlyingBird. Bird garantiza ahora solo lo que de verdad puede hacer toda ave, que es emitir un sonido, y las especies que vuelan se suman implementando FlyingBird. Penguin sigue siendo un Bird legítimo, utilizable en cualquier sitio donde se espere un Bird, sin un fallo esperando en tiempo de ejecución. El diseño ahora se ajusta a la realidad en lugar de pelearse con ella.

I: segregación de interfaces

No se debería obligar a los clientes a depender de interfaces que no usan. El principio prefiere interfaces más pequeñas y específicas a una sola grande y de propósito general.

Un ejemplo del problema:

package InterfaceSegregationWithViolation;
// Violates ISP: Robot is forced to implement eat()
// even though it doesn't need it.
interface Worker {
    void work();
    void eat();
}class Human implements Worker {
    public void work() {
        System.out.println("Human working...");
    }    public void eat() {
        System.out.println("Human eating...");
    }
}class Robot implements Worker {
    public void work() {
        System.out.println("Robot working...");
    }    public void eat() {
        throw new UnsupportedOperationException("Robots do not eat!");
    }
}public class ISPViolation {
    public static void main(String[] args) {
        Human human = new Human();
        human.work();
        human.eat();        Robot robot = new Robot();
        robot.work();
        robot.eat(); // Causes error
    }
}

La interfaz Worker mete dos capacidades sin relación, trabajar y comer, en un solo contrato. Un robot puede trabajar pero no comer, y aun así tiene que proporcionar eat(). Lanzar una excepción es la única salida, y eso hace que la clase informe mal de sus capacidades. Llamar a eat() sobre algo tipado como Worker puede entonces fallar si el objeto resulta ser un Robot. Para algunas implementaciones, esta interfaz pide demasiado.

La versión dividida:

package InterfaceSegregationWithoutViolation;
interface Worker {
    void work();
}interface Eatable {
    void eat();
}class Human implements Worker, Eatable {
    public void work() {
        System.out.println("Developer is coding...");
    }    public void eat() {
        System.out.println("Developer is eating...");
    }
}class Robot implements Worker {
    public void work() {
        System.out.println("Robot is working...");
    }
}public class ISPExample {
    public static void main(String[] args) {
        Human human = new Human();
        human.work();
        human.eat();        Robot robot = new Robot();
        robot.work();
    }
}

La interfaz gorda se convirtió en dos interfaces enfocadas, Worker y Eatable. Cada clase implementa solo lo que realmente puede hacer: Human implementa ambas, mientras que Robot implementa solo Worker. No quedan métodos no soportados, ni excepciones que lanzar, ni contratos engañosos. Cada clase refleja honestamente sus propias capacidades.

D: inversión de dependencias

Los módulos de alto nivel no deberían depender de los de bajo nivel. Ambos deberían depender de abstracciones en lugar de implementaciones concretas.

Esta es la versión que lo rompe:

package DependencyInversionWithViolation;
class WiredKeyboard {
    public void connect() {
        System.out.println("Wired keyboard connected");
    }
}// Violates DIP: Computer is tightly coupled to WiredKeyboard.
class Computer {
    private WiredKeyboard keyboard = new WiredKeyboard(); // Tight coupling    public void start() {
        keyboard.connect();
    }
}public class DIPViolation {
    public static void main(String[] args) {
        Computer pc = new Computer();
        pc.start();
    }
}

En este esquema, Computer está en el nivel alto y WiredKeyboard es un detalle de bajo nivel. Como Computer construye él mismo un WiredKeyboard y lo guarda en un campo, queda atado para siempre a ese único tipo de teclado. Admitir un teclado inalámbrico supondría editar el propio Computer. Las pruebas unitarias también son casi imposibles, ya que no se puede colocar un teclado falso o mock cuando la dependencia se construye internamente en lugar de pasarse desde fuera.

Y esta es la versión que lo sigue:

package DependencyInversionWithoutViolation;
interface Keyboard {
    void connect();
}class WiredKeyboard implements Keyboard {
    public void connect() {
        System.out.println("Wired keyboard connected");
    }
}class WirelessKeyboard implements Keyboard {
    public void connect() {
        System.out.println("Wireless keyboard connected");
    }
}class Computer {
    private Keyboard keyboard;    public Computer(Keyboard keyboard) {
        this.keyboard = keyboard;
    }    public void start() {
        keyboard.connect();
    }
}public class DIPExample {
    public static void main(String[] args) {
        Keyboard wired = new WiredKeyboard();
        Computer pc1 = new Computer(wired);
        pc1.start();        Keyboard wireless = new WirelessKeyboard();
        Computer pc2 = new Computer(wireless);
        pc2.start();
    }
}

Computer depende ahora de la interfaz Keyboard, una abstracción, en lugar de una clase de teclado concreta. El teclado real llega desde fuera a través del constructor, una técnica llamada inyección de dependencias. Se puede conectar un teclado con cable, uno inalámbrico o cualquier tipo futuro sin modificar nunca Computer. Probar también resulta fácil, porque se puede inyectar un teclado mock.

Los cinco de un vistazo

  • SRP: dé a cada clase un solo trabajo y un solo motivo para cambiar.
  • OCP: añada comportamiento extendiendo, sin tocar el código que funciona.
  • LSP: una clase hija tiene que funcionar en cualquier sitio donde funcione su padre.
  • ISP: mejor varias interfaces delgadas que una inflada.
  • DIP: apóyese en abstracciones, nunca en clases concretas.

Un hilo común recorre los cinco ejemplos: abstracción y separación. Casi todas las infracciones se reducían a una clase que hacía demasiadas cosas o estaba soldada a un tipo concreto, y casi todas las correcciones separaban responsabilidades y programaban contra una interfaz.

Dominar estos cinco principios ya hace que el código sea notablemente más limpio y fácil de mantener, y es una buena preparación para las rondas de entrevistas de LLD. Los patrones de diseño son el siguiente tema natural.