Te piden cambiar la presentación de un informe y acabas editando la clase que calcula sus resultados. Antes de tocar una cabecera, tienes que comprobar si vas a cambiar también las cuentas. El informe venía con más trabajo del anunciado.
SOLID reúne cinco principios para revisar cómo repartimos responsabilidades y cómo colaboran las piezas. Cada uno mira un problema distinto. Vamos a recorrerlos con ejemplos pequeños: un informe, unas modalidades de envío y una impresora. Cada caso trae lo necesario para entenderlo; no hace falta reconstruir una aplicación entre apartados.
S: responsabilidad única
El principio de responsabilidad única, o SRP, propone separar responsabilidades que cambian por motivos diferentes. Interesa entender quién necesita cada cambio y qué conocimiento afecta.
Imagina una clase que calcula el resultado de un examen y prepara el texto que se muestra en pantalla. La regla de aprobación puede cambiar por una decisión académica; el formato del mensaje, por una petición de presentación. Si ambas viven mezcladas, tocar la frase obliga a trabajar junto a la regla.
Podemos separar esas decisiones:
final class ExamResult {
boolean hasPassed(int score) {
if (score < 0 || score > 10) {
throw new IllegalArgumentException("Score must be between 0 and 10.");
}
return score >= 5;
}
}
final class ResultFormatter {
String format(boolean passed) {
if (passed) {
return "Passed";
}
return "Failed";
}
}
Con una nota de 7, new ExamResult().hasPassed(7) devuelve true. Al entregar ese resultado a new ResultFormatter().format(true), obtenemos "Passed". Para cambiar el texto buscamos ResultFormatter; para cambiar la nota mínima, ExamResult.
La coordinación que llama a ambos queda fuera del ejemplo. Separar responsabilidades no elimina esa colaboración: deja claro qué decisión pertenece a cada pieza. Una responsabilidad puede necesitar varios métodos; contar métodos nos dice poco sobre sus motivos para cambiar.
Señal para revisarlo: una clase recibe cambios frecuentes por asuntos que apenas tienen relación entre sí.
O: abierto a extensión, cerrado a modificación
El principio abierto/cerrado, u OCP, busca incorporar una variación sin tener que modificar repetidamente una pieza estable.
Una tienda ofrece envío estándar y recogida en tienda. El primero cuesta 5 euros y la segunda es gratuita. Si el proceso contiene una lista creciente de condiciones para calcular cada modalidad, añadir otra obliga a editarlo una y otra vez.
Podemos expresar lo que todas ofrecen: un coste en céntimos, mayor o igual que cero. Cada modalidad proporciona su cálculo:
interface ShippingMethod {
int costInCents();
}
final class StandardShipping implements ShippingMethod {
public int costInCents() {
return 500;
}
}
final class StorePickup implements ShippingMethod {
public int costInCents() {
return 0;
}
}
final class DeliveryPrice {
int calculate(ShippingMethod method) {
return method.costInCents();
}
}
new DeliveryPrice().calculate(new StandardShipping()) devuelve 500; con new StorePickup(), devuelve 0. DeliveryPrice usa la operación del contrato sin preguntar qué clase concreta ha recibido. Estas implementaciones representan tarifas fijas: su resultado constante es la regla que estamos mostrando.
Para añadir un envío urgente, podemos crear otra implementación que devuelva su coste. Habrá que seleccionarla al preparar la compra, pero calculate() conserva su recorrido. El principio orienta qué parte queda estable frente a una variación concreta; el sistema seguirá recibiendo cambios.

