ISP: Principio de Segregación de Interfaces

Por: Artiko
solidispinterfacescohesióntypescript

ISP: Principio de Segregación de Interfaces

Llegamos a la I de SOLID. Al final del capítulo anterior sobre LSP vimos que muchas subclases mienten sobre su contrato porque las obligan a implementar métodos que no les corresponden. ISP ataca ese problema desde su raíz: el tamaño y la forma de nuestras interfaces. Si las interfaces fueran más pequeñas y precisas, nadie se vería forzado a mentir.

Definición formal

El principio también viene de Robert C. Martin (Uncle Bob), y nació de un problema real en Xerox. Su enunciado es:

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

O en su versión hermana:

Muchas interfaces específicas y pequeñas son mejores que una única interfaz de propósito general.

En cristiano

Cuando una clase implementa una interfaz, está firmando un contrato: “prometo saber hacer todo lo que esta interfaz declara”. El problema aparece cuando esa interfaz declara demasiadas cosas. Entonces obligamos a implementaciones a cargar con métodos que no tienen sentido para ellas, y los “resuelven” de la peor manera posible: dejándolos vacíos o lanzando excepciones.

ISP dice, simplemente: partí esas interfaces gordas en varias pequeñas y cohesivas, para que cada cliente dependa solo de lo que de verdad usa. Una clase debería poder mirar la interfaz que implementa y decir “sí, todo esto tiene sentido para mí”, sin excepciones ni cuerpos vacíos.

El code smell que ataca

El olor central de ISP es la fat interface (interfaz gorda): un contrato con demasiados métodos, heterogéneos, que sirven a distintos clientes a la vez. Los síntomas que la delatan:

Cuando ves un implements seguido de tres métodos reales y dos que hacen throw, estás oliendo una violación de ISP.

Ejemplo MALO: la interfaz IMaquinaOficina

El caso clásico de ISP es la multifunción de oficina. Modelamos un contrato único que reúne todo lo que “una máquina de oficina” podría hacer: imprimir, escanear, enviar fax.

interface IMaquinaOficina {
  imprimir(documento: string): void;
  escanear(documento: string): string;
  enviarFax(documento: string, numero: string): void;
  grapar(documento: string): void;
}

Para una multifunción cara y moderna, este contrato es cómodo: implementa todo y listo.

class MultifuncionXerox implements IMaquinaOficina {
  imprimir(documento: string): void {
    console.log(`Imprimiendo: ${documento}`);
  }

  escanear(documento: string): string {
    return `Escaneado: ${documento}`;
  }

  enviarFax(documento: string, numero: string): void {
    console.log(`Enviando fax de ${documento} a ${numero}`);
  }

  grapar(documento: string): void {
    console.log(`Grapando: ${documento}`);
  }
}

El desastre llega cuando queremos modelar una impresora simple y barata, de esas que solo imprimen. El contrato nos obliga a implementar métodos que la máquina física no puede cumplir:

class ImpresoraSimple implements IMaquinaOficina {
  imprimir(documento: string): void {
    console.log(`Imprimiendo: ${documento}`);
  }

  // 💥 Esta impresora NO escanea. Pero el contrato me obliga.
  escanear(documento: string): string {
    throw new Error("Esta impresora no puede escanear");
  }

  // 💥 Tampoco envía fax.
  enviarFax(documento: string, numero: string): void {
    throw new Error("Esta impresora no puede enviar fax");
  }

  // 💥 Ni grapa. La dejo vacía porque "algo tengo que poner".
  grapar(documento: string): void {
    // no hace nada... ¿está bien? ¿está mal? nadie sabe.
  }
}

El dolor, en concreto

Mirá los problemas que esto genera:

// Código que recibe "cualquier máquina de oficina" y escanea.
// Confía en el contrato IMaquinaOficina.
function digitalizarArchivo(maquina: IMaquinaOficina, doc: string): string {
  return maquina.escanear(doc); // el contrato dice que esto funciona
}

const barata = new ImpresoraSimple();
// digitalizarArchivo(barata, "contrato.pdf");
// 💥 Runtime: "Esta impresora no puede escanear"

