Una clase que guarda un usuario en la base de datos, le manda un correo de bienvenida y da formato a un recibo: basta un cambio para romper dos funcionalidades que no tienen nada que ver entre sí. Tocas la plantilla del correo y arriesgas la escritura en la base de datos. Tocas el diseño del recibo y arriesgas el correo. SOLID son cinco reglas que atacan ese tipo de enredo desde ángulos distintos, y comparten una idea: una clase debe cambiar por un solo motivo, y el resto del código debe depender de lo que algo promete hacer, no de cómo lo hace.
Los principios vienen de un artículo de Robert C. Martin de 2000, “Design Principles and Design Patterns”. Michael Feathers los ordenó después para que formaran el acrónimo SOLID. Ninguno de los cinco trata de sintaxis ni de un lenguaje concreto. Tratan de dónde van las costuras de tu código, para que un cambio en un sitio se quede en ese sitio.
Qué significa SOLID, y por qué son cinco reglas y no una
- S, Single Responsibility: una clase tiene un solo motivo para cambiar.
- O, Open/Closed: añade comportamiento añadiendo código, no editando código que ya funciona.
- L, Liskov Substitution: una subclase tiene que comportarse como su clase padre en todos los sitios donde se use la clase padre.
- I, Interface Segregation: no obligues a una clase a implementar métodos que no usa.
- D, Dependency Inversion: depende de una abstracción, no de una clase concreta específica.
Se solapan a propósito. Dependency Inversion es lo que hace que Open/Closed funcione en la práctica: solo puedes añadir una implementación nueva sin tocar código viejo si ese código se escribió contra una interfaz. Interface Segregation es Single Responsibility aplicado a interfaces en vez de a clases. Trátalos como cinco vistas de un mismo objetivo, no como cinco casillas que marcar.
Single Responsibility Principle: un solo motivo para cambiar
Aquí hay una clase con dos motivos para cambiar escondidos en un solo cuerpo:
| |
Un cambio de esquema y un cambio en la plantilla del correo afectan ahora los dos a User. Probar save() arrastra una conexión a base de datos (o un mock), y probar send_welcome_email() arrastra un cliente SMTP, aunque a ninguna de las dos pruebas le importe el otro método. Aquí también empieza el enredo descrito en Qué Es el Spaghetti Code y Cómo Refactorizarlo: una clase sigue absorbiendo tareas que no le corresponden porque ya estaba ahí.
Separa las tareas:
| |
Ahora cada clase cambia por su propio motivo, y cada una se puede probar con un fake que cubre una tarea pequeña. “Un solo motivo para cambiar” no es “un solo método”. UserRepository podría crecer con find_by_email y delete y seguir teniendo una sola responsabilidad, porque cada método sirve a la misma tarea: guardar usuarios.
Open/Closed Principle: añadir comportamiento sin editar código que funciona
Una calculadora que se ramifica según una etiqueta de tipo:
| |
Añadir un triángulo implica editar area() otra vez. Peor aún, hay que encontrar y editar también cualquier otra función que se ramifique según shape["type"]. Cada forma nueva es un cambio en código ya probado, con riesgo de romper los casos del círculo y el rectángulo de paso.
Define el contrato una vez y añade formas nuevas como clases nuevas:
| |
Para añadir Triangle solo hace falta una clase nueva que implemente area(). total_area no cambia nunca, y tampoco cambian Circle ni Rectangle. Eso es todo lo que significa “abierto para extensión, cerrado para modificación”: el comportamiento nuevo llega como código nuevo, y el código que ya tiene cobertura de pruebas se queda intacto.
Liskov Substitution Principle: una subclase tiene que mantener las promesas de la clase padre
El fallo clásico es un Square construido como subclase de Rectangle:
| |
| |
resize_and_check funciona con cualquier Rectangle. Pásale un Square y la aserción falla: fijar la altura cambió también la anchura sin avisar, así que el área es 16, no 20. Square pasa el chequeo de tipos como Rectangle, así que mypy no tiene nada que marcar, y sin embargo rompe el comportamiento que cualquier código que llama a Rectangle estaba escrito para esperar. Guarda esa distinción. Las violaciones de Liskov casi nunca son de firmas de métodos, que las herramientas ya detectan. Son de una subclase que cambia sin avisar lo que el código que la llama tiene derecho a asumir.
No lo arregles con un caso especial para cuadrados dentro de resize_and_check. Arréglalo no haciendo que Square sea subclase de Rectangle. Un cuadrado no tiene dos dimensiones que se puedan redimensionar por separado, así que no puede cumplir el contrato de Rectangle. Los dos pueden implementar la interfaz Shape del ejemplo de Open/Closed, sin herencia entre ellos.
Interface Segregation Principle: no imponer métodos sin uso a una clase
Una interfaz que junta capacidades que no están relacionadas:
| |
Cualquier código que tenga un Printer ahora tiene que saber, fuera del sistema de tipos, qué métodos son seguros en qué instancia. La interfaz promete tres capacidades y entrega una, más dos minas.
Separa la interfaz por capacidad en vez de por dispositivo:
| |
Una función que solo necesita imprimir recibe un Printable, y no hay ningún .scan() que se pueda llamar por error en un dispositivo que nunca lo tuvo. El contrato viene de lo que el objeto implementa, no de un comentario que te avisa qué métodos van a lanzar una excepción.
Dependency Inversion Principle: depende de abstracciones, no de clases concretas
Un servicio construido directamente sobre una biblioteca de base de datos:
| |
UserService está soldado a SQLite. Pasar a Postgres, o darle un repositorio falso en una prueba, significa editar UserService en vez de cambiar una dependencia.
| |
UserService ahora depende de la abstracción UserRepository. Construir un SQLiteUserRepository(), o un FakeUserRepository() para una prueba, y pasarlo es un paso aparte: eso es la dependency injection. Los dos términos se usan como sinónimos, pero son cosas distintas: dependency inversion es la regla de diseño (depende de abstracciones), y dependency injection es la técnica que entrega el objeto concreto. Qué Es la Dependency Injection (con Ejemplos) cubre el lado de la injection, incluido lo que añade un contenedor DI cuando una clase necesita más de una o dos dependencias conectadas.
Cuándo SOLID se convierte en over-engineering
Partir un script de veinte líneas en cinco interfaces no aporta nada si solo existe una implementación y no hay plan para una segunda. Interface Segregation y Dependency Inversion añaden las dos una capa de indirección. Esa capa se amortiza cuando de verdad cambias implementaciones, como producción contra pruebas o SQLite contra Postgres, o cuando necesitas aislar una clase detrás de un test double como se describe en Pruebas Unitarias vs de Integración vs End-to-End. Si nunca cambias nada, la abstracción es pura carga extra.
Más abstracciones no significa mejor diseño. Un constructor que recibe ocho interfaces inyectadas no es más SOLID que uno que recibe tres. Significa que la clase de detrás todavía tiene demasiadas responsabilidades, lo que te devuelve a la primera letra.
SOLID también es vocabulario orientado a objetos. Hay mucho código bueno sin una sola clase. Los hábitos de fondo valen igual: funciones pequeñas con una sola tarea, y dependencias que se pasan en vez de buscarse en variables globales. Eso importa en un estilo funcional incluso cuando ninguna de las cinco letras aplica al pie de la letra.
Cómo empezar a aplicar SOLID a un código ya existente
No reformes todo un código contra una checklist. Aplica primero Single Responsibility, y solo donde una clase ya duela al modificarla: un archivo que causa conflictos de merge cada sprint, o una prueba que necesita tres mocks sin relación antes de poder correr. Pon una dependencia detrás de una abstracción solo cuando una segunda implementación o un fake para pruebas sea una necesidad actual, no hipotética. La mayoría de los problemas de Open/Closed y Liskov empiezan como un problema de Single Responsibility al que le creció una subclase, así que los otros tres principios suelen seguir en cuanto esos dos hábitos se afianzan.