Programación · Patrones de diseño
Principios SOLID explicados con ejemplos
Respuesta corta
SOLID es un acrónimo de cinco principios de diseño orientado a objetos: responsabilidad única (Single responsibility), abierto/cerrado (Open/closed), sustitución de Liskov (Liskov substitution), segregación de interfaces (Interface segregation) e inversión de dependencias (Dependency inversion). Los reunió Robert C. Martin, y el acrónimo lo acuñó Michael Feathers. En conjunto buscan que el código sea más fácil de cambiar: cada clase tiene una sola razón para cambiar, el comportamiento nuevo se añade sin editar código antiguo, los subtipos son sustitutos seguros, las interfaces siguen siendo pequeñas y el código de alto nivel depende de abstracciones.
Qué significa SOLID
| Letra | Principio | Regla breve |
|---|---|---|
| S | Principio de responsabilidad única (SRP) | Una clase debe tener una sola razón para cambiar. |
| O | Principio abierto/cerrado (OCP) | El software debe estar abierto a la extensión pero cerrado a la modificación. |
| L | Principio de sustitución de Liskov (LSP) | Los subtipos deben poder usarse allí donde se espera su tipo base. |
| I | Principio de segregación de interfaces (ISP) | Los clientes no deben verse obligados a depender de métodos que no usan. |
| D | Principio de inversión de dependencias (DIP) | Depende de abstracciones, no de detalles concretos. |
Las ideas son más antiguas que el acrónimo. El principio abierto/cerrado viene del libro de Bertrand Meyer Object-Oriented Software Construction (1988), y el principio de sustitución, de la conferencia magistral de Barbara Liskov de 1987, Data Abstraction and Hierarchy. Robert C. Martin describió los cinco en sus escritos hacia el año 2000, y Michael Feathers los ordenó después en la palabra SOLID.
S: principio de responsabilidad única
Una clase debe tener una sola razón para cambiar, es decir, debe servir a un solo actor o a una sola preocupación. Cuando una clase mezcla reglas de negocio, formato y almacenamiento, un cambio en cualquiera de ellos puede romper los demás.
Antes: una clase calcula, da formato y guarda un informe.
class Report {
constructor(private sales: number[]) {}
total(): number { return this.sales.reduce((a, b) => a + b, 0); }
toHtml(): string { return `<p>Total: ${this.total()}</p>`; }
saveToFile(path: string): void { /* write this.toHtml() to disk */ }
}Después: cada preocupación vive en su propia unidad pequeña, así que un nuevo formato de salida no toca el cálculo.
class SalesReport {
constructor(private sales: number[]) {}
total(): number { return this.sales.reduce((a, b) => a + b, 0); }
}
class HtmlFormatter {
format(r: SalesReport): string { return `<p>Total: ${r.total()}</p>`; }
}
class ReportWriter {
save(content: string, path: string): void { /* write to disk */ }
}
const html = new HtmlFormatter().format(new SalesReport([10, 20, 5]));
console.log(html); // <p>Total: 35</p>O: principio abierto/cerrado
Debes poder añadir comportamiento nuevo añadiendo código nuevo, no editando código que ya funciona y está probado. La herramienta habitual es una interfaz más polimorfismo.
Antes: cada forma nueva obliga a editar la función area.
type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number {
if (s.kind === "circle") return Math.PI * s.r ** 2;
if (s.kind === "square") return s.side ** 2;
throw new Error("unknown shape");
}Después: una forma nueva es una clase nueva; el código existente no cambia.
interface Shape { area(): number; }
class Circle implements Shape {
constructor(private r: number) {}
area(): number { return Math.PI * this.r ** 2; }
}
class Square implements Shape {
constructor(private side: number) {}
area(): number { return this.side ** 2; }
}
const totalArea = (shapes: Shape[]) => shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(totalArea([new Square(2), new Square(3)])); // 13L: principio de sustitución de Liskov
Si el código funciona con un tipo base, debe seguir funcionando al recibir cualquier subtipo. Un subtipo no debe reforzar las precondiciones, debilitar las garantías ni lanzar errores donde el tipo base no lo hace.
Antes: el clásico problema del cuadrado y el rectángulo. Square extiende Rectangle pero rompe la expectativa de que el ancho y el alto son independientes.
class Rectangle {
constructor(public width: number, public height: number) {}
setWidth(w: number): void { this.width = w; }
setHeight(h: number): void { this.height = h; }
area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w: number): void { this.width = w; this.height = w; }
setHeight(h: number): void { this.width = h; this.height = h; }
}
function stretch(r: Rectangle): number {
r.setWidth(5);
r.setHeight(4);
return r.area(); // caller expects 20
}
console.log(stretch(new Rectangle(1, 1))); // 20
console.log(stretch(new Square(1, 1))); // 16, surpriseDespués: no modeles la relación como herencia. Ambos tipos implementan una interfaz común de solo lectura, y nadie espera cambiar de forma independiente los lados de un cuadrado.
interface Shape { area(): number; }
class Rectangle implements Shape {
constructor(readonly width: number, readonly height: number) {}
area(): number { return this.width * this.height; }
}
class Square implements Shape {
constructor(readonly side: number) {}
area(): number { return this.side ** 2; }
}
console.log([new Rectangle(5, 4), new Square(4)].map((s) => s.area())); // [ 20, 16 ]I: principio de segregación de interfaces
Las interfaces grandes obligan a las clases a implementar métodos que no pueden soportar, normalmente lanzando errores. Divídelas en interfaces pequeñas y específicas, y deja que cada clase implemente solo lo que realmente hace.
Antes: una impresora simple debe fingir que puede escanear y enviar faxes.
interface Machine {
print(doc: string): void;
scan(): string;
fax(doc: string): void;
}
class BasicPrinter implements Machine {
print(doc: string): void { console.log("print", doc); }
scan(): string { throw new Error("not supported"); }
fax(doc: string): void { throw new Error("not supported"); }
}Después: pequeñas interfaces de rol que pueden combinarse.
interface Printer { print(doc: string): void; }
interface Scanner { scan(): string; }
class BasicPrinter implements Printer {
print(doc: string): void { console.log("print", doc); }
}
class OfficeMachine implements Printer, Scanner {
print(doc: string): void { console.log("print", doc); }
scan(): string { return "scanned page"; }
}
function printAll(p: Printer, docs: string[]): void { docs.forEach((d) => p.print(d)); }
printAll(new BasicPrinter(), ["a.pdf"]); // print a.pdfD: principio de inversión de dependencias
La política de alto nivel no debe depender de detalles de bajo nivel, como una base de datos o un servicio de correo concretos. Ambos deben depender de una abstracción, y el módulo de alto nivel suele ser el dueño de esa abstracción. En la práctica, pasas las dependencias desde fuera (inyección de dependencias) en lugar de crearlas dentro.
Antes: el servicio crea un cliente de base de datos concreto, así que no se puede probar ni cambiar.
class MySqlUserStore {
find(id: string): string { return "user " + id; }
}
class UserService {
private store = new MySqlUserStore(); // hard-wired detail
greet(id: string): string { return "Hello, " + this.store.find(id); }
}Después: el servicio depende de una interfaz; se puede inyectar cualquier implementación, incluida una simulada en las pruebas.
interface UserStore { find(id: string): string; }
class UserService {
constructor(private store: UserStore) {}
greet(id: string): string { return "Hello, " + this.store.find(id); }
}
class InMemoryUserStore implements UserStore {
constructor(private users: Record<string, string>) {}
find(id: string): string { return this.users[id] ?? "unknown"; }
}
const service = new UserService(new InMemoryUserStore({ u1: "Ada" }));
console.log(service.greet("u1")); // Hello, AdaCuándo aplicar SOLID y cuándo no
Los principios SOLID son pautas para código que cambia a menudo, no leyes. Aplicados a ciegas, producen muchas clases diminutas e interfaces con una sola implementación, lo que dificulta seguir el código.
- Aplícalos cuando sientas el dolor que resuelven: una clase que se rompe cada vez que cambia cualquier cosa, una prueba que necesita una base de datos real o una instrucción switch copiada en muchos sitios.
- No añadas por adelantado una interfaz para cada clase. Extrae una cuando aparezca una segunda implementación o un doble de prueba.
- En scripts pequeños y prototipos, el código simple y directo suele ser mejor.
- Muchos patrones de diseño son aplicaciones concretas de SOLID: el patrón Strategy y el patrón Decorator favorecen el principio abierto/cerrado, el patrón Adapter y el patrón Facade ayudan a aislar detalles, y el patrón Abstract Factory favorece la inversión de dependencias.
Preguntas frecuentes
¿Quién creó los principios SOLID?
Robert C. Martin reunió y describió los cinco principios, basándose en trabajos anteriores de Bertrand Meyer (abierto/cerrado) y Barbara Liskov (sustitución). Michael Feathers acuñó el acrónimo SOLID.
¿SOLID se aplica a la programación funcional?
Las ideas se trasladan: funciones pequeñas con un único propósito, componer el comportamiento en lugar de editarlo y pasar las dependencias como parámetros. La formulación basada en clases es específica del código orientado a objetos.
¿Cuál es la diferencia entre inversión de dependencias e inyección de dependencias?
La inversión de dependencias es el principio de que el código de alto nivel debe depender de abstracciones. La inyección de dependencias es una técnica que suministra esas dependencias desde fuera, por ejemplo mediante un constructor.
¿Cuál es el principio SOLID más importante?
No existe una clasificación oficial. La responsabilidad única y la inversión de dependencias suelen dar el beneficio más rápido, porque hacen que el código sea más fácil de entender y de probar.
¿Es malo romper un principio SOLID?
No siempre. Todo principio tiene un coste en código adicional. Rómpelo deliberadamente cuando el diseño más simple sea más claro y el cambio del que protege sea poco probable.