Guía completa de los principios SOLID
Los principios SOLID son un conjunto de 5 principios que nos pueden ayudar a crear software más estructurado, mantenible y flexible.
Cada uno de estos principios aborda desafíos comunes en el diseño de software, ofreciendo soluciones claras para evitar código frágil y difícil de escalar.
Recuerda que son principios y no reglas, por lo que debemos tener cierto margen de flexibilidad a la hora de aplicarlos siempre adaptándonos a nuestras necesidades.
En este tutorial, exploraremos cada principio con ejemplos prácticos y vamos a ver cómo aplicarlos para mejorar la calidad de tu código.
Los 5 principios SOLID
Estos principios se dividen en 5, uno por cada letra y son los siguientes:
- Single Responsibility Principle (Principio de responsabilidad única)
- Open/Closed Principle (Principio de abierto y cerrado)
- Liskov Substitution Principle (Sustitución de Liskov)
- Interface Segregation Principle (Segregación de interfaz)
- Dependency Inversión Principle (Inversión de dependencias)
Principio de Responsabilidad Única (Single Responsibility Principle - SRP)
El Principio de Responsabilidad Única establece que una clase, módulo o función debe centrarse exclusivamente en una responsabilidad. Esto no significa que una clase solo pueda realizar una tarea específica, sino que todas sus funcionalidades deben estar relacionadas con un mismo propósito o concepto.
Identificar violaciones al SRP puede ser más sencillo si te planteas las siguientes preguntas o señales de alerta:
- ¿El nombre de la clase o módulo es demasiado genérico? Esto puede indicar que está abarcando múltiples responsabilidades.
- ¿Cambios en el código afectan esta clase con frecuencia? Las modificaciones constantes pueden ser señal de que está haciendo demasiado.
- ¿La clase está involucrada en varias capas de la aplicación? Si una misma clase gestiona lógica de negocio, acceso a datos y presentación, es probable que viole el SRP.
- ¿Tiene demasiadas importaciones? Un alto número de dependencias puede sugerir que la clase está asumiendo más roles de los necesarios.
- ¿Cuenta con muchos métodos públicos? Esto puede indicar que la clase está sirviendo a múltiples propósitos.
- ¿Es excesivamente larga? Un número elevado de líneas de código suele ser un síntoma de que la clase maneja más responsabilidades de las que debería.
A continuación te dejo un ejemplo en el que se viola este principio para que lo puedas ver más claro:
class User {
constructor(public id: number, public name: string, public email: string) {}
getDisplayName(): string {
return `${this.name} (${this.email})`;
}
sendWelcomeEmail(): void {
console.log(`Enviando correo a ${this.email}: Bienvenido\nHola ${this.name}, bienvenido a nuestra plataforma.`);
}
}
const user = new User(1, 'Alber', 'alber@example.com');
user.sendWelcomeEmail();
En este ejemplo no estamos cumpliendo con el principio de responsabilidad única, ya que la clase user no debe encargarse de enviar el email de bienvenida. De esto deberíamos encargarnos con una clase en concreto para enviar emails:
class User {
constructor(public id: number, public name: string, public email: string) {}
getDisplayName(): string {
return `${this.name} (${this.email})`;
}
}
// Clase responsable únicamente de enviar correos electrónicos
class EmailService {
sendEmail(to: string, subject: string, body: string): void {
console.log(`Enviando correo a ${to}: ${subject}\n${body}`);
}
}
const user = new User(1, 'Alber', 'alber@example.com');
const emailService = new EmailService();
emailService.sendEmail(user.email, 'Bienvenido', `Hola ${user.name}, bienvenido a nuestra plataforma.`);
Principio de Abierto/Cerrado (Open/Closed Principle - OCP)
El Principio de Abierto/Cerrado establece que las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para la extensión, permitiendo añadir nuevas funcionalidades sin alterar su estructura interna, pero cerradas para la modificación, evitando cambiar el código existente para no introducir errores en las partes que ya funcionan correctamente.
Un ejemplo común es abstraer el uso de dependencias externas. Si estás utilizando una librería de terceros, encapsularla en una clase propia te permitirá cambiarla o actualizarla sin afectar el resto de tu código. De esta forma, el impacto del cambio estará limitado a esa única clase.
Indicadores de violaciones del OCP:
- Los cambios en los requisitos o funcionalidades requieren modificar una clase o módulo existente.
- Una clase o módulo mezcla responsabilidades, afectando múltiples capas (presentación, lógica de negocio, almacenamiento, etc.).
Ejemplo de una clase que viola este principio:
class Order {
checkout(amount: number, paymentMethod: string): void {
if (paymentMethod === 'PayPal') {
console.log(`Procesando pago de $${amount} con PayPal.`);
} else if (paymentMethod === 'Stripe') {
console.log(`Procesando pago de $${amount} con Stripe.`);
} else {
console.log('Método de pago no soportado.');
}
}
}
const order = new Order();
order.checkout(100, 'PayPal');
order.checkout(200, 'Stripe');
Aquí, la clase Order
viola el OCP porque para añadir un nuevo método de pago, como "Apple Pay", tendrías que modificar su implementación, lo que podría introducir errores y afectar la lógica existente.
Esto podríamos resolverlo de la siguiente forma:
interface PaymentProcessor {
processPayment(amount: number): void;
}
// Implementación para PayPal
class PayPalPaymentProcessor implements PaymentProcessor {
processPayment(amount: number): void {
console.log(`Procesando pago de $${amount} con PayPal.`);
}
}
// Implementación para Stripe
class StripePaymentProcessor implements PaymentProcessor {
processPayment(amount: number): void {
console.log(`Procesando pago de $${amount} con Stripe.`);
}
}
// Clase de orden que es abierta para extenderse a nuevos procesadores, pero cerrada para modificaciones
class Order {
constructor(private paymentProcessor: PaymentProcessor) {}
checkout(amount: number): void {
this.paymentProcessor.processPayment(amount);
}
}
// Uso de la clase
const orderWithPayPal = new Order(new PayPalPaymentProcessor());
orderWithPayPal.checkout(100);
const orderWithStripe = new Order(new StripePaymentProcessor());
orderWithStripe.checkout(200);
En este caso, Order
es cerrada para modificaciones porque no necesitas cambiar su implementación si añades un nuevo método de pago. Solo necesitas extender creando una nueva clase que implemente PaymentProcessor
.
Principio de Sustitución de Liskov (Liskov Substitution Principle - LSP)
El Principio de Sustitución de Liskov establece que si una clase hija (A) extiende una clase padre (B), debe poder ser utilizada en lugar de la clase padre sin que esto afecte el comportamiento esperado del programa. En otras palabras, las clases derivadas deben ser completamente intercambiables con sus clases base sin causar errores o resultados inesperados.
Esto implica que una clase hija no debe:
- Alterar el contrato definido por la clase base.
- Introducir restricciones adicionales que no están presentes en la clase base.
- Cambiar significativamente el comportamiento esperado de los métodos heredados.
Cumplir con este principio es esencial para garantizar que un sistema sea flexible y extensible, especialmente cuando se utilizan polimorfismo y herencia.
Para detectar posibles violaciones, considera lo siguiente:
- Excepciones inesperadas: ¿La clase hija lanza excepciones en escenarios donde la clase base no lo haría?
- Restricciones adicionales: ¿La clase hija introduce reglas que no están definidas en la clase base?
- Resultados inconsistentes: ¿El comportamiento esperado de un método cambia al sustituir la clase base por la hija?
- Dependencia de tipos específicos: ¿El código depende explícitamente de la clase hija en lugar de la base?
- Sobrecarga no coherente: ¿Los métodos sobrescritos no mantienen las mismas precondiciones y postcondiciones que la clase base?
Ejemplo que viola este principio:
class Bird {
fly(): void {
console.log('Volando...');
}
}
// Clase hija que viola LSP porque no puede volar
class Penguin extends Bird {
fly(): void {
throw new Error('Los pingüinos no pueden volar.');
}
}
// Código que utiliza la clase base
function makeBirdFly(bird: Bird): void {
bird.fly();
}
const sparrow = new Bird();
makeBirdFly(sparrow); // "Volando..."
const penguin = new Penguin();
makeBirdFly(penguin); // Error: Los pingüinos no pueden volar.
En este caso, Penguin
no cumple con el contrato de la clase base Bird
, ya que redefine el método fly
con una funcionalidad que rompe la expectativa del código. Esto viola el LSP porque Penguin
no puede sustituir a Bird
sin causar errores.
Lo podemos solucionar así:
interface Bird {
move(): void;
}
// Clase hija para aves que vuelan
class FlyingBird implements Bird {
move(): void {
console.log('Volando...');
}
}
// Clase hija para aves que no vuelan
class NonFlyingBird implements Bird {
move(): void {
console.log('Caminando...');
}
}
// Código que utiliza la interfaz base
function makeBirdMove(bird: Bird): void {
bird.move();
}
// Uso
const sparrow = new FlyingBird();
makeBirdMove(sparrow); // "Volando..."
const penguin = new NonFlyingBird();
makeBirdMove(penguin); // "Caminando..."
En este ejemplo, el uso de una interfaz permite modelar diferentes comportamientos sin forzar un contrato que algunas clases no puedan cumplir. Tanto FlyingBird
como NonFlyingBird
implementan la interfaz Bird
de manera coherente, permitiendo sustituir una clase por otra sin problemas.
Principio de Segregación de Interfaces (Interface Segregation Principle - ISP)
El Principio de Segregación de Interfaces establece que las clases que implementan interfaces no deberían estar obligadas a depender de métodos que no utilizan.
En términos simples, las interfaces deben ser específicas y contener únicamente los métodos necesarios para las clases que las implementan.
Esto significa que es preferible diseñar múltiples interfaces pequeñas y específicas en lugar de una interfaz general y extensa que todas las clases deben implementar, incluso si algunas de esas clases no necesitan todos los métodos definidos en ella.
Cumplir este principio mejora la flexibilidad del diseño y facilita la reutilización y el mantenimiento del código, ya que las clases no se ven sobrecargadas con métodos irrelevantes.
Para identificar posibles problemas, considera lo siguiente:
- Interfaces infladas: ¿La interfaz contiene demasiados métodos que no son usados por todas las clases?
- Implementaciones vacías o irrelevantes: ¿Alguna clase tiene métodos implementados sin funcionalidad porque no los necesita?
- Falta de coherencia: ¿La interfaz incluye métodos que no están relacionados directamente entre sí?
- Problemas al extender: ¿Al añadir un nuevo método a la interfaz se obliga a modificar todas las clases que la implementan, incluso aquellas que no lo necesitan?
Ejemplo que viola el principio:
interface Animal {
eat(): void;
sleep(): void;
fly(): void;
swim(): void;
}
// Clase que implementa métodos irrelevantes
class Dog implements Animal {
eat(): void {
console.log('El perro está comiendo.');
}
sleep(): void {
console.log('El perro está durmiendo.');
}
fly(): void {
throw new Error('Los perros no pueden volar.');
}
swim(): void {
console.log('El perro está nadando.');
}
}
class Bird implements Animal {
eat(): void {
console.log('El ave está comiendo.');
}
sleep(): void {
console.log('El ave está durmiendo.');
}
fly(): void {
console.log('El ave está volando.');
}
swim(): void {
throw new Error('Las aves no nadan.');
}
Podemos solucionarlo de la siguiente manera:
// Interfaces específicas para diferentes comportamientos
interface Eater {
eat(): void;
}
interface Sleeper {
sleep(): void;
}
interface Flyer {
fly(): void;
}
interface Swimmer {
swim(): void;
}
// Clases que implementan solo las interfaces que necesitan
class Dog implements Eater, Sleeper, Swimmer {
eat(): void {
console.log('El perro está comiendo.');
}
sleep(): void {
console.log('El perro está durmiendo.');
}
swim(): void {
console.log('El perro está nadando.');
}
}
class Bird implements Eater, Sleeper, Flyer {
eat(): void {
console.log('El ave está comiendo.');
}
sleep(): void {
console.log('El ave está durmiendo.');
}
fly(): void {
console.log('El ave está volando.');
}
}
Aquí, cada clase implementa únicamente las interfaces que son relevantes para su comportamiento. Esto asegura que no se incluyan métodos innecesarios, lo que hace que el diseño sea más limpio, flexible y fácil de mantener.
Principio de Inversión de Dependencias (Dependency Inversion Principle - DIP)
El Principio de Inversión de Dependencias establece que:
- Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones (interfaces o clases abstractas).
- Las abstracciones no deben depender de implementaciones concretas.
- Los detalles (implementaciones concretas) deben depender de abstracciones.
Este principio fomenta la creación de sistemas desacoplados y flexibles, donde los módulos de alto nivel (que contienen la lógica central de la aplicación) no se ven afectados por cambios en los módulos de bajo nivel (implementaciones específicas).
Esto se logra definiendo interfaces o clases abstractas para comunicar los módulos, dejando que las implementaciones concretas dependan de estas abstracciones.
Para identificar posibles problemas, analiza si:
- Dependencias rígidas: ¿El código de alto nivel depende directamente de implementaciones concretas?
- Acoplamiento fuerte: ¿Un cambio en una clase concreta rompe o requiere modificaciones en el código de alto nivel?
- Reutilización limitada: ¿Es difícil reutilizar el módulo de alto nivel porque está atado a detalles específicos?
- Falta de inyección de dependencias: ¿El código crea instancias de clases concretas directamente en lugar de recibirlas como dependencias?
Ahora vamos a ver un ejemplo que viola este principio:
// Módulo de bajo nivel
class MySQLDatabase {
connect(): void {
console.log('Conectando a MySQL...');
}
}
// Módulo de alto nivel que depende directamente del módulo de bajo nivel
class UserService {
private database: MySQLDatabase;
constructor() {
this.database = new MySQLDatabase(); // Dependencia directa
}
getUser(id: string): void {
this.database.connect();
console.log(`Obteniendo usuario con ID ${id}`);
}
}
// Uso
const userService = new UserService();
userService.getUser('123');
En este ejemplo, UserService
depende directamente de la clase concreta MySQLDatabase
. Si el algún momento decidimos cambiar a otra base de datos (por ejemplo, PostgreSQL), sería necesario modificar UserService
, lo que rompe el principio de inversión de dependencias.
Para solucionarlo podemos hacer lo siguiente:
// Abstracción (interfaz) para bases de datos
interface Database {
connect(): void;
}
// Implementación concreta para MySQL
class MySQLDatabase implements Database {
connect(): void {
console.log('Conectando a MySQL...');
}
}
// Implementación concreta para PostgreSQL
class PostgreSQLDatabase implements Database {
connect(): void {
console.log('Conectando a PostgreSQL...');
}
}
// Módulo de alto nivel que depende de la abstracción
class UserService {
private database: Database;
constructor(database: Database) {
this.database = database; // Dependencia inyectada
}
getUser(id: string): void {
this.database.connect();
console.log(`Obteniendo usuario con ID ${id}`);
}
}
// Uso
const mysqlDatabase = new MySQLDatabase();
const userServiceWithMySQL = new UserService(mysqlDatabase);
userServiceWithMySQL.getUser('123');
const postgresDatabase = new PostgreSQLDatabase();
const userServiceWithPostgres = new UserService(postgresDatabase);
userServiceWithPostgres.getUser('456');
En este caso, UserService
depende de la interfaz Database
y no de implementaciones concretas. Esto permite cambiar la base de datos sin necesidad de modificar UserService
, haciendo el código más flexible y fácil de mantener.
Espero que este post te ayude y como siempre, te recomiendo seguirme en Twitter para estar al tanto de los nuevo contenido. Ahora también puedes seguirme en Instagram donde estoy subiendo tips, tutoriales en vídeo e información sobre herramientas para developers.
Por último os dejo mi guía para aprender a trabajar con APIs donde explico todo el funcionamiento de una API, el protocolo HTTP y veremos como construir una API con arquitectura REST.
Nos leemos 👋.