Anti-patrones y cuándo NO aplicar SOLID

Por: Artiko
solidanti-patronesover-engineeringstupidpragmatismotypescript

Anti-patrones y cuándo NO aplicar SOLID

Hasta acá el curso te vendió SOLID. Este capítulo te lo va a desvender un poco, y es probablemente el más importante de todos.

Porque hay algo peor que un código que no aplica SOLID: un código que lo aplica mal, o donde no hacía falta. El diseño rígido te duele cuando llega el cambio; la sobre-ingeniería te duele todos los días, cada vez que abrís el archivo y tenés que saltar por siete capas de indirección para entender qué hace una función de tres líneas.

SOLID es una caja de herramientas, no una religión. Y como toda herramienta, usarla donde no corresponde hace más daño que no usarla. Este capítulo es el contrapeso honesto de los ocho anteriores: cuándo no abstraer, cómo reconocer que te pasaste de la raya, y por qué el juicio de ingeniería vale más que cualquier acrónimo.

La sobre-ingeniería: SOLID donde no hace falta

Empecemos con un caso real. Necesitás una función que calcule el impuesto de una venta: el 21% del monto. Eso es, literalmente, esto:

function calcularIva(monto: number): number {
  return monto * 0.21;
}

Tres líneas. Compila, se testea en un renglón, cualquiera la entiende. Ahora observá qué pasa cuando alguien decide “aplicar SOLID” a esto sin criterio:

// El horror de la sobre-ingeniería

interface CalculadoraImpuesto {
  calcular(monto: number): number;
}

interface FabricaCalculadora {
  crear(): CalculadoraImpuesto;
}

class CalculadoraIva implements CalculadoraImpuesto {
  private readonly tasa: number;

  constructor(tasa: number) {
    this.tasa = tasa;
  }

  calcular(monto: number): number {
    return monto * this.tasa;
  }
}

class FabricaCalculadoraIva implements FabricaCalculadora {
  crear(): CalculadoraImpuesto {
    return new CalculadoraIva(0.21);
  }
}

class ServicioImpuestos {
  private readonly calculadora: CalculadoraImpuesto;

  constructor(fabrica: FabricaCalculadora) {
    this.calculadora = fabrica.crear();
  }

  procesar(monto: number): number {
    return this.calculadora.calcular(monto);
  }
}

// Y para usarlo:
const servicio = new ServicioImpuestos(new FabricaCalculadoraIva());
const iva = servicio.procesar(1000);

Pasamos de 3 líneas a más de 40. Agregamos una interfaz, una fábrica, otra interfaz para la fábrica, un servicio que orquesta, e inyección de dependencias. ¿Ganamos algo? Nada. No hay un segundo tipo de impuesto. No hay un requisito de intercambiar la implementación. No hay un test que necesite un doble de esa calculadora. Solo agregamos superficie, indirección y ruido.

Esto viola dos principios más viejos y más importantes que SOLID:

La abstracción no es gratis. Cada interfaz, cada capa, cada indirección tiene un costo de comprensión que alguien paga cada vez que lee el código. Si la abstracción no te devuelve más de lo que cuesta, es una pérdida neta.

Abstracción prematura: la interfaz “por si acaso”

El vicio más común es crear una interfaz con una sola implementación “por si algún día aparece otra”. Suena prudente. Es, casi siempre, un error.

// Solo existe UNA implementación. La interfaz no aporta nada hoy.
interface RepositorioUsuario {
  guardar(usuario: Usuario): Promise<void>;
  buscarPorId(id: string): Promise<Usuario | null>;
}

class RepositorioUsuarioPostgres implements RepositorioUsuario {
  async guardar(usuario: Usuario): Promise<void> { /* ... */ }
  async buscarPorId(id: string): Promise<Usuario | null> { /* ... */ }
}

Acá hay un matiz importante, no una regla absoluta. Si esa interfaz es un puerto de dominio que vas a sustituir por un doble en los tests (como vimos en el capítulo de arquitectura hexagonal), la abstracción sí se está pagando sola: la segunda implementación es tu fake de test. Ese es un caso legítimo.

El problema es otro: la interfaz que creás para código que nunca vas a sustituir ni testear en aislamiento, solo porque “es buena práctica tener interfaces”. Un helper de formateo de fechas, un cálculo puro, un mapper trivial. Ahí la interfaz es puro peso muerto.

La regla del tres (Rule of Three)

