Saltar al contenido
Explorar conocimiento

práctica · intermedio · 20 min de lectura

Principios SOLID

SOLID reúne cinco principios de diseño que guían la creación de clases y módulos con responsabilidades claras, acoplamiento bajo y capacidad de evolucionar sin reescribir lo que ya funciona.

No requiere conocimientos previos.

Alcance en breve

Cubre

  • the five SOLID principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion
  • each principle explained with a before-and-after code example in TypeScript
  • the relationship between SOLID and object-oriented design
  • practical signs that a codebase would benefit from applying SOLID

No cubre

  • design patterns beyond their connection to SOLID principles
  • formal proofs of the Liskov Substitution Principle
  • architectural styles (hexagonal, clean architecture) beyond how they depend on SOLID
  • functional programming equivalents of SOLID

Supone

  • The reader understands object-oriented programming fundamentals: classes, interfaces, inheritance, and polymorphism.

Resumen

SOLID es un acrónimo de cinco principios de diseño de software formulados por Robert C. Martin (Uncle Bob) a principios de los 2000. Cada letra aborda un problema distinto del diseño orientado a objetos, pero juntos forman un sistema coherente: cuando los seguís, las clases y los módulos se vuelven más fáciles de entender, testear, modificar y extender.

Los cinco principios son:

  • Single Responsibility Principle (SRP)
  • Open/Closed Principle (OCP)
  • Liskov Substitution Principle (LSP)
  • Interface Segregation Principle (ISP)
  • Dependency Inversion Principle (DIP)

Importan porque atacan la raíz del software que se vuelve imposible de mantener: clases que hacen demasiado, módulos donde cada cambio rompe tres cosas, jerarquías de herencia donde una subclase no puede reemplazar a su padre, interfaces obesas que fuerzan a implementar métodos vacíos, y dependencias directas que impiden testear o reemplazar componentes.

SOLID no es un checklist para aplicar a ciegas. Es un termómetro: cuando el código duele al modificarlo, SOLID te ayuda a diagnosticar por qué y te da un vocabulario para explicarlo al resto del equipo. Un PaymentService que también envía emails y escribe logs probablemente viola SRP. Un switch que crece cada vez que agregás un medio de pago probablemente viola OCP. Saber nombrar el problema es el primer paso para arreglarlo.

Alcance y supuestos

Este paquete cubre los cinco principios SOLID uno por uno, con definiciones, ejemplos de violación en TypeScript y la corrección correspondiente. También explica cómo se relacionan entre sí y cuándo tiene sentido aplicarlos versus cuándo agregarían complejidad innecesaria.

No cubre patrones de diseño específicos más allá de mencionarlos como implementaciones naturales de SOLID. Tampoco cubre arquitecturas como Clean Architecture o Hexagonal, aunque sí señala que dependen de SOLID como base.

Asume que entendés los fundamentos de POO: clases, interfaces, herencia, polimorfismo y composición. Si necesitás un repaso de esos conceptos, el nodo de POO cubre los cuatro pilares.

Modelo mental

Imaginá una cocina profesional. Cada estación tiene una responsabilidad clara: el parrillero no pela papas, el pastelero no corta carne, el encargado de salsas no hornea pan. Si alguien falta, otra persona puede cubrir su estación sin necesidad de saber hacer todo lo demás. Los insumos llegan en containers estándar que cualquier estación puede recibir, sin saber qué proveedor los trajo. Cuando el menú cambia, agregás una estación o modificás una receta sin rediseñar la cocina entera.

Esa cocina aplica SOLID instintivamente. SRP: cada estación hace una sola cosa. OCP: podés agregar una estación de sushi sin tocar la parrilla. LSP: cualquier cocinero que separe la estación de postres puede producir el mismo tiramisú. ISP: el pastelero no recibe un manual que incluye instrucciones de parrilla. DIP: las estaciones dependen de insumos estándar (huevos, harina), no de proveedores específicos (Granja Don José).

Uso práctico

Aplicá SOLID cuando el código muestra estos síntomas:

  • ✅ Una clase tiene más de un motivo para cambiar (SRP).
  • ✅ Cada nueva feature te obliga a modificar clases existentes en vez de agregar nuevas (OCP).
  • ✅ Una subclase rompe el comportamiento esperado por quien usa la clase base (LSP).
  • ✅ Una interfaz obliga a implementar métodos que no necesitás (ISP).
  • ✅ Cambiar una dependencia concreta (base de datos, API, servicio de email) requiere tocar todo el sistema (DIP).
  • ❌ Un script de 50 líneas que procesa un CSV no necesita SOLID. Los principios existen para sistemas que crecen y cambian; aplicarlos a código trivial agrega complejidad sin beneficio.

S — Single Responsibility Principle (SRP)

Una clase debe tener un solo motivo para cambiar.

Si una clase hace dos cosas, un cambio en una puede romper la otra. "Motivo para cambiar" no significa "método": significa "fuente de requisitos". Si el equipo de negocio pide cambiar el cálculo de impuestos, y el equipo de operaciones pide cambiar el formato del log, y las dos cosas viven en la misma clase, esa clase tiene dos responsabilidades.

