Fundamentos 12 min de lectura

Principios SOLID en la Vida Real

Publicado el 24 de noviembre de 2025

Principios SOLID en la Vida Real

Los principios SOLID son uno de esos conceptos que todo desarrollador ha escuchado, pero que pocos realmente entienden en la práctica. No son reglas abstractas para debates teóricos; son principios prácticos que, cuando se aplican correctamente, pueden hacer la diferencia entre un proyecto mantenible y uno que colapsa bajo su propio peso.

He trabajado en proyectos que violaban todos los principios SOLID, y cada cambio era una pesadilla. He trabajado en proyectos que los seguían, y los cambios eran simples y seguros. La diferencia no es académica; es real y afecta tu productividad diaria.

En este artículo, explicaré cada principio SOLID con ejemplos prácticos del mundo real, mostrando qué pasa cuando los violas y cómo aplicarlos correctamente.

¿Qué es SOLID?

SOLID es un acrónimo de cinco principios de diseño de software:

  • S - Single Responsibility Principle (Principio de Responsabilidad Única)
  • O - Open/Closed Principle (Principio Abierto/Cerrado)
  • L - Liskov Substitution Principle (Principio de Sustitución de Liskov)
  • I - Interface Segregation Principle (Principio de Segregación de Interfaces)
  • D - Dependency Inversion Principle (Principio de Inversión de Dependencias)

Cada principio resuelve un problema específico que encontrarás en proyectos reales.

S: Single Responsibility Principle (SRP)

Una clase debe tener una sola razón para cambiar.

Este es probablemente el principio más importante y el más violado. Una clase con múltiples responsabilidades es difícil de entender, probar y mantener.

❌ Violación: Clase con múltiples responsabilidades

class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
    
    // Responsabilidad 1: Validación
    validate() {
        if (!this.email.includes('@')) {
            throw new Error('Invalid email');
        }
    }
    
    // Responsabilidad 2: Persistencia
    save() {
        database.save(this);
    }
    
    // Responsabilidad 3: Envío de emails
    sendWelcomeEmail() {
        emailService.send(this.email, 'Welcome!');
    }
    
    // Responsabilidad 4: Generación de reportes
    generateReport() {
        return reportService.generate(this);
    }
}

Problemas:

  • Si cambia la lógica de validación, tienes que tocar User
  • Si cambia cómo se guarda en la base de datos, tienes que tocar User
  • Si cambia el servicio de email, tienes que tocar User
  • Si cambia cómo se generan reportes, tienes que tocar User

Cada cambio afecta la misma clase, aumentando el riesgo de romper algo.

✅ Solución: Separar responsabilidades

// Responsabilidad única: Representar un usuario
class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
}

// Responsabilidad única: Validar usuarios
class UserValidator {
    validate(user) {
        if (!user.email || !user.email.includes('@')) {
            throw new ValidationError('Invalid email');
        }
        if (!user.name || user.name.length < 2) {
            throw new ValidationError('Invalid name');
        }
    }
}

// Responsabilidad única: Persistencia de usuarios
class UserRepository {
    save(user) {
        database.save(user);
    }
    
    findById(id) {
        return database.findById(id);
    }
}

// Responsabilidad única: Envío de emails
class EmailService {
    sendWelcomeEmail(user) {
        emailService.send(user.email, 'Welcome!');
    }
}

// Responsabilidad única: Generación de reportes
class ReportService {
    generateUserReport(user) {
        return reportService.generate(user);
    }
}

Ahora cada clase tiene una sola razón para cambiar. Si cambia la validación, solo tocas UserValidator. Si cambia la persistencia, solo tocas UserRepository.

Ejemplo práctico: Sistema de cursos

En un sistema de gestión de cursos, puedes separar las responsabilidades así:

// ❌ Antes: Todo en una clase
class Course {
    validate() { /* ... */ }
    save() { /* ... */ }
    sendNotification() { /* ... */ }
    calculatePrice() { /* ... */ }
    generateCertificate() { /* ... */ }
}

// ✅ Después: Responsabilidades separadas
class Course { /* Solo datos */ }
class CourseValidator { /* Validación */ }
class CourseRepository { /* Persistencia */ }
class NotificationService { /* Notificaciones */ }
class PricingService { /* Cálculo de precios */ }
class CertificateService { /* Generación de certificados */ }