La heurística práctica más útil que existe para esto:

  1. La primera vez que escribís algo, escribilo directo, concreto, sin abstraer.
  2. La segunda vez que aparece algo parecido, aguantá la tentación. Duplicá, o abstraé solo si el patrón ya es evidente.
  3. La tercera vez, ahora sí: el patrón se manifestó por sí solo, tenés tres casos reales que te muestran dónde está la variación de verdad, y la abstracción que diseñás se ajusta a la realidad y no a tu imaginación.

La clave es esta: abstraé cuando el patrón se te muestra, no cuando lo adivinás. Una abstracción diseñada sobre un solo caso casi siempre está mal ubicada: elegís el eje de variación equivocado, y cuando llega el segundo caso real resulta que varía por otro lado. Ahora tenés una abstracción incorrecta que además hay que deshacer. Duplicar un poco de código es barato y reversible; una abstracción equivocada es cara y pegajosa.

Sobre-fragmentación por SRP: el enjambre de micro-clases

El SRP es el principio más malinterpretado, y su malinterpretación tiene un nombre: creer que “una responsabilidad” significa “un método”. Llevado al extremo, partís cada clase hasta tener un enjambre de micro-clases anémicas donde la lógica está tan dispersa que seguir un flujo simple te obliga a abrir quince archivos.

// SRP llevado al absurdo: cada gramo de lógica en su propia clase
class ValidadorEmailUsuario {
  validar(email: string): boolean { return email.includes("@"); }
}

class NormalizadorEmailUsuario {
  normalizar(email: string): string { return email.trim().toLowerCase(); }
}

class ConstructorUsuario {
  construir(email: string, nombre: string): Usuario { /* ... */ }
}

class OrquestadorRegistroUsuario {
  constructor(
    private validador: ValidadorEmailUsuario,
    private normalizador: NormalizadorEmailUsuario,
    private constructor_: ConstructorUsuario,
  ) {}
  // ...y así con ocho colaboradores más
}

Para registrar un usuario ahora saltás por cinco o seis clases, cada una con un método de una línea. Ninguna tiene estado propio significativo, ninguna encapsula una decisión de negocio real: son funciones disfrazadas de clases. La cohesión que el SRP promete se perdió: la lógica de “qué es un usuario válido” está esparcida y ya nadie la ve de un vistazo.

Recordá qué dice de verdad el SRP: una clase debe tener una sola razón para cambiar, donde “razón” es un actor (un origen de cambio: negocio, seguridad, contabilidad). No es “un método”, no es “una acción”. Un Usuario que se valida, se normaliza y se construye a sí mismo puede ser perfectamente cohesivo: todas esas cosas cambian por la misma razón (las reglas de qué es un usuario válido). Fragmentar por debajo de ese nivel no aumenta la cohesión, la destruye.

Interface hell y DI hell: cuando la indirección te ciega

Cuando combinás abstracción prematura con sobre-fragmentación, llegás al interface hell y al dependency-injection hell: tanta indirección que el código se vuelve imposible de seguir.

flowchart TD
    A["Controlador"] --> B["IServicioA"]
    B --> C["ServicioAImpl"]
    C --> D["IServicioB"]
    D --> E["ServicioBImpl"]
    E --> F["IRepositorioX"]
    F --> G["RepositorioXImpl"]
    G --> H["IMapperY"]
    H --> I["MapperYImpl"]
    I --> J["¿Dónde estaba<br/>la lógica de negocio?"]
    style J fill:#ff6b6b,color:#fff

Los síntomas del interface hell:

La indirección es como la sal: en la dosis justa realza el diseño; de más, lo arruina y ya no hay forma de sacarla.

STUPID: el reverso de la moneda

Si SOLID es el acrónimo de las buenas prácticas, STUPID es la lista de los seis olores que delatan un mal diseño. Conocerlo te da vocabulario para nombrar lo que está mal, igual que SOLID te lo da para nombrar lo que está bien.

LetraAnti-patrónQué significa
SSingleton abuseAbusar del patrón Singleton. El estado global disfrazado: acopla todo a una instancia única, oculta dependencias y hace los tests un infierno (nadie puede aislar ni resetear ese estado compartido).
TTight couplingAcoplamiento fuerte. Módulos tan entrelazados que no podés tocar uno sin romper otro ni reutilizar ninguno por separado. El enemigo número uno de SOLID.
UUntestabilityImposibilidad de testear. Consecuencia directa del acoplamiento: si no podés instanciar una clase sin levantar la base de datos real, tu diseño te está gritando algo.
PPremature optimizationOptimización prematura. Complicar el código por performance que nadie midió y que probablemente no importa. “La optimización prematura es la raíz de todos los males” (Knuth). Medí primero.
IIndescriptive namingNombres poco descriptivos. data, mgr, proc, tmp, x. Si tenés que leer la implementación para saber qué hace algo por su nombre, el nombre falló.
DDuplicationDuplicación. No la accidental (dos cosas que casualmente se parecen hoy), sino la de conocimiento: la misma regla de negocio copiada en tres lugares que van a divergir. Es lo que ataca DRY (Don’t Repeat Yourself).

