SOLID en conjunto: refactor de un caso real

Por: Artiko
solidrefactoringclean-codediseñotypescript

SOLID en conjunto: refactor de un caso real

Hasta acá recorriste los cinco principios por separado, cada uno con su ejemplo aislado. Pero en el código real no vas a encontrar una clase que viole solo OCP y respete todo lo demás. El diseño rígido viene en paquete: la misma clase monolítica suele violar los cinco principios a la vez, y no por casualidad. Las violaciones se alimentan entre sí.

Este es el capítulo integrador. La tesis es simple y poderosa: los principios SOLID no son islas, son un sistema que se refuerza mutuamente. Aplicar uno abre la puerta al siguiente. Vamos a tomar un módulo realista, feo y creíble —un procesador de pedidos que hace de todo— y lo vamos a transformar aplicando los principios uno tras otro, observando en cada paso cómo el diseño respira mejor.

Los principios se refuerzan entre sí

Antes de tocar código, fijemos el mapa mental. Estos son los vínculos que hacen de SOLID un sistema y no una lista:

flowchart TD
    SRP["SRP<br/>Responsabilidad Única<br/><i>la base</i>"]
    ISP["ISP<br/>Segregación de Interfaces"]
    LSP["LSP<br/>Sustitución de Liskov"]
    OCP["OCP<br/>Abierto/Cerrado"]
    DIP["DIP<br/>Inversión de Dependencias"]

    SRP -->|"clases pequeñas<br/>necesitan interfaces propias"| ISP
    ISP -->|"contratos finos<br/>son fáciles de honrar"| LSP
    LSP -->|"subtipos sustituibles<br/>hacen segura la extensión"| OCP
    DIP -->|"depender de abstracciones<br/>cierra el núcleo al cambio"| OCP
    SRP -->|"separar colaboradores<br/>invita a invertir la dependencia"| DIP

    style SRP fill:#1e40af,color:#fff
    style DIP fill:#166534,color:#fff
    style OCP fill:#9a3412,color:#fff

La flecha clave es la que sale de SRP: todo empieza por separar responsabilidades. Por eso el refactor que sigue arranca por ahí.

El caso real: un ProcesadorPedidos que lo hace todo

Imaginá un e-commerce. Alguien —vos, hace seis meses, con la deadline encima— escribió una clase que “procesa un pedido”. Con el tiempo fue acumulando responsabilidades hasta convertirse en esto:

// ❌ EL CÓDIGO MALO: una clase que viola los cinco principios a la vez

interface LineaPedido {
  sku: string;
  cantidad: number;
  precioUnitario: number;
}

interface Pedido {
  id: string;
  clienteEmail: string;
  lineas: LineaPedido[];
  tipoDescuento: "ninguno" | "porcentaje" | "cliente-vip" | "cupon-fijo";
  valorDescuento: number; // % o monto según el tipo
}

class ProcesadorPedidos {
  procesar(pedido: Pedido): void {
    // 1) VALIDACIÓN — mezclada con todo lo demás
    if (!pedido.clienteEmail.includes("@")) {
      throw new Error("Email inválido");
    }
    if (pedido.lineas.length === 0) {
      throw new Error("El pedido no tiene líneas");
    }
    for (const linea of pedido.lineas) {
      if (linea.cantidad <= 0) throw new Error("Cantidad inválida");
      if (linea.precioUnitario < 0) throw new Error("Precio inválido");
    }

    // 2) CÁLCULO DE TOTAL — un switch que crece con cada tipo de descuento
    let subtotal = 0;
    for (const linea of pedido.lineas) {
      subtotal += linea.cantidad * linea.precioUnitario;
    }

    let total = subtotal;
    switch (pedido.tipoDescuento) {
      case "porcentaje":
        total = subtotal * (1 - pedido.valorDescuento / 100);
        break;
      case "cliente-vip":
        total = subtotal * 0.85; // 15% fijo para VIP
        break;
      case "cupon-fijo":
        total = Math.max(0, subtotal - pedido.valorDescuento);
        break;
      case "ninguno":
        total = subtotal;
        break;
    }

    // 3) PERSISTENCIA — la clase instancia su propia conexión concreta
    const conexion = new MySQLConnection("localhost", "root", "1234");
    conexion.ejecutar(
      `INSERT INTO pedidos (id, email, total) VALUES ('${pedido.id}', '${pedido.clienteEmail}', ${total})`,
    );

    // 4) NOTIFICACIÓN — instancia un cliente SMTP concreto
    const smtp = new SmtpClient("smtp.miempresa.com", 587);
    smtp.enviar(
      pedido.clienteEmail,
      "Tu pedido fue procesado",
      `Total a pagar: $${total.toFixed(2)}`,
    );

    // 5) REPORTE — genera el PDF acá mismo
    const pdf = new GeneradorPDF();
    pdf.crear(`factura-${pedido.id}.pdf`, {
      pedido,
      total,
    });

    console.log(`Pedido ${pedido.id} procesado. Total: $${total.toFixed(2)}`);
  }
}

