Principios SOLID en JavaScript: teoría y práctica de la A a la Z
SOLID es el acrónimo de cinco principios fundamentales de la programación orientada a objetos, formulados por Robert Martin (Tío Bob). Aunque JavaScript no es un lenguaje POO clásico (utiliza herencia prototípica), estos principios también son aplicables a él, especialmente en el contexto de Node.js y los frameworks modernos. Cumplir con SOLID hace que el código sea más flexible, testeable y mantenible. En este artículo, analizaremos cada principio con ejemplos prácticos en JavaScript.
1. S — Single Responsibility Principle (Principio de Responsabilidad Única)
Una clase o módulo debe tener una sola razón para cambiar. En otras palabras, cada entidad debe ser responsable de una tarea específica.
Ejemplo incorrecto: La clase User gestiona simultáneamente los datos del usuario y envía notificaciones por correo electrónico.
class User { constructor(name, email) { this.name = name; this.email = email; }
saveToDatabase() { // Lógica de guardado en BD console.log(`Guardando usuario ${this.name} en la BD`); }
sendWelcomeEmail() { // Lógica de envío de correo console.log(`Enviando saludo a ${this.email}`); }}Ejemplo correcto: Separamos la responsabilidad en dos clases.
class User { constructor(name, email) { this.name = name; this.email = email; }}
class UserRepository { save(user) { console.log(`Guardando usuario ${user.name} en la BD`); }}
class EmailService { sendWelcome(user) { console.log(`Enviando saludo a ${user.email}`); }}
// Usoconst user = new User('Iván', 'ivan@example.com');const repo = new UserRepository();const emailService = new EmailService();
repo.save(user);emailService.sendWelcome(user);2. O — Open-Closed Principle (Principio de Abierto/Cerrado)
Las entidades de software deben estar abiertas para la extensión, pero cerradas para la modificación. Esto significa que la nueva funcionalidad debe agregarse mediante código nuevo, no modificando el existente.
Ejemplo incorrecto: La clase AreaCalculator requiere modificación al agregar una nueva figura.
class AreaCalculator { calculate(shape) { if (shape.type === 'circle') { return Math.PI * shape.radius ** 2; } else if (shape.type === 'rectangle') { return shape.width * shape.height; } // Al agregar una nueva figura, hay que modificar este método }}Ejemplo correcto: Usamos polimorfismo y herencia.
class Shape { area() { throw new Error('El método area() debe ser sobrescrito'); }}
class Circle extends Shape { constructor(radius) { super(); this.radius = radius; }
area() { return Math.PI * this.radius ** 2; }}
class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; }
area() { return this.width * this.height; }}
class AreaCalculator { calculate(shape) { return shape.area(); }}
// Usoconst calculator = new AreaCalculator();const circle = new Circle(5);const rectangle = new Rectangle(4, 6);
console.log(calculator.calculate(circle)); // 78.54console.log(calculator.calculate(rectangle)); // 243. L — Liskov Substitution Principle (Principio de Sustitución de Liskov)
Los objetos de un programa deben ser reemplazables por instancias de sus subtipos sin alterar la corrección del programa. En otras palabras, las subclases deben poder sustituir a sus clases base.
Ejemplo incorrecto: Una subclase Square que hereda de Rectangle pero viola el comportamiento esperado.
class Rectangle { constructor(width, height) { this.width = width; this.height = height; }
setWidth(width) { this.width = width; }
setHeight(height) { this.height = height; }
area() { return this.width * this.height; }}
class Square extends Rectangle { setWidth(width) { this.width = width; this.height = width; // ¡Violación! Cambia la altura también }
setHeight(height) { this.height = height; this.width = height; // ¡Violación! Cambia el ancho también }}
// Uso problemáticofunction increaseRectangleWidth(rectangle) { rectangle.setWidth(rectangle.width + 1);}
const square = new Square(5, 5);increaseRectangleWidth(square);// Se esperaría que el área aumente en height, pero cambia tanto width como heightconsole.log(square.area()); // Resultado inesperadoEjemplo correcto: Diseñamos una abstracción adecuada sin herencia forzada.
class Shape { area() { throw new Error('Método area() debe ser implementado'); }}
class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; }
area() { return this.width * this.height; }}
class Square extends Shape { constructor(side) { super(); this.side = side; }
area() { return this.side ** 2; }}
// Uso correctoconst shapes = [ new Rectangle(4, 5), new Square(4)];
shapes.forEach(shape => console.log(shape.area())); // 20, 164. I — Interface Segregation Principle (Principio de Segregación de Interfaces)
Ningún cliente debe verse obligado a depender de interfaces que no utiliza. Es mejor tener interfaces específicas y pequeñas que una interfaz grande y general.
Ejemplo incorrecto: Una interfaz grande que obliga a implementar métodos innecesarios.
class Worker { work() { throw new Error('Método work() debe ser implementado'); }
eat() { throw new Error('Método eat() debe ser implementado'); }
sleep() { throw new Error('Método sleep() debe ser implementado'); }}
class Human extends Worker { work() { console.log('Trabajando'); } eat() { console.log('Comiendo'); } sleep() { console.log('Durmiendo'); }}
class Robot extends Worker { work() { console.log('Trabajando'); } eat() { throw new Error('Los robots no comen'); } // Método innecesario sleep() { throw new Error('Los robots no duermen'); } // Método innecesario}Ejemplo correcto: Interfaces segregadas.
class Workable { work() { throw new Error('Método work() debe ser implementado'); }}
class Eatable { eat() { throw new Error('Método eat() debe ser implementado'); }}
class Sleepable { sleep() { throw new Error('Método sleep() debe ser implementado'); }}
class Human extends Workable { work() { console.log('Trabajando'); }}
// Human también implementa Eatable y SleepableObject.assign(Human.prototype, { eat() { console.log('Comiendo'); }, sleep() { console.log('Durmiendo'); }});
class Robot extends Workable { work() { console.log('Trabajando'); }}
// Robot solo implementa Workable, sin métodos innecesarios5. D — Dependency Inversion Principle (Principio de Inversión de Dependencias)
Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones. Las abstracciones no deben depender de detalles; los detalles deben depender de abstracciones.
Ejemplo incorrecto: Dependencia directa de una implementación concreta.
class MySQLDatabase { save(data) { console.log(`Guardando datos en MySQL: ${data}`); }}
class UserService { constructor() { this.database = new MySQLDatabase(); // Dependencia directa }
saveUser(user) { this.database.save(user); }}Ejemplo correcto: Dependencia de una abstracción (interfaz).
class Database { save(data) { throw new Error('Método save() debe ser implementado'); }}
class MySQLDatabase extends Database { save(data) { console.log(`Guardando datos en MySQL: ${data}`); }}
class MongoDBDatabase extends Database { save(data) { console.log(`Guardando datos en MongoDB: ${data}`); }}
class UserService { constructor(database) { this.database = database; // Inyección de dependencia }
saveUser(user) { this.database.save(user); }}
// Usoconst mysqlDb = new MySQLDatabase();const mongoDb = new MongoDBDatabase();
const userService1 = new UserService(mysqlDb);userService1.saveUser('Usuario 1');
const userService2 = new UserService(mongoDb);userService2.saveUser('Usuario 2');Conclusión
Los principios SOLID no son reglas estrictas, sino guías que ayudan a crear código más mantenible y escalable. En JavaScript, especialmente con Node.js y frameworks modernos como React o Angular, aplicar estos principios mejora significativamente la calidad del código. Recuerde: el objetivo no es aplicarlos ciegamente, sino entender cuándo y cómo usarlos para resolver problemas reales de diseño.
¿Tiene preguntas o ejemplos propios? ¡Compártalos en los comentarios!