El compilador de TypeScript acepta ImpresoraSimple como IMaquinaOficina sin una queja: cumple la firma. Pero la promesa es falsa. Y hay más daño colateral:

Es exactamente lo que ISP prohíbe: la ImpresoraSimple depende de escanear, enviarFax y grapar, métodos que no usa.

El refactor: partir en interfaces cohesivas

La solución de ISP es directa: descomponer la interfaz gorda en varias interfaces pequeñas, cada una con una sola capacidad cohesiva. Cada máquina implementa solo las que le corresponden.

interface Impresora {
  imprimir(documento: string): void;
}

interface Escaner {
  escanear(documento: string): string;
}

interface Fax {
  enviarFax(documento: string, numero: string): void;
}

interface Grapadora {
  grapar(documento: string): void;
}

Ahora la impresora simple implementa solo lo que puede cumplir, sin excepciones ni métodos vacíos:

class ImpresoraSimple implements Impresora {
  imprimir(documento: string): void {
    console.log(`Imprimiendo: ${documento}`);
  }
}

Y la multifunción compone varias capacidades. Como en TypeScript una clase puede implementar múltiples interfaces, el modelo encaja perfecto:

class MultifuncionXerox
  implements Impresora, Escaner, Fax, Grapadora
{
  imprimir(documento: string): void {
    console.log(`Imprimiendo: ${documento}`);
  }

  escanear(documento: string): string {
    return `Escaneado: ${documento}`;
  }

  enviarFax(documento: string, numero: string): void {
    console.log(`Enviando fax de ${documento} a ${numero}`);
  }

  grapar(documento: string): void {
    console.log(`Grapando: ${documento}`);
  }
}

Lo mejor viene ahora: cada cliente pide exactamente la capacidad que necesita, ni más ni menos:

// Este servicio solo necesita escanear. Pide solo Escaner.
function digitalizarArchivo(escaner: Escaner, doc: string): string {
  return escaner.escanear(doc);
}

digitalizarArchivo(new MultifuncionXerox(), "contrato.pdf"); // ✅ ok
// digitalizarArchivo(new ImpresoraSimple(), "contrato.pdf");
//                    ^^^^^^^^^^^^^^^^^^^^^
// ❌ Error de COMPILACIÓN: ImpresoraSimple no es un Escaner.

El bug que antes explotaba en runtime ("no puede escanear") ahora es imposible de escribir: el compilador rechaza pasar una impresora simple donde se necesita un escáner. Convertimos un error de ejecución en un error de compilación. Eso es progreso puro.

Segundo ejemplo: el trabajador y el robot

La misma lección aparece con el clásico ITrabajador. Modelar todo lo que “un trabajador hace” en un solo contrato parece natural… hasta que aparece un robot.

// ❌ Interfaz gorda
interface ITrabajador {
  trabajar(): void;
  comer(): void;
  dormir(): void;
}

class EmpleadoHumano implements ITrabajador {
  trabajar(): void { console.log("Trabajando"); }
  comer(): void { console.log("Almorzando"); }
  dormir(): void { console.log("Durmiendo"); }
}

class RobotEnsamblador implements ITrabajador {
  trabajar(): void { console.log("Ensamblando piezas 24/7"); }

  // 💥 Un robot no come ni duerme, pero el contrato lo exige.
  comer(): void { throw new Error("Un robot no come"); }
  dormir(): void { throw new Error("Un robot no duerme"); }
}

Segregamos por capacidad y el robot queda honesto:

interface Trabajable {
  trabajar(): void;
}

interface Alimentable {
  comer(): void;
}

interface Descansable {
  dormir(): void;
}

class EmpleadoHumano implements Trabajable, Alimentable, Descansable {
  trabajar(): void { console.log("Trabajando"); }
  comer(): void { console.log("Almorzando"); }
  dormir(): void { console.log("Durmiendo"); }
}

class RobotEnsamblador implements Trabajable {
  trabajar(): void { console.log("Ensamblando piezas 24/7"); }
}

// El planificador de tareas solo necesita algo "trabajable":
function asignarTarea(recurso: Trabajable): void {
  recurso.trabajar();
}

