OCP: Principio Abierto/Cerrado

Por: Artiko
solidocpopen-closedstrategypolimorfismotypescript

OCP: Principio Abierto/Cerrado

El Principio Abierto/Cerrado (Open/Closed Principle, OCP) es la O de SOLID, y encierra una de las ideas más contraintuitivas y poderosas del diseño orientado a objetos: deberías poder agregar comportamiento nuevo a tu sistema sin modificar el código que ya existe y ya funciona. Suena casi mágico —¿cómo cambio lo que hace un programa sin tocarlo?— y la respuesta, como veremos, es el polimorfismo.

Definición formal y “en cristiano”

La formulación original es de Bertrand Meyer, en su libro Object-Oriented Software Construction (1988):

Las entidades de software (clases, módulos, funciones) deben estar abiertas a la extensión pero cerradas a la modificación.

Años después, Robert C. Martin la popularizó con un matiz distinto —la variante polimórfica—: en lugar de extender por herencia como proponía Meyer, la clave está en depender de abstracciones (interfaces) y agregar comportamiento creando nuevas implementaciones.

En cristiano: cuando llega un requisito nuevo, la pregunta es ¿tengo que abrir y editar código que ya está probado y en producción, o me alcanza con agregar un archivo nuevo? Un diseño que respeta OCP te deja agregar sin modificar. El código existente permanece intacto —cerrado—, y el sistema crece por los bordes —abierto—.

Detengámonos en por qué esto importa tanto. Cada vez que modificás código existente, corrés el riesgo de romper algo que ya funcionaba: eso es una regresión. Si en cambio agregás código nuevo sin tocar lo viejo, lo que ya funcionaba sigue funcionando por definición, porque nadie lo tocó. OCP convierte el crecimiento del software de una operación de riesgo en una operación segura.

El code smell que ataca: el switch que nunca deja de crecer

El enemigo natural de OCP es la estructura condicional que enumera casos y que hay que editar cada vez que aparece un caso nuevo: el switch gigante, la cadena de if / else if interminable. Aparece por todos lados:

Todos comparten el mismo defecto estructural: hay un lugar central que conoce todos los casos, y ese lugar hay que abrirlo y modificarlo cada vez que el negocio inventa un caso más.

Ejemplo MALO: descuentos por tipo de cliente con un switch

type TipoCliente = "regular" | "vip" | "empleado";

// ❌ Viola OCP: cada nuevo tipo de cliente obliga a editar este método
class CalculadoraDescuento {
  calcular(tipo: TipoCliente, monto: number): number {
    switch (tipo) {
      case "regular":
        return monto; // sin descuento
      case "vip":
        return monto * 0.85; // 15% off
      case "empleado":
        return monto * 0.7; // 30% off
      default:
        throw new Error(`Tipo de cliente desconocido: ${tipo}`);
    }
  }
}

Hoy funciona. El problema llega la semana que viene, cuando Marketing pide un tipo de cliente "mayorista" con 20% de descuento. Tenés que abrir CalculadoraDescuento, editar el switch y agregar un case. Y la semana siguiente "estudiante". Y después "black_friday". Cada nuevo caso te obliga a tocar la misma clase.

Los síntomas

Refactor paso a paso: polimorfismo y el patrón Strategy

La idea central: en vez de un lugar que decide entre casos con un switch, definimos una abstracción (una interfaz) y una implementación por cada caso. Agregar un caso nuevo pasa a ser crear una clase nueva, no editar una existente. Este patrón se conoce como Strategy.

Paso 1: definir la abstracción

// La abstracción: todo descuento sabe calcularse a sí mismo.
interface EstrategiaDescuento {
  aplicar(monto: number): number;
}

Paso 2: una implementación por cada caso

class DescuentoRegular implements EstrategiaDescuento {
  aplicar(monto: number): number {
    return monto;
  }
}

class DescuentoVIP implements EstrategiaDescuento {
  aplicar(monto: number): number {
    return monto * 0.85;
  }
}

class DescuentoEmpleado implements EstrategiaDescuento {
  aplicar(monto: number): number {
    return monto * 0.7;
  }
}

Paso 3: el consumidor depende de la abstracción, no de los casos

// Esta clase ya NO conoce los tipos de cliente. Depende de la abstracción.
class CalculadoraDescuento {
  calcular(estrategia: EstrategiaDescuento, monto: number): number {
    return estrategia.aplicar(monto);
  }
}

