Principios SOLID Básico¶
Cinco principios de diseño orientado a objetos, recopilados por Robert C. Martin, cuyo objetivo común es que el código sea fácil de cambiar: que añadir o modificar comportamiento afecte a pocas piezas y no rompa las demás.
| Letra | Principio | Idea en una línea | Síntoma de que se incumple |
|---|---|---|---|
| S | Single Responsibility | Un módulo tiene un solo motivo para cambiar | Una clase cambia por peticiones de áreas distintas |
| O | Open/Closed | Abierto a extensión, cerrado a modificación | Cada caso nuevo obliga a editar switch en varios sitios |
| L | Liskov Substitution | Un subtipo puede sustituir a su tipo base sin romper nada | instanceof para tratar un subtipo como caso especial |
| I | Interface Segregation | Nadie debe depender de métodos que no usa | Implementaciones que lanzan UnsupportedOperationException |
| D | Dependency Inversion | Depende de abstracciones, no de concreciones | La lógica de negocio crea con new clientes de BBDD o HTTP |
No son reglas absolutas
SOLID son heurísticas para gestionar el cambio. Aplicarlas donde no hay cambio previsible produce sobreingeniería (interfaces con una sola implementación que nunca tendrá otra). Aplícalas donde el código cambia de verdad.
S — Single Responsibility¶
Qué dice¶
"Un módulo debe tener una sola razón para cambiar". En Arquitectura Limpia, Martin lo precisa: un módulo debe responder a un solo actor, es decir, a un único grupo de personas que pide cambios (Finanzas, Operaciones, Seguridad…).
Por qué importa¶
Si una clase atiende a dos actores, un cambio pedido por uno puede romper lo que usa el otro, y dos equipos acaban modificando el mismo fichero (conflictos, regresiones, revisiones difíciles).
Ejemplo¶
public class Employee {
public Money calculatePay() { ... } // lo pide Finanzas
public Hours reportHours() { ... } // lo pide Recursos Humanos
public void save() { ... } // lo piden los administradores de base de datos
}
calculatePay y reportHours comparten un método auxiliar para calcular horas extra y Finanzas pide cambiarlo,
el informe de Recursos Humanos cambia sin que nadie lo haya pedido.
public record Employee(EmployeeId id, String name, Rate rate) { } // solo datos
public class PayCalculator { public Money calculatePay(Employee e) { ... } }
public class HourReporter { public Hours reportHours(Employee e) { ... } }
public class EmployeeRepository { public void save(Employee e) { ... } }
Cómo detectarlo¶
- Describe la clase en una frase: si necesitas "y" o "además" ("calcula la nómina y genera el informe y guarda…"), hay varias responsabilidades.
- Mira el historial de Git: si los commits que tocan la clase vienen de peticiones de áreas distintas, se incumple.
- Clases con muchos imports de dominios distintos (HTTP, SQL, email, PDF) suelen mezclar responsabilidades.
Confusión frecuente
SRP no significa "una clase hace una sola cosa" o "tiene un solo método": eso es un principio sobre funciones (de Código Limpio). Una clase puede tener varios métodos cohesionados que responden al mismo actor.
O — Open/Closed¶
Qué dice¶
Un módulo debe estar abierto a extensión (se le puede añadir comportamiento) pero cerrado a modificación (no hace falta editar el código existente, que ya funciona y está probado, para hacerlo).
Cómo se consigue¶
Con polimorfismo: el código estable depende de una abstracción, y cada variante nueva es una implementación nueva. Patrones que lo materializan: Strategy, Decorator, Template Method, plugins.
public Money shippingCost(Order order) {
switch (order.shippingType()) {
case STANDARD: return Money.of(5);
case EXPRESS: return Money.of(15);
default: throw new IllegalStateException();
}
}
// Y probablemente otro switch igual en estimatedDays(), otro en label()...
switch; olvidar uno es un error en producción.
public interface ShippingPolicy {
Money costFor(Order order);
int estimatedDays();
}
public class StandardShipping implements ShippingPolicy {
public Money costFor(Order o) { return Money.of(5); }
public int estimatedDays() { return 3; }
}
public class ExpressShipping implements ShippingPolicy {
public Money costFor(Order o) { return Money.of(15); }
public int estimatedDays() { return 1; }
}
// Tipo nuevo = clase nueva. Nada existente se modifica, y el compilador obliga a implementar todo.
public class DroneShipping implements ShippingPolicy {
public Money costFor(Order o) { return Money.of(25); }
public int estimatedDays() { return 0; }
}
El switch no siempre es malo
Un único switch exhaustivo sobre un tipo sellado (Java 21) es perfectamente válido cuando el conjunto de casos es
cerrado y estable: el compilador avisa si falta alguno. OCP ataca los switch repetidos sobre casos que crecen.
L — Liskov Substitution¶
Qué dice¶
Si S es un subtipo de T, cualquier código que funcione con un T debe funcionar igual con un S sin saberlo.
Formulado como contrato: un subtipo no puede exigir más (reforzar precondiciones) ni garantizar menos
(debilitar postcondiciones) que su tipo base.
Ejemplo clásico: rectángulo y cuadrado¶
class Rectangle {
protected int w, h;
void setWidth(int w) { this.w = w; }
void setHeight(int h) { this.h = h; }
int area() { return w * h; }
}
class Square extends Rectangle { // "un cuadrado ES UN rectángulo"... matemáticamente
@Override void setWidth(int w) { this.w = w; this.h = w; }
@Override void setHeight(int h) { this.w = h; this.h = h; }
}
void resize(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // con un Square da 16: el código que esperaba un Rectangle se rompe
}
Rectangle promete que cambiar el ancho no cambia el
alto; Square rompe esa promesa.
Señales de que se incumple¶
if (x instanceof SubtipoRaro)para tratarlo como caso especial.- Métodos sobrescritos que lanzan
UnsupportedOperationExceptiono no hacen nada. - Subclases que ignoran parámetros o cambian el significado de un método.
- Tests del tipo base que fallan al ejecutarse con el subtipo.
LSP a nivel de arquitectura
También aplica a servicios: si defines un contrato de API y existen varias implementaciones (p. ej. un almacén de
objetos S3 y uno MinIO), todas deben respetar el mismo comportamiento, o los clientes acabarán con if proveedor == ….
I — Interface Segregation¶
Qué dice¶
Los clientes no deben verse obligados a depender de métodos que no usan. Es preferible varias interfaces pequeñas y específicas que una grande y general.
Por qué importa¶
Si un cliente depende de una interfaz enorme, cualquier cambio en un método que no usa le obliga a recompilar, redesplegar o adaptar sus mocks, y las implementaciones parciales acaban lanzando excepciones en los métodos que no soportan.
interface PodReader { List<Pod> listPods(); }
interface Deployer { void deploy(Manifest m); }
interface NodeMaintenance { void drainNode(String node); }
interface MetricsSource { Metrics metrics(); }
// Una implementación puede cumplir varias; cada cliente pide solo lo que usa
class KubernetesClusterClient implements PodReader, Deployer, NodeMaintenance, MetricsSource { ... }
class Dashboard {
Dashboard(PodReader pods, MetricsSource metrics) { ... } // imposible que despliegue por error
}
Un beneficio extra: la firma del constructor documenta qué puede hacer cada clase (principio de mínimo privilegio en el código).
D — Dependency Inversion¶
Qué dice¶
- Los módulos de alto nivel (políticas de negocio) no deben depender de los de bajo nivel (detalles técnicos: base de datos, HTTP, colas). Ambos deben depender de abstracciones.
- Las abstracciones no dependen de los detalles; los detalles dependen de las abstracciones.
La clave está en quién define la interfaz: la define el módulo de alto nivel, según lo que necesita, y el de bajo nivel la implementa. Así la flecha de dependencia de código "se invierte" respecto al flujo de ejecución.
flowchart LR
subgraph Negocio["Alto nivel (negocio)"]
AS[AlertService] --> N[[Notifier<br/>interfaz]]
end
subgraph Detalles["Bajo nivel (infraestructura)"]
SL[SlackNotifier]
PD[PagerDutyNotifier]
end
SL -. implementa .-> N
PD -. implementa .-> N
public interface Notifier { void notify(Alert alert); } // definida junto a AlertService, en su lenguaje
public class AlertService {
private final Notifier notifier;
public AlertService(Notifier notifier) { this.notifier = notifier; } // se inyecta desde fuera
public void nodeDown(String node) { notifier.notify(Alert.critical(node + " caído")); }
}
public class SlackNotifier implements Notifier { ... } // en el paquete de infraestructura
public class PagerDutyNotifier implements Notifier { ... }
// En un test, sin ningún framework:
List<Alert> sent = new ArrayList<>();
new AlertService(sent::add).nodeDown("site-042");
assertThat(sent).hasSize(1);
DIP no es lo mismo que inyección de dependencias
DIP es un principio sobre la dirección de las dependencias. DI (Spring, Guice o pasar objetos por el
constructor) es una técnica para entregar las dependencias. DI facilita DIP, pero puedes usar Spring e incumplir
DIP (por ejemplo, si AlertService recibe un SlackClient concreto inyectado).
Cómo se relacionan con la arquitectura¶
| Principio | A nivel de código | A nivel de arquitectura |
|---|---|---|
| SRP | Una clase, un actor | Un servicio o módulo por capacidad de negocio (bounded context) |
| OCP | Polimorfismo, Strategy | Plugins, eventos, extensiones sin tocar el núcleo |
| LSP | Subtipos que respetan contratos | Implementaciones intercambiables de un mismo contrato de API |
| ISP | Interfaces pequeñas | APIs específicas por cliente (BFF), scopes mínimos |
| DIP | Interfaces definidas por quien las usa | Puertos y adaptadores / Arquitectura Limpia |
Preguntas de repaso¶
¿Qué significa exactamente 'un motivo para cambiar'?
Que el módulo responde a un único actor (grupo de personas que solicita cambios). No significa "hacer una sola cosa" ni "tener un solo método".
¿Qué patrón es la forma más habitual de cumplir OCP?
Strategy (y en general el polimorfismo). También Decorator, Template Method, plugins y los eventos.
Da un ejemplo de incumplimiento de LSP en la librería estándar de Java
Collections.unmodifiableList(...) devuelve una List cuyo add() lanza UnsupportedOperationException: el código
que espera una List modificable falla en ejecución.
¿Quién 'posee' la interfaz en DIP y por qué importa?
El módulo de alto nivel (el que la usa). Así la interfaz se expresa en términos del negocio y la infraestructura se adapta a ella; si la definiera la infraestructura, el negocio seguiría acoplado a sus conceptos.
Ejercicios¶
Ejercicio 1 · Básico — Identificar el principio incumplido
Para cada caso, di qué principio se incumple: (a) ReportService genera el PDF, lo envía por email y guarda el
histórico en la base de datos; (b) ReadOnlyRepository implementa Repository y su save() lanza una excepción;
(c) un OrderService hace new PostgresOrderDao(); (d) cada método de pago nuevo requiere tocar 4 switch.
Solución
(a) SRP: tres motivos de cambio (formato, canal de envío, persistencia). (b) LSP (y probablemente ISP): el
subtipo no puede sustituir al tipo base; la solución es separar ReadRepository y WriteRepository. (c) DIP:
la lógica de negocio depende de un detalle concreto. (d) OCP: añadir un caso obliga a modificar código existente.
Ejercicio 2 · Básico — Separar responsabilidades
Refactoriza esta clase aplicando SRP:
class SiteHealthChecker {
boolean check(Site s) { /* llama por HTTP al /health del sitio */ }
String toHtml(List<Site> sites) { /* genera una tabla HTML */ }
void emailReport(String html) { /* envía por SMTP */ }
}
Solución
interface HealthProbe { HealthStatus check(Site site); } // HTTP u otro mecanismo
interface ReportRenderer { String render(List<SiteHealth> results); } // HTML, Markdown, JSON...
interface ReportSender { void send(String report); } // email, Slack...
class HealthReportJob { // orquesta el caso de uso
HealthReportJob(HealthProbe probe, ReportRenderer renderer, ReportSender sender) { ... }
void run(List<Site> sites) {
var results = sites.stream().map(s -> new SiteHealth(s, probe.check(s))).toList();
sender.send(renderer.render(results));
}
}
Ejercicio 3 · Medio — Eliminar switches con OCP
El sistema calcula el tamaño de la primera oleada de un rollout con un switch (strategy) que aparece en tres
métodos (firstWaveSize, nextWaveSize, maxParallel) para ALL_AT_ONCE, CANARY y BY_REGION. Rediseña.
Solución
interface RolloutStrategy {
int firstWaveSize(int totalSites);
int nextWaveSize(int completed, int totalSites);
int maxParallel();
}
class AllAtOnce implements RolloutStrategy {
public int firstWaveSize(int total) { return total; }
public int nextWaveSize(int done, int total) { return 0; }
public int maxParallel() { return 200; }
}
class Canary implements RolloutStrategy {
public int firstWaveSize(int total) { return Math.max(1, total / 100); } // 1 %
public int nextWaveSize(int done, int total) { return Math.min(total - done, done * 4); }
public int maxParallel() { return 50; }
}
// BY_REGION análogo. La elección se hace UNA vez (fábrica o configuración) y el resto del código usa la interfaz.
Ejercicio 4 · Medio — Segregar una interfaz
Una interfaz GitOpsRepository tiene readManifest, listClusters, commit, push, createPullRequest y
mergePullRequest. La usan un validador (solo lee), un bot de promoción (lee, hace commit y abre PR) y un proceso de
aprobación (solo fusiona PRs). Propón las interfaces.
Solución
interface ManifestReader { Manifest readManifest(Path p); List<String> listClusters(); }
interface ChangeProposer { PullRequest propose(ChangeSet changes, String title); } // commit + push + PR
interface ChangeApprover { void merge(PullRequest pr); }
ManifestReader; bot → ManifestReader + ChangeProposer; aprobación → ChangeApprover. Además de
simplificar los fakes, la separación refleja permisos distintos (el validador ni siquiera podría empujar código).
Ejercicio 5 · Avanzado — Aplicar DIP en un proyecto real
En tu servicio, RolloutService llama directamente a KubernetesClient (fabric8) para leer el estado de las
Kustomizations. Aplica DIP y escribe un test unitario sin Kubernetes.
Solución
// Núcleo: la interfaz habla el lenguaje del negocio
public interface ReconciliationStatus {
Optional<Revision> appliedRevision(SiteId site, String kustomization);
}
// Infraestructura: adaptador con fabric8
class FluxReconciliationStatus implements ReconciliationStatus {
private final KubernetesClient client;
public Optional<Revision> appliedRevision(SiteId site, String name) {
var ks = client.genericKubernetesResources("kustomize.toolkit.fluxcd.io/v1", "Kustomization")
.inNamespace("flux-system").withName(name).get();
return Optional.ofNullable(ks)
.map(r -> (String) ((Map<?, ?>) r.getAdditionalProperties().get("status")).get("lastAppliedRevision"))
.map(Revision::new);
}
}
// Test del núcleo con un fake
@Test
void waveIsCompleteWhenAllSitesApplyTargetRevision() {
ReconciliationStatus status = (site, ks) -> Optional.of(new Revision("abc123"));
var service = new RolloutService(status);
assertThat(service.isWaveComplete(wave(site("site-1"), site("site-2")), new Revision("abc123"))).isTrue();
}
kind o Testcontainers (k3s).