25 KiB
Principios SOLID
Introducción
Los principios SOLID constituyen un conjunto de criterios de diseño orientados a desarrollar software flexible, mantenible, comprensible y verificable.
Su aplicación adecuada permite:
- Reducir el acoplamiento innecesario.
- Mejorar la cohesión de clases y módulos.
- Facilitar la evolución de funcionalidades.
- Proteger contratos y comportamientos existentes.
- Simplificar las pruebas y el mantenimiento.
- Controlar el impacto de los cambios.
Estos principios deben aplicarse con criterio profesional y teniendo en cuenta las necesidades reales del sistema.
SOLID no exige utilizar siempre interfaces, herencia, patrones de diseño ni arquitecturas complejas.
El objetivo es mejorar la estructura del software, no maximizar el número de abstracciones.
Este documento desarrolla los criterios establecidos en el SKILL.md principal.
1. S — Principio de responsabilidad única (Single Responsibility Principle, SRP)
"Una clase debería tener una, y solo una, razón para cambiar."
1.1. Qué problema resuelve
SRP ayuda a evitar clases o módulos que acumulan responsabilidades independientes.
Estas estructuras suelen presentar:
- Dificultad para comprender su comportamiento.
- Cambios frecuentes por motivos diferentes.
- Pruebas complejas.
- Dependencias excesivas.
- Mayor riesgo de regresiones.
- Acoplamiento entre funcionalidades que deberían evolucionar por separado.
1.2. Cómo aplicarlo
Cada clase o módulo debe representar una responsabilidad coherente.
Una responsabilidad no equivale necesariamente a un único método, operación o dato.
Una clase puede realizar varias operaciones relacionadas y seguir cumpliendo SRP.
Ejemplo: responsabilidades mezcladas
// MAL: Mezcla reglas de negocio, persistencia y generación de documentos.
class Order {
calculateTotal(): Money {
// Calcular el importe del pedido.
}
async saveToDatabase(): Promise<void> {
// Persistir el pedido.
}
generateInvoice(): Invoice {
// Generar la factura.
}
}
En este caso, la clase puede cambiar por motivos independientes:
- Modificación de reglas de cálculo.
- Cambios en la persistencia.
- Cambios en la generación de facturas.
Alternativa: responsabilidades diferenciadas
class Order {
private readonly items: OrderItem[] = [];
addItem(item: OrderItem): void {
this.items.push(item);
}
calculateTotal(): Money {
return calculateOrderTotal(this.items);
}
}
class OrderRepository {
async save(order: Order): Promise<void> {
// Persistir el pedido.
}
}
class InvoiceGenerator {
generate(order: Order): Invoice {
// Generar una factura a partir del pedido.
}
}
La entidad Order mantiene las operaciones relacionadas con sus reglas de negocio, mientras que la persistencia y la generación de documentos pertenecen a responsabilidades diferenciadas.
Los ejemplos son ilustrativos y presuponen los tipos y colaboradores necesarios.
1.3. Preguntas para detectar problemas
- ¿Tiene esta clase varios motivos independientes para cambiar?
- ¿Sus operaciones responden a una responsabilidad coherente?
- ¿Distintos requisitos obligan a modificar partes no relacionadas?
- ¿La clase depende de demasiados componentes externos?
- ¿Sería más sencillo probar y mantener determinadas responsabilidades por separado?
1.4. Evitar una interpretación excesivamente estricta
No dividas una clase únicamente porque su descripción incluya la palabra «y».
Por ejemplo, una entidad Order puede:
- Añadir artículos.
- Eliminar artículos.
- Calcular importes.
- Comprobar restricciones.
- Cambiar de estado.
Todas estas operaciones pueden pertenecer a una única responsabilidad: gestionar el comportamiento del pedido.
SRP busca separar motivos de cambio independientes, no reducir todas las clases a una única operación.
2. O — Principio abierto/cerrado (Open/Closed Principle, OCP)
"Las entidades de software deberían estar abiertas a la extensión, pero cerradas a la modificación."
2.1. Qué problema resuelve
OCP busca reducir la necesidad de modificar código estable cada vez que se incorpora una variante de comportamiento.
Esto resulta especialmente útil cuando:
- Existen múltiples implementaciones de una capacidad.
- Se incorporan nuevas variantes con frecuencia.
- Los componentes tienen contratos estables.
- Modificar lógica existente supone un riesgo elevado.
2.2. Cómo aplicarlo
Cuando exista una necesidad real de extensión, diseña contratos que permitan incorporar nuevos comportamientos sin modificar innecesariamente los consumidores.
Ejemplo: cálculo de gastos de envío
// Implementación sencilla basada en condicionales.
type ShippingType = 'standard' | 'express';
class ShippingCalculator {
calculate(type: ShippingType, orderValue: number): number {
if (type === 'standard') {
return orderValue < 50 ? 5 : 0;
}
return 15;
}
}
Esta implementación no es necesariamente incorrecta.
Si los tipos de envío son pocos y estables, puede ser la solución más sencilla y mantenible.
Sin embargo, cuando las políticas de envío evolucionan independientemente, puede resultar conveniente introducir abstracciones.
2.3. Alternativa extensible
interface ShippingMethod {
calculateCost(orderValue: number): number;
}
class StandardShipping implements ShippingMethod {
calculateCost(orderValue: number): number {
return orderValue < 50 ? 5 : 0;
}
}
class ExpressShipping implements ShippingMethod {
calculateCost(_orderValue: number): number {
return 15;
}
}
Para incorporar un nuevo método:
class SameDayShipping implements ShippingMethod {
calculateCost(_orderValue: number): number {
return 25;
}
}
Un consumidor puede utilizar el contrato común:
function calculateShipping(
method: ShippingMethod,
orderValue: number,
): number {
return method.calculateCost(orderValue);
}
Este diseño permite incorporar nuevas políticas sin modificar el cálculo del consumidor.
No obstante, el mecanismo que selecciona o registra las implementaciones puede necesitar cambios según la arquitectura utilizada.
2.4. Aplicación pragmática
- Introduce puntos de extensión cuando exista una necesidad identificada.
- Mantén estables los contratos que tengan consumidores.
- Favorece composición y polimorfismo cuando reduzcan el acoplamiento.
- Evita crear interfaces para comportamientos que probablemente no variarán.
- No introduzcas extensibilidad hipotética.
- Acepta modificaciones de código existente cuando sean la solución más clara.
2.5. OCP a nivel arquitectónico
A nivel de arquitectura, OCP favorece la existencia de módulos con responsabilidades claras y contratos estables.
Esto permite incorporar funcionalidades nuevas con un impacto controlado.
Sin embargo, no significa que toda funcionalidad nueva deba implementarse exclusivamente añadiendo archivos o clases.
Modificar código existente es legítimo cuando cambian sus requisitos o responsabilidades.
El objetivo es minimizar modificaciones innecesarias en componentes estables, no prohibirlas.
3. L — Principio de sustitución de Liskov (Liskov Substitution Principle, LSP)
"Los subtipos deben poder sustituir a sus tipos base sin alterar la corrección del programa."
3.1. Qué problema resuelve
LSP evita jerarquías o implementaciones que incumplen las expectativas establecidas por un contrato común.
Cuando se vulnera este principio, suelen aparecer:
- Comprobaciones del tipo concreto en los consumidores.
- Condicionales especiales para determinadas implementaciones.
- Errores inesperados al sustituir componentes.
- Contratos inconsistentes.
- Dependencias del comportamiento interno de cada implementación.
3.2. El contrato es más que una firma
Dos implementaciones no son necesariamente sustituibles por tener los mismos métodos.
La sustitución requiere respetar el comportamiento esperado, incluyendo:
- Precondiciones.
- Postcondiciones.
- Invariantes.
- Semántica de los resultados.
- Comportamiento ante errores.
- Garantías relevantes para los consumidores.
3.3. Ejemplo de incumplimiento
Supongamos que el contrato establece que un descuento nunca puede ser negativo.
interface DiscountPolicy {
getDiscount(orderValue: number): number;
}
Una implementación correcta:
class StandardDiscount implements DiscountPolicy {
getDiscount(_orderValue: number): number {
return 5;
}
}
Una implementación incorrecta:
class InvalidDiscount implements DiscountPolicy {
getDiscount(_orderValue: number): number {
return -5;
}
}
Aunque ambas cumplen la interfaz TypeScript, la segunda incumple el contrato semántico si este exige descuentos no negativos.
3.4. Protección del contrato
El contrato puede expresarse mediante documentación, tipos, validaciones y pruebas.
class FixedDiscount implements DiscountPolicy {
constructor(private readonly amount: number) {
if (!Number.isFinite(amount) || amount < 0) {
throw new InvalidDiscountError();
}
}
getDiscount(_orderValue: number): number {
return this.amount;
}
}
La validación impide crear una política de descuento fijo con un importe negativo.
No obstante, validar únicamente esta clase no garantiza que todas las implementaciones de DiscountPolicy cumplan el contrato.
Para ello pueden utilizarse pruebas de contrato compartidas y validaciones en los límites apropiados.
3.5. Reglas de sustitución
Una implementación sustitutiva debe:
- Respetar las invariantes del contrato.
- No exigir precondiciones más estrictas que las comprometidas por el tipo base.
- No proporcionar postcondiciones más débiles.
- Mantener el significado de los resultados.
- Respetar las garantías de error relevantes.
- No introducir efectos secundarios incompatibles con el contrato.
3.6. Ejemplo con repositorios
interface UserRepository {
findById(id: UserId): Promise<User | null>;
save(user: User): Promise<void>;
}
Las implementaciones:
class InMemoryUserRepository implements UserRepository {
// Implementación en memoria.
}
class PostgresUserRepository implements UserRepository {
// Implementación sobre PostgreSQL.
}
Pueden sustituirse cuando respetan el mismo contrato funcional.
Esto incluye:
- Significado de
nullcuando no existe el usuario. - Garantías de persistencia.
- Restricciones de integridad.
- Tratamiento de errores.
- Comportamientos relevantes para los consumidores.
Compartir una interfaz no garantiza automáticamente la sustituibilidad.
4. I — Principio de segregación de interfaces (Interface Segregation Principle, ISP)
"Los clientes no deberían verse obligados a depender de métodos que no utilizan."
4.1. Qué problema resuelve
ISP evita interfaces excesivamente amplias que obligan a los consumidores o implementaciones a conocer capacidades innecesarias.
Los síntomas habituales incluyen:
- Interfaces con numerosas operaciones independientes.
- Implementaciones parciales.
- Métodos vacíos.
- Excepciones de funcionalidad no soportada.
- Dependencias innecesarias.
- Cambios en interfaces que afectan a consumidores no relacionados.
4.2. Ejemplo problemático
interface WarehouseDevice {
printLabel(orderId: string): void;
scanBarcode(): string;
packageItem(orderId: string): void;
}
Una impresora sencilla no debería verse obligada a implementar operaciones que no admite.
class BasicPrinter implements WarehouseDevice {
printLabel(_orderId: string): void {
// Imprimir etiqueta.
}
scanBarcode(): string {
throw new Error('Operación no soportada');
}
packageItem(_orderId: string): void {
throw new Error('Operación no soportada');
}
}
4.3. Alternativa con interfaces cohesionadas
interface LabelPrinter {
printLabel(orderId: string): void;
}
interface BarcodeScanner {
scanBarcode(): string;
}
interface ItemPackager {
packageItem(orderId: string): void;
}
Una impresora implementa únicamente su capacidad real:
class BasicPrinter implements LabelPrinter {
printLabel(_orderId: string): void {
// Imprimir etiqueta.
}
}
Los consumidores pueden depender de las capacidades que necesitan.
4.4. Aplicación pragmática
- Diseña interfaces alrededor de necesidades reales de los consumidores.
- Agrupa operaciones que representen una responsabilidad coherente.
- Divide interfaces cuando obliguen a depender de capacidades no utilizadas.
- Evita crear una interfaz independiente para cada método sin una razón clara.
- No fragmentes contratos cohesionados únicamente para reducir su tamaño.
4.5. Señales de revisión
Revisa una interfaz cuando:
- Varias implementaciones contienen métodos vacíos.
- Algunas operaciones lanzan excepciones de no implementación de forma sistemática.
- Los consumidores utilizan subconjuntos completamente independientes.
- Los cambios afectan a componentes que no necesitan la funcionalidad modificada.
Sin embargo, un método opcional o una operación no soportada no demuestra por sí solo una vulneración de ISP.
Evalúa el contrato y el contexto antes de modificarlo.
ISP trata de reducir dependencias innecesarias, no de maximizar el número de interfaces.
5. D — Principio de inversión de dependencias (Dependency Inversion Principle, DIP)
"Los módulos de alto nivel no deberían depender de los módulos de bajo nivel. Ambos deberían depender de abstracciones."
5.1. Qué problema resuelve
DIP busca evitar que las reglas de negocio queden acopladas innecesariamente a detalles técnicos.
Ejemplos:
- Bases de datos concretas.
- Proveedores de correo electrónico.
- APIs externas.
- Sistemas de almacenamiento.
- Frameworks.
- Implementaciones de infraestructura.
Este acoplamiento puede dificultar las pruebas, la evolución o la sustitución de componentes.
5.2. Ejemplo de dependencia concreta
class OrderService {
private readonly emailService = new SendGridEmailService();
async confirmOrder(email: string): Promise<void> {
await this.emailService.send(email, 'Pedido confirmado');
}
}
La clase conoce directamente al proveedor de correo electrónico.
Si cambia el proveedor, puede ser necesario modificar el servicio de aplicación.
5.3. Dependencia de una abstracción
Define un contrato que represente la capacidad necesaria.
interface EmailService {
send(to: string, message: string): Promise<void>;
}
El servicio depende del contrato:
class OrderService {
constructor(
private readonly emailService: EmailService,
) {}
async confirmOrder(email: string): Promise<void> {
await this.emailService.send(email, 'Pedido confirmado');
}
}
Las implementaciones concretas pueden variar:
const sendGridOrderService = new OrderService(
new SendGridEmailService(),
);
const sesOrderService = new OrderService(
new SesEmailService(),
);
En una prueba puede utilizarse una implementación controlada:
const testOrderService = new OrderService(
new FakeEmailService(),
);
El servicio de aplicación no necesita conocer los detalles internos de cada proveedor.
5.4. Inversión de dependencias e inyección de dependencias
Son conceptos relacionados, pero diferentes.
Inversión de dependencias (DIP):
Principio de diseño que establece cómo deben organizarse las dependencias entre políticas y detalles.
Inyección de dependencias (DI):
Técnica mediante la cual un componente recibe sus colaboradores desde el exterior.
Puede realizarse mediante:
- Constructores.
- Parámetros.
- Factorías.
- Composición explícita.
- Contenedores de dependencias.
Aplicar DIP no exige utilizar un contenedor de inyección de dependencias.
5.5. Evitar abstracciones innecesarias
No es obligatorio crear una interfaz para cada clase.
Una dependencia concreta puede resultar apropiada cuando:
- El componente es sencillo y estable.
- No existe una necesidad real de sustitución.
- No introduce acoplamiento arquitectónico problemático.
- La abstracción adicional no aporta claridad ni capacidad de evolución.
- Se trata de un objeto de valor, una utilidad pura u otro componente concreto cuyo contrato es suficientemente estable.
La dependencia de abstracciones debe justificarse por su utilidad, no por el cumplimiento mecánico de DIP.
5.6. Regla de dependencias arquitectónicas
En arquitecturas limpias, hexagonales o equivalentes, las dependencias del código fuente deben orientarse hacia las políticas de alto nivel.
Representación simplificada:
Infraestructura → Aplicación → Dominio
exterior medio interior
Esta representación expresa la dirección deseada de las dependencias en una arquitectura organizada de esa forma.
En la práctica, la infraestructura puede implementar contratos definidos en la aplicación o el dominio.
Lo importante es que las reglas de negocio no dependan innecesariamente de los detalles de infraestructura.
5.7. Aplicación pragmática
- Mantén independientes las reglas de negocio respecto a detalles técnicos cuando resulte necesario.
- Define contratos en los límites donde aporten desacoplamiento.
- Utiliza inyección de dependencias cuando facilite la composición o las pruebas.
- No introduzcas interfaces y adaptadores para todas las operaciones.
- Evita contenedores complejos en aplicaciones que puedan resolverse mediante composición directa.
- Respeta la arquitectura existente salvo que exista una razón técnica o funcional para modificarla.
6. Aplicación de SOLID a nivel arquitectónico
Los principios SOLID pueden orientar el diseño de módulos, subsistemas y límites arquitectónicos.
No obstante, su aplicación a nivel de arquitectura requiere interpretar correctamente cada principio.
| Principio | Aplicación arquitectónica |
|---|---|
| SRP | Módulos y subsistemas con responsabilidades cohesionadas y motivos de cambio relacionados. |
| OCP | Contratos y puntos de extensión que permiten incorporar capacidades sin modificar innecesariamente componentes estables. |
| LSP | Implementaciones sustituibles que respetan los contratos funcionales y no funcionales relevantes. |
| ISP | Interfaces y contratos entre módulos adaptados a las necesidades reales de sus consumidores. |
| DIP | Separación entre políticas de negocio y detalles técnicos mediante dependencias orientadas adecuadamente. |
6.1. SRP en módulos y subsistemas
Un módulo puede contener numerosas clases y operaciones siempre que representen una responsabilidad coherente.
No debe interpretarse que cada módulo o contexto delimitado (bounded context) tenga que realizar una única operación.
6.2. OCP en arquitectura
Favorece módulos estables que puedan ampliarse mediante nuevos componentes cuando exista una necesidad real de extensión.
No significa que las funcionalidades nuevas deban implementarse exclusivamente añadiendo módulos.
6.3. LSP en contratos distribuidos
En sistemas distribuidos, compartir un contrato técnico no garantiza sustituibilidad.
Deben considerarse también:
- Semántica de las operaciones.
- Garantías de consistencia.
- Tratamiento de errores.
- Idempotencia cuando corresponda.
- Compatibilidad de versiones.
- Restricciones operativas relevantes.
Dos microservicios que exponen la misma API no son necesariamente intercambiables.
6.4. ISP entre módulos
Los contratos deben exponer las capacidades necesarias sin obligar a los consumidores a depender de funcionalidades ajenas.
Esto no exige crear un servicio o API independiente para cada operación.
6.5. DIP en arquitectura
Las reglas de negocio deben mantenerse desacopladas de detalles de infraestructura cuando esa separación facilite la evolución del sistema.
La arquitectura concreta debe adaptarse a los requisitos y complejidad reales.
SOLID no obliga a utilizar Clean Architecture, arquitectura hexagonal o microservicios en cualquier proyecto.
7. Errores habituales al aplicar SOLID
Aplicar SRP como "una clase, un método"
Una responsabilidad puede incluir numerosas operaciones relacionadas.
Dividirlas indiscriminadamente puede reducir la cohesión.
Aplicar OCP mediante abstracciones especulativas
No crees extensibilidad para requisitos hipotéticos.
Introduce puntos de extensión cuando exista una necesidad identificada.
Considerar LSP únicamente como compatibilidad de tipos
Compartir una interfaz no garantiza cumplir el mismo contrato semántico.
Comprueba invariantes, resultados y expectativas.
Aplicar ISP creando demasiadas interfaces
Las interfaces deben representar capacidades coherentes.
No dividas contratos únicamente para reducir el número de métodos.
Aplicar DIP mediante interfaces para todas las clases
DIP no consiste en prohibir dependencias concretas.
Consiste en evitar que las políticas importantes dependan innecesariamente de detalles técnicos.
Introducir patrones sin necesidad
SOLID no exige utilizar automáticamente:
- Repositorios.
- Factorías.
- Estrategias.
- Adaptadores.
- Contenedores de dependencias.
- Jerarquías de herencia.
Utiliza estos mecanismos cuando resuelvan un problema concreto.
Refactorizar código estable sin justificación
No modifiques una implementación que funciona correctamente únicamente para ajustarla a una interpretación más rígida de SOLID.
Evalúa el beneficio esperado, el riesgo y el alcance de la tarea.
8. Referencia rápida
| Principio | Idea fundamental | Señal de revisión |
|---|---|---|
| SRP | Una responsabilidad coherente y motivos de cambio relacionados. | Clase que acumula responsabilidades independientes. |
| OCP | Permitir extensiones justificadas sin modificar innecesariamente código estable. | Variantes frecuentes que obligan a cambiar múltiples componentes. |
| LSP | Las implementaciones deben respetar el contrato común. | Comportamientos incompatibles o comprobaciones especiales por tipo. |
| ISP | Los consumidores dependen únicamente de las capacidades necesarias. | Interfaces amplias con operaciones irrelevantes o implementaciones parciales. |
| DIP | Evitar dependencias innecesarias entre políticas de alto nivel y detalles técnicos. | Reglas de negocio fuertemente acopladas a infraestructura concreta. |
9. Lista de comprobación SOLID
Responsabilidad única (SRP)
- ¿Cada clase o módulo tiene una responsabilidad coherente?
- ¿Existen motivos de cambio independientes que justifiquen una separación?
- ¿He evitado fragmentar responsabilidades relacionadas?
- ¿La organización facilita comprender y mantener el código?
Abierto/cerrado (OCP)
- ¿Existe una necesidad real de extensión?
- ¿Puedo incorporar variantes sin modificar innecesariamente código estable?
- ¿Las abstracciones introducidas están justificadas?
- ¿He evitado diseñar para requisitos hipotéticos?
Sustitución de Liskov (LSP)
- ¿Las implementaciones respetan el contrato común?
- ¿Se mantienen las invariantes?
- ¿Las precondiciones y postcondiciones son compatibles?
- ¿Los consumidores pueden utilizar otra implementación sin tratamientos especiales?
- ¿Las diferencias de comportamiento están correctamente definidas?
Segregación de interfaces (ISP)
- ¿Cada interfaz representa capacidades coherentes?
- ¿Los consumidores dependen de operaciones que realmente necesitan?
- ¿Existen implementaciones forzadas a soportar operaciones irrelevantes?
- ¿He evitado fragmentar interfaces artificialmente?
Inversión de dependencias (DIP)
- ¿Las reglas de negocio dependen innecesariamente de detalles técnicos?
- ¿Las abstracciones están situadas en límites apropiados?
- ¿La inyección de dependencias aporta ventajas concretas?
- ¿He evitado interfaces y adaptadores innecesarios?
- ¿Se respeta la arquitectura existente y sus contratos?
Principio final
"Los principios SOLID deben facilitar el cambio, reducir el acoplamiento y mejorar la comprensión del software, no convertirse en una obligación de introducir más clases e interfaces."
Aplica SOLID con criterio profesional, considerando las necesidades del dominio, el tamaño del sistema, sus requisitos de evolución y el coste de mantenimiento.
Una solución sencilla, cohesionada y correctamente desacoplada es preferible a una arquitectura artificialmente compleja que únicamente aparenta cumplir SOLID.