Fijate en lo que pasó: CalculadoraDescuento desapareció como cuello de botella. Ya no tiene ningún switch, ya no conoce ningún tipo de cliente. Recibe una estrategia y la ejecuta. Es completamente agnóstica de cuántos tipos de descuento existan.

Paso 4: agregar un caso nuevo… sin tocar nada

Ahora Marketing pide el cliente mayorista. Miralo:

// ✅ Extensión pura: un archivo nuevo. NO se modifica ninguna clase existente.
class DescuentoMayorista implements EstrategiaDescuento {
  aplicar(monto: number): number {
    return monto * 0.8; // 20% off
  }
}

No abrimos CalculadoraDescuento. No abrimos ninguna de las estrategias anteriores. El código que estaba en producción sigue exactamente igual, byte por byte, así que es imposible que hayamos introducido una regresión en él. Eso es OCP en acción: cerrado a la modificación, abierto a la extensión.

En un sistema real conectaríamos las estrategias con quien las necesita mediante un mapa o una factory que también respeta OCP si se registra desde fuera:

const estrategias: Record<string, EstrategiaDescuento> = {
  regular: new DescuentoRegular(),
  vip: new DescuentoVIP(),
  empleado: new DescuentoEmpleado(),
  mayorista: new DescuentoMayorista(), // el único punto que se toca al extender
};

Diagrama antes / después

classDiagram
    class CalculadoraDescuento_Antes {
        +calcular(tipo, monto) number
        -switch tipo
    }
    note for CalculadoraDescuento_Antes "ANTES: un switch que hay\nque editar por cada caso nuevo"

    class EstrategiaDescuento {
        <<interface>>
        +aplicar(monto) number
    }
    class DescuentoRegular {
        +aplicar(monto) number
    }
    class DescuentoVIP {
        +aplicar(monto) number
    }
    class DescuentoEmpleado {
        +aplicar(monto) number
    }
    class DescuentoMayorista {
        +aplicar(monto) number
    }
    class CalculadoraDescuento {
        +calcular(estrategia, monto) number
    }
    note for DescuentoMayorista "DESPUÉS: agregar un caso =\ncrear una clase nueva"

    EstrategiaDescuento <|.. DescuentoRegular
    EstrategiaDescuento <|.. DescuentoVIP
    EstrategiaDescuento <|.. DescuentoEmpleado
    EstrategiaDescuento <|.. DescuentoMayorista
    CalculadoraDescuento ..> EstrategiaDescuento : depende de

Observá que en el diagrama “después” las flechas de implementación apuntan hacia la interfaz, y CalculadoraDescuento también depende de la interfaz. Nadie depende de un caso concreto. Por eso podés sumar DescuentoMayorista sin que ninguna otra caja del diagrama se entere.

Matices, cuándo aplicarlo y errores comunes

OCP se apoya en la abstracción… pero abstraer de más es un error

El motor de OCP es la abstracción: la interfaz EstrategiaDescuento es lo que permite extender sin modificar. Pero acá aparece la trampa. Si aplicás OCP en todos lados, desde el principio, por si acaso, terminás con una interfaz por cada clase, factories que solo tienen un producto, y capas de indirección que solo sirven para que leer el código sea más difícil. Eso es abstracción prematura, un anti-patrón que atacamos en el capítulo 9.

La regla práctica: no abstraigas hasta que el segundo caso llame a la puerta. Un switch con dos casos que no van a crecer no necesita el patrón Strategy. El momento de aplicar OCP es cuando anticipás con fundamento que esa dimensión va a variar —cuando el negocio ya te avisó que vendrán más tipos de cliente, más países, más medios de pago—.

El “cierre” nunca es total

Este es el matiz más profundo de OCP, y viene del propio Uncle Bob: es imposible cerrar un módulo contra todos los cambios posibles. Nuestro diseño está cerrado contra la aparición de nuevos tipos de cliente, pero no está cerrado contra, por ejemplo, un cambio en la firma del método aplicar (si mañana el descuento necesitara también la fecha, hay que tocar la interfaz y todas las implementaciones).

Por eso OCP es una decisión estratégica: elegís contra qué eje de cambio querés cerrar tu módulo, apostando a los cambios más probables. No podés protegerte de todo; te protegés de lo que razonablemente esperás que varíe. Cerrar contra el eje equivocado es esfuerzo tirado a la basura.

Relación con los otros principios

Resumen

Y justamente porque OCP depende de que los subtipos se comporten como prometen, el siguiente principio es el que garantiza esa promesa:

LSP: Principio de Sustitución de Liskov →