Fijate en la simetría: la T (acoplamiento) y la U (intesteabilidad) son exactamente lo que DIP e ISP vienen a curar. La D (duplicación) es lo que OCP y SRP previenen. STUPID y SOLID son dos caras de la misma moneda: describen el mismo eje de calidad, uno desde la enfermedad y el otro desde la salud.

Ojo con la D de STUPID (duplicación) y la regla del tres: no se contradicen. La regla del tres habla de duplicación estructural incidental (dos bloques que se parecen pero varían por razones distintas); DRY habla de duplicación de conocimiento (la misma decisión de negocio repetida). Duplicar forma es tolerable; duplicar conocimiento no.

Entonces, ¿cuándo SÍ y cuándo NO?

La respuesta honesta es: depende del tiempo de vida y del ritmo de cambio del código. No todo el software es igual, y aplicarle a un script de una tarde el mismo rigor que al núcleo de un sistema que vivirá diez años es un error de juicio.

flowchart TD
    Start["¿Debería abstraer<br/>esto ahora?"] --> Q1{"¿Ya tengo un<br/>segundo caso real<br/>que lo pide?"}
    Q1 -->|"No"| Q2{"¿Necesito un doble<br/>de prueba para testear<br/>esto en aislamiento?"}
    Q1 -->|"Sí"| Abstraer["Abstraé:<br/>el patrón se manifestó"]
    Q2 -->|"Sí"| Abstraer
    Q2 -->|"No"| Q3{"¿Es un límite<br/>de infraestructura<br/>(BD, API, cola)?"}
    Q3 -->|"Sí"| Abstraer
    Q3 -->|"No"| Q4{"¿Este código va a<br/>vivir años y cambiar<br/>seguido?"}
    Q4 -->|"No (script,<br/>prototipo, throwaway)"| Directo["Escribilo directo.<br/>YAGNI + KISS"]
    Q4 -->|"Sí, pero todavía<br/>no sé cómo variará"| Directo
    Q4 -->|"Sí, y el eje de<br/>variación es claro"| Abstraer
    style Abstraer fill:#51cf66,color:#000
    style Directo fill:#ffd43b,color:#000

Una guía por tipo de código:

El patrón que emerge: SOLID rinde donde hay cambio frecuente y larga vida; estorba donde hay poco cambio o vida corta. Y el que decide de qué lado cae cada pieza no es el acrónimo: sos vos, con tu juicio de ingeniería.

El juicio de ingeniería está por encima de la regla

Ninguna heurística piensa por vos. SOLID te da preguntas buenas, no respuestas automáticas. La habilidad que separa a quien conoce SOLID de quien lo domina es saber cuándo NO aplicarlo.

Preguntas honestas para hacerte frente al código, antes de agregar una abstracción:

El costo de agregar una abstracción después, cuando el segundo caso realmente llega, suele ser bajo: refactorizás con los tests como red y con el conocimiento real de dónde varía. El costo de quitar una abstracción equivocada es alto: ya se enredó en todo el código y desmontarla asusta. Por eso, ante la duda, la asimetría favorece esperar.

El objetivo real

Volvamos a la pregunta que atraviesa todo el curso. El objetivo de SOLID nunca fue “cumplir SOLID”. El objetivo es:

Software que resista el cambio con el mínimo de complejidad necesaria.

Fijate en las dos partes, porque ambas importan y tiran en direcciones opuestas:

El buen diseño vive en la tensión entre esos dos polos, no en ninguno de los extremos. Un código sin ningún diseño es rígido y frágil. Un código sobre-diseñado es un laberinto de indirección que nadie entiende. El punto justo se busca caso por caso, con juicio, y se corrige a medida que el sistema te muestra por dónde cambia de verdad.

SOLID es un mapa, no el territorio. Usalo para orientarte, no para reemplazar tu criterio.

Resumen

Ahora que sabemos cuándo aplicar SOLID y cuándo frenarnos, veamos su recompensa más tangible y medible: cómo el código que respeta estos principios se vuelve un placer de testear.

SOLID y testing →