La flecha simple significa «utiliza». Las líneas discontinuas con triángulo vacío apuntan desde las implementaciones hacia la interfaz. Son relaciones entre tipos del código, no el orden de ejecución.
Si solo existe una tarifa y no necesitamos variaciones, esta separación puede añadir trabajo sin resolver un problema presente. Es la precaución de KISS y YAGNI: introducir una estructura cuando aporta algo concreto.
Señal para revisarlo: cada variante obliga a editar los mismos bloques de decisiones, aunque el resto del proceso apenas cambie.
L: sustitución de Liskov
El principio de sustitución de Liskov, o LSP, pide que podamos utilizar una implementación donde se espera su abstracción conservando las garantías del contrato. Coincidir en los nombres y los tipos de los métodos no basta.
Volvamos al envío: ShippingMethod promete un coste no negativo. Esta implementación es un contraejemplo:
final class BrokenShipping implements ShippingMethod {
public int costInCents() {
return -500;
}
}
DeliveryPrice aceptaría ese objeto porque implementa la interfaz, pero recibiría −500 céntimos. Si la compra suma esa cantidad, estaría restando 5 euros por un envío que debía tener un coste válido. El tipo encaja; la promesa se ha roto.
Una modalidad gratuita debe devolver 0, como StorePickup. Si necesitamos representar una compensación económica, hace falta expresar esa otra operación y su significado en el modelo.
El contrato incluye también las entradas aceptadas y los errores. Si una operación promete aceptar cualquier nota entre 0 y 10, una implementación que solo admite notas de 5 a 10 exige más de lo que el consumidor había acordado. Conservar las garantías del resultado y respetar las entradas permitidas permite intercambiar implementaciones con confianza.
Podemos comprobar el coste no negativo contra todas las modalidades; esas pruebas de contrato buscan que cada una cumpla las mismas garantías. También deben cubrir los casos particulares que el contrato incluya.
Señal para revisarlo: el consumidor necesita detectar qué implementación ha recibido para esquivar comportamientos incompatibles con lo prometido.
I: segregación de interfaces
El principio de segregación de interfaces, o ISP, propone que los consumidores dependan de las operaciones que necesitan.
Una impresora sencilla sabe imprimir; una multifunción también escanea. Si un único contrato exige ambas operaciones, la impresora sencilla acabaría declarando un scan() que solo lanza «operación no soportada».
Podemos separar las capacidades:
interface DocumentPrinter {
void print(String text);
}
interface DocumentScanner {
String scan();
}
final class ConsolePrinter implements DocumentPrinter {
public void print(String text) {
System.out.println(text);
}
}
new ConsolePrinter().print("Monthly report") escribe ese texto en la consola. Usamos esa salida como ejemplo sencillo de impresión; no estamos conectando un dispositivo físico. La clase implementa lo que realmente hace y no necesita inventar un escaneo. Una multifunción podría implementar ambas interfaces y conectarlas a su dispositivo.
El proceso que imprime un informe puede recibir un DocumentPrinter; conocer DocumentScanner no le ayuda a cumplir su trabajo. El tamaño de cada contrato depende de sus consumidores y de la coherencia de sus operaciones. Una interfaz con varios métodos relacionados puede servir perfectamente.
Señal para revisarlo: aparecen métodos no soportados porque una implementación solo necesita una parte del contrato.
D: inversión de dependencias
El principio de inversión de dependencias, o DIP, propone que las políticas de alto nivel y los detalles que las soportan dependan de abstracciones. Los detalles se adaptan a lo que necesita la aplicación.
El proceso que publica un informe necesita imprimir su texto. Podemos escribirlo utilizando el contrato anterior:
final class PublishReport {
private final DocumentPrinter printer;
PublishReport(DocumentPrinter printer) {
if (printer == null) {
throw new IllegalArgumentException("Printer is required.");
}
this.printer = printer;
}
void publish(String text) {
if (text == null || text.isBlank()) {
throw new IllegalArgumentException("Report text must not be blank.");
}
printer.print(text);
}
}
Conectamos las piezas entregando la implementación al constructor:
DocumentPrinter printer = new ConsolePrinter();
PublishReport report = new PublishReport(printer);
report.publish("Monthly report");
publish() valida el texto y llama a print(). En esta construcción, la llamada termina en ConsolePrinter y aparece Monthly report en la consola. El proceso conoce DocumentPrinter; la clase concreta y su uso de System.out quedan fuera de él.

Las flechas representan dependencias de código. La inversión está en que el detalle ConsolePrinter se adapta al contrato que necesita el proceso. Durante la ejecución, el proceso sigue llamando a esa implementación.
Entregar el objeto desde fuera es inyección de dependencias. Elegir que el proceso conozca la interfaz expresa la dirección de sus dependencias. Podemos inyectar una clase concreta y seguir ligados a sus detalles: son decisiones relacionadas, pero diferentes.
Este reparto aparece en la arquitectura hexagonal y en Clean Architecture. Para aislar una regla de una herramienta externa, el contrato debe expresar la necesidad de la aplicación sin arrastrar los tipos particulares de esa herramienta.
Señal para revisarlo: una regla necesita conocer formatos o decisiones de infraestructura que podrían quedar dentro de una integración.
Aplicarlos con un problema concreto delante
Podemos empezar por una dificultad observable: cambios que se mezclan, condiciones que se repiten o contratos que prometen más de lo que cumplen. Después elegimos la separación que ayude a resolverla.
La composición permite combinar colaboradores con papeles claros, como el proceso y su impresora. La herencia también puede servir cuando respeta los contratos; usarla solo para compartir unas líneas puede arrastrar obligaciones innecesarias.
Tras un cambio, conviene recorrer el código y comprobar qué hemos ganado. ¿Se localizan mejor las reglas? ¿Resulta más fácil añadir la variación que necesitábamos? ¿Se entiende qué promete cada operación? SOLID resulta útil cuando mejora esas respuestas.