Cada cambio ahora es localizado y seguro.

O: Open/Closed Principle (OCP)

Las entidades de software deben estar abiertas para extensión, pero cerradas para modificación.

En lugar de modificar código existente (que puede romper cosas), deberías poder extenderlo.

❌ Violación: Modificar código existente

class PaymentProcessor {
    processPayment(amount, method) {
        if (method === 'credit_card') {
            // Lógica para tarjeta de crédito
            return creditCardProcessor.charge(amount);
        } else if (method === 'paypal') {
            // Lógica para PayPal
            return paypalProcessor.charge(amount);
        } else if (method === 'crypto') {
            // Lógica para criptomonedas
            return cryptoProcessor.charge(amount);
        }
        // Cada nuevo método requiere modificar esta clase
    }
}

Problema: Cada vez que agregas un nuevo método de pago, tienes que modificar PaymentProcessor, lo que puede romper código existente.

✅ Solución: Extensión sin modificación

// Interfaz común
class PaymentMethod {
    process(amount) {
        throw new Error('Must implement process method');
    }
}

// Implementaciones específicas
class CreditCardPayment extends PaymentMethod {
    process(amount) {
        return creditCardProcessor.charge(amount);
    }
}

class PayPalPayment extends PaymentMethod {
    process(amount) {
        return paypalProcessor.charge(amount);
    }
}

class CryptoPayment extends PaymentMethod {
    process(amount) {
        return cryptoProcessor.charge(amount);
    }
}

// Procesador que no necesita modificación
class PaymentProcessor {
    processPayment(amount, paymentMethod) {
        return paymentMethod.process(amount);
    }
}

Ahora puedes agregar nuevos métodos de pago sin modificar PaymentProcessor:

class BankTransferPayment extends PaymentMethod {
    process(amount) {
        return bankTransferProcessor.charge(amount);
    }
}

// No necesitas modificar PaymentProcessor
const processor = new PaymentProcessor();
processor.processPayment(100, new BankTransferPayment());

Ejemplo práctico: Sistema de notificaciones

En un sistema de notificaciones, puedes usar este principio para diferentes tipos:

class NotificationService {
    send(notification) {
        return notification.send();
    }
}

class EmailNotification {
    send() { /* ... */ }
}

class SMSNotification {
    send() { /* ... */ }
}

class PushNotification {
    send() { /* ... */ }
}

// Agregar Discord notifications no requiere modificar NotificationService
class DiscordNotification {
    send() { /* ... */ }
}

L: Liskov Substitution Principle (LSP)

Los objetos de una superclase deben poder ser reemplazados por objetos de sus subclases sin romper la aplicación.

Si tienes una clase base y una clase derivada, deberías poder usar la clase derivada en cualquier lugar donde uses la clase base.

❌ Violación: Subclase que rompe el contrato

class Rectangle {
    constructor(width, height) {
        this.width = width;
        this.height = height;
    }
    
    setWidth(width) {
        this.width = width;
    }
    
    setHeight(height) {
        this.height = height;
    }
    
    getArea() {
        return this.width * this.height;
    }
}

class Square extends Rectangle {
    constructor(side) {
        super(side, side);
    }
    
    setWidth(width) {
        this.width = width;
        this.height = width; // Rompe el comportamiento esperado
    }
    
    setHeight(height) {
        this.width = height;
        this.height = height; // Rompe el comportamiento esperado
    }
}

// Código que espera un Rectangle
function testRectangle(rectangle) {
    rectangle.setWidth(5);
    rectangle.setHeight(4);
    console.log(rectangle.getArea()); // Espera 20
    
    // Con Square, obtienes 16, no 20 - comportamiento inesperado
}

Problema: Square no puede reemplazar a Rectangle sin romper el comportamiento esperado.

✅ Solución: Diseño que respeta el contrato

class Shape {
    getArea() {
        throw new Error('Must implement getArea');
    }
}

class Rectangle extends Shape {
    constructor(width, height) {
        super();
        this.width = width;
        this.height = height;
    }
    