Es código que funciona. Ese es el problema: funciona lo suficiente para llegar a producción y quedarse ahí pudriéndose. Marquemos qué principio viola cada parte:

Parte del códigoPrincipio violadoPor qué
Toda la clase junta (validar + calcular + persistir + notificar + reportar)SRPTiene cinco razones para cambiar: un nuevo formato de email, una nueva regla de descuento, migrar de MySQL, cambiar de proveedor de correo, o rediseñar la factura. Cinco actores distintos tocando la misma clase.
El switch (pedido.tipoDescuento)OCPCada tipo de descuento nuevo obliga a modificar este método. La clase no está cerrada al cambio: está abierta de par en par.
new MySQLConnection(...) y new SmtpClient(...) dentro del métodoDIPEl módulo de alto nivel (la lógica de negocio del pedido) depende de detalles concretos de infraestructura. No hay abstracción en el medio.
Imposible testear el cálculo sin abrir MySQL y mandar un mail realDIP (síntoma)Como consecuencia de instanciar dependencias adentro, no podés sustituirlas por dobles de prueba.
Concatenación directa en el SQL ('${pedido.id}')Seguridad (SQL injection)Un efecto secundario típico del código sin capas: la validación y la persistencia viven tan cerca que nadie parametriza la query.

Todavía no aparecieron ISP ni LSP porque no hay ninguna abstracción. Van a surgir naturalmente en cuanto separemos las piezas. Empecemos.

Paso 1 — SRP: una responsabilidad por clase

El primer corte es identificar las razones para cambiar y darle a cada una su propia clase. Encontramos cinco: validar, calcular, persistir, notificar, reportar.

// ✅ PASO 1 — cada responsabilidad en su propia clase

class ValidadorPedido {
  validar(pedido: Pedido): void {
    if (!pedido.clienteEmail.includes("@")) {
      throw new Error("Email inválido");
    }
    if (pedido.lineas.length === 0) {
      throw new Error("El pedido no tiene líneas");
    }
    for (const linea of pedido.lineas) {
      if (linea.cantidad <= 0) throw new Error("Cantidad inválida");
      if (linea.precioUnitario < 0) throw new Error("Precio inválido");
    }
  }
}

class CalculadoraTotal {
  calcular(pedido: Pedido): number {
    const subtotal = pedido.lineas.reduce(
      (acc, l) => acc + l.cantidad * l.precioUnitario,
      0,
    );
    // el switch sigue acá... por ahora. Lo atacamos en el paso 2.
    switch (pedido.tipoDescuento) {
      case "porcentaje":
        return subtotal * (1 - pedido.valorDescuento / 100);
      case "cliente-vip":
        return subtotal * 0.85;
      case "cupon-fijo":
        return Math.max(0, subtotal - pedido.valorDescuento);
      default:
        return subtotal;
    }
  }
}