Violación:

class ReportService {
  generateReport(data: Order[]) { /* lógica del reporte */ }
  sendEmail(recipient: string, body: string) { /* lógica de envío SMTP */ }
  saveToDatabase(data: Order[]) { /* lógica de persistencia */ }
}

Esta clase cambiaría si cambia el formato del reporte, si cambia el proveedor de email, o si cambia la base de datos. Tres motivos.

Corrección:

class ReportGenerator {
  generate(data: Order[]) { /* solo lógica del reporte */ }
}

class EmailSender {
  send(recipient: string, body: string) { /* solo lógica de envío */ }
}

class OrderRepository {
  save(data: Order[]) { /* solo lógica de persistencia */ }
}

class ReportOrchestrator {
  constructor(
    private generator: ReportGenerator,
    private email: EmailSender,
    private repo: OrderRepository
  ) {}

  process(orders: Order[], recipient: string) {
    const report = this.generator.generate(orders);
    this.repo.save(orders);
    this.email.send(recipient, report);
  }
}

Cada clase tiene un solo motivo para cambiar. ReportOrchestrator orquesta el flujo, pero no conoce los detalles de generación, envío ni persistencia.

O — Open/Closed Principle (OCP)

Abierto a extensión, cerrado a modificación.

Debés poder agregar comportamiento nuevo sin modificar el código existente. La estrategia más común es usar polimorfismo: definís una abstracción y dejás que nuevas implementaciones extiendan el sistema.

Violación:

function calculateDiscount(order: Order): number {
  if (order.type === "regular") return order.total * 0.05;
  if (order.type === "premium") return order.total * 0.10;
  if (order.type === "vip") return order.total * 0.15;
  throw new Error(`Unknown order type: ${order.type}`);
}

Cada nuevo tipo de cliente te obliga a modificar esta función y arriesgarte a romper los descuentos existentes.

Corrección:

interface DiscountStrategy {
  calculate(total: number): number;
}

class RegularDiscount implements DiscountStrategy {
  calculate(total: number) { return total * 0.05; }
}

class PremiumDiscount implements DiscountStrategy {
  calculate(total: number) { return total * 0.10; }
}

class VIPDiscount implements DiscountStrategy {
  calculate(total: number) { return total * 0.15; }
}

function calculateDiscount(order: Order, strategy: DiscountStrategy): number {
  return strategy.calculate(order.total);
}

Agregar un descuento nuevo ahora es crear una clase nueva sin tocar las existentes. El switch o if/else if desapareció. Eso es OCP.

L — Liskov Substitution Principle (LSP)

Una subclase debe poder reemplazar a su clase base sin alterar el funcionamiento correcto del programa.

Si B hereda de A, cualquier código que use A debería poder usar B sin saberlo y sin que el programa falle o produzca resultados incorrectos.

Violación:

class Rectangle {
  constructor(protected width: number, protected height: number) {}
  setWidth(w: number) { this.width = w; }
  setHeight(h: number) { this.height = h; }
  area() { return this.width * this.height; }
}

class Square extends Rectangle {
  constructor(size: number) { super(size, size); }
  setWidth(w: number) { this.width = w; this.height = w; }
  setHeight(h: number) { this.height = h; this.width = h; }
}

Un Square no es realmente un Rectangle. Si alguien escribe rect.setWidth(5); rect.setHeight(10) y espera area() === 50, un Square devolvería 100. La subclase rompió el contrato de la clase base.

Corrección: no heredar. Usar una abstracción compartida.

interface Shape {
  area(): number;
}

class Rectangle implements Shape {
  constructor(private width: number, private height: number) {}
  area() { return this.width * this.height; }
}

class Square implements Shape {
  constructor(private side: number) {}
  area() { return this.side ** 2; }
}

I — Interface Segregation Principle (ISP)

Ningún cliente debería depender de métodos que no usa.

Las interfaces grandes —"gordas"— obligan a las clases que las implementan a definir métodos vacíos o a lanzar excepciones. Partí las interfaces en unidades más chicas y específicas.

Violación:

interface Worker {
  work(): void;
  eat(): void;
  sleep(): void;
}

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

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

Corrección:

interface Workable {
  work(): void;
}

interface Eatable {
  eat(): void;
}

interface Sleepable {
  sleep(): void;
}

class Human implements Workable, Eatable, Sleepable { /* ... */ }
class Robot implements Workable { /* solo work */ }

Cada clase implementa solo lo que necesita. Quien consume Workable no sabe ni le importa si el objeto también come o duerme.

D — Dependency Inversion Principle (DIP)

Dependé de abstracciones, no de implementaciones concretas.

Los módulos de alto nivel (lógica de negocio) no deberían depender de módulos de bajo nivel (base de datos, API, sistema de archivos). Ambos deberían depender de abstracciones.

Violación:

