Módulo 10: Patrones de diseño
Objetivos del módulo
- Entender qué es un patrón de diseño y conocer el catálogo de los 23 patrones GoF
- Aplicar los principios SOLID y reconocer el código que los viola
- Dominar los patrones creacionales (Singleton, Factory Method, Abstract Factory, Builder), estructurales (Adapter, Decorator, Facade, Proxy, Composite) y de comportamiento (Strategy, Observer, Template Method, Command, State, Iterator, Chain of Responsibility)
- Leer el diagrama de clases UML de cada patrón y descubrir dónde aparece en el JDK y en Spring
- Organizar una aplicación en capas con MVC, inyección de dependencias manual y el patrón Repository/DAO
- Reconocer los antipatrones más frecuentes y evitar la “patronitis”
1. ¿Qué es un patrón de diseño?
📘 Concepto: Un patrón de diseño es una solución probada, con nombre propio, a un problema de diseño que se repite en contextos distintos. No es código para copiar, sino una plantilla de solución: describe qué clases participan, qué responsabilidad tiene cada una y cómo colaboran.
Piensa en los planos de un arquitecto: nadie inventa desde cero cómo se hace una escalera o una puerta corredera. Existen soluciones conocidas, con nombre, que cualquier profesional entiende al instante. Si un compañero te dice “aquí he puesto un Decorator sobre el repositorio para medir tiempos”, ya sabes cómo está hecho el código sin leerlo. Los patrones son, ante todo, vocabulario compartido.
El catálogo clásico procede del libro Design Patterns: Elements of Reusable Object-Oriented Software (1994), de Erich Gamma, Richard Helm, Ralph Johnson y John Vlissides, conocidos como la Banda de los Cuatro (Gang of Four, GoF). Cada patrón se describe con cuatro elementos: su nombre, el problema que resuelve (cuándo aplicarlo), la solución (qué clases participan y cómo se relacionan) y sus consecuencias (qué ganas y qué pagas: más clases, más indirección…).
1.1. El catálogo GoF
Los 23 patrones se agrupan en tres categorías según su propósito: creacionales (cómo se crean los objetos, ocultando qué clase concreta se instancia), estructurales (cómo se combinan clases y objetos para formar estructuras mayores) y de comportamiento (cómo se reparten las responsabilidades y cómo se comunican los objetos). En negrita, los que estudiarás a fondo: son los que más vas a encontrarte en el JDK, en Spring y en Hibernate. No memorices el catálogo: aprende a reconocer el problema que resuelve cada patrón.
| Categoría | Patrón | Intención | Ejemplo en el JDK o uso típico |
|---|---|---|---|
| Creacional | Abstract Factory | Crear familias de objetos relacionados sin indicar sus clases concretas | DocumentBuilderFactory.newInstance() |
| Creacional | Builder | Construir paso a paso un objeto complejo | StringBuilder, HttpClient.newBuilder() |
| Creacional | Factory Method | Dejar que las subclases decidan qué clase instanciar | Collection.iterator() |
| Creacional | Prototype | Crear objetos copiando un prototipo | Object.clone(), constructores de copia |
| Creacional | Singleton | Garantizar una única instancia con acceso global | Runtime.getRuntime() |
| Estructural | Adapter | Convertir una interfaz en otra que el cliente espera | Arrays.asList(), InputStreamReader |
| Estructural | Bridge | Separar una abstracción de su implementación para que varíen por separado | API JDBC frente a cada driver |
| Estructural | Composite | Tratar igual objetos simples y compuestos (árboles) | java.awt.Container y Component |
| Estructural | Decorator | Añadir responsabilidades envolviendo un objeto | BufferedReader, FilterInputStream |
| Estructural | Facade | Ofrecer una interfaz sencilla a un subsistema complejo | SLF4J (Simple Logging Facade for Java) |
| Estructural | Flyweight | Compartir objetos pequeños e inmutables | Caché de Integer.valueOf() (-128 a 127) |
| Estructural | Proxy | Un sustituto que controla el acceso a otro objeto | java.lang.reflect.Proxy |
| Comportamiento | Chain of Responsibility | Pasar una petición por una cadena de manejadores | jakarta.servlet.Filter, SecurityFilterChain |
| Comportamiento | Command | Encapsular una petición como un objeto | Runnable, Callable |
| Comportamiento | Interpreter | Representar una gramática y evaluarla | java.util.regex.Pattern |
| Comportamiento | Iterator | Recorrer una colección sin exponer su estructura | Iterator, bucle for-each |
| Comportamiento | Mediator | Un objeto central coordina a otros que no se conocen | Controlador que coordina los campos de un formulario |
| Comportamiento | Memento | Guardar y restaurar el estado sin romper la encapsulación | Instantáneas para “deshacer” |
| Comportamiento | Observer | Avisar automáticamente a los interesados en un cambio | PropertyChangeListener, Flow |
| Comportamiento | State | Cambiar el comportamiento según el estado interno | Ciclo de vida de un pedido |
| Comportamiento | Strategy | Encapsular algoritmos intercambiables | Comparator |
| Comportamiento | Template Method | Fijar el esqueleto de un algoritmo y dejar pasos a las subclases | AbstractList, HttpServlet |
| Comportamiento | Visitor | Añadir operaciones a una estructura sin cambiar sus clases | FileVisitor con Files.walkFileTree() |
1.2. Cómo leer los diagramas de clases
Cada patrón viene acompañado de su diagrama de clases UML. Esta es la notación que se usa en todos ellos:
Figura 1. Notación UML de clases y relaciones usada en el módulo. En cada diagrama, la clase resaltada en naranja es la pieza central del patrón.
1.3. Cómo ejecutar los ejemplos
Todos los ejemplos son un único fichero .java que se ejecuta sin compilar aparte (lanzamiento directo de código fuente): java Strategy.java. Si no tienes el JDK 25 instalado, usa Docker: docker run --rm -v "$PWD:/w" -w /w maven:3.9-eclipse-temurin-25 java Strategy.java.
Para que el código sea breve, casi todos usan los ficheros fuente compactos de Java 25 (JEP 512): un void main() sin clase alrededor y IO.println() para escribir. Un fichero compacto importa automáticamente todo el módulo java.base (java.util, java.io, java.math…), por eso apenas verás líneas import; en la forma clásica se consigue lo mismo con import module java.base; (JEP 511). Ten en cuenta otro detalle: en un fichero compacto, las clases que declaras son miembros de una clase implícita (los records, enums e interfaces son estáticos; las clases normales, internas). Cuando el patrón necesita clases de nivel superior de verdad (Singleton y Builder), el ejemplo usa la forma clásica public class ... { public static void main(String[] args) }.
2. Principios SOLID
Antes de los patrones, los principios. Los patrones son soluciones concretas; los principios son los criterios que te dicen si un diseño es bueno. Los reunió Robert C. Martin (Uncle Bob) a principios de los 2000 y Michael Feathers acuñó después el acrónimo SOLID, que agrupa cinco principios de diseño orientado a objetos.
Figura 2. Los cinco principios SOLID.
2.1. S: Principio de responsabilidad única (SRP)
📘 Concepto: Una clase debe tener un único motivo para cambiar. “Motivo” significa “actor” o área del negocio: si la cambian por razones de contabilidad, de diseño gráfico y de base de datos, tiene tres responsabilidades.
FacturaTodoEnUno cambiará si sube el IVA, si cambia el formato de la factura o si se migra la base de datos. La primera parte del programa muestra el problema y la segunda lo corrige separando responsabilidades:
// ---------- VIOLA SRP: tres motivos para cambiar en una sola clase ----------
record FacturaTodoEnUno(String cliente, List<Double> importes) {
double total() { return importes.stream().mapToDouble(d -> d).sum() * 1.21; } // 1) negocio
String comoTexto() { return "Factura de " + cliente + ": %.2f €".formatted(total()); } // 2) presentación
void guardar() { IO.println("[BD] INSERT INTO facturas ... (" + cliente + ")"); } // 3) persistencia
}
// ---------- CUMPLE SRP: cada clase, una responsabilidad ----------
record Factura(String cliente, List<Double> importes) { }
class CalculadoraFactura {
private static final double IVA = 0.21;
double total(Factura f) { return f.importes().stream().mapToDouble(d -> d).sum() * (1 + IVA); }
}
class FormateadorFactura {
private final CalculadoraFactura calculadora;
FormateadorFactura(CalculadoraFactura calculadora) { this.calculadora = calculadora; }
String comoTexto(Factura f) { return "Factura de " + f.cliente() + ": %.2f €".formatted(calculadora.total(f)); }
}
class RepositorioFacturas {
void guardar(Factura f) { IO.println("[BD] INSERT INTO facturas ... (" + f.cliente() + ")"); }
}
void main() {
var mala = new FacturaTodoEnUno("Ana", List.of(100.0, 50.0));
IO.println(mala.comoTexto()); mala.guardar();
var factura = new Factura("Luis", List.of(200.0, 30.0));
IO.println(new FormateadorFactura(new CalculadoraFactura()).comoTexto(factura));
new RepositorioFacturas().guardar(factura);
}
Factura de Ana: 181.50 €
[BD] INSERT INTO facturas ... (Ana)
Factura de Luis: 278.30 €
[BD] INSERT INTO facturas ... (Luis)
Ahora cada clase se prueba por separado y un cambio en el formato no puede romper el cálculo del IVA. Ojo: SRP no significa “una clase, un método”, sino cohesión: todo lo que hay dentro de la clase cambia por la misma razón.
2.2. O: Principio abierto/cerrado (OCP)
📘 Concepto: Las entidades de software deben estar abiertas a la extensión pero cerradas a la modificación: debes poder añadir comportamiento nuevo sin tocar el código que ya funciona (y que ya está probado).
En la versión mala, cada descuento nuevo obliga a abrir la clase, añadir otro else if y volver a probarlo todo. La corrección: una abstracción (Descuento) y un tipo por regla.
// ---------- VIOLA OCP: cada descuento nuevo obliga a modificar este método ----------
class CalculadoraDescuentosMala {
double aplicar(String tipo, double precio) {
if (tipo.equals("NINGUNO")) return precio;
else if (tipo.equals("REBAJAS")) return precio * 0.80;
else if (tipo.equals("SOCIO")) return precio * 0.90; // ¿Black Friday? Otro else if...
throw new IllegalArgumentException("Tipo desconocido: " + tipo);
}
}
// ---------- CUMPLE OCP: abierta a extensión, cerrada a modificación ----------
interface Descuento { double aplicar(double precio); }
record SinDescuento() implements Descuento { public double aplicar(double p) { return p; } }
record DescuentoPorcentaje(double pct) implements Descuento { public double aplicar(double p) { return p * (1 - pct / 100); } }
// Regla nueva: se AÑADE un tipo, no se toca nada de lo anterior
record DescuentoFijo(double euros) implements Descuento { public double aplicar(double p) { return Math.max(0, p - euros); } }
class Caja { // no cambia nunca
double cobrar(double precio, Descuento d) { return d.aplicar(precio); }
}
void main() {
IO.println("Mala, SOCIO: " + new CalculadoraDescuentosMala().aplicar("SOCIO", 50));
var caja = new Caja(); var descuentos = new LinkedHashMap<String, Descuento>();
descuentos.put("Sin descuento", new SinDescuento());
descuentos.put("Rebajas 20 %", new DescuentoPorcentaje(20));
descuentos.put("Vale de 15 €", new DescuentoFijo(15));
descuentos.put("Mitad (lambda)", precio -> precio * 0.5); // ¡también vale una lambda!
descuentos.forEach((nombre, d) -> IO.println("%-15s -> %.2f".formatted(nombre, caja.cobrar(50, d))));
}
Mala, SOCIO: 45.0
Sin descuento -> 50.00
Rebajas 20 % -> 40.00
Vale de 15 € -> 35.00
Mitad (lambda) -> 25.00
💡 Consejo: Esta corrección es el patrón Strategy (sección 5.1); al ser
Descuentouna interfaz funcional, una lambda también vale como descuento. Con Java 21 tienes otra herramienta: una interfazsealedcon unswitchcon patrones. Úsala cuando el conjunto de casos es cerrado y conocido (formas de pago de tu empresa, tipos de nodo de un árbol): el compilador te avisa si olvidas un caso. Usa una interfaz abierta comoDescuentocuando quieras que terceros añadan casos sin tocar tu código.
2.3. L: Principio de sustitución de Liskov (LSP)
📘 Concepto: Formulado por Barbara Liskov (1987): si
Ses subtipo deT, cualquier código que funcione con unTdebe seguir funcionando con unSsin sorpresas. La herencia debe respetar el contrato del padre, no solo su firma. Síntomas de violarlo: subclases que lanzanUnsupportedOperationException, métodos sobrescritos que no hacen nada o clientes llenos deinstanceofpara “el caso raro”.
El ejemplo clásico: en geometría un cuadrado es un rectángulo, pero en programación orientada a objetos con setters no lo es. La corrección consiste en no forzar una jerarquía que no respeta el contrato: con records inmutables y una interfaz sellada (Java 17), cada figura cumple el suyo.
// ---------- VIOLA LSP: un Cuadrado "es un" Rectángulo... ¿seguro? ----------
class Rectangulo {
protected int ancho, alto;
void setAncho(int ancho) { this.ancho = ancho; }
void setAlto(int alto) { this.alto = alto; }
int area() { return ancho * alto; }
}
class Cuadrado extends Rectangulo {
@Override void setAncho(int lado) { ancho = lado; alto = lado; }
@Override void setAlto(int lado) { ancho = lado; alto = lado; }
}
void comprobar(Rectangulo r) { // cliente escrito pensando en Rectangulo
r.setAncho(5);
r.setAlto(4);
IO.println(r.getClass().getSimpleName() + ": área esperada 20, obtenida " + r.area());
}
// ---------- CUMPLE LSP: figuras inmutables sin jerarquía engañosa (Java 17+) ----------
sealed interface Figura permits Rect, Cuad { double area(); }
record Rect(double ancho, double alto) implements Figura { public double area() { return ancho * alto; } }
record Cuad(double lado) implements Figura { public double area() { return lado * lado; } }
void main() {
comprobar(new Rectangulo());
comprobar(new Cuadrado()); // se rompe la expectativa del cliente
for (Figura f : List.of(new Rect(5, 4), new Cuad(4))) IO.println(f + " -> área " + f.area());
}
Rectangulo: área esperada 20, obtenida 20
Cuadrado: área esperada 20, obtenida 16
Rect[ancho=5.0, alto=4.0] -> área 20.0
Cuad[lado=4.0] -> área 16.0
2.4. I: Principio de segregación de interfaces (ISP)
📘 Concepto: Ningún cliente debe verse obligado a depender de métodos que no usa. Es mejor tener varias interfaces pequeñas y específicas que una sola interfaz “gorda”.
// ---------- VIOLA ISP: interfaz "gorda" que obliga a implementar lo que no se usa ----------
interface Multifuncion { void imprimir(String doc); void escanear(String doc); void enviarFax(String doc); }
class ImpresoraBarata implements Multifuncion {
public void imprimir(String doc) { IO.println("Imprimiendo " + doc); }
public void escanear(String doc) { throw new UnsupportedOperationException("No escaneo"); }
public void enviarFax(String doc) { throw new UnsupportedOperationException("No tengo fax"); }
}
// ---------- CUMPLE ISP: interfaces pequeñas y específicas ----------
interface Impresora { void imprimir(String doc); }
interface Escaner { void escanear(String doc); }
class ImpresoraSencilla implements Impresora {
public void imprimir(String doc) { IO.println("Imprimiendo " + doc); }
}
class EquipoOficina implements Impresora, Escaner {
public void imprimir(String doc) { IO.println("Equipo imprime " + doc); }
public void escanear(String doc) { IO.println("Equipo escanea " + doc); }
}
void digitalizar(Escaner e, String doc) { e.escanear(doc); } // pide solo lo que necesita
void main() {
Multifuncion m = new ImpresoraBarata(); m.imprimir("informe.pdf");
try { m.escanear("dni.png"); }
catch (UnsupportedOperationException e) { IO.println("Fallo en tiempo de ejecución: " + e.getMessage()); }
new ImpresoraSencilla().imprimir("informe.pdf"); digitalizar(new EquipoOficina(), "dni.png");
// digitalizar(new ImpresoraSencilla(), "dni.png"); // ¡no compila! El error se detecta antes
}
Imprimiendo informe.pdf
Fallo en tiempo de ejecución: No escaneo
Imprimiendo informe.pdf
Equipo escanea dni.png
Con interfaces pequeñas, el error de pasar una impresora sin escáner se detecta al compilar, no en producción. El propio JDK tiene un ejemplo discutible: List declara add() y remove() como “operaciones opcionales”, y las listas inmutables (List.of(), Collections.unmodifiableList()) lanzan UnsupportedOperationException. Los diseñadores lo aceptaron para no multiplicar las interfaces de colecciones; tú, en tu dominio, evítalo.
2.5. D: Principio de inversión de dependencias (DIP)
📘 Concepto: Los módulos de alto nivel (la lógica de negocio) no deben depender de los de bajo nivel (base de datos, red, ficheros). Ambos deben depender de abstracciones, y la abstracción la define y la posee el módulo de alto nivel.
Figura 3. Sin DIP, el servicio crea y depende de la clase MySQL; con DIP, ambos dependen de la interfaz PedidoRepositorio.
// ---------- VIOLA DIP: el módulo de alto nivel crea y conoce el detalle ----------
class MySqlPedidos {
void insertar(String pedido) { IO.println("[MySQL] INSERT " + pedido); }
}
class ServicioPedidosAcoplado {
private final MySqlPedidos bd = new MySqlPedidos(); // dependencia concreta y fija
void registrar(String pedido) { bd.insertar(pedido.toUpperCase()); }
}
// ---------- CUMPLE DIP: ambos dependen de una abstracción ----------
interface PedidoRepositorio { void guardar(String pedido); } // la define el alto nivel
class ServicioPedidos {
private final PedidoRepositorio repositorio;
ServicioPedidos(PedidoRepositorio repositorio) { this.repositorio = repositorio; } // se inyecta
void registrar(String pedido) { repositorio.guardar(pedido.toUpperCase()); }
}
class PedidoRepositorioMySql implements PedidoRepositorio {
public void guardar(String pedido) { IO.println("[MySQL] INSERT " + pedido); }
}
class PedidoRepositorioMemoria implements PedidoRepositorio { // ideal para pruebas
final List<String> datos = new ArrayList<>();
public void guardar(String pedido) { datos.add(pedido); }
}
void main() {
new ServicioPedidosAcoplado().registrar("pedido-1");
new ServicioPedidos(new PedidoRepositorioMySql()).registrar("pedido-2");
var memoria = new PedidoRepositorioMemoria(); var servicio = new ServicioPedidos(memoria);
servicio.registrar("pedido-3"); servicio.registrar("pedido-4");
IO.println("En memoria: " + memoria.datos);
}
[MySQL] INSERT PEDIDO-1
[MySQL] INSERT PEDIDO-2
En memoria: [PEDIDO-3, PEDIDO-4]
El servicio ya no sabe si guarda en MySQL o en memoria. Para probarlo no necesitas una base de datos: le pasas PedidoRepositorioMemoria. Este es el fundamento de la inyección de dependencias (sección 6.3) y de todo Spring.
Otros principios que debes conocer: DRY (Don’t Repeat Yourself: cada conocimiento en un solo sitio, no la validación del NIF copiada en cinco clases), KISS (Keep It Simple: la solución más simple que funcione), YAGNI (You Aren’t Gonna Need It: no programes lo que aún no necesitas), composición sobre herencia (prefiere “tiene un” a “es un”: no heredes de ArrayList para tener una lista de clientes) y la ley de Deméter (habla solo con tus vecinos: evita pedido.getCliente().getDireccion().getCiudad()).
3. Patrones creacionales
3.1. Singleton
📘 Concepto: Singleton garantiza que una clase tenga una única instancia y ofrece un punto de acceso global a ella. Se usa para recursos únicos de verdad: la configuración de la aplicación, el entorno de ejecución, un registro central. Sus claves: constructor privado, atributo estático con la instancia y método estático que la devuelve.
Figura 4. Singleton: constructor privado, atributo estático con la única instancia y método estático de acceso.
Este programa compara las cuatro implementaciones más habituales:
import module java.base; // Java 25: importa todos los paquetes del módulo java.base
// Forma clásica (no compacta): queremos clases de nivel superior, no internas
public class SingletonDemo {
public static void main(String[] args) throws InterruptedException {
IO.println("Eager: " + (ConfigEager.getInstancia() == ConfigEager.getInstancia()));
Set<Integer> ids = ConcurrentHashMap.newKeySet(); // lazy ingenuo con dos hilos a la vez
Runnable pedir = () -> ids.add(System.identityHashCode(ConfigLazyIngenua.getInstancia()));
Thread h1 = Thread.ofPlatform().start(pedir), h2 = Thread.ofPlatform().start(pedir);
h1.join(); h2.join(); IO.println("Lazy ingenuo, instancias distintas: " + ids.size());
IO.println("Antes de pedir el holder");
IO.println("Holder: " + (ConfigHolder.getInstancia() == ConfigHolder.getInstancia()));
Contador.INSTANCIA.incrementar(); Contador.INSTANCIA.incrementar();
IO.println("Enum, valor: " + Contador.INSTANCIA.valor());
Runtime rt = Runtime.getRuntime(); // Singleton del JDK
IO.println("Runtime único: " + (rt == Runtime.getRuntime()) + ", procesadores: " + rt.availableProcessors());
}
}
final class ConfigEager { // 1) temprana (eager)
private static final ConfigEager INSTANCIA = new ConfigEager();
private ConfigEager() { } // nadie más puede hacer new
public static ConfigEager getInstancia() { return INSTANCIA; }
}
final class ConfigLazyIngenua { // 2) perezosa NO segura: no la uses
private static ConfigLazyIngenua instancia;
private ConfigLazyIngenua() { try { Thread.sleep(100); } catch (InterruptedException e) { } } // carga lenta
public static ConfigLazyIngenua getInstancia() {
if (instancia == null) instancia = new ConfigLazyIngenua(); // ¡dos hilos pueden ver null!
return instancia;
}
}
final class ConfigHolder { // 3) perezosa con holder
private ConfigHolder() { IO.println(" (creando ConfigHolder)"); }
private static class Holder { static final ConfigHolder INSTANCIA = new ConfigHolder(); } // se carga al usarse
public static ConfigHolder getInstancia() { return Holder.INSTANCIA; }
}
enum Contador { // 4) enum (Effective Java)
INSTANCIA;
private int valor;
public synchronized void incrementar() { valor++; }
public synchronized int valor() { return valor; }
}
Eager: true
Lazy ingenuo, instancias distintas: 2
Antes de pedir el holder
(creando ConfigHolder)
Holder: true
Enum, valor: 2
Runtime único: true, procesadores: 8
| Variante | Cuándo se crea | ¿Segura con hilos? | Comentario |
|---|---|---|---|
| Eager (temprana) | Al cargar la clase | Sí (la JVM garantiza la inicialización de clases) | La más simple; úsala si crearla es barato |
| Lazy ingenua | La primera vez que se pide | No: dos hilos pueden crear dos instancias | Nunca la uses en código concurrente |
Lazy synchronized | La primera vez | Sí | Paga un bloqueo en cada llamada |
| Doble comprobación (double-checked locking) | La primera vez | Sí, solo si el atributo es volatile | Fácil de hacer mal; hoy no compensa |
| Holder (initialization-on-demand) | La primera vez que se pide | Sí, sin bloqueos | La JVM no carga Holder hasta que se usa |
| Enum | Al cargar el enum | Sí | Resiste también la serialización y la reflexión |
(El número de procesadores depende de tu máquina.) La salida demuestra el problema del lazy ingenuo: los dos hilos ven instancia == null a la vez y cada uno crea la suya. Es la condición de carrera que estudiaste en el módulo 06.
⚠️ Importante: Singleton es el patrón más abusado. Sus problemas son serios:
- Estado global encubierto: cualquier clase puede leer y modificar el singleton, así que el comportamiento de un método depende de algo que no aparece en su firma.
- Dificulta las pruebas: no puedes sustituir
Configuracion.getInstancia()por una configuración falsa, y el estado se arrastra de una prueba a otra.- Acoplamiento oculto: las dependencias no se ven en el constructor.
- Concurrencia: si el singleton tiene estado mutable, todos los hilos lo comparten (de ahí los
synchronizeddel contador del ejemplo).
💡 Consejo: En aplicaciones con Spring casi nunca escribirás un Singleton a mano. Los beans de Spring tienen ámbito singleton por defecto (una instancia por contenedor, no por JVM), pero se inyectan en lugar de pedirse con un método estático, así que en las pruebas puedes sustituirlos. Lo verás en el módulo 17.
3.2. Factory Method
📘 Concepto: Factory Method define un método para crear un objeto, pero deja que las subclases decidan qué clase concreta se instancia. El código de la superclase trabaja con el producto a través de su interfaz, sin saber cuál es.
Figura 5. Factory Method: Logistica usa un Transporte que crean sus subclases.
interface Transporte { String entregar(String carga); } // producto
record Camion(String matricula) implements Transporte {
public String entregar(String carga) { return "Camión " + matricula + " lleva " + carga + " por carretera"; }
}
record Barco(String nombre) implements Transporte {
public String entregar(String carga) { return "Barco " + nombre + " lleva " + carga + " por mar"; }
}
// Creador: el algoritmo es común; QUÉ transporte se crea lo deciden las subclases
abstract class Logistica {
protected abstract Transporte crearTransporte(); // el factory method
public void planificarEntrega(String carga) {
IO.println(getClass().getSimpleName() + ": " + crearTransporte().entregar(carga));
}
}
class LogisticaTerrestre extends Logistica { protected Transporte crearTransporte() { return new Camion("1234-KLM"); } }
class LogisticaMaritima extends Logistica { protected Transporte crearTransporte() { return new Barco("Nautilus"); } }
// Método fábrica ESTÁTICO (no es el patrón GoF, pero se usa muchísimo)
record Temperatura(double celsius) {
static Temperatura deFahrenheit(double f) { return new Temperatura((f - 32) * 5 / 9); }
}
void main() {
for (Logistica l : List.of(new LogisticaTerrestre(), new LogisticaMaritima())) l.planificarEntrega("40 palés");
IO.println(Temperatura.deFahrenheit(212));
// Métodos fábrica estáticos del JDK: devuelven la implementación que les conviene
IO.println(List.of(1, 2).getClass().getName());
IO.println(List.of(1, 2, 3).getClass().getName());
IO.println(Calendar.getInstance().getClass().getName());
IO.println("Integer.valueOf(100) == Integer.valueOf(100): " + (Integer.valueOf(100) == Integer.valueOf(100)));
}
LogisticaTerrestre: Camión 1234-KLM lleva 40 palés por carretera
LogisticaMaritima: Barco Nautilus lleva 40 palés por mar
Temperatura[celsius=100.0]
java.util.ImmutableCollections$List12
java.util.ImmutableCollections$ListN
java.util.GregorianCalendar
Integer.valueOf(100) == Integer.valueOf(100): true
planificarEntrega() es idéntico para todas las logísticas; lo único que varía es qué transporte se crea, y eso lo decide cada subclase sobrescribiendo crearTransporte().
⚠️ Importante: No confundas el patrón GoF con los métodos fábrica estáticos (static factory methods), como
List.of(),Integer.valueOf(),Calendar.getInstance()oTemperatura.deFahrenheit(). Estos no usan herencia, pero comparten la idea: ocultar elnewy decidir qué devolver. Mira la salida:List.of()devuelve una clase distinta según el número de elementos,Calendar.getInstance()devuelve unGregorianCalendar(según la configuración regional) eInteger.valueOf(100)reutiliza el mismo objeto (el patrón Flyweight). Además, tienen nombre (deFahrenheitdice mucho más que un constructor).
En el JDK, Collection.iterator() es un Factory Method de libro: cada colección (ArrayList, HashSet, TreeMap.keySet()…) crea su propio iterador concreto.
3.3. Abstract Factory
📘 Concepto: Abstract Factory proporciona una interfaz para crear familias de objetos relacionados sin especificar sus clases concretas. Garantiza que los productos que usas a la vez son compatibles entre sí (todos “claros” o todos “oscuros”).
Figura 6. Abstract Factory: cada fábrica concreta crea una familia completa de productos.
// Productos abstractos y dos familias concretas: "clara" y "oscura"
interface Boton { String pintar(); }
interface Casilla { String pintar(); }
record BotonClaro(String t) implements Boton { public String pintar() { return "[ " + t + " ] (fondo blanco)"; } }
record CasillaClara(String t) implements Casilla { public String pintar() { return "[ ] " + t + " (borde gris)"; } }
record BotonOscuro(String t) implements Boton { public String pintar() { return "[ " + t + " ] (fondo negro)"; } }
record CasillaOscura(String t) implements Casilla { public String pintar() { return "[ ] " + t + " (borde blanco)"; } }
// Fábrica abstracta: crea productos de UNA misma familia
interface FabricaUI { Boton crearBoton(String texto); Casilla crearCasilla(String texto); }
class FabricaClara implements FabricaUI {
public Boton crearBoton(String t) { return new BotonClaro(t); }
public Casilla crearCasilla(String t) { return new CasillaClara(t); }
}
class FabricaOscura implements FabricaUI {
public Boton crearBoton(String t) { return new BotonOscuro(t); }
public Casilla crearCasilla(String t) { return new CasillaOscura(t); }
}
record FormularioLogin(FabricaUI fabrica) { // cliente: solo conoce interfaces
void mostrar() {
IO.println(" " + fabrica.crearCasilla("Recordarme").pintar());
IO.println(" " + fabrica.crearBoton("Entrar").pintar());
}
}
void main() {
for (String tema : List.of("oscuro", "claro")) { // podría venir de la configuración
FabricaUI fabrica = tema.equals("claro") ? new FabricaClara() : new FabricaOscura();
IO.println("Tema " + tema + ":"); new FormularioLogin(fabrica).mostrar();
}
// En el JDK: DocumentBuilderFactory devuelve la fábrica de la implementación instalada
IO.println(javax.xml.parsers.DocumentBuilderFactory.newInstance().getClass().getName());
}
Tema oscuro:
[ ] Recordarme (borde blanco)
[ Entrar ] (fondo negro)
Tema claro:
[ ] Recordarme (borde gris)
[ Entrar ] (fondo blanco)
com.sun.org.apache.xerces.internal.jaxp.DocumentBuilderFactoryImpl
FormularioLogin nunca nombra BotonOscuro: cambiar de tema es cambiar una línea (la fábrica que se le pasa). En el JDK, DocumentBuilderFactory.newInstance() devuelve la fábrica del analizador XML instalado (Xerces, en el JDK estándar, como muestra la salida) y tu código solo ve las interfaces DocumentBuilder y Document. La diferencia con Factory Method: este crea un producto mediante herencia (una subclase sobrescribe un método); Abstract Factory crea una familia mediante composición (el cliente recibe un objeto fábrica).
3.4. Builder
📘 Concepto: Builder separa la construcción de un objeto complejo de su representación: lo construyes paso a paso con métodos con nombre y al final llamas a
build(), que valida y devuelve el objeto (normalmente inmutable). Resuelve el problema del constructor telescópico:new Pizza("mediana", "gruesa", true, false, null, 2). ¿Qué era cadatrue? Con un builder, el código se lee solo.
Figura 7. Builder: una clase anidada con métodos fluidos que devuelven el propio builder.
El programa usa dos builders del JDK y define otros dos: el clásico que populariza Joshua Bloch en Effective Java y una versión con un record (Java 16), cuyo constructor compacto se encarga de la validación:
import module java.base;
import module java.net.http; // HttpClient y HttpRequest
public class BuilderDemo {
public static void main(String[] args) {
Pizza p = Pizza.builder("mediana").masa("gruesa").extraQueso().ingrediente("tomate").ingrediente("jamón").build();
IO.println(p); IO.println(Pizza.builder("pequeña").build());
Usuario u = Usuario.builder().nombre("Ana").email("ana@empresa.es").roles("ADMIN").build();
IO.println(u); IO.println(u.conEmail("ana.garcia@empresa.es"));
try { Usuario.builder().nombre("Luis").email("luis-sin-arroba").build(); }
catch (IllegalArgumentException e) { IO.println("Error: " + e.getMessage()); }
// Builders del JDK
HttpClient cliente = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
HttpRequest peticion = HttpRequest.newBuilder(URI.create("https://example.org")).header("Accept", "text/html").build();
IO.println(peticion.method() + " " + peticion.uri() + " " + cliente.connectTimeout().orElseThrow());
}
}
// ---------- 1) Builder clásico (Effective Java): clase inmutable + Builder anidado ----------
final class Pizza {
private final String tamano, masa; // tamaño obligatorio; masa opcional
private final boolean extraQueso;
private final List<String> ingredientes;
private Pizza(Builder b) { // solo el Builder puede crearla
tamano = b.tamano; masa = b.masa; extraQueso = b.extraQueso;
ingredientes = List.copyOf(b.ingredientes); // copia defensiva: inmutable
}
public static Builder builder(String tamano) { return new Builder(tamano); }
@Override public String toString() {
return "Pizza " + tamano + ", masa " + masa + (extraQueso ? ", extra de queso" : "") + ", " + ingredientes;
}
public static final class Builder {
private final String tamano;
private String masa = "fina"; // valor por defecto
private boolean extraQueso;
private final List<String> ingredientes = new ArrayList<>();
private Builder(String tamano) { this.tamano = tamano; }
public Builder masa(String masa) { this.masa = masa; return this; } // fluido: devuelve this
public Builder extraQueso() { this.extraQueso = true; return this; }
public Builder ingrediente(String i) { ingredientes.add(i); return this; }
public Pizza build() {
if (ingredientes.size() > 5) throw new IllegalStateException("Máximo 5 ingredientes");
return new Pizza(this);
}
}
}
// ---------- 2) Builder con un record (Java 16+): validación en el constructor compacto ----------
record Usuario(String nombre, String email, Set<String> roles) {
Usuario {
if (nombre == null || nombre.isBlank()) throw new IllegalArgumentException("nombre obligatorio");
if (!email.contains("@")) throw new IllegalArgumentException("email no válido: " + email);
roles = Set.copyOf(roles);
}
static Builder builder() { return new Builder(); }
Usuario conEmail(String nuevo) { return new Usuario(nombre, nuevo, roles); } // "wither"
static final class Builder {
private String nombre, email = "sin@email";
private Set<String> roles = Set.of("USER");
Builder nombre(String n) { nombre = n; return this; }
Builder email(String e) { email = e; return this; }
Builder roles(String... r) { roles = Set.of(r); return this; }
Usuario build() { return new Usuario(nombre, email, roles); }
}
}
Pizza mediana, masa gruesa, extra de queso, [tomate, jamón]
Pizza pequeña, masa fina, []
Usuario[nombre=Ana, email=ana@empresa.es, roles=[ADMIN]]
Usuario[nombre=Ana, email=ana.garcia@empresa.es, roles=[ADMIN]]
Error: email no válido: luis-sin-arroba
GET https://example.org PT5S
Observa tres detalles profesionales: el constructor de Pizza es privado (solo el builder puede crearla), List.copyOf() hace una copia defensiva para que el objeto sea realmente inmutable, y conEmail() es un wither: en lugar de modificar el record, crea una copia con un campo cambiado. Builders del JDK que ya conoces: StringBuilder, HttpClient.newBuilder() y HttpRequest.newBuilder() (módulo 09), Stream.builder(), Locale.Builder. En proyectos reales es frecuente generar el builder con la anotación @Builder de Lombok, pero conviene que sepas escribirlo a mano.
4. Patrones estructurales
4.1. Adapter
📘 Concepto: Adapter convierte la interfaz de una clase en otra interfaz que el cliente espera. Es el adaptador de enchufe que te llevas de viaje: no cambia el aparato ni la pared, solo hace que encajen.
Figura 8. Adapter: TermometroAdapter implementa la interfaz que espera el panel y delega en la clase que no podemos cambiar.
interface SensorTemperatura { double celsius(); } // Target: lo que espera nuestra aplicación
class TermometroUsa { // Adaptee: de terceros, no podemos cambiarlo
double leerFahrenheit() { return 98.6; }
}
class TermometroAdapter implements SensorTemperatura { // implementa Target y delega en el Adaptee
private final TermometroUsa termometro;
TermometroAdapter(TermometroUsa termometro) { this.termometro = termometro; }
public double celsius() { return (termometro.leerFahrenheit() - 32) * 5 / 9; }
}
class PanelControl {
void mostrar(SensorTemperatura s) { IO.println("Temperatura: %.1f ºC".formatted(s.celsius())); }
}
void main() throws IOException {
var panel = new PanelControl();
panel.mostrar(() -> 21.5); // un sensor "nativo" (lambda)
panel.mostrar(new TermometroAdapter(new TermometroUsa())); // el adaptado
// Adaptadores del JDK
String[] array = {"c", "a", "b"};
List<String> vista = Arrays.asList(array); // adapta un array a List
vista.sort(null); IO.println("Array tras ordenar la lista: " + Arrays.toString(array));
var bytes = new ByteArrayInputStream("año\nñandú".getBytes(StandardCharsets.UTF_8));
try (var lector = new BufferedReader(new InputStreamReader(bytes, StandardCharsets.UTF_8))) { // bytes -> chars
lector.lines().forEach(l -> IO.println("Línea: " + l));
}
}
Temperatura: 21.5 ºC
Temperatura: 37.0 ºC
Array tras ordenar la lista: [a, b, c]
Línea: año
Línea: ñandú
Arrays.asList() adapta un array a la interfaz List (es una vista: ordenar la lista ordena el array), e InputStreamReader adapta un flujo de bytes (InputStream) a un flujo de caracteres (Reader), como viste en el módulo 08. Usarás adaptadores constantemente al integrar bibliotecas externas: envuelve la API de terceros detrás de una interfaz tuya. Si mañana cambias de proveedor de pagos, solo reescribes el adaptador.
4.2. Decorator
📘 Concepto: Decorator añade responsabilidades a un objeto dinámicamente, envolviéndolo en otro objeto que implementa la misma interfaz. Los decoradores se pueden apilar en cualquier orden, como capas de una cebolla.
Figura 9. Decorator: el decorador es un Notificador y además tiene un Notificador al que delega.
interface Notificador { void enviar(String mensaje); } // componente
class NotificadorEmail implements Notificador { // componente concreto
public void enviar(String mensaje) { IO.println(" Email: " + mensaje); }
}
// Decorador base: ES un Notificador y TIENE un Notificador
abstract class NotificadorDecorador implements Notificador {
protected final Notificador envuelto;
NotificadorDecorador(Notificador envuelto) { this.envuelto = envuelto; }
public void enviar(String mensaje) { envuelto.enviar(mensaje); }
}
class ConSms extends NotificadorDecorador {
ConSms(Notificador n) { super(n); }
@Override public void enviar(String m) { super.enviar(m); IO.println(" SMS: " + m); } // delega y añade
}
class ConSlack extends NotificadorDecorador {
ConSlack(Notificador n) { super(n); }
@Override public void enviar(String m) { super.enviar(m); IO.println(" Slack: " + m); }
}
class ConMayusculas extends NotificadorDecorador { // transforma ANTES de delegar
ConMayusculas(Notificador n) { super(n); }
@Override public void enviar(String m) { super.enviar(m.toUpperCase()); }
}
void main() throws IOException {
IO.println("Email + SMS + Slack:");
new ConSlack(new ConSms(new NotificadorEmail())).enviar("Servidor caído");
IO.println("Mayúsculas + email + SMS:");
new ConMayusculas(new ConSms(new NotificadorEmail())).enviar("disco al 95 %");
// java.io: cada Reader envuelve a otro y le añade una capacidad
try (var lector = new LineNumberReader(new BufferedReader(new StringReader("uno\ndos\ntres")))) {
String linea;
while ((linea = lector.readLine()) != null) IO.println(lector.getLineNumber() + ": " + linea);
}
}
Email + SMS + Slack:
Email: Servidor caído
SMS: Servidor caído
Slack: Servidor caído
Mayúsculas + email + SMS:
Email: DISCO AL 95 %
SMS: DISCO AL 95 %
1: uno
2: dos
3: tres
La alternativa con herencia sería una explosión de clases: NotificadorEmailSms, NotificadorEmailSlack, NotificadorEmailSmsSlack… Con decoradores combinas piezas en tiempo de ejecución. El ejemplo más famoso es java.io: FilterInputStream y FilterReader son decoradores base, y BufferedReader, LineNumberReader, BufferedInputStream, GZIPInputStream o DataInputStream añaden cada uno una capacidad (búfer, número de línea, descompresión, lectura de tipos primitivos). Cuando escribes new BufferedReader(new InputStreamReader(new FileInputStream(f))) estás apilando un decorador sobre un adaptador sobre un flujo. Collections.synchronizedList() y Collections.unmodifiableList() también envuelven una lista y devuelven otra List. El primero añade sincronización (un decorador); el segundo controla el acceso (lo verás como proxy de protección). La frontera entre ambos patrones es la intención: añadir comportamiento o controlar el acceso.
4.3. Facade
📘 Concepto: Facade (fachada) proporciona una interfaz unificada y sencilla a un conjunto de interfaces de un subsistema. Es el mostrador de recepción de un hotel: no necesitas hablar con limpieza, cocina y mantenimiento; pides en recepción y ellos coordinan.
Figura 10. Facade: el cliente hace una sola llamada; la fachada orquesta el subsistema.
// Subsistema: muchas clases, cada una con su propia API
class Inventario {
boolean hayStock(String p, int n) { IO.println(" [Inventario] comprobando " + n + " x " + p); return n <= 10; }
void reservar(String p, int n) { IO.println(" [Inventario] reservadas " + n); }
}
class PasarelaPago {
String cobrar(String tarj, double imp) {
IO.println(" [Pago] cobrando %.2f € a ****%s".formatted(imp, tarj.substring(tarj.length() - 4)));
return "TX-" + Math.abs(tarj.hashCode() % 10000);
}
}
class ServicioEnvios {
String programar(String dir) { IO.println(" [Envíos] envío programado a " + dir); return "ENV-42"; }
}
class Notificaciones {
void email(String texto) { IO.println(" [Email] " + texto); }
}
// Fachada: una operación de alto nivel que orquesta el subsistema
class TiendaFacade {
private final Inventario inventario = new Inventario();
private final PasarelaPago pago = new PasarelaPago();
private final ServicioEnvios envios = new ServicioEnvios();
private final Notificaciones avisos = new Notificaciones();
public boolean comprar(String producto, int uds, double precio, String tarjeta, String direccion) {
if (!inventario.hayStock(producto, uds)) { IO.println(" Sin stock suficiente"); return false; }
inventario.reservar(producto, uds);
String tx = pago.cobrar(tarjeta, uds * precio), envio = envios.programar(direccion);
avisos.email("Pedido confirmado (" + tx + ", " + envio + ")");
return true;
}
}
void main() {
var tienda = new TiendaFacade();
IO.println("Compra 1: " + tienda.comprar("Teclado", 2, 35.0, "4111111111111111", "C/ Mayor 1, Valencia"));
IO.println("Compra 2: " + tienda.comprar("Ratón", 50, 9.95, "4111111111111111", "C/ Mayor 1, Valencia"));
}
[Inventario] comprobando 2 x Teclado
[Inventario] reservadas 2
[Pago] cobrando 70.00 € a ****1111
[Envíos] envío programado a C/ Mayor 1, Valencia
[Email] Pedido confirmado (TX-6765, ENV-42)
Compra 1: true
[Inventario] comprobando 50 x Ratón
Sin stock suficiente
Compra 2: false
La fachada no impide usar el subsistema directamente si alguien lo necesita; simplemente ofrece el camino fácil para el caso habitual. En una aplicación por capas, la capa de servicio suele actuar como fachada de la persistencia y de otros servicios. SLF4J lleva el patrón en el nombre (Simple Logging Facade for Java): tu código escribe en su API y por debajo funciona Logback, Log4j 2 u otro sistema de logging.
4.4. Proxy
📘 Concepto: Proxy es un sustituto que implementa la misma interfaz que el objeto real y controla el acceso a él. Tipos frecuentes: virtual (crea el objeto caro solo cuando hace falta), de protección (comprueba permisos), remoto (representa un objeto de otra máquina) y de registro o caché.
Figura 11. Proxy virtual: ImagenProxy crea la ImagenReal la primera vez que se necesita.
Además del proxy escrito a mano, el ejemplo crea un proxy dinámico con java.lang.reflect.Proxy: un único InvocationHandler intercepta todas las llamadas a los métodos de una interfaz.
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Proxy; // import explícito: java.net también tiene una clase Proxy
interface Imagen { void mostrar(); } // sujeto
class ImagenReal implements Imagen { // sujeto real: caro de crear
private final String fichero;
ImagenReal(String fichero) { this.fichero = fichero; IO.println(" Cargando " + fichero + " del disco (lento)..."); }
public void mostrar() { IO.println(" Mostrando " + fichero); }
}
class ImagenProxy implements Imagen { // proxy virtual: crea el real solo cuando hace falta
private final String fichero;
private ImagenReal real; // aún no existe
ImagenProxy(String fichero) { this.fichero = fichero; }
public void mostrar() {
if (real == null) real = new ImagenReal(fichero); // carga perezosa
real.mostrar();
}
}
interface Calculadora { int sumar(int a, int b); int dividir(int a, int b); }
record CalculadoraSimple() implements Calculadora {
public int sumar(int a, int b) { return a + b; }
public int dividir(int a, int b) { return a / b; }
}
// Proxy dinámico: un único manejador vale para CUALQUIER interfaz
@SuppressWarnings("unchecked")
<T> T conTraza(T objetivo, Class<T> interfaz) {
InvocationHandler h = (proxy, metodo, args) -> {
IO.println(" [traza] -> " + metodo.getName() + Arrays.toString(args));
try {
Object r = metodo.invoke(objetivo, args); // delega en el objeto real
IO.println(" [traza] <- " + r);
return r;
} catch (InvocationTargetException e) {
IO.println(" [traza] <- excepción " + e.getCause());
throw e.getCause(); // relanza la original
}
};
return (T) Proxy.newProxyInstance(interfaz.getClassLoader(), new Class<?>[]{interfaz}, h);
}
void main() {
Imagen foto = new ImagenProxy("vacaciones.jpg"); IO.println("Proxy creado. Primera llamada:");
foto.mostrar(); IO.println("Segunda llamada:"); foto.mostrar();
Calculadora calc = conTraza(new CalculadoraSimple(), Calculadora.class);
calc.sumar(2, 3);
try { calc.dividir(1, 0); } catch (ArithmeticException e) { IO.println("Capturada: " + e.getMessage()); }
IO.println("Clase: " + calc.getClass().getSimpleName() + ", ¿es proxy? " + Proxy.isProxyClass(calc.getClass()));
}
Proxy creado. Primera llamada:
Cargando vacaciones.jpg del disco (lento)...
Mostrando vacaciones.jpg
Segunda llamada:
Mostrando vacaciones.jpg
[traza] -> sumar[2, 3]
[traza] <- 5
[traza] -> dividir[1, 0]
[traza] <- excepción java.lang.ArithmeticException: / by zero
Capturada: / by zero
Clase: $Proxy0, ¿es proxy? true
Proxy.newProxyInstance() ha generado en tiempo de ejecución una clase ($Proxy0) que implementa Calculadora y pasa cada llamada por el manejador. Así funciona la programación orientada a aspectos (AOP) de Spring: cuando anotas un método con @Transactional, Spring no te devuelve tu objeto sino un proxy que abre la transacción, llama a tu método y hace commit o rollback. Spring crea proxies JDK dinámicos (como el del ejemplo) cuando el objetivo implementa una interfaz, o proxies CGLIB (una subclase generada) cuando no; Spring Boot configura por defecto los de tipo CGLIB. Hibernate usa también proxies para la carga perezosa de entidades (módulo 15).
⚠️ Importante: Una consecuencia práctica de los proxies de Spring: si un método de un bean llama a otro método del mismo objeto (
this.otroMetodo()), la llamada no pasa por el proxy, así que su@Transactionalno se aplica. Recuérdalo cuando llegues al módulo 19.
Resumiendo los “envoltorios”: Adapter ofrece una interfaz distinta para hacer compatibles dos interfaces; Decorator mantiene la misma interfaz y añade comportamiento; Proxy mantiene la misma interfaz y controla el acceso (carga perezosa, permisos, transacciones); Facade ofrece una interfaz nueva y más simple para todo un subsistema.
4.5. Composite
📘 Concepto: Composite compone objetos en estructuras de árbol y permite que el cliente trate igual a un objeto individual (hoja) que a un grupo de objetos (compuesto). Un sistema de ficheros, un menú con submenús, el organigrama de una empresa o los componentes de una interfaz gráfica son árboles.
Figura 12. Composite: una Carpeta contiene Elementos, que pueden ser ficheros u otras carpetas.
// Componente: hojas y compuestos se tratan igual
sealed interface Elemento permits Fichero, Carpeta {
String nombre();
long tamano();
default void imprimir(String sangria) { IO.println(sangria + nombre() + " (" + tamano() + " KB)"); }
}
record Fichero(String nombre, long tamano) implements Elemento { } // hoja
record Carpeta(String nombre, List<Elemento> hijos) implements Elemento { // compuesto
Carpeta(String nombre) { this(nombre, new ArrayList<>()); }
Carpeta anadir(Elemento e) { hijos.add(e); return this; }
public long tamano() { return hijos.stream().mapToLong(Elemento::tamano).sum(); } // recursión
@Override public void imprimir(String sangria) {
IO.println(sangria + nombre() + "/ (" + tamano() + " KB)");
hijos.forEach(h -> h.imprimir(sangria + " "));
}
}
// Con sealed + switch con patrones (Java 21) también se recorre el árbol "desde fuera"
int contarFicheros(Elemento e) {
return switch (e) {
case Fichero f -> 1;
case Carpeta c -> c.hijos().stream().mapToInt(this::contarFicheros).sum();
};
}
void main() {
var raiz = new Carpeta("proyecto")
.anadir(new Fichero("pom.xml", 4))
.anadir(new Carpeta("src")
.anadir(new Fichero("Main.java", 12))
.anadir(new Carpeta("modelo").anadir(new Fichero("Cliente.java", 6)).anadir(new Fichero("Pedido.java", 9))))
.anadir(new Fichero("README.md", 3));
raiz.imprimir(""); IO.println("Ficheros: " + contarFicheros(raiz));
}
proyecto/ (34 KB)
pom.xml (4 KB)
src/ (27 KB)
Main.java (12 KB)
modelo/ (15 KB)
Cliente.java (6 KB)
Pedido.java (9 KB)
README.md (3 KB)
Ficheros: 5
La magia está en Carpeta.tamano(): suma el tamaño de sus hijos sin preguntar si son ficheros o carpetas; la recursión baja sola por el árbol. Con sealed (Java 17) y el switch con patrones (Java 21), además, puedes escribir operaciones nuevas fuera de las clases (contarFicheros) y el compilador comprueba que cubres todos los casos. En el JDK, java.awt.Container es un Component que contiene otros Component; en JavaFX, Parent es un Node que contiene Node. En el módulo 08 recorriste directorios con Files.walk(): el sistema de ficheros es el Composite por excelencia.
5. Patrones de comportamiento
5.1. Strategy
📘 Concepto: Strategy define una familia de algoritmos, encapsula cada uno en su propia clase y los hace intercambiables. El objeto que los usa (el contexto) no sabe qué estrategia tiene; solo conoce la interfaz.
Figura 13. Strategy: el Carrito delega el cálculo del envío en una estrategia intercambiable.
@FunctionalInterface
interface EstrategiaEnvio { double coste(double pesoKg); } // la familia de algoritmos
class EnvioEstandar implements EstrategiaEnvio { public double coste(double kg) { return 4.95 + 0.5 * kg; } }
class EnvioUrgente implements EstrategiaEnvio { public double coste(double kg) { return 9.95 + 1.2 * kg; } }
class Carrito { // contexto
private EstrategiaEnvio envio = new EnvioEstandar();
void setEnvio(EstrategiaEnvio envio) { this.envio = envio; } // se cambia en ejecución
double totalEnvio(double pesoKg) { return envio.coste(pesoKg); }
}
record Producto(String nombre, double precio, int stock) { }
void main() {
var carrito = new Carrito(); IO.println("Estándar: %.2f".formatted(carrito.totalEnvio(3)));
carrito.setEnvio(new EnvioUrgente());
IO.println("Urgente: %.2f".formatted(carrito.totalEnvio(3)));
carrito.setEnvio(peso -> 0); // con lambdas (Java 8+) no hace falta clase
IO.println("Gratis: %.2f".formatted(carrito.totalEnvio(3)));
// El ejemplo más famoso del JDK: Comparator es una estrategia de comparación
var productos = List.of(new Producto("Ratón", 9.95, 8), new Producto("Monitor", 189.0, 15), new Producto("Teclado", 35.0, 12));
IO.println(productos.stream().sorted(Comparator.comparingDouble(Producto::precio)).map(Producto::nombre).toList());
IO.println(productos.stream().sorted(Comparator.comparingInt(Producto::stock).reversed()).map(Producto::nombre).toList());
}
Estándar: 6.45
Urgente: 13.55
Gratis: 0.00
[Ratón, Teclado, Monitor]
[Monitor, Teclado, Ratón]
Desde Java 8, si la interfaz de la estrategia es funcional (un único método abstracto), cada estrategia sencilla es una lambda: el patrón casi desaparece en una línea. Para estrategias con estado o lógica compleja, sigue siendo mejor una clase con nombre. Comparator es el Strategy más famoso del JDK: sort() y sorted() implementan el algoritmo de ordenación y tú les pasas la estrategia de comparación. Otros ejemplos: el RejectedExecutionHandler de un ThreadPoolExecutor (qué hacer cuando la cola está llena, módulo 07) o los distintos PasswordEncoder de Spring Security (módulo 22).
5.2. Observer
📘 Concepto: Observer define una dependencia uno a muchos: cuando un objeto (el sujeto o publicador) cambia de estado, avisa automáticamente a todos sus observadores (suscriptores). El sujeto no sabe quiénes son ni qué harán con el aviso: es el modelo de las suscripciones, en el que recibes cada novedad sin preguntar cada cinco minutos.
Figura 14. Observer: la Cotizacion notifica a una lista de ObservadorBolsa.
record CambioPrecio(String accion, double anterior, double nuevo) { } // el evento
@FunctionalInterface
interface ObservadorBolsa { void actualizar(CambioPrecio cambio); } // observador
class Cotizacion { // sujeto
private final String accion;
private double precio;
private final List<ObservadorBolsa> observadores = new CopyOnWriteArrayList<>();
Cotizacion(String accion, double precio) { this.accion = accion; this.precio = precio; }
Runnable suscribir(ObservadorBolsa o) { observadores.add(o); return () -> observadores.remove(o); } // y su baja
void setPrecio(double nuevo) {
var cambio = new CambioPrecio(accion, precio, nuevo);
precio = nuevo;
observadores.forEach(o -> o.actualizar(cambio)); // avisa a todos
}
}
record AlertaCaida(double umbral) implements ObservadorBolsa { // observador con estado
public void actualizar(CambioPrecio c) {
double variacion = (c.nuevo() - c.anterior()) / c.anterior() * 100;
if (variacion <= -umbral) IO.println(" ¡ALERTA! " + c.accion() + " cae un %.1f %%".formatted(-variacion));
}
}
void main() {
var acme = new Cotizacion("ACME", 100);
Runnable baja = acme.suscribir(c -> IO.println(" Panel: " + c.accion() + " " + c.anterior() + " -> " + c.nuevo()));
acme.suscribir(new AlertaCaida(5)); IO.println("Precio 95:"); acme.setPrecio(95);
baja.run(); // el panel se da de baja
IO.println("Precio 80 (sin panel):"); acme.setPrecio(80);
}
Precio 95:
Panel: ACME 100.0 -> 95.0
¡ALERTA! ACME cae un 5.0 %
Precio 80 (sin panel):
¡ALERTA! ACME cae un 15.8 %
Detalles profesionales del ejemplo: suscribir() devuelve un Runnable para darse de baja (sin baja, los observadores olvidados provocan fugas de memoria); la lista es una CopyOnWriteArrayList, que admite que un observador se dé de baja mientras se recorre la lista; y el evento es un record inmutable, así que ningún observador puede alterarlo para los demás.
En el JDK, java.util.Observable y Observer están obsoletas desde Java 9: no las uses. Las alternativas son java.beans.PropertyChangeSupport con PropertyChangeListener (notificación de cambios de propiedades, muy usada en Swing y JavaFX) y java.util.concurrent.Flow (Java 9), con Publisher y Subscriber asíncronos y control de flujo (Reactive Streams; SubmissionPublisher es una implementación). En Spring publicas un evento con ApplicationEventPublisher.publishEvent() y cualquier bean con un método anotado con @EventListener lo recibe. Cuidado: los observadores se ejecutan en el hilo del sujeto y en serie. Un observador lento o que lanza una excepción afecta a todos los demás. Si la notificación es costosa, envíala a un Executor (módulo 07).
5.3. Template Method
📘 Concepto: Template Method define en una superclase el esqueleto de un algoritmo (el método plantilla, normalmente
final) y deja que las subclases implementen algunos pasos concretos. Los pasos opcionales con una implementación por defecto se llaman ganchos (hooks).
Figura 15. Template Method: exportar() fija el orden de los pasos; las subclases rellenan cada paso.
record Venta(String producto, int unidades, double precio) { }
abstract class Exportador {
// Método plantilla: fija el esqueleto del algoritmo; final para que nadie lo altere
public final String exportar(List<Venta> ventas) {
var sb = new StringBuilder(cabecera());
for (Venta v : ventas) sb.append(fila(v));
if (incluirTotal()) sb.append(total(ventas.stream().mapToDouble(v -> v.unidades() * v.precio()).sum()));
return sb.append(pie()).toString();
}
protected abstract String cabecera(); // pasos obligatorios
protected abstract String fila(Venta v);
protected abstract String total(double total);
protected boolean incluirTotal() { return true; } // "ganchos" (hooks) opcionales
protected String pie() { return ""; }
}
class ExportadorCsv extends Exportador {
protected String cabecera() { return "producto;unidades;precio\n"; }
protected String fila(Venta v) { return v.producto() + ";" + v.unidades() + ";" + v.precio() + "\n"; }
protected String total(double t) { return "TOTAL;;" + t + "\n"; }
}
class ExportadorHtml extends Exportador {
protected String cabecera() { return "<table>\n"; }
protected String fila(Venta v) { return " <tr><td>" + v.producto() + "</td><td>" + v.unidades() + "</td></tr>\n"; }
protected String total(double t) { return ""; }
@Override protected boolean incluirTotal() { return false; } // redefine los ganchos
@Override protected String pie() { return "</table>\n"; }
}
class Cuadrados extends AbstractList<Integer> { // JDK: AbstractList es un Template Method
private final int n;
Cuadrados(int n) { this.n = n; }
@Override public Integer get(int i) { return i * i; } // solo dos métodos...
@Override public int size() { return n; }
}
void main() {
var ventas = List.of(new Venta("Teclado", 2, 35.0), new Venta("Ratón", 3, 9.5));
IO.print(new ExportadorCsv().exportar(ventas)); IO.print(new ExportadorHtml().exportar(ventas));
var cuadrados = new Cuadrados(6); // ...y obtenemos toString, contains, subList, stream...
IO.println(cuadrados + " " + cuadrados.contains(16) + " " + cuadrados.subList(1, 3) + " " + cuadrados.stream().mapToInt(i -> i).sum());
}
producto;unidades;precio
Teclado;2;35.0
Ratón;3;9.5
TOTAL;;98.5
<table>
<tr><td>Teclado</td><td>2</td></tr>
<tr><td>Ratón</td><td>3</td></tr>
</table>
[0, 1, 4, 9, 16, 25] true [1, 4] 55
AbstractList es un Template Method a gran escala: implementa contains(), indexOf(), iterator(), subList(), equals(), toString() y muchos más a partir de get() y size(), que tú defines. Del mismo modo, InputStream.read(byte[], int, int) está implementado llamando repetidamente a read(), y HttpServlet.service() despacha a doGet(), doPost(), etc., que sobrescribes tú. Spring lleva la idea más lejos con sus clases Template: JdbcTemplate (lo conocerás al trabajar con Spring) ejecuta todos los pasos fijos de JDBC (obtener la conexión, crear la sentencia, ejecutarla, recorrer el ResultSet, traducir excepciones y cerrar recursos) y tú solo aportas el SQL y un RowMapper que convierte cada fila. Los pasos variables se pasan como callbacks (lambdas) en lugar de subclases: es Template Method con composición en vez de herencia.
5.4. Command
📘 Concepto: Command encapsula una petición como un objeto. Así puedes guardarla en una cola, ejecutarla más tarde o en otro hilo, registrarla y, sobre todo, deshacerla. Participan: el invocador (quien lanza la orden sin saber qué hace), la orden y el receptor (quien hace el trabajo real).
Figura 16. Command: el Editor guarda un historial de órdenes que saben ejecutarse y deshacerse.
class Documento { // receptor: hace el trabajo real
private final StringBuilder texto = new StringBuilder();
void insertar(int pos, String s) { texto.insert(pos, s); }
void borrar(int pos, int longitud) { texto.delete(pos, pos + longitud); }
int longitud() { return texto.length(); }
String contenido() { return texto.toString(); }
@Override public String toString() { return "'" + texto + "'"; }
}
interface Comando { void ejecutar(); void deshacer(); } // orden: una petición como objeto
class Escribir implements Comando {
private final Documento doc;
private final String texto;
private int posicion;
Escribir(Documento doc, String texto) { this.doc = doc; this.texto = texto; }
public void ejecutar() { posicion = doc.longitud(); doc.insertar(posicion, texto); }
public void deshacer() { doc.borrar(posicion, texto.length()); }
}
class BorrarFinal implements Comando {
private final Documento doc;
private final int n;
private String borrado = ""; // lo necesario para deshacer
BorrarFinal(Documento doc, int n) { this.doc = doc; this.n = n; }
public void ejecutar() {
int desde = Math.max(0, doc.longitud() - n);
borrado = doc.contenido().substring(desde);
doc.borrar(desde, borrado.length());
}
public void deshacer() { doc.insertar(doc.longitud(), borrado); }
}
class Editor { // invocador: no sabe qué hace cada orden
private final Deque<Comando> historial = new ArrayDeque<>(), rehacer = new ArrayDeque<>();
void ejecutar(Comando c) { c.ejecutar(); historial.push(c); rehacer.clear(); }
void deshacer() { if (!historial.isEmpty()) { Comando c = historial.pop(); c.deshacer(); rehacer.push(c); } }
void rehacer() { if (!rehacer.isEmpty()) { Comando c = rehacer.pop(); c.ejecutar(); historial.push(c); } }
}
void main() {
var doc = new Documento(); var editor = new Editor();
for (String s : List.of("Hola", " mundo", "!!!")) editor.ejecutar(new Escribir(doc, s));
IO.println("Escrito: " + doc);
editor.ejecutar(new BorrarFinal(doc, 2)); IO.println("Borro 2: " + doc);
editor.deshacer(); IO.println("Deshacer: " + doc);
editor.deshacer(); editor.deshacer(); IO.println("Deshacer x2: " + doc);
editor.rehacer(); IO.println("Rehacer: " + doc);
}
Escrito: 'Hola mundo!!!'
Borro 2: 'Hola mundo!'
Deshacer: 'Hola mundo!!!'
Deshacer x2: 'Hola'
Rehacer: 'Hola mundo'
Cada orden guarda lo necesario para revertirse (posicion, borrado). El editor mantiene dos pilas: al ejecutar una orden nueva se vacía la de “rehacer”, como en cualquier procesador de textos. En el JDK, Runnable y Callable son órdenes sin deshacer: creas la tarea en un sitio y un ExecutorService (el invocador) la ejecuta en otro hilo, cuando le toca. En las interfaces gráficas, javax.swing.Action encapsula una acción que comparten un botón, una opción de menú y un atajo de teclado.
5.5. State
📘 Concepto: State permite que un objeto cambie su comportamiento cuando cambia su estado interno, como si cambiara de clase. Cada estado se representa con un objeto que sabe qué acciones admite y a qué estado lleva cada una. Sustituye los
switchsobre el estado repetidos en todos los métodos.
Figura 17. State: el Pedido delega en su estado; a la derecha, la máquina de estados que implementa.
interface EstadoPedido { // cada estado decide qué admite y cuál es el siguiente
default EstadoPedido pagar() { return invalida("pagar"); }
default EstadoPedido enviar() { return invalida("enviar"); }
default EstadoPedido entregar() { return invalida("entregar"); }
default EstadoPedido cancelar() { return invalida("cancelar"); }
private EstadoPedido invalida(String accion) {
IO.println(" No se puede " + accion + " un pedido " + this);
return this; // se queda como estaba
}
}
enum Estado implements EstadoPedido { // estados concretos: cada constante sobrescribe lo que admite
PENDIENTE {
@Override public EstadoPedido pagar() { return PAGADO; }
@Override public EstadoPedido cancelar() { return CANCELADO; }
},
PAGADO {
@Override public EstadoPedido enviar() { return ENVIADO; }
@Override public EstadoPedido cancelar() { IO.println(" Reembolsando el pago..."); return CANCELADO; }
},
ENVIADO { @Override public EstadoPedido entregar() { return ENTREGADO; } },
ENTREGADO, CANCELADO; // estados finales: no admiten nada
}
class Pedido { // contexto: delega en su estado, sin un solo if sobre él
private final String id;
private EstadoPedido estado = Estado.PENDIENTE;
Pedido(String id) { this.id = id; }
void pagar() { cambiar(estado.pagar()); }
void enviar() { cambiar(estado.enviar()); }
void entregar() { cambiar(estado.entregar()); }
void cancelar() { cambiar(estado.cancelar()); }
private void cambiar(EstadoPedido nuevo) {
if (nuevo != estado) IO.println(" " + id + ": " + estado + " -> " + nuevo);
estado = nuevo;
}
}
void main() {
IO.println("Pedido A:");
var a = new Pedido("A");
a.enviar(); // aún no está pagado
a.pagar(); a.enviar();
a.cancelar(); // un pedido enviado no se cancela
a.entregar();
IO.println("Pedido B:");
var b = new Pedido("B");
b.pagar(); b.cancelar(); b.pagar();
}
Pedido A:
No se puede enviar un pedido PENDIENTE
A: PENDIENTE -> PAGADO
A: PAGADO -> ENVIADO
No se puede cancelar un pedido ENVIADO
A: ENVIADO -> ENTREGADO
Pedido B:
B: PENDIENTE -> PAGADO
Reembolsando el pago...
B: PAGADO -> CANCELADO
No se puede pagar un pedido CANCELADO
Fíjate en que Pedido no tiene ni un if sobre el estado. Usar un enum cuyas constantes sobrescriben métodos es una forma muy compacta de implementar State en Java cuando los estados no tienen datos propios; los métodos default y private de la interfaz (Java 8 y 9) aportan el comportamiento común “acción no permitida”. Si cada estado necesitara datos (la fecha de envío, el motivo de la cancelación), lo modelarías con records que implementan una interfaz sealed (lo harás en el ejercicio 5). State y Strategy tienen el mismo diagrama de clases. La diferencia está en quién cambia el objeto: en Strategy lo elige el cliente desde fuera; en State los propios estados deciden la transición al siguiente.
5.6. Iterator
📘 Concepto: Iterator proporciona una forma de recorrer los elementos de una colección secuencialmente sin exponer su estructura interna (array, lista enlazada, árbol…). Es tan útil que Java lo integró en el lenguaje: el bucle for-each funciona con cualquier
Iterable.
Figura 18. Iterator: la colección crea iteradores que la recorren sin mostrar su array interno.
class ListaReproduccion implements Iterable<String> { // agregado
private final String[] canciones = new String[10]; // estructura interna oculta
private int total;
void anadir(String c) { canciones[total++] = c; }
@Override public Iterator<String> iterator() { // crea un iterador concreto
return new Iterator<>() {
private int i = 0;
public boolean hasNext() { return i < total; }
public String next() {
if (!hasNext()) throw new NoSuchElementException();
return canciones[i++];
}
};
}
}
// Iterador generado al vuelo: nunca existe la colección completa
record Rango(int desde, int hasta, int paso) implements Iterable<Integer> {
public Iterator<Integer> iterator() {
return new Iterator<>() {
private int actual = desde;
public boolean hasNext() { return actual < hasta; }
public Integer next() { int v = actual; actual += paso; return v; }
};
}
}
void main() {
var lista = new ListaReproduccion(); lista.anadir("Bohemian Rhapsody"); lista.anadir("Imagine");
for (String c : lista) IO.println("> " + c); // for-each llama a iterator()
var numeros = new StringJoiner(" ");
for (int n : new Rango(0, 20, 5)) numeros.add(String.valueOf(n));
IO.println(numeros);
List<Integer> valores = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
for (Iterator<Integer> it = valores.iterator(); it.hasNext(); )
if (it.next() % 2 == 0) it.remove(); // la forma segura de borrar al recorrer
IO.println(valores);
}
> Bohemian Rhapsody
> Imagine
0 5 10 15
[1, 3, 5]
El cliente nunca ve el array interno, y un iterador ni siquiera necesita una colección detrás: Rango genera los valores al vuelo. Una misma colección puede ofrecer además varios recorridos (en orden inverso, filtrado…) devolviendo distintos iteradores. La última parte muestra la regla de oro: para borrar mientras recorres, usa Iterator.remove() o removeIf(); si modificas la colección directamente dentro de un for-each, el siguiente next() lanza ConcurrentModificationException (los iteradores de ArrayList son fail-fast). Los streams (módulo 04) son la evolución del iterador: en lugar de recorrer tú los elementos (iteración externa), le dices a la colección qué hacer con ellos y ella los recorre (iteración interna). Por debajo, cada stream se apoya en un Spliterator, un iterador capaz de dividirse para el procesamiento paralelo.
5.7. Chain of Responsibility
📘 Concepto: Chain of Responsibility pasa una petición a lo largo de una cadena de manejadores. Cada uno decide si la procesa, si la rechaza (cortando la cadena) o si la pasa al siguiente. El emisor no sabe qué manejador acabará atendiéndola.
Figura 19. Chain of Responsibility: cada Filtro conoce al siguiente; cualquiera puede cortar la cadena.
record Peticion(String ruta, String token, String rol, String ip) { }
abstract class Filtro { // manejador base: procesa o pasa al siguiente
private Filtro siguiente;
Filtro enlazar(Filtro siguiente) { this.siguiente = siguiente; return siguiente; }
final String manejar(Peticion p) {
String rechazo = comprobar(p);
if (rechazo != null) return rechazo; // corta la cadena
return siguiente == null ? "200 OK " + p.ruta() : siguiente.manejar(p);
}
protected abstract String comprobar(Peticion p); // null = "yo no la paro"
}
class FiltroIpBloqueada extends Filtro {
protected String comprobar(Peticion p) { return p.ip().equals("10.0.0.66") ? "403 IP bloqueada" : null; }
}
class FiltroAutenticacion extends Filtro {
protected String comprobar(Peticion p) { return p.token() == null ? "401 Falta el token" : null; }
}
class FiltroAutorizacion extends Filtro {
protected String comprobar(Peticion p) {
return p.ruta().startsWith("/admin") && !p.rol().equals("ADMIN") ? "403 Solo administradores" : null;
}
}
void main() {
Filtro cadena = new FiltroIpBloqueada();
cadena.enlazar(new FiltroAutenticacion()).enlazar(new FiltroAutorizacion());
for (Peticion p : List.of(
new Peticion("/productos", "abc", "USER", "192.168.1.5"),
new Peticion("/productos", null, "USER", "192.168.1.5"),
new Peticion("/admin/usuarios", "abc", "USER", "192.168.1.5"),
new Peticion("/admin/usuarios", "xyz", "ADMIN", "192.168.1.9"),
new Peticion("/productos", "abc", "ADMIN", "10.0.0.66")))
IO.println("%-16s %-5s -> %s".formatted(p.ruta(), p.rol(), cadena.manejar(p)));
}
/productos USER -> 200 OK /productos
/productos USER -> 401 Falta el token
/admin/usuarios USER -> 403 Solo administradores
/admin/usuarios ADMIN -> 200 OK /admin/usuarios
/productos ADMIN -> 403 IP bloqueada
Añadir un filtro nuevo (límite de peticiones por minuto, registro de auditoría) es crear una clase y enlazarla, sin tocar las demás (OCP). Este patrón es la base de la web en Java: cada jakarta.servlet.Filter recibe la petición y un FilterChain, y decide si llama a chain.doFilter() para continuar. Spring Security (módulo 22) es, en esencia, una cadena de filtros configurable: defines un bean de tipo SecurityFilterChain y cada petición atraviesa los filtros de autenticación, autorización, CSRF, etc., en orden.
6. Patrones de arquitectura
Los patrones GoF organizan unas pocas clases. Los patrones de arquitectura organizan la aplicación entera. En esta sección construimos una pequeña aplicación de gestión de clientes (la tabla clientes del esquema EMPRESA) que combina capas, Repository e inyección de dependencias: es exactamente la estructura que tendrán tus aplicaciones JDBC (módulos 12-13) y Spring Boot (módulos 18-23).
6.1. Arquitectura en capas
📘 Concepto: La arquitectura en capas divide la aplicación en niveles con responsabilidades separadas (SRP a gran escala). Cada capa solo conoce y llama a la capa inmediatamente inferior, nunca a la superior.
Figura 20. Arquitectura en capas y en qué módulos del curso trabajarás cada una.
La presentación lee la entrada y muestra resultados, pero no contiene reglas ni SQL; el servicio aplica las reglas de negocio y las transacciones sin saber si la petición llegó por web o por consola; la persistencia guarda y recupera objetos sin decidir nada; y el dominio (Cliente, Pedido) viaja entre todas ellas sin depender de ninguna.
6.2. Repository y DAO
📘 Concepto: Un DAO (Data Access Object) encapsula el acceso a una fuente de datos concreta (normalmente una tabla) detrás de una interfaz con operaciones CRUD. Un Repository (de Domain-Driven Design) es una abstracción algo más alta: se comporta como una colección de objetos del dominio en memoria (
buscarPorNif,guardar), ocultando por completo que hay una base de datos detrás. En Java ambos términos se usan casi como sinónimos; lo esencial es que la lógica de negocio depende de una interfaz y la implementación (memoria, JDBC, JPA) se puede cambiar.
Figura 21. Repository: el servicio usa la interfaz; hoy la implementamos en memoria y en el módulo 13 con JDBC.
// ===== Dominio: la entidad (tabla clientes del esquema EMPRESA) =====
record Cliente(Integer id, String nombre, String nif, String ciudad, BigDecimal limiteCredito) {
Cliente conId(int nuevoId) { return new Cliente(nuevoId, nombre, nif, ciudad, limiteCredito); }
Cliente conLimite(BigDecimal nuevo) { return new Cliente(id, nombre, nif, ciudad, nuevo); }
}
// ===== Capa de persistencia: patrón Repository =====
interface ClienteRepositorio {
List<Cliente> buscarTodos();
Optional<Cliente> buscarPorId(int id);
Optional<Cliente> buscarPorNif(String nif);
Cliente guardar(Cliente c); // inserta si id == null; si no, actualiza
}
class ClienteRepositorioMemoria implements ClienteRepositorio { // en el módulo 13: ClienteRepositorioJdbc
private final Map<Integer, Cliente> tabla = new TreeMap<>();
private int siguienteId = 1001; // simula AUTO_INCREMENT
public List<Cliente> buscarTodos() { return List.copyOf(tabla.values()); }
public Optional<Cliente> buscarPorId(int id) { return Optional.ofNullable(tabla.get(id)); }
public Optional<Cliente> buscarPorNif(String nif) { return tabla.values().stream().filter(c -> c.nif().equals(nif)).findFirst(); }
public Cliente guardar(Cliente c) {
Cliente guardado = c.id() == null ? c.conId(siguienteId++) : c;
tabla.put(guardado.id(), guardado);
return guardado;
}
}
El TreeMap simula la tabla y el contador simula el AUTO_INCREMENT, empezando en 1001 como los clientes del esquema EMPRESA. En el módulo 13 escribirás ClienteRepositorioJdbc con la misma interfaz, usando PreparedStatement contra MySQL, y en el módulo 19 Spring Data JPA generará la implementación por ti a partir de una interfaz que extiende JpaRepository (con Cliente convertido en entidad JPA, porque un record no puede serlo).
6.3. Capa de servicio e inyección de dependencias manual
📘 Concepto: La inyección de dependencias (DI) consiste en que un objeto no crea sus dependencias, sino que las recibe desde fuera, preferiblemente por el constructor. Es la forma práctica de aplicar DIP. El lugar donde se crean y conectan todos los objetos se llama raíz de composición (composition root).
Figura 22. Sin DI, cada clase hace new de lo que usa; con DI, solo la raíz de composición hace new.
// ===== Capa de servicio: reglas de negocio; no sabe nada de SQL ni de consola =====
class ServicioClientes {
private static final BigDecimal LIMITE_INICIAL = new BigDecimal("3000"), LIMITE_MAXIMO = new BigDecimal("50000");
private final ClienteRepositorio repositorio;
ServicioClientes(ClienteRepositorio repositorio) { this.repositorio = repositorio; } // DI por constructor
Cliente alta(String nombre, String nif, String ciudad) {
if (nif == null || nif.length() != 9) throw new IllegalArgumentException("NIF no válido: " + nif);
if (repositorio.buscarPorNif(nif).isPresent()) throw new IllegalArgumentException("NIF repetido: " + nif);
return repositorio.guardar(new Cliente(null, nombre, nif, ciudad, LIMITE_INICIAL));
}
Cliente ampliarCredito(int id, BigDecimal importe) {
Cliente c = repositorio.buscarPorId(id).orElseThrow(() -> new IllegalArgumentException("No existe el cliente " + id));
BigDecimal nuevo = c.limiteCredito().add(importe);
if (nuevo.compareTo(LIMITE_MAXIMO) > 0) throw new IllegalArgumentException("Supera el máximo de " + LIMITE_MAXIMO);
return repositorio.guardar(c.conLimite(nuevo));
}
List<Cliente> todos() { return repositorio.buscarTodos(); }
}
// ===== Capa de presentación: entrada/salida; delega todo en el servicio =====
class ClienteConsola {
private final ServicioClientes servicio;
ClienteConsola(ServicioClientes servicio) { this.servicio = servicio; }
void ejecutar(String descripcion, Supplier<Cliente> accion) {
try { var c = accion.get(); IO.println(descripcion + " -> OK: " + c.id() + ", " + c.nombre() + ", límite " + c.limiteCredito()); }
catch (IllegalArgumentException e) { IO.println(descripcion + " -> Error: " + e.getMessage()); }
}
void alta(String nombre, String nif, String ciudad) { ejecutar("Alta " + nif, () -> servicio.alta(nombre, nif, ciudad)); }
void ampliar(int id, String importe) { ejecutar("Ampliar " + id, () -> servicio.ampliarCredito(id, new BigDecimal(importe))); }
void listar() { servicio.todos().forEach(c -> IO.println(" %d %-28s %-9s %8s".formatted(c.id(), c.nombre(), c.ciudad(), c.limiteCredito()))); }
}
// ===== Raíz de composición: el ÚNICO sitio donde se hace new y se conectan las piezas =====
void main() {
ClienteRepositorio repositorio = new ClienteRepositorioMemoria();
ServicioClientes servicio = new ServicioClientes(repositorio);
ClienteConsola consola = new ClienteConsola(servicio);
consola.alta("Talleres Martín S.L.", "B12345678", "Valencia");
consola.alta("Construcciones Levante S.A.", "A23456789", "Alicante");
consola.alta("Copia de Talleres", "B12345678", "Valencia");
consola.alta("María José Herrera", "1234", "Madrid"); consola.ampliar(1001, "7000");
consola.ampliar(1002, "60000"); consola.ampliar(1999, "100"); consola.listar();
}
Alta B12345678 -> OK: 1001, Talleres Martín S.L., límite 3000
Alta A23456789 -> OK: 1002, Construcciones Levante S.A., límite 3000
Alta B12345678 -> Error: NIF repetido: B12345678
Alta 1234 -> Error: NIF no válido: 1234
Ampliar 1001 -> OK: 1001, Talleres Martín S.L., límite 10000
Ampliar 1002 -> Error: Supera el máximo de 50000
Ampliar 1999 -> Error: No existe el cliente 1999
1001 Talleres Martín S.L. Valencia 10000
1002 Construcciones Levante S.A. Alicante 3000
Repasa lo que has conseguido: ServicioClientes contiene todas las reglas (NIF de 9 caracteres, sin duplicados, límite inicial de 3000 € y máximo de 50 000 €) y no sabe nada de consola ni de SQL; ClienteConsola solo traduce entrada y salida (si mañana es un @RestController, el servicio no cambia); para probar el servicio (módulo 11) basta con new ServicioClientes(new ClienteRepositorioMemoria()), sin base de datos y en milisegundos; y main() es la raíz de composición, el único sitio que conoce las clases concretas.
💡 Consejo: Esta raíz de composición manual es justo lo que automatiza Spring (módulo 17). Anotas las clases y Spring hace los
newy las conexiones por ti: su contenedor IoC (Inversion of Control) crea los beans e inyecta cada uno en el constructor de quien lo necesite. En Spring bastará con anotarClienteRepositorioJdbccon@RepositoryyServicioClientescon@Service, sin cambiar su constructor: el contenedor verá que el servicio necesita unClienteRepositorio, buscará el bean que implementa esa interfaz y se lo pasará. Prefiere siempre la inyección por constructor: las dependencias quedan explícitas, los atributos pueden serfinaly el objeto nunca existe a medio construir. La inyección en atributos (@Autowiredsobre un campo privado) oculta dependencias y dificulta las pruebas.
6.4. Modelo-Vista-Controlador (MVC)
📘 Concepto: MVC separa una aplicación interactiva en tres papeles: el modelo (datos y reglas), la vista (presentación) y el controlador (recibe las acciones del usuario y las traduce en operaciones sobre el modelo). Nació en Smalltalk a finales de los 70 y combina varios patrones GoF: la vista observa al modelo (Observer) y el controlador es la estrategia de respuesta de la vista (Strategy).
Figura 23. MVC: el controlador actualiza el modelo y el modelo notifica a la vista.
record Tarea(int id, String texto, boolean hecha) { }
class ModeloTareas { // MODELO: datos y reglas; avisa cuando cambia. No sabe pintar.
private final List<Tarea> tareas = new ArrayList<>();
private final List<Consumer<List<Tarea>>> oyentes = new ArrayList<>();
void alCambiar(Consumer<List<Tarea>> oyente) { oyentes.add(oyente); }
void anadir(String texto) {
if (texto.isBlank()) throw new IllegalArgumentException("La tarea no puede estar vacía");
tareas.add(new Tarea(tareas.size() + 1, texto, false));
oyentes.forEach(o -> o.accept(List.copyOf(tareas)));
}
void completar(int id) {
tareas.replaceAll(t -> t.id() == id ? new Tarea(id, t.texto(), true) : t);
oyentes.forEach(o -> o.accept(List.copyOf(tareas)));
}
}
class VistaConsola { // VISTA: solo presenta; se podría cambiar por una web
void mostrar(List<Tarea> tareas) {
tareas.forEach(t -> IO.println(" " + (t.hecha() ? "[x] " : "[ ] ") + t.id() + ". " + t.texto()));
}
void error(String mensaje) { IO.println(" ERROR: " + mensaje); }
}
class ControladorTareas { // CONTROLADOR: traduce la entrada en operaciones del modelo
private final ModeloTareas modelo;
private final VistaConsola vista;
ControladorTareas(ModeloTareas modelo, VistaConsola vista) {
this.modelo = modelo;
this.vista = vista;
modelo.alCambiar(vista::mostrar); // la vista observa al modelo
}
void procesar(String linea) {
IO.println("> " + linea);
String[] partes = linea.split(" ", 2);
try {
switch (partes[0]) {
case "nueva" -> modelo.anadir(partes.length > 1 ? partes[1] : "");
case "hecha" -> modelo.completar(Integer.parseInt(partes[1]));
default -> vista.error("Orden desconocida: " + partes[0]);
}
} catch (IllegalArgumentException e) { vista.error(e.getMessage()); } // incluye NumberFormatException
}
}
void main() {
var controlador = new ControladorTareas(new ModeloTareas(), new VistaConsola());
for (String orden : List.of("nueva Estudiar patrones", "nueva Hacer ejercicios", "nueva", "hecha 1", "borrar 2"))
controlador.procesar(orden); // la vista se repinta tras cada cambio
}
> nueva Estudiar patrones
[ ] 1. Estudiar patrones
> nueva Hacer ejercicios
[ ] 1. Estudiar patrones
[ ] 2. Hacer ejercicios
> nueva
ERROR: La tarea no puede estar vacía
> hecha 1
[x] 1. Estudiar patrones
[ ] 2. Hacer ejercicios
> borrar 2
ERROR: Orden desconocida: borrar
El modelo no imprime nada y la vista no valida nada: puedes sustituir VistaConsola por una vista web sin tocar ModeloTareas. En las aplicaciones web (Spring MVC, módulo 21) la idea es la misma con otro reparto: un @Controller recibe la petición HTTP, llama al servicio, rellena un Model y elige la plantilla Thymeleaf (la vista) que genera el HTML. Como HTTP no permite que el servidor “avise” al navegador, ahí no hay Observer entre modelo y vista: se vuelve a pintar la página en cada petición.
7. Antipatrones
📘 Concepto: Un antipatrón es una solución que parece buena, se repite mucho… y produce más problemas de los que resuelve. Conocerlos te ayuda a detectarlos en las revisiones de código, empezando por el tuyo.
| Antipatrón | Síntoma | Principio que viola | Remedio |
|---|---|---|---|
| Objeto Dios (God object) | Una clase GestorAplicacion de 3000 líneas que lo sabe y lo hace todo | SRP | Dividir por responsabilidades; capas |
| Singleton abusivo | Xxx.getInstance() por todas partes para evitar pasar parámetros | DIP, testabilidad | Inyección de dependencias |
| Código espagueti | Flujo enrevesado, métodos enormes, if anidados, sin estructura | KISS, SRP | Refactorizar en métodos y clases pequeñas |
| Copiar y pegar | El mismo bloque repetido con pequeños cambios | DRY | Extraer métodos, Template Method, Strategy |
| Martillo de oro (golden hammer) | Usar siempre la misma herramienta o patrón (“todo es un Observer”) | KISS | Elegir la solución según el problema |
| Sobreingeniería / patronitis | Interfaces con una sola implementación “por si acaso”, fábricas de fábricas | YAGNI, KISS | Empezar simple y refactorizar cuando haga falta |
| Números y cadenas mágicos | if (tipo == 3), precio * 1.21 sin explicar | Legibilidad | Constantes con nombre, enum |
| Flujo de lava (lava flow) | Código muerto o experimental que nadie se atreve a borrar | KISS | Borrarlo: para eso está Git |
Detente en dos de ellos, los errores típicos de quien acaba de aprender patrones. El Singleton abusivo es tentador: BaseDatos.getInstancia() desde cualquier sitio y te ahorras pasar parámetros. Pero has creado una variable global: no puedes probar nada sin una base de datos real y no sabes qué clases dependen de ella. La alternativa es la de la sección 6.3: crear el objeto una vez en la raíz de composición e inyectarlo. La patronitis es aplicar patrones porque sí: un EstrategiaSumaFactoryBuilder para sumar dos números. Cada patrón añade clases e indirección; solo compensa si resuelve un problema real y presente. La regla práctica: escribe primero la solución sencilla y, cuando aparezca el segundo o tercer caso (otro tipo de descuento, otra base de datos), refactoriza hacia el patrón. Un buen diseño no es el que tiene más patrones, sino el que es más fácil de cambiar.
8. Ejercicios
Los seis primeros ejercicios están resueltos (intenta hacerlos antes de mirar la solución). Los seis siguientes son propuestos.
Ejercicio 1: Tarifas de aparcamiento sin if (OCP + Strategy)
Un aparcamiento calcula el precio con un método cobrar(String tipo, int minutos) lleno de if/else if que crece con cada tipo de vehículo: coche, 0,04 € por minuto; moto, 0,02 €; furgoneta, 1 € más 0,06 € por minuto; cualquier otro tipo lanza IllegalArgumentException. Refactorízalo para que cumpla OCP: crea una interfaz funcional Tarifa y una CalculadoraParking con un método registrar(tipo, tarifa). Después añade la tarifa de los coches eléctricos (primera hora gratis y 0,03 € por minuto a partir de ahí) sin modificar CalculadoraParking. Calcula el precio de 90 minutos para cada tipo.
Ver solución
@FunctionalInterface
interface Tarifa { double precio(int minutos); }
class CalculadoraParking {
private final Map<String, Tarifa> tarifas = new HashMap<>();
void registrar(String tipo, Tarifa tarifa) { tarifas.put(tipo, tarifa); } // extensión sin modificar
double cobrar(String tipo, int minutos) {
Tarifa t = tarifas.get(tipo);
if (t == null) throw new IllegalArgumentException("Tipo de vehículo desconocido: " + tipo);
return t.precio(minutos);
}
}
void main() {
var calc = new CalculadoraParking(); calc.registrar("COCHE", min -> min * 0.04);
calc.registrar("MOTO", min -> min * 0.02); calc.registrar("FURGONETA", min -> 1.0 + min * 0.06);
calc.registrar("ELECTRICO", min -> Math.max(0, min - 60) * 0.03); // requisito nuevo: no se toca la clase
for (String tipo : List.of("COCHE", "MOTO", "FURGONETA", "ELECTRICO"))
IO.println("%-10s 90 min -> %.2f €".formatted(tipo, calc.cobrar(tipo, 90)));
try { calc.cobrar("BICI", 30); } catch (IllegalArgumentException e) { IO.println(e.getMessage()); }
}
COCHE 90 min -> 3.60 €
MOTO 90 min -> 1.80 €
FURGONETA 90 min -> 6.40 €
ELECTRICO 90 min -> 0.90 €
Tipo de vehículo desconocido: BICI
Cada tarifa es una estrategia y, al ser Tarifa una interfaz funcional, basta una lambda. La calculadora está cerrada a la modificación (no cambia nunca) y abierta a la extensión (registrar). En una aplicación real las tarifas podrían cargarse desde la configuración.
Ejercicio 2: Builder para un correo electrónico (record)
Crea un record Correo(String de, List<String> para, String asunto, String cuerpo, Prioridad prioridad) con un builder que se use así: Correo.de("rrhh@empresa.es").para("ana@empresa.es").para("luis@empresa.es", "eva@empresa.es").asunto("...").build(). Requisitos: para() acumula destinatarios; el asunto por defecto es "(sin asunto)" y la prioridad NORMAL; urgente() pone la prioridad ALTA; debe haber al menos un destinatario y el asunto no puede superar 78 caracteres; la lista de destinatarios debe ser inmutable.
Ver solución
enum Prioridad { BAJA, NORMAL, ALTA }
record Correo(String de, List<String> para, String asunto, String cuerpo, Prioridad prioridad) {
Correo {
if (para.isEmpty()) throw new IllegalStateException("Hace falta al menos un destinatario");
if (asunto.length() > 78) throw new IllegalStateException("Asunto demasiado largo");
para = List.copyOf(para);
}
static Builder de(String remitente) { return new Builder(remitente); }
static final class Builder {
private final String de;
private final List<String> para = new ArrayList<>();
private String asunto = "(sin asunto)", cuerpo = "";
private Prioridad prioridad = Prioridad.NORMAL;
private Builder(String de) { this.de = de; }
Builder para(String... direcciones) { para.addAll(List.of(direcciones)); return this; }
Builder asunto(String a) { asunto = a; return this; }
Builder cuerpo(String c) { cuerpo = c; return this; }
Builder urgente() { prioridad = Prioridad.ALTA; return this; }
Correo build() { return new Correo(de, para, asunto, cuerpo, prioridad); }
}
}
void main() {
IO.println(Correo.de("rrhh@empresa.es").para("ana@empresa.es").para("luis@empresa.es", "eva@empresa.es")
.asunto("Nóminas de octubre").cuerpo("Ya están disponibles.").build());
Correo c2 = Correo.de("sistemas@empresa.es").para("todos@empresa.es").urgente().build();
IO.println(c2.asunto() + " / " + c2.prioridad() + " / " + c2.para());
try { Correo.de("yo@empresa.es").asunto("Hola").build(); }
catch (IllegalStateException e) { IO.println("Error: " + e.getMessage()); }
}
Correo[de=rrhh@empresa.es, para=[ana@empresa.es, luis@empresa.es, eva@empresa.es], asunto=Nóminas de octubre, cuerpo=Ya están disponibles., prioridad=NORMAL]
(sin asunto) / ALTA / [todos@empresa.es]
Error: Hace falta al menos un destinatario
El remitente es obligatorio y por eso es el parámetro del método de entrada de(); el resto son opcionales con valor por defecto. La validación vive en el constructor compacto del record, así que ni siquiera con new Correo(...) directo se puede crear un correo inválido, y List.copyOf() garantiza la inmutabilidad.
Ejercicio 3: Un decorador de java.io que cuenta bytes
Escribe ContadorInputStream, un decorador que extienda FilterInputStream y cuente cuántos bytes se han leído a través de él. Úsalo debajo de un InputStreamReader y un BufferedReader para contar las líneas de un texto en UTF-8 y, al terminar, comprueba que los bytes contados coinciden con el tamaño real.
Ver solución
class ContadorInputStream extends FilterInputStream { // decorador sobre la jerarquía de java.io
private long bytesLeidos;
ContadorInputStream(InputStream in) { super(in); } // 'in' es el objeto decorado
@Override public int read() throws IOException {
int b = super.read();
if (b != -1) bytesLeidos++;
return b;
}
@Override public int read(byte[] buffer, int desde, int longitud) throws IOException {
int n = super.read(buffer, desde, longitud);
if (n > 0) bytesLeidos += n;
return n;
}
@Override public long skip(long n) throws IOException {
long saltados = super.skip(n);
bytesLeidos += saltados;
return saltados;
}
long getBytesLeidos() { return bytesLeidos; }
}
void main() throws IOException {
byte[] datos = "Primera línea\nSegunda línea\nTercera\n".getBytes(StandardCharsets.UTF_8);
IO.println("Tamaño real: " + datos.length + " bytes");
var contador = new ContadorInputStream(new ByteArrayInputStream(datos));
// Se apila con otros decoradores del JDK sin que ninguno sepa de los demás
try (var lector = new BufferedReader(new InputStreamReader(contador, StandardCharsets.UTF_8))) {
IO.println("Líneas: " + lector.lines().count() + ", bytes contados: " + contador.getBytesLeidos());
}
}
Tamaño real: 38 bytes
Líneas: 3, bytes contados: 38
Hay que sobrescribir read(), read(byte[], int, int) y skip(), porque los decoradores superiores pueden usar cualquiera de ellos (BufferedReader lee por bloques). No hace falta sobrescribir read(byte[]): en FilterInputStream ya llama a read(b, 0, b.length). El texto tiene 36 caracteres pero 38 bytes, porque cada í ocupa dos bytes en UTF-8 (módulo 08).
Ejercicio 4: Un bus de eventos (Observer)
Implementa una clase BusEventos con suscribir(Class<E> tipo, Consumer<? super E> oyente) y publicar(Object evento). Un oyente suscrito a un tipo debe recibir también los eventos de sus subtipos. Pruébalo con una interfaz sellada EventoPedido y dos records, PedidoCreado y PedidoPagado: el servicio de email escucha PedidoCreado, almacén y contabilidad escuchan PedidoPagado, y auditoría escucha cualquier EventoPedido.
Ver solución
sealed interface EventoPedido permits PedidoCreado, PedidoPagado { int pedidoId(); }
record PedidoCreado(int pedidoId, String cliente) implements EventoPedido { }
record PedidoPagado(int pedidoId, double importe) implements EventoPedido { }
class BusEventos { // los publicadores no conocen a los suscriptores
private final Map<Class<?>, List<Consumer<Object>>> suscriptores = new LinkedHashMap<>();
@SuppressWarnings("unchecked")
<E> void suscribir(Class<E> tipo, Consumer<? super E> oyente) {
suscriptores.computeIfAbsent(tipo, k -> new ArrayList<>()).add((Consumer<Object>) oyente);
}
void publicar(Object evento) {
suscriptores.forEach((tipo, oyentes) -> {
if (tipo.isInstance(evento)) oyentes.forEach(o -> o.accept(evento)); // admite subtipos
});
}
}
void main() {
var bus = new BusEventos();
bus.suscribir(PedidoCreado.class, e -> IO.println(" [Email] Gracias por tu pedido, " + e.cliente()));
bus.suscribir(PedidoPagado.class, e -> IO.println(" [Almacén] Preparar pedido " + e.pedidoId()));
bus.suscribir(PedidoPagado.class, e -> IO.println(" [Contabilidad] Ingreso de %.2f €".formatted(e.importe())));
bus.suscribir(EventoPedido.class, e -> IO.println(" [Auditoría] " + e));
IO.println("Publico PedidoCreado:"); bus.publicar(new PedidoCreado(7, "Academia Nova"));
IO.println("Publico PedidoPagado:"); bus.publicar(new PedidoPagado(7, 120.5));
}
Publico PedidoCreado:
[Email] Gracias por tu pedido, Academia Nova
[Auditoría] PedidoCreado[pedidoId=7, cliente=Academia Nova]
Publico PedidoPagado:
[Almacén] Preparar pedido 7
[Contabilidad] Ingreso de 120.50 €
[Auditoría] PedidoPagado[pedidoId=7, importe=120.5]
tipo.isInstance(evento) es lo que permite que auditoría, suscrita a la interfaz, reciba todos los eventos. El comodín Consumer<? super E> (PECS, módulo 02) permite suscribir oyentes de un supertipo. Es una versión en miniatura de ApplicationEventPublisher y @EventListener de Spring: el código que crea el pedido no conoce a almacén ni a contabilidad.
Ejercicio 5: Semáforo con estados sellados (State con Java 21)
Modela un semáforo con una interfaz sellada EstadoSemaforo y tres records: Verde(int segundos), Ambar(int segundos) y Rojo(int segundos, boolean peatonEsperando). Escribe una función de transición siguiente() con switch con patrones: verde pasa a ámbar (3 s), ámbar a rojo (20 s) y rojo a verde (30 s), salvo que haya un peatón esperando, en cuyo caso el rojo se alarga 10 s más. Añade pulsarBoton(), que solo tiene efecto en rojo, y simula cinco ciclos en los que un peatón pulsa durante el primer rojo.
Ver solución
sealed interface EstadoSemaforo permits Verde, Ambar, Rojo { }
record Verde(int segundos) implements EstadoSemaforo { }
record Ambar(int segundos) implements EstadoSemaforo { }
record Rojo(int segundos, boolean peatonEsperando) implements EstadoSemaforo { }
// Transición: el compilador comprueba que el switch cubre todos los estados (Java 21)
EstadoSemaforo siguiente(EstadoSemaforo e) {
return switch (e) {
case Verde _ -> new Ambar(3); // '_': variable sin nombre (Java 22)
case Ambar _ -> new Rojo(20, false);
case Rojo(int s, boolean esperando) when esperando -> new Rojo(s + 10, false); // record pattern + guarda
case Rojo _ -> new Verde(30);
};
}
EstadoSemaforo pulsarBoton(EstadoSemaforo e) {
return e instanceof Rojo(int s, boolean _) ? new Rojo(s, true) : e; // solo tiene efecto en rojo
}
void main() {
EstadoSemaforo e = new Verde(30);
for (int ciclo = 1; ciclo <= 5; ciclo++) {
IO.println(ciclo + ": " + e); e = siguiente(e);
if (ciclo == 2) e = pulsarBoton(e); // un peatón pulsa en rojo
}
}
1: Verde[segundos=30]
2: Ambar[segundos=3]
3: Rojo[segundos=20, peatonEsperando=true]
4: Rojo[segundos=30, peatonEsperando=false]
5: Verde[segundos=30]
Es la alternativa a la versión con enum de la sección 5.5: aquí cada estado lleva datos (los segundos, si hay un peatón) y las transiciones están juntas en una sola función en lugar de repartidas por las clases. Como la interfaz es sealed, el switch no necesita default y, si añades un estado nuevo (por ejemplo, AmbarIntermitente), el compilador te señala la transición que falta. Fíjate en el orden de los casos: el patrón con guarda when esperando debe ir antes que el Rojo _ general. Elige enum cuando el comportamiento varía por estado y records sellados cuando el estado tiene datos.
Ejercicio 6: Aprobación de gastos (Chain of Responsibility funcional)
En una empresa, un gasto lo aprueba el supervisor si es de hasta 500 €, el jefe de departamento hasta 3000 €, el director financiero hasta 20 000 € y, por encima, el consejo de administración. Implementa la cadena con una interfaz funcional Aprobador que devuelva Optional<String> (el cargo que aprueba o vacío) y un método default siNo(Aprobador siguiente) para encadenar.
Ver solución
record Gasto(String concepto, double importe) { }
@FunctionalInterface
interface Aprobador { // devuelve quién aprueba, o vacío si no le corresponde
Optional<String> aprobar(Gasto g);
default Aprobador siNo(Aprobador siguiente) { // si yo no lo resuelvo, lo intenta el siguiente
return g -> this.aprobar(g).or(() -> siguiente.aprobar(g));
}
static Aprobador hasta(double limite, String cargo) {
return g -> g.importe() <= limite ? Optional.of(cargo) : Optional.empty();
}
}
void main() {
Aprobador cadena = Aprobador.hasta(500, "Supervisor")
.siNo(Aprobador.hasta(3_000, "Jefe de departamento"))
.siNo(Aprobador.hasta(20_000, "Director financiero"))
.siNo(g -> Optional.of("Consejo de administración"));
for (Gasto g : List.of(new Gasto("Material de oficina", 120), new Gasto("Portátil", 1_450),
new Gasto("Servidor", 9_800), new Gasto("Nave industrial", 350_000)))
IO.println("%-20s %10.2f € -> %s".formatted(g.concepto(), g.importe(), cadena.aprobar(g).orElseThrow()));
}
Material de oficina 120.00 € -> Supervisor
Portátil 1450.00 € -> Jefe de departamento
Servidor 9800.00 € -> Director financiero
Nave industrial 350000.00 € -> Consejo de administración
siNo() devuelve un nuevo aprobador que prueba el actual y, si el resultado está vacío, el siguiente (Optional.or(), Java 9). El último eslabón siempre responde, por eso orElseThrow() nunca falla. Es el mismo estilo de composición que Comparator.thenComparing() o Function.andThen() (módulo 03).
Ejercicio 7 (propuesto): Fábrica abstracta de persistencia
Define las interfaces ClienteRepositorio y PedidoRepositorio y una fábrica abstracta FabricaPersistencia con crearClientes() y crearPedidos(). Implementa dos familias: FabricaMemoria (con mapas) y FabricaSimulacionMySql (que solo imprime el SQL que ejecutaría). Elige la familia en main() a partir de un argumento ("memoria" o "mysql") y comprueba que el servicio funciona igual con ambas.
Ejercicio 8 (propuesto): Organigrama con Composite
Modela el organigrama del esquema EMPRESA: una interfaz sellada Unidad con dos records, Empleado(String nombre, double salario) y Departamento(String nombre, List<Unidad> miembros). Un departamento puede contener empleados y subdepartamentos. Implementa masaSalarial(), numeroEmpleados() y un método que imprima el árbol con sangría. Añade una operación externa con switch con patrones que encuentre al empleado mejor pagado.
Ejercicio 9 (propuesto): Proxy de caché genérico
Con java.lang.reflect.Proxy, escribe static <T> T conCache(T objetivo, Class<T> interfaz) que memorice el resultado de cada llamada según el método y sus argumentos (usa como clave un record Clave(Method metodo, List<Object> args)). Pruébalo con una interfaz ServicioTipoCambio cuya implementación tarde 500 ms en cada consulta y mide con System.nanoTime() la diferencia entre la primera y la segunda llamada.
Ejercicio 10 (propuesto): Importador con Template Method
Crea una clase abstracta Importador<T> con el método plantilla final List<T> importar(List<String> lineas) que ejecute los pasos: saltar cabecera (gancho, por defecto true), parsear(String linea) (abstracto), validar(T) (gancho, por defecto acepta todo) y alFinalizar(int correctos, int descartados) (gancho). Implementa ImportadorClientesCsv (separador ;) y ImportadorProductosCsv, que descarte productos con precio negativo.
Ejercicio 11 (propuesto): Lista de la compra con deshacer y rehacer (Command)
Crea una ListaCompra (producto y cantidad) con anadir(producto, n), quitar(producto), deshacer() y rehacer(). Modela cada orden como un record Orden(String descripcion, Runnable hacer, Runnable deshacer) cuyas lambdas capturen lo necesario para revertirse (por ejemplo, la cantidad que había antes de quitar). Deshacer sin órdenes pendientes debe avisar sin fallar y ejecutar una orden nueva debe vaciar la pila de rehacer.
Ejercicio 12 (propuesto): Repositorio genérico en memoria
Escribe una interfaz genérica Repositorio<T, ID> (buscarTodos, buscarPorId, guardar, borrar) y una implementación RepositorioMemoria<T, ID> que reciba en el constructor una Function<T, ID> para obtener el identificador de cada entidad. Úsala para Cliente y para Producto sin escribir ninguna clase de repositorio específica. Compara tu interfaz con CrudRepository de Spring Data (módulo 19).
Resumen
| Patrón / principio | Categoría | Idea clave | En el JDK / Spring |
|---|---|---|---|
| SOLID | Principios | Responsabilidad única, abierto/cerrado, Liskov, interfaces pequeñas, depender de abstracciones | Interfaces funcionales, inyección de dependencias |
| Singleton | Creacional | Una única instancia; cuidado con el estado global | Runtime.getRuntime(), beans de Spring |
| Factory Method | Creacional | La subclase decide qué crear | Collection.iterator(); estáticos: List.of() |
| Abstract Factory | Creacional | Familias de productos compatibles | DocumentBuilderFactory |
| Builder | Creacional | Construcción paso a paso, objeto inmutable | StringBuilder, HttpClient.newBuilder() |
| Adapter | Estructural | Convertir una interfaz en otra | Arrays.asList(), InputStreamReader |
| Decorator | Estructural | Envolver para añadir comportamiento | BufferedReader, FilterInputStream |
| Facade | Estructural | Una interfaz simple para un subsistema | SLF4J, capa de servicio |
| Proxy | Estructural | Sustituto que controla el acceso | java.lang.reflect.Proxy, AOP y @Transactional |
| Composite | Estructural | Árboles: hojas y compuestos iguales | Container/Component de AWT |
| Strategy | Comportamiento | Algoritmos intercambiables (lambdas) | Comparator |
| Observer | Comportamiento | Avisar a los suscriptores | PropertyChangeListener, Flow, @EventListener |
| Template Method | Comportamiento | Esqueleto fijo, pasos variables | AbstractList, HttpServlet, JdbcTemplate |
| Command | Comportamiento | Petición como objeto; deshacer | Runnable, Callable |
| State | Comportamiento | El comportamiento depende del estado | Enums con métodos, sealed |
| Iterator | Comportamiento | Recorrer sin exponer la estructura | Iterator, for-each, Spliterator |
| Chain of Responsibility | Comportamiento | Cadena de manejadores | Filter/FilterChain, SecurityFilterChain |
| Capas, Repository/DAO e inyección de dependencias | Arquitectura | Separar presentación, negocio y datos; recibir las dependencias por constructor | @Controller, @Service, @Repository, contenedor IoC de Spring |
| MVC | Arquitectura | Modelo, vista y controlador separados | Spring MVC + Thymeleaf |