class RepositorioPedidosMySQL {
  private conexion = new MySQLConnection("localhost", "root", "1234");
  guardar(pedido: Pedido, total: number): void {
    this.conexion.ejecutar(
      "INSERT INTO pedidos (id, email, total) VALUES (?, ?, ?)",
      [pedido.id, pedido.clienteEmail, total],
    );
  }
}

class NotificadorEmail {
  private smtp = new SmtpClient("smtp.miempresa.com", 587);
  notificar(pedido: Pedido, total: number): void {
    this.smtp.enviar(
      pedido.clienteEmail,
      "Tu pedido fue procesado",
      `Total a pagar: $${total.toFixed(2)}`,
    );
  }
}

class GeneradorFacturaPDF {
  generar(pedido: Pedido, total: number): void {
    new GeneradorPDF().crear(`factura-${pedido.id}.pdf`, { pedido, total });
  }
}

// El procesador ahora ORQUESTA, no hace el trabajo él mismo
class ProcesadorPedidos {
  private validador = new ValidadorPedido();
  private calculadora = new CalculadoraTotal();
  private repositorio = new RepositorioPedidosMySQL();
  private notificador = new NotificadorEmail();
  private factura = new GeneradorFacturaPDF();

  procesar(pedido: Pedido): void {
    this.validador.validar(pedido);
    const total = this.calculadora.calcular(pedido);
    this.repositorio.guardar(pedido, total);
    this.notificador.notificar(pedido, total);
    this.factura.generar(pedido, total);
  }
}

Qué ganamos. Cada clase tiene ahora una sola razón para cambiar. ProcesadorPedidos pasó de hacer todo a coordinar a quienes lo hacen: quedó como un caso de uso legible de cinco líneas. De paso, al aislar la persistencia parametrizamos la query y cerramos el agujero de SQL injection.

Qué falta. Todavía hay dos problemas grandes: el switch sigue vivo dentro de CalculadoraTotal (viola OCP) y el procesador instancia sus colaboradores concretos con new (viola DIP). Los atacamos ahora.

Paso 2 — OCP + LSP: descuentos como estrategias polimórficas

El switch es el enemigo clásico de OCP: cada nuevo tipo de descuento obliga a abrir y modificar la calculadora. La solución es el patrón Strategy: convertimos cada rama en una clase que implementa una interfaz común.

// ✅ PASO 2 — cada descuento es una estrategia intercambiable

interface EstrategiaDescuento {
  // Contrato: recibe un subtotal ≥ 0 y devuelve un total ≥ 0 y ≤ subtotal.
  aplicar(subtotal: number): number;
}

class SinDescuento implements EstrategiaDescuento {
  aplicar(subtotal: number): number {
    return subtotal;
  }
}

class DescuentoPorcentaje implements EstrategiaDescuento {
  constructor(private readonly porcentaje: number) {}
  aplicar(subtotal: number): number {
    return subtotal * (1 - this.porcentaje / 100);
  }
}

class DescuentoClienteVIP implements EstrategiaDescuento {
  aplicar(subtotal: number): number {
    return subtotal * 0.85;
  }
}

class DescuentoCuponFijo implements EstrategiaDescuento {
  constructor(private readonly monto: number) {}
  aplicar(subtotal: number): number {
    return Math.max(0, subtotal - this.monto);
  }
}

// La calculadora ya no conoce los tipos concretos: solo el contrato
class CalculadoraTotal {
  calcular(pedido: Pedido, descuento: EstrategiaDescuento): number {
    const subtotal = pedido.lineas.reduce(
      (acc, l) => acc + l.cantidad * l.precioUnitario,
      0,
    );
    return descuento.aplicar(subtotal);
  }
}

OCP en acción. ¿Aparece un descuento nuevo —digamos, “2x1 en la segunda unidad”? Creás una clase DescuentoSegundaUnidad implements EstrategiaDescuento y listo. No tocás CalculadoraTotal ni ninguna estrategia existente. El código quedó cerrado a la modificación y abierto a la extensión.