    getArea() {
        return this.width * this.height;
    }
}

class Square extends Shape {
    constructor(side) {
        super();
        this.side = side;
    }
    
    getArea() {
        return this.side * this.side;
    }
}

// Ahora ambas pueden usarse donde se espera un Shape
function calculateTotalArea(shapes) {
    return shapes.reduce((total, shape) => total + shape.getArea(), 0);
}

const shapes = [
    new Rectangle(5, 4),
    new Square(3)
];

console.log(calculateTotalArea(shapes)); // Funciona correctamente

I: Interface Segregation Principle (ISP)

Los clientes no deben depender de interfaces que no usan.

En lugar de una interfaz grande, usa interfaces específicas y pequeñas.

❌ Violación: Interfaz grande

class Worker {
    work() { /* ... */ }
    eat() { /* ... */ }
    sleep() { /* ... */ }
}

class Human extends Worker {
    work() { /* ... */ }
    eat() { /* ... */ }
    sleep() { /* ... */ }
}

class Robot extends Worker {
    work() { /* ... */ }
    eat() { 
        throw new Error('Robots don\'t eat');
    }
    sleep() { 
        throw new Error('Robots don\'t sleep');
    }
}

Problema: Robot está forzado a implementar métodos que no necesita.

✅ Solución: Interfaces segregadas

class Workable {
    work() {
        throw new Error('Must implement work');
    }
}

class Eatable {
    eat() {
        throw new Error('Must implement eat');
    }
}

class Sleepable {
    sleep() {
        throw new Error('Must implement sleep');
    }
}

class Human extends Workable, Eatable, Sleepable {
    work() { /* ... */ }
    eat() { /* ... */ }
    sleep() { /* ... */ }
}

class Robot extends Workable {
    work() { /* ... */ }
    // No necesita implementar eat() o sleep()
}

Ejemplo práctico: Sistema de transacciones

En un sistema de transacciones, puedes separar las interfaces para diferentes tipos:

// ❌ Antes: Interfaz grande
class Transaction {
    process() { /* ... */ }
    validate() { /* ... */ }
    sendEmail() { /* ... */ }
    generateReceipt() { /* ... */ }
    refund() { /* ... */ }
}

// ✅ Después: Interfaces segregadas
class Processable {
    process() { /* ... */ }
}

class Validatable {
    validate() { /* ... */ }
}

class Refundable {
    refund() { /* ... */ }
}

// Una transacción simple solo implementa lo que necesita
class SimpleTransaction extends Processable, Validatable {
    process() { /* ... */ }
    validate() { /* ... */ }
}

// Una transacción completa implementa todo
class FullTransaction extends Processable, Validatable, Refundable {
    process() { /* ... */ }
    validate() { /* ... */ }
    refund() { /* ... */ }
}

D: Dependency Inversion Principle (DIP)

Depende de abstracciones, no de concreciones.

Los módulos de alto nivel no deben depender de módulos de bajo nivel. Ambos deben depender de abstracciones.

❌ Violación: Dependencia directa de concreciones

class UserService {
    constructor() {
        // Dependencia directa de MySQLDatabase
        this.database = new MySQLDatabase();
        this.emailService = new SendGridEmailService();
    }
    
    createUser(userData) {
        this.database.save(userData);
        this.emailService.send(userData.email, 'Welcome!');
    }
}

Problema: UserService está acoplado a implementaciones específicas. Si quieres cambiar de MySQL a PostgreSQL, o de SendGrid a otro servicio de email, tienes que modificar UserService.

✅ Solución: Depender de abstracciones

// Abstracciones (interfaces)
class UserRepository {
    save(user) {
        throw new Error('Must implement save');
    }
}

class EmailService {
    send(to, subject, body) {
        throw new Error('Must implement send');
    }
}

// Implementaciones concretas
class MySQLUserRepository extends UserRepository {
    save(user) {
        // Implementación específica de MySQL
    }
}

class SendGridEmailService extends EmailService {
    send(to, subject, body) {
        // Implementación específica de SendGrid
    }
}

// Servicio de alto nivel depende de abstracciones
class UserService {
    constructor(userRepository, emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }
    