Fijate cómo el RobotEnsamblador refactorizado ya no tiene métodos que lanzan excepción. La violación de LSP del capítulo anterior desapareció sola al aplicar ISP. No es casualidad; ya volveremos sobre eso.

Diagrama: antes y después

Antes — una fat interface que obliga a implementar de más:

classDiagram
    class IMaquinaOficina {
        <<interface>>
        +imprimir(doc)
        +escanear(doc) string
        +enviarFax(doc, num)
        +grapar(doc)
    }
    class ImpresoraSimple {
        +imprimir(doc)
        +escanear(doc) throw!
        +enviarFax(doc, num) throw!
        +grapar(doc) vacío!
    }
    class MultifuncionXerox {
        +imprimir(doc)
        +escanear(doc)
        +enviarFax(doc, num)
        +grapar(doc)
    }
    IMaquinaOficina <|.. ImpresoraSimple : implements (MIENTE)
    IMaquinaOficina <|.. MultifuncionXerox : implements
    note for ImpresoraSimple "Forzada a implementar\nescanear/enviarFax/grapar\nque no usa → viola ISP"

Después — interfaces pequeñas y cohesivas que se componen:

classDiagram
    class Impresora {
        <<interface>>
        +imprimir(doc)
    }
    class Escaner {
        <<interface>>
        +escanear(doc) string
    }
    class Fax {
        <<interface>>
        +enviarFax(doc, num)
    }
    class Grapadora {
        <<interface>>
        +grapar(doc)
    }
    class ImpresoraSimple {
        +imprimir(doc)
    }
    class MultifuncionXerox {
        +imprimir(doc)
        +escanear(doc)
        +enviarFax(doc, num)
        +grapar(doc)
    }
    Impresora <|.. ImpresoraSimple : implements
    Impresora <|.. MultifuncionXerox
    Escaner <|.. MultifuncionXerox
    Fax <|.. MultifuncionXerox
    Grapadora <|.. MultifuncionXerox
    note for ImpresoraSimple "Solo implementa Impresora.\nSin métodos vacíos ni throws.\nCada cliente depende solo\nde lo que usa → respeta ISP"

Matices y errores comunes

ISP es SRP aplicado a las interfaces. El SRP (capítulo 2) dice que una clase debe tener una sola razón para cambiar; ISP dice lo mismo de una interfaz. Una interfaz gorda tiene muchas razones para cambiar (cada cliente distinto la empuja en su dirección), y por eso conviene partirla. Si entendés SRP, ISP es su reflejo en el espejo de los contratos.

El equilibrio: no caigas en el exceso de micro-interfaces. ISP no te pide una interfaz por método. Eso sería el extremo opuesto: decenas de interfaces de un solo método, tanto ruido que nadie encuentra nada. La guía es la cohesión por cliente: agrupá en una interfaz los métodos que un mismo tipo de cliente usa junto. Si leer y escribir siempre se usan de a pares por los mismos clientes, tenerlos en un Repositorio común está bien. Segregá cuando los clientes se separan, no por deporte.

En TypeScript las interfaces son estructurales: aprovechalo. A diferencia de Java o C#, en TypeScript no hace falta declarar implements para que un objeto cumpla una interfaz: si tiene la forma correcta, encaja (duck typing). Esto hace que definir interfaces pequeñas y específicas en el punto de consumo sea barato y natural. Podés declarar function digitalizar(e: Escaner) y pasarle cualquier cosa que tenga escanear, sin ceremonia. Definí las interfaces desde la perspectiva del cliente que las consume, no desde la clase que las implementa.

El olor viaja con vos. Cada vez que te encuentres escribiendo un método con throw new Error("no soportado") o un cuerpo vacío “porque hay que ponerlo”, frená: es casi seguro una interfaz que hay que segregar.

Relación con los otros principios

ISP es un puente entre varios principios de SOLID.

Resumen

Ahora que sabemos diseñar abstracciones pequeñas y honestas, llega el principio que las pone en el centro de todo: hacer que la lógica de negocio dependa de esas abstracciones y no de los detalles concretos.

Siguiente capítulo: DIP: Principio de Inversión de Dependencias →