LSP en acción. Fijate que el contrato de EstrategiaDescuento está documentado en el comentario: “devuelve un total ≥ 0 y ≤ subtotal”. Todas las implementaciones lo respetan honestamente. Ninguna lanza excepciones inesperadas ni devuelve un total negativo ni mayor al subtotal. Por eso CalculadoraTotal puede tratar a cualquier estrategia exactamente igual: son de verdad sustituibles. Si una implementación rompiera ese contrato (por ejemplo, aplicar devolviendo un valor mayor al subtotal), OCP se derrumbaría, porque agregar esa estrategia introduciría un bug silencioso. LSP es lo que hace segura a la extensión de OCP.

Paso 3 — ISP: interfaces pequeñas para cada colaborador

Ahora que cada colaborador es una clase concreta, démosle a cada uno una interfaz propia y mínima, describiendo solo lo que el caso de uso necesita de él. Nada de un IServicioPedidos gigante con quince métodos que nadie usa entero.

// ✅ PASO 3 — un puerto pequeño y específico por colaborador

interface RepositorioPedidos {
  guardar(pedido: Pedido, total: number): void;
}

interface Notificador {
  notificar(pedido: Pedido, total: number): void;
}

interface GeneradorFactura {
  generar(pedido: Pedido, total: number): void;
}

ISP en acción. Son tres interfaces de un solo método. El procesador que solo necesita guardar depende de RepositorioPedidos y nada más; no arrastra métodos de buscar, eliminar o listarTodos que no usa. Cada cliente depende exactamente de lo que consume. Además, estas interfaces finas son triviales de implementar honestamente —lo que, otra vez, refuerza LSP: es casi imposible mentir en un contrato de un método.

Paso 4 — DIP: inyectar las abstracciones por constructor

Última pieza. El procesador todavía hacía new RepositorioPedidosMySQL() adentro. Invirtamos esa dependencia: en lugar de que el módulo de alto nivel construya sus detalles de bajo nivel, se los inyectamos por constructor, dependiendo solo de las interfaces del paso 3.

// ✅ PASO 4 — el procesador depende de abstracciones, no de detalles

// Adaptadores concretos: implementan los puertos
class RepositorioPedidosMySQL implements RepositorioPedidos {
  constructor(private readonly conexion: MySQLConnection) {}
  guardar(pedido: Pedido, total: number): void {
    this.conexion.ejecutar(
      "INSERT INTO pedidos (id, email, total) VALUES (?, ?, ?)",
      [pedido.id, pedido.clienteEmail, total],
    );
  }
}

class NotificadorEmail implements Notificador {
  constructor(private readonly smtp: SmtpClient) {}
  notificar(pedido: Pedido, total: number): void {
    this.smtp.enviar(
      pedido.clienteEmail,
      "Tu pedido fue procesado",
      `Total a pagar: $${total.toFixed(2)}`,
    );
  }
}

class GeneradorFacturaPDF implements GeneradorFactura {
  generar(pedido: Pedido, total: number): void {
    new GeneradorPDF().crear(`factura-${pedido.id}.pdf`, { pedido, total });
  }
}

// El caso de uso: alto nivel, depende SOLO de abstracciones
class ProcesadorPedidos {
  constructor(
    private readonly validador: ValidadorPedido,
    private readonly calculadora: CalculadoraTotal,
    private readonly repositorio: RepositorioPedidos,
    private readonly notificador: Notificador,
    private readonly factura: GeneradorFactura,
  ) {}

  procesar(pedido: Pedido, descuento: EstrategiaDescuento): void {
    this.validador.validar(pedido);
    const total = this.calculadora.calcular(pedido, descuento);
    this.repositorio.guardar(pedido, total);
    this.notificador.notificar(pedido, total);
    this.factura.generar(pedido, total);
  }
}

Y el cableado (composición) se hace en un solo lugar, en el borde de la aplicación:

// Composición: el único lugar que conoce los detalles concretos
const procesador = new ProcesadorPedidos(
  new ValidadorPedido(),
  new CalculadoraTotal(),
  new RepositorioPedidosMySQL(new MySQLConnection("localhost", "root", "1234")),
  new NotificadorEmail(new SmtpClient("smtp.miempresa.com", 587)),
  new GeneradorFacturaPDF(),
);

const descuento = new DescuentoClienteVIP();
procesador.procesar(pedidoDelCliente, descuento);

DIP en acción. ProcesadorPedidos ya no sabe que existe MySQL ni SMTP. Solo conoce las interfaces. ¿Migrás a PostgreSQL? Escribís RepositorioPedidosPostgres implements RepositorioPedidos y lo pasás en el cableado —cero cambios en el procesador. ¿Testeás? Le inyectás un RepositorioPedidos falso en memoria y un Notificador espía, sin abrir una base de datos ni mandar un mail real. La testeabilidad, que era imposible en el código original, ahora es gratis.

El diseño final

Después de los cuatro pasos, el módulo se transformó de una bola de barro en un conjunto de piezas pequeñas, cada una con un propósito claro y sustituibles entre sí:

classDiagram
    class ProcesadorPedidos {
        -validador: ValidadorPedido
        -calculadora: CalculadoraTotal
        -repositorio: RepositorioPedidos
        -notificador: Notificador
        -factura: GeneradorFactura
        +procesar(pedido, descuento)
    }

    class RepositorioPedidos {
        <<interface>>
        +guardar(pedido, total)
    }
    class Notificador {
        <<interface>>
        +notificar(pedido, total)
    }
    class GeneradorFactura {
        <<interface>>
        +generar(pedido, total)
    }
    class EstrategiaDescuento {
        <<interface>>
        +aplicar(subtotal) number
    }

    class RepositorioPedidosMySQL
    class NotificadorEmail
    class GeneradorFacturaPDF
    class DescuentoPorcentaje
    class DescuentoClienteVIP
    class DescuentoCuponFijo
    class SinDescuento

    ProcesadorPedidos --> RepositorioPedidos : depende de abstracción (DIP)
    ProcesadorPedidos --> Notificador : depende de abstracción (DIP)
    ProcesadorPedidos --> GeneradorFactura : depende de abstracción (DIP)
    ProcesadorPedidos ..> EstrategiaDescuento : usa (OCP)

    RepositorioPedidos <|.. RepositorioPedidosMySQL
    Notificador <|.. NotificadorEmail
    GeneradorFactura <|.. GeneradorFacturaPDF

    EstrategiaDescuento <|.. DescuentoPorcentaje
    EstrategiaDescuento <|.. DescuentoClienteVIP
    EstrategiaDescuento <|.. DescuentoCuponFijo
    EstrategiaDescuento <|.. SinDescuento

Observá el patrón: todas las flechas de dependencia del ProcesadorPedidos apuntan a interfaces, nunca a clases concretas. Las implementaciones concretas dependen de las interfaces (flechas de realización, hacia arriba). Esa es la firma visual de un diseño SOLID —y, como verás en el próximo capítulo, también es la firma de la arquitectura hexagonal.

Reflexión: el balance

Recorré con la mirada lo que cambió. Pasamos de una clase que hacía todo a poco más de una docena de clases e interfaces pequeñas. Y ese es exactamente el punto que hay que mirar con honestidad.

Lo que ganamos es real y grande:

Lo que costó también es real: más clases, más archivos, más indirección. Para un script de 20 líneas que corrés una vez, este refactor sería sobre-ingeniería pura. SOLID cobra su valor cuando el código va a cambiar y va a vivir: en el núcleo de un sistema que evoluciona con el negocio. Dónde está exactamente ese equilibrio —cuándo aplicar SOLID y cuándo no— es el tema del capítulo 9 sobre anti-patrones.

Resumen

En el próximo capítulo vamos a subir un nivel: veremos cómo aplicar SOLID a escala de arquitectura conduce naturalmente a la arquitectura hexagonal.

Siguiente: SOLID y la arquitectura hexagonal →