    createUser(userData) {
        this.userRepository.save(userData);
        this.emailService.send(userData.email, 'Welcome!', '...');
    }
}

// Inyección de dependencias
const userService = new UserService(
    new MySQLUserRepository(),
    new SendGridEmailService()
);

Ahora puedes cambiar las implementaciones sin modificar UserService:

// Cambiar a PostgreSQL sin modificar UserService
class PostgreSQLUserRepository extends UserRepository {
    save(user) {
        // Implementación de PostgreSQL
    }
}

// Cambiar a otro servicio de email sin modificar UserService
class MailgunEmailService extends EmailService {
    send(to, subject, body) {
        // Implementación de Mailgun
    }
}

const userService = new UserService(
    new PostgreSQLUserRepository(),
    new MailgunEmailService()
);

Ejemplo real: Todos mis proyectos

En proyectos modernos, la inyección de dependencias es fundamental:

class CourseService {
    constructor(
        courseRepository,
        notificationService,
        pricingService
    ) {
        this.courseRepository = courseRepository;
        this.notificationService = notificationService;
        this.pricingService = pricingService;
    }
    
    // Lógica de negocio que no depende de implementaciones específicas
}

Esto permite:

  • Testing fácil: Puedes inyectar mocks
  • Flexibilidad: Cambias implementaciones sin modificar lógica de negocio
  • Desacoplamiento: Los servicios no conocen detalles de implementación

Aplicando SOLID en la práctica

Los principios SOLID no son reglas rígidas. Son guías que te ayudan a escribir código más mantenible. No necesitas aplicarlos perfectamente desde el inicio, pero entenderlos te ayuda a tomar mejores decisiones.

Cuándo aplicar SOLID

  • Proyectos que crecerán: Si sabes que el código cambiará, SOLID te ayuda a prepararlo
  • Equipos grandes: SOLID facilita que múltiples desarrolladores trabajen sin conflictos
  • Código crítico: Si el código es crítico para el negocio, SOLID reduce riesgos

Cuándo no obsesionarse

  • Prototipos: Para código que se descartará, no necesitas SOLID perfecto
  • Scripts simples: Un script de una sola vez no necesita arquitectura compleja
  • Código que no cambiará: Si el código nunca cambiará, la sobre-ingeniería es innecesaria

Mi experiencia práctica

He visto proyectos que violaban todos los principios SOLID. Cada cambio requería modificar múltiples clases, cada bug era difícil de encontrar, y cada nuevo desarrollador tardaba semanas en ser productivo.

He visto proyectos que seguían SOLID. Los cambios eran localizados, los bugs eran fáciles de encontrar, y los nuevos desarrolladores eran productivos en días.

La diferencia no es teórica; es práctica y afecta tu trabajo diario.

Aplicar SOLID constantemente no es sobre perfección, es sobre hacer el código un poco mejor cada vez. Cada vez que ves una clase con múltiples responsabilidades, sepárala. Cada vez que ves dependencias directas, inviértelas.

Mi perspectiva personal

Los principios SOLID no son conceptos abstractos para debates teóricos. Son principios prácticos que, cuando se aplican correctamente, hacen tu código más mantenible, testeable y extensible.

He visto proyectos que violaban todos los principios SOLID. Cada cambio requería modificar múltiples clases, cada bug era difícil de encontrar, y cada nuevo desarrollador tardaba semanas en ser productivo.

He visto proyectos que seguían SOLID. Los cambios eran localizados, los bugs eran fáciles de encontrar, y los nuevos desarrolladores eran productivos en días.

La diferencia no es teórica; es práctica y afecta tu trabajo diario.

Aplicar SOLID constantemente no es sobre perfección, es sobre hacer el código un poco mejor cada vez. Cada vez que ves una clase con múltiples responsabilidades, sepárala. Cada vez que ves dependencias directas, inviértelas.

No necesitas aplicarlos perfectamente desde el inicio. Pero entenderlos te ayuda a reconocer problemas antes de que se conviertan en pesadillas, y a tomar mejores decisiones arquitectónicas.

Porque al final del día, el código que escribes hoy será mantenido por alguien (posiblemente tú mismo) mañana. Y los principios SOLID son una de las mejores formas de asegurar que ese mantenimiento sea posible.