class OrderService {
  private db = new MySQLDatabase(); // dependencia directa y concreta
  save(order: Order) { this.db.insert(order); }
}

OrderService no se puede testear sin una base de datos MySQL real. Si el equipo decide migrar a PostgreSQL, hay que tocar OrderService.

Corrección:

interface Database {
  insert(data: unknown): void;
}

class MySQLDatabase implements Database {
  insert(data: unknown) { /* MySQL específico */ }
}

class OrderService {
  constructor(private db: Database) {}
  save(order: Order) { this.db.insert(order); }
}

// En producción
const service = new OrderService(new MySQLDatabase());

// En tests
const service = new OrderService(new InMemoryDatabase());

OrderService recibe su dependencia desde fuera —inyección de dependencias— y solo conoce la interfaz Database. Cambiar de MySQL a PostgreSQL, o usar una base en memoria para tests, es cuestión de pasar una instancia distinta al constructor.

Cómo se relacionan los cinco principios

No son independientes. SRP y OCP se refuerzan: una clase con una sola responsabilidad es más fácil de extender sin modificar. LSP y DIP trabajan juntos: si las subclases respetan el contrato de la abstracción, los módulos de alto nivel pueden depender de esa abstracción sin miedo. ISP hace que DIP sea práctico: dependés de interfaces chicas y específicas, no de monstruos que te atan a métodos que no usás.

El orden de las letras no es casual. SRP es la base: si las clases tienen responsabilidades claras, todo lo demás es más fácil. DIP es la culminación: cuando llegás a invertir dependencias, el sistema está desacoplado de verdad.

Ejemplo trabajado: refactorizar un NotificationService

Un sistema tiene un NotificationService que envía notificaciones por email, SMS y push. Con el tiempo, la clase creció con cada nuevo canal:

class NotificationService {
  sendEmail(user: User, message: string) {
    // conecta a SMTP, arma headers, envía
  }

  sendSMS(user: User, message: string) {
    // conecta a Twilio, verifica crédito, envía
  }

  sendPush(user: User, message: string) {
    // conecta a Firebase, arma payload, envía
  }

  notify(user: User, message: string) {
    if (user.prefersEmail) this.sendEmail(user, message);
    else if (user.prefersSMS) this.sendSMS(user, message);
    else if (user.prefersPush) this.sendPush(user, message);
  }
}

Problemas visibles con SOLID:

  • SRP: la clase sabe de SMTP, Twilio y Firebase. Cambiar cualquiera de esos proveedores la modifica.
  • OCP: agregar un canal nuevo (WhatsApp, Telegram) obliga a tocar notify.
  • DIP: la clase depende directamente de implementaciones concretas. Testearla requiere servidores reales o mocks complejos.

Refactorizando hacia SOLID:

// 1. ISP + DIP: abstracción por canal
interface NotificationChannel {
  send(user: User, message: string): void;
}

// 2. SRP: cada canal en su propia clase
class EmailChannel implements NotificationChannel {
  send(user: User, message: string) { /* SMTP */ }
}

class SMSChannel implements NotificationChannel {
  send(user: User, message: string) { /* Twilio */ }
}

class PushChannel implements NotificationChannel {
  send(user: User, message: string) { /* Firebase */ }
}

// 3. SRP: el resolver de canales es una responsabilidad separada
interface ChannelResolver {
  resolve(user: User): NotificationChannel;
}

class PreferenceBasedResolver implements ChannelResolver {
  constructor(private channels: Map<string, NotificationChannel>) {}
  resolve(user: User) { return this.channels.get(user.preference)!; }
}

// 4. DIP: NotificationService depende de abstracciones
class NotificationService {
  constructor(private resolver: ChannelResolver) {}
  notify(user: User, message: string) {
    this.resolver.resolve(user).send(user, message);
  }
}

Agregar WhatsApp ahora es:

class WhatsAppChannel implements NotificationChannel {
  send(user: User, message: string) { /* WhatsApp API */ }
}

Ni NotificationService ni PreferenceBasedResolver se modificaron. Cada clase tiene una responsabilidad. Cada canal es intercambiable. El sistema está abierto a extensión y cerrado a modificación. Eso es SOLID aplicado.

Cuándo NO aplicar SOLID

SOLID no es gratis. Cada abstracción, interfaz y clase extra tiene un costo en complejidad. Preguntate antes de aplicar:

  1. ¿Esta clase va a cambiar por motivos distintos en el futuro cercano? Si no, SRP puede esperar.
  2. ¿Realmente voy a necesitar múltiples implementaciones de esta abstracción? Si la respuesta es «no» y solo hay una, DIP puede ser premature abstraction.
  3. ¿El equipo entiende y valora estos principios, o las abstracciones extra van a confundir a quien herede el código?

SOLID es un medio, no un fin. El objetivo no es cumplir los cinco principios; es producir código que se pueda modificar sin miedo. Si tu código ya tiene esa propiedad sin seguir SOLID al pie de la letra, no lo toques.

Evidencia

Fuentes citadas