Esta lección salda todas las deudas del curso.

Desde el módulo 3 has ido escribiendo patrones de diseño sin llamarlos por su nombre. En 03-04, al ver constructores con demasiados parámetros, se prometió un Builder «para más adelante». En 04-02, al hablar de clases abstractas con un método plantilla, se dijo «esto tiene nombre, y lo veremos». En 07-03, los flujos de E/S envueltos unos en otros eran «un patrón que estudiaremos». En 10-03 escribiste proxies dinámicos completos sin decir que eso era el patrón Proxy. En 11-02, la inyección de dependencias era «la D de un conjunto de principios que veremos al final». Ese final es ahora.

Pero antes, una advertencia que importa más que el catálogo entero.

Los patrones no son objetivos. Nadie debería proponerse «usar el patrón Observador» del mismo modo que nadie se propone «usar un bucle while». Un patrón es una respuesta reconocida a un problema recurrente, y aplicarlo sin tener el problema produce exactamente el mismo daño que no aplicarlo cuando lo tienes: código más difícil de entender, con más piezas y más indirección, sin ninguna ventaja a cambio. El sobrediseño es un problema tan real como la falta de diseño, y en proyectos jóvenes es más frecuente.

La utilidad real de conocer los patrones es doble, y ninguna de las dos es «aplicarlos». La primera es vocabulario: decir «CatalogoConCache es un Decorador del repositorio» ahorra tres minutos de explicación y transmite exactamente la estructura y sus consecuencias. La segunda es reconocimiento: cuando un problema te resulta incómodo, saber que alguien ya lo resolvió te da un punto de partida en lugar de una hoja en blanco.

Al terminar esta lección conocerás los principios SOLID con ejemplos reales de BiblioTech antes y después, dominarás los patrones GoF que de verdad se usan en Java moderno, sabrás implementarlos y —sobre todo— sabrás cuándo no hacerlo, reconocerás los patrones arquitectónicos que Spring y JPA aplican por ti, e identificarás los antipatrones más comunes, incluido uno que BiblioTech tiene y que vamos a discutir honestamente.

Contenido

  1. Qué es un patrón y qué no
  2. El catálogo GoF y su contexto histórico
  3. Principios antes que patrones
  4. SOLID: responsabilidad única (S)
  5. SOLID: abierto/cerrado (O)
  6. SOLID: sustitución de Liskov (L)
  7. SOLID: segregación de interfaces (I)
  8. SOLID: inversión de dependencias (D)
  9. DRY, KISS, YAGNI y la ley de Demeter
  10. Patrones creacionales: Singleton
  11. Patrones creacionales: Factory Method y Abstract Factory
  12. Patrones creacionales: Builder
  13. Patrones creacionales: Prototype
  14. Patrones estructurales: Adaptador
  15. Patrones estructurales: Decorador
  16. Patrones estructurales: Proxy
  17. Patrones estructurales: Facade y Composite
  18. Patrones de comportamiento: Estrategia
  19. Patrones de comportamiento: Template Method
  20. Patrones de comportamiento: Observador
  21. Patrones de comportamiento: Estado
  22. Patrones de comportamiento: Comando
  23. Patrones de comportamiento: Cadena de responsabilidad e Iterador
  24. Patrones arquitectónicos y de empresa
  25. Antipatrones
  26. Tabla final: problema → patrón candidato
  27. Errores Comunes y Consejos
  28. Ejercicios
  29. Conclusión

  1. Qué es un patrón y qué no

Un patrón de diseño tiene cuatro elementos:

Elemento Qué es Ejemplo (Estrategia)
Nombre Vocabulario compartido «Estrategia»
Problema Cuándo aplicarlo Varias variantes de un algoritmo, elegidas en ejecución
Solución La estructura, no el código Una interfaz con el algoritmo, implementaciones intercambiables, un contexto que delega
Consecuencias Lo que ganas y lo que pagas Ganas extensibilidad; pagas una clase por variante

Lo que un patrón no es:

  • No es código para copiar. Es una estructura. La implementación depende del lenguaje: en Java 8+, muchos patrones GoF se reducen a una lambda.
  • No es obligatorio. Si if (tipo == LIBRO) ... else ... cubre dos casos que no van a crecer, un if está bien.
  • No es una medida de calidad. Un proyecto con quince patrones no es mejor que uno con tres. Suele ser peor.
  • No es universal en el tiempo. Varios patrones GoF existen para suplir carencias de C++ y Java de 1994. En Java 21, algunos son una línea.

  1. El catálogo GoF y su contexto histórico

En 1994, Gamma, Helm, Johnson y Vlissides —la Gang of Four— publicaron Design Patterns, catalogando 23 patrones observados en sistemas reales de C++ y Smalltalk. Se organizan en tres familias:

Familia Se ocupa de Patrones (los que se usan hoy en negrita)
Creacionales Cómo se crean los objetos Singleton, Factory Method, Abstract Factory, Builder, Prototype
Estructurales Cómo se componen Adaptador, Decorador, Proxy, Facade, Composite, Bridge, Flyweight
De comportamiento Cómo colaboran y reparten responsabilidades Estrategia, Observador, Template Method, Estado, Comando, Iterador, Cadena de responsabilidad, Mediator, Memento, Visitor, Interpreter

Dos avisos históricos importantes:

  1. Java tiene muchos incorporados. Iterator está en el lenguaje (for-each). Observador es la API de eventos de Spring. Estrategia es cualquier interfaz funcional. Los autores del libro dijeron explícitamente que un patrón es una carencia del lenguaje: cuando el lenguaje mejora, el patrón desaparece.
  2. Algunos han envejecido mal. El Singleton clásico es hoy mayoritariamente considerado un antipatrón (lo veremos). Observer/Observable de java.util están deprecados desde Java 9.

  1. Principios antes que patrones

Los patrones son soluciones concretas; los principios son los criterios que te dicen si una solución es buena. Si tuvieras que elegir entre memorizar los 23 patrones o interiorizar SOLID, elige SOLID sin dudar: con los principios deduces los patrones, al revés no.

flowchart TD
    P["PRINCIPIOS<br/>SOLID, DRY, KISS, YAGNI, Demeter"]
    PA["PATRONES<br/>Estrategia, Decorador, Adaptador…"]
    C["CÓDIGO<br/>BiblioTech"]
    P -->|"justifican"| PA
    PA -->|"estructuran"| C
    C -->|"revela problemas que exigen"| P

  1. SOLID: responsabilidad única (S)

Una clase debe tener una sola razón para cambiar.

El enunciado popular «una clase, una cosa» es engañoso, porque «una cosa» no está definido. La formulación útil es la de las razones para cambiar: si dos personas distintas, con motivos distintos, pueden pedirte que toques la misma clase, esa clase tiene dos responsabilidades.

Antes (el GestorPrestamos que BiblioTech llegó a tener):

@Service
public class GestorPrestamos {

    public void prestar(String isbn, Long idEmpleado, int dias) {
        // 1. Validación de entrada
        if (isbn == null || !isbn.matches("97[89]-\\d{10}")) {
            throw new IllegalArgumentException("ISBN inválido");
        }
        // 2. Acceso a datos
        Material m = em.createQuery("select m from Material m where m.isbn = :i", Material.class)
                       .setParameter("i", isbn).getSingleResult();
        // 3. Reglas de negocio
        long activos = em.createQuery("select count(p) from Prestamo p where …").getSingleResult();
        if (activos >= 3) throw new LimiteExcedidoException();
        // 4. Persistencia
        Prestamo p = new Prestamo(m, empleado, LocalDate.now(), LocalDate.now().plusDays(dias));
        em.persist(p);
        // 5. Notificación
        smtp.enviar(empleado.getCorreo(), "Préstamo confirmado", "Has cogido " + m.getTitulo());
        // 6. Formato de salida
        System.out.printf("%-40s %s%n", m.getTitulo(), p.getFechaVencimiento());
        // 7. Auditoría
        Files.writeString(Path.of("auditoria.log"), LocalDateTime.now() + " PRESTAMO " + isbn + "\n",
                          StandardOpenOption.APPEND);
    }
}

Siete razones para cambiar: el formato del ISBN, el modelo de datos, la regla del límite, el mapeo, el proveedor de correo, el formato de la consola y el de auditoría. Cada una la pide una persona distinta.

Después:

// DOMINIO: la regla, y solo la regla
public class PoliticaPrestamos {
    private final int maximoActivos;

    public void verificarPuedePrestar(Empleado empleado, List<Prestamo> activos) {
        if (activos.size() >= maximoActivos) {
            throw new LimiteDePrestamosExcedidoException(empleado.getNombre(), maximoActivos);
        }
        if (empleado.tieneMultasPendientes()) {
            throw new MultasPendientesException(empleado.getNombre());
        }
    }
}

// APLICACIÓN: orquestación, y solo orquestación
public class GestorPrestamos implements GestionarPrestamos {

    private final RepositorioMateriales materiales;
    private final RepositorioPrestamos prestamos;
    private final RepositorioEmpleados empleados;
    private final PoliticaPrestamos politica;
    private final NotificadorAvisos notificador;
    private final Clock reloj;

    @Override
    public Prestamo prestar(Isbn isbn, Long idEmpleado, int dias) {
        Material material = materiales.buscarPorIsbn(isbn)
                .orElseThrow(() -> new MaterialNoEncontradoException(isbn));
        Empleado empleado = empleados.buscarPorId(idEmpleado)
                .orElseThrow(() -> new EmpleadoNoEncontradoException(idEmpleado));

        politica.verificarPuedePrestar(empleado, prestamos.activosDe(empleado));

        Prestamo prestamo = Prestamo.nuevo(material, empleado, LocalDate.now(reloj), dias);
        prestamos.guardar(prestamo);
        notificador.notificar(Aviso.porPrestamo(prestamo));
        return prestamo;
    }
}

La validación del ISBN vive en el objeto de valor Isbn (imposible construir uno inválido). Las consultas viven en los repositorios. El correo, en el adaptador. El formato de salida, en la CLI o en el DTO. La auditoría, en un aspecto (11-02) o en un @EntityListener.

Cuidado con el extremo contrario. Una clase por método es tan malo como una clase por sistema: repartes la lógica en veinte ficheros y hay que leerlos todos para entender un caso de uso. El criterio es razones para cambiar, no número de líneas.

  1. SOLID: abierto/cerrado (O)

Una entidad debe estar abierta a la extensión y cerrada a la modificación: añadir comportamiento nuevo sin tocar el código existente.

Es la justificación teórica del polimorfismo que estudiaste en 03-06.

Antes — cada tipo nuevo de material obliga a tocar el mismo switch, y a acordarse de tocar los otros cuatro repartidos por el proyecto:

public int diasDePrestamo(Material material) {
    switch (material.getTipo()) {
        case LIBRO:   return 15;
        case REVISTA: return 7;
        case DVD:     return 3;
        default: throw new IllegalStateException("Tipo desconocido: " + material.getTipo());
    }
}

Después — cada tipo sabe su propia política, y añadir Audiolibro es crear una clase:

public abstract class Material {
    // Cada subtipo responde por sí mismo; el resto del sistema no cambia.
    public abstract int diasDePrestamoPorDefecto();
    public abstract Dinero multaPorDia();
}

public class Libro extends Material {
    @Override public int diasDePrestamoPorDefecto() { return 15; }
    @Override public Dinero multaPorDia() { return Dinero.euros("0.50"); }
}

public class Revista extends Material {
    @Override public int diasDePrestamoPorDefecto() { return 7; }
    @Override public Dinero multaPorDia() { return Dinero.euros("0.25"); }
}

public class Dvd extends Material {
    @Override public int diasDePrestamoPorDefecto() { return 3; }
    @Override public Dinero multaPorDia() { return Dinero.euros("1.00"); }
}

Cuándo NO aplicarlo. No abstraigas por si acaso. La versión con switch es perfectamente razonable si:

  • Los casos son cerrados y no van a crecer (los días de la semana, por ejemplo).
  • El switch está en un solo sitio.
  • Con sealed (10-06), el compilador te obliga a cubrir todos los casos, lo que elimina el riesgo principal.

El problema del switch no es el switch: es tener el mismo switch en siete sitios y olvidarte de uno.

  1. SOLID: sustitución de Liskov (L)

Si S es subtipo de T, se debe poder sustituir T por S sin que el programa deje de funcionar correctamente.

Es el principio que más se viola sin darse cuenta, porque el compilador no ayuda: la violación es semántica.

Ejemplo de violación en BiblioTech. Los materiales de la sección de referencia no se prestan, solo se consultan en sala. Tentación:

public class MaterialDeReferencia extends Libro {

    @Override
    public Prestamo prestarA(Empleado empleado, int dias) {
        throw new UnsupportedOperationException("Los materiales de referencia no se prestan");
    }
}

Compila perfectamente. Y rompe todo el código que trabaja con Material:

// Este método era correcto. Ahora explota en producción cuando Diego Alonso
// añade un material de referencia al carrito de préstamo.
public void prestarTodos(List<Material> materiales, Empleado empleado) {
    for (Material m : materiales) {
        m.prestarA(empleado, m.diasDePrestamoPorDefecto());   // UnsupportedOperationException
    }
}

Los tres síntomas clásicos de violación de Liskov:

Síntoma Ejemplo
El subtipo lanza excepción donde el supertipo no lo hacía MaterialDeReferencia.prestarA
El subtipo fortalece las precondiciones El padre acepta 1-30 días; el hijo solo 1-7
El subtipo debilita las postcondiciones El padre garantiza lista no nula; el hijo devuelve null
El código cliente necesita instanceof para funcionar if (m instanceof MaterialDeReferencia) continue;

Solución correcta: no forzar la herencia. Separar la capacidad de la jerarquía, que es exactamente la segregación de interfaces:

/** No todo material es prestable. La capacidad se modela aparte del tipo. */
public interface Prestable {
    Prestamo prestarA(Empleado empleado, int dias);
    int diasDePrestamoPorDefecto();
}

public abstract class Material {                 // todos: título, ISBN, ubicación
    public abstract String getTitulo();
    public abstract Isbn getIsbn();
}

public class Libro extends Material implements Prestable { … }
public class MaterialDeReferencia extends Material { }   // simplemente NO es Prestable

Y ahora la firma dice la verdad, y el compilador la hace cumplir:

public void prestarTodos(List<Prestable> materiales, Empleado empleado) { … }
// Ya es IMPOSIBLE pasarle un MaterialDeReferencia.

Regla práctica: si al escribir una subclase sientes la necesidad de lanzar UnsupportedOperationException o de vaciar un método heredado, la relación no es «es un». Reconsidera la jerarquía.

  1. SOLID: segregación de interfaces (I)

Ningún cliente debe verse obligado a depender de métodos que no usa.

En 04-01 se crearon Prestable y Notificable como interfaces separadas, y ahora tiene nombre.

Antes — la interfaz que lo sabe todo:

public interface GestionBiblioteca {
    Prestamo prestar(Isbn isbn, Long idEmpleado, int dias);
    void devolver(Long idPrestamo);
    void reservar(Isbn isbn, Long idEmpleado);
    void enviarAviso(Aviso aviso);
    InformeMensual generarInforme(YearMonth periodo);
    void importarCatalogo(Path fichero);
    void exportarCatalogo(Path destino);
}

Consecuencias: cualquier implementación debe implementar los siete métodos, aunque solo le interesen dos; en las pruebas, un doble manual necesita siete métodos vacíos; y cambiar la firma de generarInforme recompila a todo el que solo usaba prestar.

Después — interfaces pequeñas, orientadas al cliente:

public interface Prestable {
    Prestamo prestarA(Empleado empleado, int dias);
}

public interface Notificable {
    String destinoDeNotificacion();
}

public interface GestionarPrestamos {           // puerto de entrada del caso de uso
    Prestamo prestar(Isbn isbn, Long idEmpleado, int dias);
    void devolver(Long idPrestamo);
}

public interface ConsultarCatalogo {
    List<Material> buscar(CriterioBusqueda criterio);
    Optional<Material> porIsbn(Isbn isbn);
}

Regla mnemotécnica: las interfaces se diseñan desde el cliente, no desde la implementación. La pregunta correcta no es «¿qué sabe hacer esta clase?», sino «¿qué necesita quien la llama?».

Cuidado con el extremo: una interfaz por método produce un enjambre de tipos. Si dos métodos se usan siempre juntos, van juntos.

  1. SOLID: inversión de dependencias (D)

Los módulos de alto nivel no deben depender de los de bajo nivel. Ambos deben depender de abstracciones. Las abstracciones no deben depender de los detalles; los detalles deben depender de las abstracciones.

Es lo que estructura BiblioTech desde 12-01, y lo que Spring automatiza desde 11-02.

Antes:

public class GestorPrestamos {
    // La política de alto nivel depende de un detalle de bajo nivel.
    private final PrestamoRepositoryJpa repositorio = new PrestamoRepositoryJpa();
    private final ClienteMetadatosHttp metadatos = new ClienteMetadatosHttp();
}

Para probar el cálculo de un préstamo necesitas base de datos y red. Cambiar a MongoDB es reescribir la clase.

Después:

public class GestorPrestamos {
    private final RepositorioPrestamos repositorio;   // abstracción definida en el dominio
    private final PasarelaMetadatos metadatos;

    public GestorPrestamos(RepositorioPrestamos repositorio, PasarelaMetadatos metadatos) {
        this.repositorio = repositorio;
        this.metadatos = metadatos;
    }
}

Un matiz que casi nadie explica y que conviene tener claro:

Concepto Qué es
Inversión de dependencias (DIP) El principio: depender de abstracciones que define el módulo de alto nivel
Inversión de control (IoC) El patrón general: alguien externo controla el flujo (el framework llama a tu código)
Inyección de dependencias (DI) La técnica concreta: las dependencias llegan desde fuera, por constructor

Spring te da IoC y DI. El DIP no te lo da Spring: depende de que la interfaz la definas tú en el dominio y no en la infraestructura. Si RepositorioPrestamos vive en el paquete de persistencia y extiende JpaRepository, estás usando DI sin DIP.

  1. DRY, KISS, YAGNI y la ley de Demeter

Principio Enunciado El matiz que importa
DRY (Don't Repeat Yourself) Cada pieza de conocimiento debe tener una representación única Es sobre conocimiento, no sobre texto. Dos métodos idénticos por casualidad no son duplicación: acoplarlos crea un problema
KISS (Keep It Simple) Prefiere la solución más simple que funcione Simple no es «poco código»: es «fácil de entender»
YAGNI (You Aren't Gonna Need It) No implementes lo que no necesitas hoy El coste no es escribirlo: es mantenerlo, probarlo y que confunda a quien lo lea
Demeter Habla solo con tus amigos inmediatos Cadenas largas de getters revelan que la lógica está en el sitio equivocado

La ley de Demeter, que retoma 03-07. Un método de A solo debería llamar a: métodos del propio A, de sus parámetros, de objetos que él mismo crea, y de sus campos directos.

// VIOLACIÓN: cuatro niveles de profundidad. Conoces toda la estructura interna.
String ciudad = prestamo.getEmpleado().getDepartamento().getSede().getDireccion().getCiudad();

El problema es concreto: esta línea se rompe si cambia cualquiera de los cuatro modelos, y falla con NullPointerException en cualquiera de los cuatro puntos.

// CORRECTO: cada objeto expone lo que le preguntan, y oculta cómo lo sabe.
String ciudad = prestamo.ciudadDelEmpleado();

// en Prestamo
public String ciudadDelEmpleado() { return empleado.ciudad(); }
// en Empleado
public String ciudad() { return departamento.ciudad(); }

Excepción explícita: las APIs fluidas (Builder, Stream, AssertJ) encadenan a propósito, y no violan Demeter, porque cada llamada devuelve el mismo objeto o uno del mismo tipo, no navega por una estructura ajena.

  1. Patrones creacionales: Singleton

Problema: garantizar que existe una única instancia de una clase y dar un punto de acceso global.

Solución clásica:

public class ConfiguracionBiblioTech {

    private static final ConfiguracionBiblioTech INSTANCIA = new ConfiguracionBiblioTech();

    private ConfiguracionBiblioTech() { }          // nadie más puede construirla

    public static ConfiguracionBiblioTech getInstancia() { return INSTANCIA; }
}

Y la variante idiomática en Java, con enum, que además es inmune a la serialización y a la reflexión:

public enum ConfiguracionBiblioTech {
    INSTANCIA;

    private final int diasPorDefecto = 15;
    public int diasPorDefecto() { return diasPorDefecto; }
}
classDiagram
    class Singleton {
        -static INSTANCIA: Singleton
        -Singleton()
        +static getInstancia() Singleton
    }
    class Cliente
    Cliente ..> Singleton : getInstancia()

Y ahora la parte importante: por qué el bean de Spring lo hace mejor.

Aspecto Singleton clásico Bean singleton de Spring
Unicidad Por ClassLoader de la JVM Por contenedor
Cómo lo obtiene el cliente getInstancia() dentro del código Inyectado por constructor
Dependencia visible No: oculta en el cuerpo del método Sí: aparece en la firma del constructor
Sustituible en pruebas No, sin trucos sucios Sí: pasas otro objeto
Configurable por entorno No Sí: perfiles, propiedades
Ciclo de vida Fijo @PostConstruct, @PreDestroy, ámbitos
Estado mutable compartido Peligro de concurrencia oculto El mismo peligro, pero explícito

El problema de fondo del Singleton clásico es que es una variable global disfrazada. ConfiguracionBiblioTech.getInstancia() escrito dentro de CalculadoraMultas crea una dependencia que no aparece en ninguna firma, que nadie puede sustituir y que hace la clase imposible de probar con otra configuración.

Regla: en una aplicación con contenedor, no escribas Singletons. Declara un bean. Si no hay contenedor, usa enum y limita el estado a lo inmutable.

  1. Patrones creacionales: Factory Method y Abstract Factory

Factory Method — problema: crear objetos sin acoplar el cliente a la clase concreta, y sin que un constructor tenga que decidirlo todo.

En BiblioTech, la importación del catálogo recibe registros con un campo tipo:

public final class FabricaMateriales {

    private FabricaMateriales() { }

    public static Material desde(RegistroImportacion registro) {
        return switch (registro.tipo()) {
            case LIBRO   -> new Libro(registro.titulo(), new Isbn(registro.isbn()),
                                      registro.autor(), registro.paginas());
            case REVISTA -> new Revista(registro.titulo(), new Isbn(registro.isbn()),
                                        registro.numero(), registro.periodicidad());
            case DVD     -> new Dvd(registro.titulo(), new Isbn(registro.isbn()),
                                    registro.duracionMinutos());
        };
    }
}

Una variante muy útil en Java moderno es el método de fábrica estático en la propia clase, que aporta algo que el constructor no puede: nombre.

public class Prestamo {

    private Prestamo(...) { }   // constructor privado: la creación pasa por las fábricas

    /** Préstamo estándar, con los días por defecto del material. */
    public static Prestamo nuevo(Material material, Empleado empleado, LocalDate hoy) {
        return new Prestamo(material, empleado, hoy,
                            hoy.plusDays(material.diasDePrestamoPorDefecto()),
                            EstadoPrestamo.ACTIVO);
    }

    /** Préstamo prorrogado por dirección: hasta 90 días. */
    public static Prestamo especial(Material material, Empleado empleado, LocalDate hoy, int dias) {
        if (dias > 90) throw new IllegalArgumentException("Máximo 90 días");
        return new Prestamo(material, empleado, hoy, hoy.plusDays(dias), EstadoPrestamo.ACTIVO);
    }
}

Ventajas sobre el constructor: tiene nombre, puede devolver un subtipo, puede devolver una instancia cacheada y puede fallar antes de construir nada.

Abstract Factory — problema: crear familias de objetos relacionados sin acoplarse a sus clases concretas.

En BiblioTech aparece al exportar informes en varios formatos, donde cada formato necesita un generador, un formateador de cabeceras y una extensión coherentes entre sí:

classDiagram
    class FabricaExportacion {
        <<interface>>
        +generador() GeneradorInforme
        +formateadorTabla() FormateadorTabla
        +extension() String
    }
    class FabricaPdf
    class FabricaCsv
    class FabricaJson
    FabricaExportacion <|.. FabricaPdf
    FabricaExportacion <|.. FabricaCsv
    FabricaExportacion <|.. FabricaJson
public interface FabricaExportacion {
    GeneradorInforme generador();
    FormateadorTabla formateadorTabla();
    String extension();

    static FabricaExportacion para(Formato formato) {
        return switch (formato) {
            case PDF  -> new FabricaPdf();
            case CSV  -> new FabricaCsv();
            case JSON -> new FabricaJson();
        };
    }
}

Cuándo NO usarlos. Si solo hay una implementación y no se prevén más, la fábrica es una capa de indirección sin ninguna ventaja. new Libro(...) está perfectamente.

  1. Patrones creacionales: Builder

Deuda desde 03-04, y ahora se paga.

Problema: un objeto con muchos parámetros, varios opcionales. El constructor telescópico es ilegible y peligroso:

// ¿Qué significa el 15? ¿Y el true? ¿Y el segundo null?
Prestamo p = new Prestamo(material, empleado, LocalDate.now(), 15, true, null, false, null, "Marta Ruiz");

El peligro real: si dos parámetros consecutivos son del mismo tipo, intercambiarlos compila y produce un error silencioso en producción.

Solución:

public class Prestamo {

    private final Material material;
    private final Empleado empleado;
    private final LocalDate fechaPrestamo;
    private final LocalDate fechaVencimiento;
    private final boolean renovable;
    private final String observaciones;
    private final Empleado autorizadoPor;
    private final EstadoPrestamo estado;

    private Prestamo(Builder b) {
        this.material = b.material;
        this.empleado = b.empleado;
        this.fechaPrestamo = b.fechaPrestamo;
        this.fechaVencimiento = b.fechaVencimiento;
        this.renovable = b.renovable;
        this.observaciones = b.observaciones;
        this.autorizadoPor = b.autorizadoPor;
        this.estado = EstadoPrestamo.ACTIVO;
    }

    public static Builder de(Material material, Empleado empleado) {
        return new Builder(material, empleado);      // obligatorios en la fábrica del builder
    }

    public static class Builder {
        // Obligatorios: final, exigidos en el constructor del builder
        private final Material material;
        private final Empleado empleado;
        // Opcionales: con valor por defecto sensato
        private LocalDate fechaPrestamo = LocalDate.now();
        private LocalDate fechaVencimiento;
        private boolean renovable = true;
        private String observaciones = "";
        private Empleado autorizadoPor;

        private Builder(Material material, Empleado empleado) {
            this.material = Objects.requireNonNull(material, "material");
            this.empleado = Objects.requireNonNull(empleado, "empleado");
        }

        public Builder desde(LocalDate fecha)      { this.fechaPrestamo = fecha; return this; }
        public Builder durante(int dias)           { this.fechaVencimiento = fechaPrestamo.plusDays(dias); return this; }
        public Builder hasta(LocalDate fecha)      { this.fechaVencimiento = fecha; return this; }
        public Builder noRenovable()               { this.renovable = false; return this; }
        public Builder conObservaciones(String o)  { this.observaciones = o; return this; }
        public Builder autorizadoPor(Empleado e)   { this.autorizadoPor = e; return this; }

        public Prestamo construir() {
            // La validación de coherencia se hace UNA VEZ, aquí:
            // así el objeto construido es siempre válido.
            if (fechaVencimiento == null) {
                fechaVencimiento = fechaPrestamo.plusDays(material.diasDePrestamoPorDefecto());
            }
            if (fechaVencimiento.isBefore(fechaPrestamo)) {
                throw new IllegalStateException("El vencimiento no puede ser anterior al préstamo");
            }
            if (!material.esPrestable() && autorizadoPor == null) {
                throw new IllegalStateException("Un material restringido requiere autorización");
            }
            return new Prestamo(this);
        }
    }
}

Uso, que ahora se lee como una frase:

Prestamo p = Prestamo.de(javaEfectivo, martaRuiz)
        .desde(LocalDate.of(2026, 3, 1))
        .durante(30)
        .noRenovable()
        .conObservaciones("Para el curso interno de Java")
        .autorizadoPor(nuriaVidal)
        .construir();
classDiagram
    class Prestamo {
        -Prestamo(Builder)
        +static de(Material, Empleado) Builder
    }
    class Builder {
        -material
        -empleado
        -fechaVencimiento
        +desde(LocalDate) Builder
        +durante(int) Builder
        +noRenovable() Builder
        +construir() Prestamo
    }
    Prestamo +.. Builder : clase interna estática
    Builder ..> Prestamo : construye

Contraste con las alternativas de Java moderno:

Alternativa Ventajas Inconvenientes Cuándo
Constructor Directo, sin código extra Ilegible con más de 4 parámetros; peligroso con tipos repetidos Pocos parámetros, todos obligatorios
record Una línea; inmutable; equals/hashCode/toString gratis Sin opcionales; sin validación por pasos; todos los campos en el constructor canónico DTOs y objetos de valor
Builder a mano Legible; validación en un punto; opcionales naturales ~40 líneas por clase; duplica los campos Objetos complejos, con reglas de coherencia
@Builder de Lombok Cero código repetitivo Magia; genera setters implícitos en el builder; peligroso en entidades JPA (11-07) Cuando el equipo ya usa Lombok con criterio

Combinación muy útil: record + builder para un objeto de valor con muchos campos opcionales.

public record CriterioBusqueda(String titulo, String autor, TipoMaterial tipo,
                               Integer anioDesde, Integer anioHasta, Boolean soloDisponibles) {

    public static Builder builder() { return new Builder(); }

    public static class Builder {
        private String titulo; private String autor; private TipoMaterial tipo;
        private Integer anioDesde; private Integer anioHasta;
        private Boolean soloDisponibles = Boolean.FALSE;

        public Builder titulo(String t) { this.titulo = t; return this; }
        public Builder autor(String a)  { this.autor = a; return this; }
        public Builder tipo(TipoMaterial t) { this.tipo = t; return this; }
        public Builder entre(int desde, int hasta) { this.anioDesde = desde; this.anioHasta = hasta; return this; }
        public Builder soloDisponibles() { this.soloDisponibles = true; return this; }

        public CriterioBusqueda construir() {
            return new CriterioBusqueda(titulo, autor, tipo, anioDesde, anioHasta, soloDisponibles);
        }
    }
}

Cuándo NO usar Builder: con tres parámetros obligatorios y ninguno opcional, el builder es cuarenta líneas para no ganar nada. Y nunca en entidades JPA: Hibernate necesita un constructor sin argumentos y setters para los campos gestionados.

  1. Patrones creacionales: Prototype

Problema: crear un objeto nuevo copiando uno existente, cuando construirlo desde cero es caro o su configuración es compleja.

En BiblioTech aparece de forma marginal: duplicar un CriterioBusqueda guardado por Nuria Vidal para modificar un campo. En Java moderno esto se resuelve mejor con métodos «wither» sobre un record:

public record CriterioBusqueda(String titulo, String autor, TipoMaterial tipo, …) {

    public CriterioBusqueda conTipo(TipoMaterial nuevo) {
        return new CriterioBusqueda(titulo, autor, nuevo, anioDesde, anioHasta, soloDisponibles);
    }
}

El Cloneable de Java es la implementación clásica del patrón, y está prácticamente desaconsejada: clone() es superficial, no llama a constructores, interactúa mal con final y su contrato está mal especificado. No lo uses. Copia con un constructor de copia o con métodos como el anterior.

  1. Patrones estructurales: Adaptador

Problema: una clase existente tiene la funcionalidad que necesitas, pero con una interfaz incompatible con la que tu sistema espera.

Es exactamente la situación de ClienteMetadatos (módulo 9, reescrito en 11-07): habla HTTP y JSON, devuelve DTOs de una API externa, lanza IOException e InterruptedException, y el dominio no debe saber nada de eso.

classDiagram
    class PasarelaMetadatos {
        <<interface>>
        +buscar(Isbn) Optional~MetadatosMaterial~
    }
    class AdaptadorMetadatosHttp {
        -ClienteMetadatos cliente
        +buscar(Isbn) Optional~MetadatosMaterial~
    }
    class ClienteMetadatos {
        +consultarPorIsbn(String) RespuestaApiDto
    }
    PasarelaMetadatos <|.. AdaptadorMetadatosHttp
    AdaptadorMetadatosHttp --> ClienteMetadatos : delega

El puerto, en el dominio, expresado en el lenguaje del dominio:

package com.nexussoftware.bibliotech.dominio.catalogo.puerto;

public interface PasarelaMetadatos {
    /** Vacío si no hay metadatos o si el servicio no está disponible. */
    Optional<MetadatosMaterial> buscar(Isbn isbn);
}

El adaptador, en infraestructura:

package com.nexussoftware.bibliotech.infraestructura.metadatos;

@Component
class AdaptadorMetadatosHttp implements PasarelaMetadatos {

    private static final Logger log = LoggerFactory.getLogger(AdaptadorMetadatosHttp.class);

    private final ClienteMetadatos cliente;   // el "adaptee": la clase con interfaz incompatible

    AdaptadorMetadatosHttp(ClienteMetadatos cliente) { this.cliente = cliente; }

    @Override
    public Optional<MetadatosMaterial> buscar(Isbn isbn) {
        try {
            RespuestaApiDto dto = cliente.consultarPorIsbn(isbn.valor());   // formato externo

            // 1. Traduce el modelo externo al del dominio
            // 2. Traduce las excepciones (frontera de errores, 06-07)
            // 3. Aísla el dominio de que existan HTTP, JSON y esta API concreta
            return Optional.of(new MetadatosMaterial(
                    dto.title(),
                    dto.authors() == null ? List.of() : dto.authors(),
                    dto.publishedDate() == null ? null : Year.parse(dto.publishedDate().substring(0, 4)),
                    dto.pageCount()));

        } catch (RecursoNoEncontradoException e) {
            return Optional.empty();                     // "no hay datos" no es un error
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) Thread.currentThread().interrupt();
            log.warn("Metadatos no disponibles para {}: {}", isbn, e.getMessage());
            return Optional.empty();                     // degradación elegante
        }
    }
}

Fíjate en las tres traducciones que hace un adaptador bien hecho: de tipos, de excepciones y de semántica (un 404 externo se convierte en Optional.empty(), no en un error).

Cuándo NO usarlo: si controlas la clase de origen y puedes cambiar su interfaz, cámbiala. El adaptador es para lo que no puedes tocar: librerías de terceros, APIs externas, código heredado.

  1. Patrones estructurales: Decorador

Deuda desde 07-03, y aquí se paga.

Problema: añadir responsabilidades a un objeto dinámicamente, sin modificar su clase ni crear una subclase por cada combinación.

El caso canónico: los flujos de E/S de Java. El módulo 7 lo usaba sin nombrarlo:

// Cada envoltorio AÑADE una capacidad al anterior, y todos son InputStream.
InputStream base       = Files.newInputStream(Path.of("catalogo.csv"));  // bytes crudos
InputStream buferizado = new BufferedInputStream(base);                  // + búfer
InputStream descomp    = new GZIPInputStream(buferizado);                // + descompresión
Reader      lector     = new InputStreamReader(descomp, UTF_8);          // + codificación
BufferedReader lineas  = new BufferedReader(lector);                     // + readLine()

Sin el patrón harían falta clases como BufferedGzipUtf8InputStream: una por combinación. Con él, cuatro clases dan dieciséis combinaciones.

El caso de BiblioTech: cachear el catálogo sin tocar el repositorio.

classDiagram
    class RepositorioMateriales {
        <<interface>>
        +buscarPorIsbn(Isbn) Optional~Material~
        +buscar(CriterioBusqueda) List~Material~
    }
    class RepositorioMaterialesJpa
    class CatalogoConCache {
        -RepositorioMateriales delegado
        -Map cache
    }
    class CatalogoConMetricas {
        -RepositorioMateriales delegado
    }
    RepositorioMateriales <|.. RepositorioMaterialesJpa
    RepositorioMateriales <|.. CatalogoConCache
    RepositorioMateriales <|.. CatalogoConMetricas
    CatalogoConCache --> RepositorioMateriales : envuelve
    CatalogoConMetricas --> RepositorioMateriales : envuelve
/**
 * Decorador: ES un RepositorioMateriales y TIENE un RepositorioMateriales.
 * Esa doble relación es la firma del patrón.
 */
public class CatalogoConCache implements RepositorioMateriales {

    private static final Logger log = LoggerFactory.getLogger(CatalogoConCache.class);

    private final RepositorioMateriales delegado;
    private final Map<Isbn, Material> cache = new ConcurrentHashMap<>();   // módulo 8

    public CatalogoConCache(RepositorioMateriales delegado) {
        this.delegado = delegado;
    }

    @Override
    public Optional<Material> buscarPorIsbn(Isbn isbn) {
        Material enCache = cache.get(isbn);
        if (enCache != null) {
            log.debug("Acierto de caché para {}", isbn);
            return Optional.of(enCache);
        }
        Optional<Material> resultado = delegado.buscarPorIsbn(isbn);
        resultado.ifPresent(m -> cache.put(isbn, m));
        return resultado;
    }

    @Override
    public List<Material> buscar(CriterioBusqueda criterio) {
        return delegado.buscar(criterio);      // las búsquedas no se cachean: son variables
    }

    @Override
    public Material guardar(Material material) {
        cache.remove(material.getIsbn());      // invalidación: lo más difícil de una caché
        return delegado.guardar(material);
    }
}

Y decoradores apilables, como los flujos:

@Bean
RepositorioMateriales repositorioMateriales(RepositorioMaterialesJpa jpa, MeterRegistry registro) {
    return new CatalogoConMetricas(          // 3.º: mide
             new CatalogoConCache(           // 2.º: cachea
               new CatalogoConReintentos(jpa, 3)));   // 1.º: reintenta
}

Nadie que use RepositorioMateriales sabe que hay tres capas. Y el orden importa: en este montaje, las métricas miden el tiempo con caché; si quisieras medir solo la base de datos, el decorador de métricas iría dentro.

Patrón Misma interfaz que lo envuelto Intención
Decorador Añadir comportamiento
Adaptador No (esa es la gracia) Cambiar la interfaz
Proxy Controlar el acceso

Decorador y Proxy son estructuralmente idénticos: se distinguen por la intención.

Cuándo NO usarlo: si solo hay una decoración y nunca va a haber más, un if en la implementación es más simple. Y si necesitas cinco capas, la depuración se vuelve dolorosa: la pila de llamadas se llena de envoltorios.

  1. Patrones estructurales: Proxy

Problema: controlar el acceso a un objeto — para retrasar su creación, verificar permisos, registrar llamadas, reintentar o gestionar una transacción.

Ya escribiste proxies dinámicos en 10-03. Ahora tienen nombre y contexto.

// Proxy dinámico del módulo 10-03: registra el tiempo de cada llamada
@SuppressWarnings("unchecked")
public static <T> T conCronometro(T objetivo, Class<T> interfaz) {
    return (T) Proxy.newProxyInstance(
        interfaz.getClassLoader(),
        new Class<?>[]{interfaz},
        (proxy, metodo, args) -> {
            long inicio = System.nanoTime();
            try {
                return metodo.invoke(objetivo, args);
            } finally {
                long ms = (System.nanoTime() - inicio) / 1_000_000;
                LoggerFactory.getLogger(interfaz).debug("{} tardó {} ms", metodo.getName(), ms);
            }
        });
}

Y aquí se cierra un círculo del módulo 11. Cuando escribes esto:

@Service
public class GestorPrestamos {
    @Transactional
    public Prestamo prestar(Isbn isbn, Long idEmpleado, int dias) { … }
}

Spring no inyecta un GestorPrestamos. Inyecta un proxy de GestorPrestamos que, en cada llamada a prestar, abre una transacción, llama al método real, y hace commit o rollback. Es exactamente el mecanismo de arriba.

sequenceDiagram
    participant C as PrestamoController
    participant P as Proxy transaccional
    participant TM as PlatformTransactionManager
    participant S as GestorPrestamos real

    C->>P: prestar(isbn, id, 15)
    P->>TM: getTransaction()
    P->>S: prestar(isbn, id, 15)
    S-->>P: Prestamo
    P->>TM: commit()
    P-->>C: Prestamo
    Note over P,TM: si S lanza RuntimeException, el proxy hace rollback

Esto explica de una vez las dos rarezas más famosas de @Transactional:

  1. La autoinvocación no funciona. Si metodoA() llama a this.metodoB() y solo metodoB es @Transactional, no hay transacción: la llamada this. no pasa por el proxy.
  2. Los métodos private y final no se interceptan. El proxy es una subclase generada (CGLIB) o un implementador de la interfaz (JDK): no puede sobrescribir lo que no es visible ni sobrescribible.

Tipos de proxy, todos presentes en BiblioTech:

Tipo Para qué Dónde está en BiblioTech
Virtual Retrasar la creación de algo caro Las relaciones LAZY de Hibernate (11-03)
De protección Verificar permisos @PreAuthorize de Spring Security (12-07)
Remoto Representar un objeto en otra máquina Un cliente HTTP declarativo
Inteligente Añadir lógica transversal @Transactional, @Cacheable, AspectoCronometro

  1. Patrones estructurales: Facade y Composite

Facade — problema: un subsistema tiene muchas clases y el cliente solo necesita unas pocas operaciones. La fachada ofrece una interfaz simple sobre un conjunto complejo.

Los servicios de aplicación de BiblioTech son fachadas: GestorPrestamos.prestar(...) esconde repositorios, política, reloj y notificador. También lo era SLF4J en 11-07: una fachada sobre implementaciones de logging.

Aviso: la fachada degenera fácilmente en objeto Dios (lo veremos en los antipatrones) si acumula operaciones sin criterio. Una fachada por área, no una para toda la aplicación.

Composite — problema: tratar de forma uniforme objetos individuales y composiciones de objetos.

En BiblioTech se ve en las colecciones de materiales: una «colección temática» (por ejemplo, «Fundamentos de diseño de software») agrupa materiales y otras colecciones, y debe poder consultarse su disponibilidad como si fuera un material más.

public interface ElementoCatalogo {
    String titulo();
    int unidadesDisponibles();
}

public class Libro implements ElementoCatalogo { … }              // hoja

public class ColeccionTematica implements ElementoCatalogo {      // compuesto
    private final String titulo;
    private final List<ElementoCatalogo> elementos;

    @Override
    public int unidadesDisponibles() {
        // Disponible si TODOS sus elementos lo están: el mínimo manda
        return elementos.stream().mapToInt(ElementoCatalogo::unidadesDisponibles).min().orElse(0);
    }
}

  1. Patrones de comportamiento: Estrategia

Problema: varias variantes de un algoritmo, seleccionables en tiempo de ejecución, sin llenar el código de condicionales.

Ya lo tienes desde el módulo 4: ReglaTarifa y FiltroMaterial eran estrategias.

classDiagram
    class ReglaTarifa {
        <<interface>>
        +calcular(int diasRetraso, Material m) Dinero
    }
    class TarifaEstandar
    class TarifaReducidaEmpleadoNuevo
    class TarifaAgravadaMaterialCritico
    class CalculadoraMultas {
        -ReglaTarifa regla
        +calcular(Prestamo, LocalDate) Dinero
    }
    ReglaTarifa <|.. TarifaEstandar
    ReglaTarifa <|.. TarifaReducidaEmpleadoNuevo
    ReglaTarifa <|.. TarifaAgravadaMaterialCritico
    CalculadoraMultas --> ReglaTarifa : delega

Antes:

public Dinero calcularMulta(Prestamo p, LocalDate hoy) {
    int retraso = (int) ChronoUnit.DAYS.between(p.getFechaVencimiento(), hoy);
    if (retraso <= 0) return Dinero.CERO;

    if (p.getEmpleado().antiguedadEnMeses() < 6) {
        return Dinero.euros("0.25").por(retraso);
    } else if (p.getMaterial().esCritico()) {
        return Dinero.euros("2.00").por(retraso);
    } else if (p.getEmpleado().esDireccion()) {
        return Dinero.CERO;
    } else {
        return Dinero.euros("0.50").por(retraso);
    }
}

Cada política nueva es una rama más en el mismo método, que además hay que volver a probar entera.

Después:

@FunctionalInterface
public interface ReglaTarifa {
    Dinero calcular(int diasDeRetraso, Prestamo prestamo);

    default boolean aplicaA(Prestamo prestamo) { return true; }
}

public class TarifaEstandar implements ReglaTarifa {
    @Override public Dinero calcular(int dias, Prestamo p) {
        return p.getMaterial().multaPorDia().por(dias).limitadaA(Dinero.euros("20.00"));
    }
}

public class TarifaReducidaEmpleadoNuevo implements ReglaTarifa {
    @Override public boolean aplicaA(Prestamo p) { return p.getEmpleado().antiguedadEnMeses() < 6; }
    @Override public Dinero calcular(int dias, Prestamo p) { return Dinero.euros("0.25").por(dias); }
}

public class TarifaExentaDireccion implements ReglaTarifa {
    @Override public boolean aplicaA(Prestamo p) { return p.getEmpleado().esDireccion(); }
    @Override public Dinero calcular(int dias, Prestamo p) { return Dinero.CERO; }
}

public class CalculadoraMultas {
    private final List<ReglaTarifa> reglas;      // ordenadas: la primera que aplica, gana
    private final ReglaTarifa porDefecto = new TarifaEstandar();

    public CalculadoraMultas(List<ReglaTarifa> reglas) { this.reglas = List.copyOf(reglas); }

    public Dinero calcular(Prestamo p, LocalDate hoy) {
        int retraso = (int) ChronoUnit.DAYS.between(p.getFechaVencimiento(), hoy);
        if (retraso <= 0) return Dinero.CERO;

        return reglas.stream()
                .filter(r -> r.aplicaA(p))
                .findFirst()
                .orElse(porDefecto)
                .calcular(retraso, p);
    }
}

Y con Spring, el patrón se vuelve casi invisible. Si cada regla es un bean, Spring inyecta la lista completa ordenada por @Order, y añadir una política es crear una clase: cero cambios en el resto del sistema.

@Component @Order(10) class TarifaExentaDireccion implements ReglaTarifa { … }
@Component @Order(20) class TarifaReducidaEmpleadoNuevo implements ReglaTarifa { … }

@Service
class CalculadoraMultas {
    private final List<ReglaTarifa> reglas;
    CalculadoraMultas(List<ReglaTarifa> reglas) { this.reglas = reglas; }   // Spring inyecta todas
}

En Java 8+, una estrategia sin estado es simplemente una lambda o una referencia a método: Comparator (módulo 5), Predicate, Function son todos estrategias.

Cuándo NO usarlo: con dos casos estables (activo/devuelto), un if es más claro. El patrón compensa cuando las variantes crecen o vienen de configuración.

  1. Patrones de comportamiento: Template Method

Deuda desde 04-02.

Problema: varios algoritmos comparten la misma estructura, pero difieren en algunos pasos.

En BiblioTech: importar el catálogo desde CSV, desde JSON o desde la API de metadatos. El esqueleto es idéntico —abrir, validar cabecera, leer registros, convertir, guardar, informar—; lo que cambia son tres pasos.

classDiagram
    class ImportadorCatalogo {
        <<abstract>>
        +importar(Path) ResultadoImportacion
        #abrir(Path)* Origen
        #leerRegistros(Origen)* List~RegistroImportacion~
        #validar(RegistroImportacion) boolean
    }
    class ImportadorCsv
    class ImportadorJson
    class ImportadorApi
    ImportadorCatalogo <|-- ImportadorCsv
    ImportadorCatalogo <|-- ImportadorJson
    ImportadorCatalogo <|-- ImportadorApi
public abstract class ImportadorCatalogo {

    private static final Logger log = LoggerFactory.getLogger(ImportadorCatalogo.class);

    private final RepositorioMateriales repositorio;

    protected ImportadorCatalogo(RepositorioMateriales repositorio) {
        this.repositorio = repositorio;
    }

    /**
     * MÉTODO PLANTILLA: define el algoritmo y es final para que nadie
     * pueda alterar la secuencia. Solo los pasos son sustituibles.
     */
    public final ResultadoImportacion importar(Path origen) {
        log.info("Iniciando importación desde {}", origen);
        long inicio = System.nanoTime();

        int correctos = 0;
        List<ErrorImportacion> errores = new ArrayList<>();

        try (Origen o = abrir(origen)) {                       // PASO ABSTRACTO
            for (RegistroImportacion registro : leerRegistros(o)) {   // PASO ABSTRACTO
                try {
                    if (!validar(registro)) {                  // GANCHO con implementación por defecto
                        errores.add(ErrorImportacion.invalido(registro));
                        continue;
                    }
                    repositorio.guardar(FabricaMateriales.desde(registro));
                    correctos++;
                } catch (Exception e) {
                    errores.add(ErrorImportacion.de(registro, e));
                }
            }
        } catch (IOException e) {
            throw new ImportacionFallidaException(origen, e);
        }

        long ms = (System.nanoTime() - inicio) / 1_000_000;
        alTerminar(correctos, errores);                        // GANCHO opcional
        log.info("Importación terminada: {} correctos, {} errores, {} ms",
                 correctos, errores.size(), ms);
        return new ResultadoImportacion(correctos, errores, Duration.ofMillis(ms));
    }

    // --- Pasos que cada subclase DEBE aportar ---
    protected abstract Origen abrir(Path ruta) throws IOException;
    protected abstract List<RegistroImportacion> leerRegistros(Origen origen) throws IOException;

    // --- Ganchos: comportamiento por defecto, sustituible ---
    protected boolean validar(RegistroImportacion r) {
        return r.isbn() != null && r.titulo() != null && !r.titulo().isBlank();
    }

    protected void alTerminar(int correctos, List<ErrorImportacion> errores) { }
}

Una subclase queda mínima:

public class ImportadorCsv extends ImportadorCatalogo {

    private final char separador;

    @Override
    protected Origen abrir(Path ruta) throws IOException {
        return new OrigenLector(Files.newBufferedReader(ruta, StandardCharsets.UTF_8));
    }

    @Override
    protected List<RegistroImportacion> leerRegistros(Origen origen) throws IOException {
        try (CSVReader csv = new CSVReaderBuilder(origen.lector())
                .withSkipLines(1)
                .withCSVParser(new CSVParserBuilder().withSeparator(separador).build())
                .build()) {
            return csv.readAll().stream().map(this::aRegistro).toList();
        }
    }
}

Template Method frente a Estrategia — resuelven problemas parecidos con mecanismos opuestos:

Aspecto Template Method Estrategia
Mecanismo Herencia Composición
Qué se fija El algoritmo completo, en la clase base Nada: se sustituye entero
Momento de la elección Compilación (qué subclase instancias) Ejecución (qué objeto inyectas)
Combinaciones Una jerarquía por variación Se combinan libremente
Riesgo Jerarquías profundas y frágiles Más objetos que coordinar

Preferencia moderna: composición sobre herencia. Usa Template Method cuando el esqueleto sea genuinamente fijo y los pasos, muchos.

  1. Patrones de comportamiento: Observador

Problema: varios objetos deben reaccionar a un suceso, y quien lo produce no debe conocerlos.

En BiblioTech, al devolver un material deben ocurrir cosas independientes entre sí: comprobar reservas pendientes, actualizar estadísticas, notificar al empleado, registrar auditoría. Sin el patrón, GestorPrestamos.devolver() acaba llamando a cinco servicios y creciendo con cada requisito nuevo.

Versión clásica:

public interface OyenteDevolucion {
    void alDevolver(EventoDevolucion evento);
}

public class GestorPrestamos {
    private final List<OyenteDevolucion> oyentes = new CopyOnWriteArrayList<>();  // módulo 8

    public void registrar(OyenteDevolucion oyente) { oyentes.add(oyente); }

    public void devolver(Long id) {
        Prestamo p = …;
        p.registrarDevolucion(LocalDate.now(reloj));
        prestamos.guardar(p);

        EventoDevolucion evento = new EventoDevolucion(p.getId(), p.getIsbn(), …);
        for (OyenteDevolucion oyente : oyentes) {
            try {
                oyente.alDevolver(evento);
            } catch (Exception e) {
                // Un oyente que falla NO puede impedir que se ejecuten los demás
                log.error("Oyente {} falló", oyente.getClass().getSimpleName(), e);
            }
        }
    }
}

Ese try/catch dentro del bucle es la parte que casi todo el mundo olvida y la que hace la diferencia entre un patrón útil y un fallo en cascada.

La versión moderna: eventos de aplicación de Spring. El mismo patrón, sin escribir la infraestructura:

// El evento: un record inmutable
public record MaterialDevuelto(Long idPrestamo, Isbn isbn, Long idEmpleado,
                               LocalDate fecha, Dinero multa) { }

// El emisor: no conoce a ningún oyente
@Service
public class GestorPrestamos {

    private final ApplicationEventPublisher eventos;

    @Transactional
    public void devolver(Long id) {
        Prestamo p = prestamos.buscarPorId(id).orElseThrow(…);
        Dinero multa = p.registrarDevolucion(LocalDate.now(reloj));
        prestamos.guardar(p);

        eventos.publishEvent(new MaterialDevuelto(p.getId(), p.getIsbn(),
                                                  p.getIdEmpleado(), LocalDate.now(reloj), multa));
    }
}

// Los oyentes: independientes, añadir uno no toca el emisor
@Component
class ActivadorReservas {
    @EventListener
    void alDevolver(MaterialDevuelto evento) {
        procesadorReservas.activarSiguienteReserva(evento.isbn());
    }
}

@Component
class ActualizadorEstadisticas {
    // Solo si la transacción hace COMMIT: no queremos contar devoluciones que se deshicieron
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void alDevolver(MaterialDevuelto evento) {
        estadisticas.registrarDevolucion(evento);
    }
}

@Component
class NotificadorMultas {
    @Async                                     // en otro hilo: no bloquea la respuesta
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    void alDevolver(MaterialDevuelto evento) {
        if (evento.multa().esPositiva()) {
            notificador.notificar(Aviso.porMulta(evento));
        }
    }
}

@TransactionalEventListener(AFTER_COMMIT) merece atención: garantiza que la reacción solo ocurre si los datos se guardaron de verdad. Es la diferencia entre notificar una multa real y notificar una que se deshizo por un error posterior.

Cuándo NO usarlo. Los eventos hacen el flujo difícil de seguir: leyendo devolver() no sabes qué va a pasar después. Si solo hay un oyente y siempre lo habrá, llámalo directamente. Y java.util.Observer/Observable están deprecados desde Java 9: no los uses.

  1. Patrones de comportamiento: Estado

Problema: un objeto cambia de comportamiento según su estado interno, y el código se llena de condicionales sobre ese estado.

BiblioTech tiene EstadoPrestamo desde el módulo 4. Con el patrón, cada estado sabe qué transiciones permite:

stateDiagram-v2
    [*] --> ACTIVO : crear
    ACTIVO --> RENOVADO : renovar
    RENOVADO --> DEVUELTO : devolver
    ACTIVO --> DEVUELTO : devolver
    ACTIVO --> VENCIDO : pasa la fecha
    RENOVADO --> VENCIDO : pasa la fecha
    VENCIDO --> DEVUELTO : devolver con multa
    DEVUELTO --> [*]
    VENCIDO --> PERDIDO : 90 dias sin devolver
    PERDIDO --> [*]

En Java, el enum con métodos abstractos es la implementación más limpia del patrón (04-07):

public enum EstadoPrestamo {

    ACTIVO {
        @Override public boolean permiteRenovar()  { return true; }
        @Override public boolean permiteDevolver() { return true; }
        @Override public EstadoPrestamo alRenovar() { return RENOVADO; }
    },
    RENOVADO {
        @Override public boolean permiteRenovar()  { return false; }   // solo una renovación
        @Override public boolean permiteDevolver() { return true; }
    },
    VENCIDO {
        @Override public boolean permiteRenovar()  { return false; }
        @Override public boolean permiteDevolver() { return true; }
        @Override public boolean generaMulta()     { return true; }
    },
    DEVUELTO {
        @Override public boolean esFinal() { return true; }
    },
    PERDIDO {
        @Override public boolean esFinal() { return true; }
        @Override public boolean generaMulta() { return true; }
    };

    public boolean permiteRenovar()  { return false; }
    public boolean permiteDevolver() { return false; }
    public boolean generaMulta()     { return false; }
    public boolean esFinal()         { return false; }

    public EstadoPrestamo alRenovar() {
        throw new TransicionInvalidaException(this, "renovar");
    }
}

Y la entidad se limita a preguntar, sin ningún switch:

public void renovar(int dias) {
    if (!estado.permiteRenovar()) {
        throw new TransicionInvalidaException(estado, "renovar");
    }
    this.fechaVencimiento = fechaVencimiento.plusDays(dias);
    this.estado = estado.alRenovar();
}

La ventaja decisiva: la máquina de estados está en un solo sitio. Sin el patrón, la regla «solo se puede renovar una vez» aparece en el servicio, en el controlador y en la CLI, y tarde o temprano una de las tres se olvida.

  1. Patrones de comportamiento: Comando

Problema: encapsular una petición como un objeto, para poder parametrizarla, encolarla, registrarla o deshacerla.

En 05-08 se construyó una pila de deshacer con Deque. Eso era el patrón Comando.

public interface Comando {
    void ejecutar();
    void deshacer();
    String descripcion();
}

public class ComandoPrestar implements Comando {

    private final GestionarPrestamos gestor;
    private final Isbn isbn;
    private final Long idEmpleado;
    private Long idPrestamoCreado;      // estado necesario para deshacer

    @Override
    public void ejecutar() {
        this.idPrestamoCreado = gestor.prestar(isbn, idEmpleado, 15).getId();
    }

    @Override
    public void deshacer() {
        if (idPrestamoCreado == null) throw new IllegalStateException("No ejecutado");
        gestor.anular(idPrestamoCreado);
    }

    @Override
    public String descripcion() { return "Prestar " + isbn + " a empleado " + idEmpleado; }
}

public class HistorialOperaciones {
    private final Deque<Comando> pila = new ArrayDeque<>();      // 05-07/05-08

    public void ejecutar(Comando c) {
        c.ejecutar();
        pila.push(c);
    }

    public void deshacerUltima() {
        if (pila.isEmpty()) throw new NadaQueDeshacerException();
        pila.pop().deshacer();
    }
}

Otros usos del mismo patrón, todos presentes en el ecosistema: los Runnable y Callable que enviaste a un ExecutorService en el módulo 8 son comandos, y las tareas encoladas para procesamiento diferido también.

  1. Patrones de comportamiento: Cadena de responsabilidad e Iterador

Cadena de responsabilidad — problema: varios manejadores pueden atender una petición; se pasa por la cadena hasta que uno la procesa.

El ejemplo canónico está en el propio Spring: la cadena de filtros de Spring Security (12-07) y los Filter de servlets. En BiblioTech, la validación de una importación:

public interface ValidadorImportacion {
    Optional<ErrorImportacion> validar(RegistroImportacion r);
}

@Service
class CadenaValidacion {
    private final List<ValidadorImportacion> validadores;   // Spring inyecta todos, ordenados

    public Optional<ErrorImportacion> validar(RegistroImportacion r) {
        return validadores.stream()
                .map(v -> v.validar(r))
                .flatMap(Optional::stream)
                .findFirst();               // el primer error corta la cadena
    }
}

Iterador — problema: recorrer una colección sin exponer su estructura interna.

Está incorporado al lenguaje desde Java 5: cualquier Iterable funciona con for-each (retoma 05-02). Rara vez hay que implementarlo, pero cuando lo hay —recorrer resultados paginados de una API como si fueran una lista— es muy útil:

public class CatalogoPaginado implements Iterable<Material> {

    private final RepositorioMateriales repositorio;
    private final int tamanoPagina;

    @Override
    public Iterator<Material> iterator() {
        return new Iterator<>() {
            private int pagina = 0;
            private Iterator<Material> actual = Collections.emptyIterator();

            @Override public boolean hasNext() {
                if (actual.hasNext()) return true;
                List<Material> siguiente = repositorio.pagina(pagina++, tamanoPagina);
                actual = siguiente.iterator();
                return actual.hasNext();
            }

            @Override public Material next() {
                if (!hasNext()) throw new NoSuchElementException();
                return actual.next();
            }
        };
    }
}
// El cliente recorre 50.000 materiales sin cargarlos todos en memoria
for (Material m : new CatalogoPaginado(repositorio, 500)) { … }

  1. Patrones arquitectónicos y de empresa

Además de los GoF, hay patrones de nivel superior que BiblioTech ya usa, catalogados en su mayoría por Martin Fowler:

Patrón Qué resuelve Dónde está en BiblioTech
Repositorio Abstraer el acceso a datos como si fuera una colección en memoria RepositorioPrestamos y los repositorios de Spring Data (11-03)
Unidad de trabajo Agrupar cambios y aplicarlos en una sola transacción El EntityManager de JPA con @Transactional
DTO Transportar datos entre capas o procesos Los record de 12-01 y 12-04
Servicio de aplicación Orquestar casos de uso sin lógica de dominio GestorPrestamos, ProcesadorReservas
Inyección de dependencias Suministrar colaboradores desde fuera Constructores + Spring (11-02)
Objeto de valor Modelar conceptos sin identidad Isbn, Dinero
Especificación Componer criterios de consulta CriterioBusqueda, Specification de Spring Data
Modelo de dominio Poner la lógica en objetos con estado y comportamiento Prestamo.registrarDevolucion()

Sobre la Unidad de trabajo, conviene que veas lo que JPA hace por debajo, porque explica por qué no llamas a save() tras modificar una entidad:

@Transactional
public void renovar(Long id, int dias) {
    Prestamo p = prestamos.buscarPorId(id).orElseThrow(…);
    p.renovar(dias);
    // NO hace falta prestamos.guardar(p):
    // el contexto de persistencia (la unidad de trabajo) rastrea la entidad
    // y hace el UPDATE al hacer flush, antes del commit.
}

  1. Antipatrones

Objeto Dios. Una clase que lo sabe y lo hace todo. Síntomas: más de 500 líneas, más de 15 dependencias, nombre genérico (GestorGeneral, UtilBiblioTech, ServicioPrincipal), y todo el mundo la modifica en cada sprint. Es la violación pura de la responsabilidad única. Se cura extrayendo por razón de cambio, no por número de líneas.

Singleton como variable global. Ya visto: crea dependencias ocultas, impide probar y esconde estado mutable compartido. La versión moderna del mismo pecado es la clase de utilidades con estado estático:

public final class ConfigGlobal {
    public static int diasPrestamo = 15;    // cualquiera lo cambia, desde cualquier hilo
}

Herencia por conveniencia. Heredar para reutilizar código, sin que haya relación «es un»:

// MAL: un gestor de préstamos NO ES una lista de préstamos.
public class GestorPrestamos extends ArrayList<Prestamo> { … }

Consecuencia: cualquiera puede llamar a clear() y borrar todos los préstamos. La regla es composición sobre herencia: si la relación no es «es un», usa un campo.

Modelo de dominio anémico — y aquí hay que ser honestos, porque afecta a BiblioTech.

Un modelo anémico es aquel en el que las entidades son solo datos con getters y setters, y toda la lógica vive en servicios. Fowler lo llamó antipatrón porque desperdicia la orientación a objetos: se separan datos y comportamiento, que es justo lo que la POO junta.

// ANÉMICO: Prestamo es una bolsa de datos
public class Prestamo {
    private LocalDate fechaVencimiento;
    private EstadoPrestamo estado;
    // 15 getters y 15 setters, cero comportamiento
}

@Service
public class GestorPrestamos {
    public void devolver(Prestamo p, LocalDate hoy) {
        // La regla de negocio vive FUERA del objeto que la protege
        if (p.getEstado() == EstadoPrestamo.DEVUELTO) throw new …;
        p.setFechaDevolucion(hoy);
        p.setEstado(EstadoPrestamo.DEVUELTO);
    }
}

El problema práctico: nada impide que otro servicio llame a setEstado(DEVUELTO) sin poner la fecha, y el objeto queda en estado inválido. Los invariantes no se pueden garantizar si cualquiera puede mutar los campos.

// RICO: el objeto protege sus propias reglas
public class Prestamo {
    public Dinero registrarDevolucion(LocalDate fecha) {
        if (estado.esFinal()) throw new PrestamoYaCerradoException(id);
        if (fecha.isBefore(fechaPrestamo)) throw new FechaInvalidaException(fecha);
        this.fechaDevolucion = fecha;
        this.estado = EstadoPrestamo.DEVUELTO;
        return multaHasta(fecha);
    }
    // sin setters públicos: es IMPOSIBLE dejar el objeto a medias
}

El debate honesto. BiblioTech tiene lógica en las entidades (Prestamo.registrarDevolucion(), Material.diasDePrestamoPorDefecto()), y eso es deliberado. Pero hay argumentos legítimos en contra:

A favor del modelo rico A favor del modelo anémico
Los invariantes se garantizan en un solo sitio Las entidades JPA tienen ciclo de vida gestionado: lógica compleja dentro complica el flush y las relaciones perezosas
La lógica está donde están los datos La lógica que necesita varios agregados o servicios externos no cabe en una entidad
Se prueba sin infraestructura Es lo que la mayoría de equipos conoce; se lee rápido
Menos servicios anodinos Separar datos de proceso encaja mejor con transacciones y con estilos funcionales

Postura defendible y la que aplica BiblioTech: entidades razonablemente ricas —los invariantes propios de la entidad viven en ella—, y servicios de aplicación para lo que involucra a varios agregados, transacciones o infraestructura. Prestamo sabe que no se puede devolver dos veces; GestorPrestamos sabe que hay que buscar el material, comprobar el límite del empleado y notificar. Ninguna de las dos cosas está en el sitio equivocado.

Lo que no es defendible es una entidad con 30 setters públicos y toda la lógica en un servicio de 800 líneas.

  1. Tabla final: problema → patrón candidato

Problema que estás teniendo Patrón candidato
Un constructor con 6 parámetros y la mitad opcionales Builder
Un switch sobre un tipo repetido en varios sitios Polimorfismo (abierto/cerrado), Estrategia
Necesito varias políticas de cálculo intercambiables Estrategia
Una librería externa tiene una interfaz que no encaja Adaptador
Quiero añadir caché o métricas sin tocar la clase Decorador
Quiero controlar el acceso, retrasar la carga o interceptar llamadas Proxy
Varios procesos comparten el esqueleto y difieren en pasos Template Method
Al ocurrir algo, varias cosas independientes deben reaccionar Observador / eventos de aplicación
Un objeto se comporta distinto según su estado y hay if por todas partes Estado (enum con métodos)
Necesito deshacer, encolar o registrar operaciones Comando
Varios manejadores pueden atender una petición Cadena de responsabilidad
Quiero recorrer algo complejo como si fuera una lista Iterador
Necesito una sola instancia compartida Bean de Spring (no Singleton clásico)
Crear objetos sin acoplarme a la clase concreta Factory Method
Crear familias coherentes de objetos Abstract Factory
El acceso a datos ensucia la lógica de negocio Repositorio
Un subsistema complejo con una interfaz difícil Facade
Tratar igual un elemento y un grupo de elementos Composite
Las entidades de la base de datos viajan a la API DTO
No puedo probar una clase sin base de datos ni red Inversión de dependencias + Inyección

Errores Comunes y Consejos

1. Aplicar un patrón porque lo acabas de aprender. El síndrome del martillo. Si al terminar esta lección tu próxima clase tiene una fábrica abstracta que produce estrategias decoradas, párate. El patrón debe llegar como respuesta a un dolor concreto, no como decoración.

2. Confundir el nombre con la estructura. Llamar PrestamoFactory a una clase que no crea nada, o RepositorioImpl a una fachada. Los nombres de patrón son promesas: si el nombre dice Decorador, quien lo lea esperará que implemente la misma interfaz que envuelve.

3. Empezar por los patrones y no por los principios. Un proyecto con quince patrones mal repartidos es peor que uno con ninguno y SOLID respetado. Los principios se aplican siempre; los patrones, cuando toca.

4. Estrategias sin estado convertidas en jerarquías de clases. En Java 8+, una estrategia sin estado es una lambda. Comparator.comparing(Material::getTitulo) es una estrategia completa en una línea. No crees tres clases para eso.

5. Olvidar el try/catch en el bucle del Observador. Un oyente que lanza excepción impide que se ejecuten los siguientes. Es el fallo más común del patrón y el más difícil de diagnosticar después.

6. Herencia donde toca composición. Si dudas, usa composición. La herencia acopla la subclase a los detalles internos de la superclase, y ese acoplamiento no se ve hasta que la superclase cambia.

7. Decoradores apilados sin control. Cinco capas de decoración convierten una traza de pila en un jeroglífico y una llamada simple en cinco saltos. Dos o tres capas está bien; cinco es señal de que el problema es otro.

8. Creer que @Transactional funciona en llamadas internas. Ahora sabes por qué no: es un proxy. Si this.metodoTransaccional() no abre transacción, no es un bug de Spring.

9. Modelo anémico por inercia. Generar entidades con todos los setters «porque el IDE los hace» y luego preguntarse por qué las reglas están duplicadas en tres servicios. Empieza sin setters y añade solo los que necesites.

10. No documentar el patrón aplicado. Si CatalogoConCache es un decorador, dilo en el Javadoc de la clase. La siguiente persona lo entenderá en cinco segundos en lugar de en diez minutos.

Consejo final: la prueba de fuego de un patrón bien aplicado es que quita código de decisión, no que lo añada. Si tras aplicarlo hay más if, más clases y la misma rigidez, quítalo.

Ejercicios

Ejercicio 1: identificar y refactorizar

Este método existe en BiblioTech. Identifica todos los principios que viola y todos los patrones que resolverían cada problema; después reescríbelo.

@Service
public class ServicioExportacion {

    @Autowired private EntityManager em;
    @Autowired private JavaMailSender correo;

    public void exportar(String formato, String destino, String correoDestino) throws Exception {
        List<Material> materiales = em.createQuery("select m from Material m", Material.class)
                                      .getResultList();
        String contenido;

        if (formato.equals("csv")) {
            StringBuilder sb = new StringBuilder("isbn;titulo;tipo\n");
            for (Material m : materiales) {
                sb.append(m.getIsbn()).append(';')
                  .append(m.getTitulo()).append(';');
                if (m instanceof Libro)        sb.append("LIBRO");
                else if (m instanceof Revista) sb.append("REVISTA");
                else if (m instanceof Dvd)     sb.append("DVD");
                sb.append('\n');
            }
            contenido = sb.toString();
        } else if (formato.equals("json")) {
            contenido = new ObjectMapper().writeValueAsString(materiales);
        } else {
            throw new IllegalArgumentException("Formato no soportado: " + formato);
        }

        Files.writeString(Path.of(destino), contenido);

        if (correoDestino != null) {
            SimpleMailMessage m = new SimpleMailMessage();
            m.setTo(correoDestino);
            m.setSubject("Catálogo BiblioTech");
            m.setText("Adjunto el catálogo en formato " + formato);
            correo.send(m);
        }
        System.out.println("Exportados " + materiales.size() + " materiales");
    }
}

Ejercicio 2: implementar un decorador con control de tasa

BiblioTech consulta la API externa de metadatos, que limita a 10 peticiones por minuto. Superarlo devuelve HTTP 429 y bloquea la cuenta durante una hora.

Implementa un decorador PasarelaConLimiteDeTasa que envuelva cualquier PasarelaMetadatos y garantice que no se superan N peticiones por minuto, esperando si hace falta. Debe ser seguro ante la concurrencia (módulo 8) y respetar la interrupción del hilo.

Ejercicio 3: máquina de estados de una reserva

Modela el ciclo de vida de una Reserva de BiblioTech con el patrón Estado usando un enum con métodos.

Estados: PENDIENTE (en cola, esperando ejemplar), DISPONIBLE (hay ejemplar, el empleado tiene 48 h para recogerlo), RECOGIDA (se convirtió en préstamo), CADUCADA (pasaron las 48 h), CANCELADA (el empleado la anuló).

Reglas: una reserva pendiente se puede cancelar o pasar a disponible; una disponible se puede recoger, cancelar o caducar; RECOGIDA, CADUCADA y CANCELADA son finales; solo DISPONIBLE tiene fecha límite; solo PENDIENTE y DISPONIBLE ocupan puesto en la cola.

Escribe el enum completo, el diagrama de estados y la clase Reserva que lo usa sin un solo switch.


Soluciones

Solución 1

Violaciones y patrones aplicables:

# Problema Principio violado Patrón
1 La clase consulta, formatea, escribe, envía correo e imprime Responsabilidad única Extraer colaboradores
2 if/else sobre el formato Abierto/cerrado Estrategia o Abstract Factory
3 Cadena de instanceof para el tipo Abierto/cerrado, Liskov Polimorfismo
4 Dependencia directa de EntityManager y JavaMailSender Inversión de dependencias Repositorio + Puerto
5 @Autowired sobre campos Inyección por constructor (11-02)
6 new ObjectMapper() en cada llamada Bean singleton (11-07: caro de construir)
7 System.out.println Separación de responsabilidades Logging (11-07)
8 throws Exception Frontera de errores (06-07) Excepciones específicas
9 Escribir en disco empotrado Inversión de dependencias Puerto AlmacenExportaciones

Reescritura. Primero, el problema 3 se resuelve donde debe estar, en el dominio:

public abstract class Material {
    public abstract TipoMaterial tipo();     // adiós al instanceof
}
public class Libro extends Material {
    @Override public TipoMaterial tipo() { return TipoMaterial.LIBRO; }
}

La estrategia de formato:

public interface FormateadorCatalogo {
    String formatear(List<Material> materiales);
    Formato formato();
    String extension();
}

@Component
class FormateadorCsv implements FormateadorCatalogo {
    @Override public Formato formato()   { return Formato.CSV; }
    @Override public String extension()  { return "csv"; }

    @Override
    public String formatear(List<Material> materiales) {
        return materiales.stream()
                .map(m -> String.join(";", m.getIsbn().valor(), m.getTitulo(), m.tipo().name()))
                .collect(Collectors.joining("\n", "isbn;titulo;tipo\n", "\n"));
    }
}

@Component
class FormateadorJson implements FormateadorCatalogo {
    private final ObjectMapper mapper;               // inyectado: uno solo, reutilizado

    FormateadorJson(ObjectMapper mapper) { this.mapper = mapper; }

    @Override public Formato formato()  { return Formato.JSON; }
    @Override public String extension() { return "json"; }

    @Override
    public String formatear(List<Material> materiales) {
        try {
            // DTOs, no entidades (12-01)
            List<MaterialDto> dtos = materiales.stream().map(MaterialDto::desde).toList();
            return mapper.writeValueAsString(dtos);
        } catch (JsonProcessingException e) {
            throw new ExportacionFallidaException("No se pudo serializar el catálogo", e);
        }
    }
}

Los puertos de salida:

public interface AlmacenExportaciones {
    URI guardar(String nombre, String contenido);
}

public interface NotificadorAvisos {
    void notificar(Aviso aviso);
}

Y el servicio, que ahora solo orquesta:

@Service
public class ServicioExportacion {

    private static final Logger log = LoggerFactory.getLogger(ServicioExportacion.class);

    private final RepositorioMateriales materiales;
    private final Map<Formato, FormateadorCatalogo> formateadores;
    private final AlmacenExportaciones almacen;
    private final NotificadorAvisos notificador;

    /** Spring inyecta TODOS los formateadores; los indexamos por formato. */
    public ServicioExportacion(RepositorioMateriales materiales,
                               List<FormateadorCatalogo> formateadores,
                               AlmacenExportaciones almacen,
                               NotificadorAvisos notificador) {
        this.materiales = materiales;
        this.formateadores = formateadores.stream()
                .collect(Collectors.toUnmodifiableMap(FormateadorCatalogo::formato, f -> f));
        this.almacen = almacen;
        this.notificador = notificador;
    }

    public ResultadoExportacion exportar(Formato formato, String nombre, String correoDestino) {
        FormateadorCatalogo formateador = formateadores.get(formato);
        if (formateador == null) {
            throw new FormatoNoSoportadoException(formato, formateadores.keySet());
        }

        List<Material> catalogo = materiales.todos();
        String contenido = formateador.formatear(catalogo);
        URI ubicacion = almacen.guardar(nombre + "." + formateador.extension(), contenido);

        if (correoDestino != null) {
            notificador.notificar(Aviso.exportacionLista(correoDestino, formato, ubicacion));
        }

        log.info("Exportados {} materiales en formato {} a {}", catalogo.size(), formato, ubicacion);
        return new ResultadoExportacion(catalogo.size(), ubicacion, formato);
    }
}

Añadir XML es ahora crear una clase FormateadorXml anotada con @Component. Cero cambios en ServicioExportacion. Eso es el principio abierto/cerrado en la práctica.

Solución 2

/**
 * Decorador que limita la tasa de peticiones a la pasarela de metadatos.
 *
 * Implementa una ventana deslizante: guarda la marca de tiempo de las
 * últimas N peticiones; si la más antigua está dentro de la ventana,
 * espera hasta que salga.
 *
 * ES una PasarelaMetadatos y TIENE una PasarelaMetadatos: decorador.
 */
public class PasarelaConLimiteDeTasa implements PasarelaMetadatos {

    private static final Logger log = LoggerFactory.getLogger(PasarelaConLimiteDeTasa.class);

    private final PasarelaMetadatos delegado;
    private final int maximoPeticiones;
    private final Duration ventana;
    private final Clock reloj;                     // 10-05: inyectable, para poder probarlo

    /** Marcas de tiempo de las últimas peticiones. Acceso siempre bajo el lock. */
    private final Deque<Instant> marcas = new ArrayDeque<>();
    private final ReentrantLock lock = new ReentrantLock(true);   // justo: orden FIFO

    public PasarelaConLimiteDeTasa(PasarelaMetadatos delegado, int maximoPeticiones,
                                   Duration ventana, Clock reloj) {
        this.delegado = Objects.requireNonNull(delegado);
        if (maximoPeticiones < 1) throw new IllegalArgumentException("maximoPeticiones >= 1");
        this.maximoPeticiones = maximoPeticiones;
        this.ventana = Objects.requireNonNull(ventana);
        this.reloj = Objects.requireNonNull(reloj);
    }

    @Override
    public Optional<MetadatosMaterial> buscar(Isbn isbn) {
        adquirirPermiso();                 // puede bloquear
        return delegado.buscar(isbn);      // la llamada real, ya fuera del lock
    }

    private void adquirirPermiso() {
        lock.lock();
        try {
            while (true) {
                Instant ahora = Instant.now(reloj);
                purgarAntiguas(ahora);

                if (marcas.size() < maximoPeticiones) {
                    marcas.addLast(ahora);
                    return;
                }

                // La ventana está llena: hay que esperar a que caduque la más antigua
                Instant masAntigua = marcas.peekFirst();
                Duration espera = Duration.between(ahora, masAntigua.plus(ventana));
                if (espera.isNegative() || espera.isZero()) continue;

                log.debug("Límite de tasa alcanzado; esperando {} ms", espera.toMillis());
                lock.unlock();                       // liberar mientras dormimos
                try {
                    Thread.sleep(espera.toMillis());
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();     // regla del módulo 8: restaurar la marca
                    throw new ConsultaInterrumpidaException("Espera de límite de tasa interrumpida", e);
                } finally {
                    lock.lock();                     // recuperar antes de volver al bucle
                }
            }
        } finally {
            lock.unlock();
        }
    }

    private void purgarAntiguas(Instant ahora) {
        Instant limite = ahora.minus(ventana);
        while (!marcas.isEmpty() && marcas.peekFirst().isBefore(limite)) {
            marcas.pollFirst();
        }
    }
}

Montaje con los decoradores apilados:

@Bean
PasarelaMetadatos pasarelaMetadatos(AdaptadorMetadatosHttp http, Clock reloj, MeterRegistry registro) {
    return new PasarelaConMetricas(                      // 3.º: mide el tiempo total (incluida la espera)
             new PasarelaConLimiteDeTasa(                // 2.º: no supera 10 por minuto
               new PasarelaConCache(http, Duration.ofHours(24)),   // 1.º: la caché evita peticiones
               10, Duration.ofMinutes(1), reloj));
}

Fíjate en el orden: la caché va dentro del limitador, de modo que un acierto de caché no consume cuota. Al revés, cada consulta cacheada gastaría una petición del presupuesto sin llegar a la red. Ese detalle de orden es la razón por la que el patrón Decorador exige pensar, no solo apilar.

Prueba, aprovechando el Clock inyectable:

@Test
void esperaCuandoSeSuperaElLimite() {
    var delegado = new PasarelaFalsa();
    var reloj = Clock.fixed(Instant.parse("2026-03-20T10:00:00Z"), ZoneOffset.UTC);
    var pasarela = new PasarelaConLimiteDeTasa(delegado, 2, Duration.ofSeconds(1), reloj);

    long inicio = System.nanoTime();
    pasarela.buscar(Isbn.de("978-0000000001"));
    pasarela.buscar(Isbn.de("978-0000000002"));
    pasarela.buscar(Isbn.de("978-0000000003"));   // la tercera debe esperar ~1 s
    long ms = (System.nanoTime() - inicio) / 1_000_000;

    assertThat(ms).isGreaterThanOrEqualTo(900);
    assertThat(delegado.llamadas()).isEqualTo(3);
}

Solución 3

stateDiagram-v2
    [*] --> PENDIENTE : reservar
    PENDIENTE --> DISPONIBLE : llega ejemplar
    PENDIENTE --> CANCELADA : cancelar
    DISPONIBLE --> RECOGIDA : recoger
    DISPONIBLE --> CANCELADA : cancelar
    DISPONIBLE --> CADUCADA : pasan 48 h
    RECOGIDA --> [*]
    CADUCADA --> [*]
    CANCELADA --> [*]
public enum EstadoReserva {

    /** En cola, esperando a que haya un ejemplar libre. */
    PENDIENTE {
        @Override public boolean puedeCancelarse()    { return true; }
        @Override public boolean ocupaPuestoEnCola()  { return true; }
        @Override public EstadoReserva alHaberEjemplar() { return DISPONIBLE; }
    },

    /** Hay ejemplar apartado; el empleado dispone de 48 horas. */
    DISPONIBLE {
        @Override public boolean puedeCancelarse()   { return true; }
        @Override public boolean puedeRecogerse()    { return true; }
        @Override public boolean puedeCaducar()      { return true; }
        @Override public boolean ocupaPuestoEnCola() { return true; }
        @Override public boolean tieneFechaLimite()  { return true; }
        @Override public EstadoReserva alRecoger()   { return RECOGIDA; }
        @Override public EstadoReserva alCaducar()   { return CADUCADA; }
    },

    RECOGIDA  { @Override public boolean esFinal() { return true; } },
    CADUCADA  { @Override public boolean esFinal() { return true; } },
    CANCELADA { @Override public boolean esFinal() { return true; } };

    // --- Consultas: por defecto, nada está permitido ---
    public boolean puedeCancelarse()   { return false; }
    public boolean puedeRecogerse()    { return false; }
    public boolean puedeCaducar()      { return false; }
    public boolean ocupaPuestoEnCola() { return false; }
    public boolean tieneFechaLimite()  { return false; }
    public boolean esFinal()           { return false; }

    // --- Transiciones: por defecto, inválidas ---
    public EstadoReserva alHaberEjemplar() { throw new TransicionInvalidaException(this, "haber ejemplar"); }
    public EstadoReserva alRecoger()       { throw new TransicionInvalidaException(this, "recoger"); }
    public EstadoReserva alCaducar()       { throw new TransicionInvalidaException(this, "caducar"); }

    /** Cancelar es la única transición común a varios estados. */
    public EstadoReserva alCancelar() {
        if (!puedeCancelarse()) throw new TransicionInvalidaException(this, "cancelar");
        return CANCELADA;
    }
}

Y la entidad, sin un solo switch ni if sobre el estado:

@Entity
public class Reserva {

    private static final int HORAS_PARA_RECOGER = 48;

    @Id @GeneratedValue private Long id;
    @ManyToOne(fetch = FetchType.LAZY) private Material material;
    @ManyToOne(fetch = FetchType.LAZY) private Empleado empleado;
    @Enumerated(EnumType.STRING) private EstadoReserva estado;
    private Instant fechaSolicitud;
    private Instant fechaLimiteRecogida;      // solo tiene sentido en DISPONIBLE
    @Version private long version;            // bloqueo optimista (11-03)

    protected Reserva() { }                   // exigido por JPA

    public static Reserva nueva(Material material, Empleado empleado, Clock reloj) {
        Reserva r = new Reserva();
        r.material = material;
        r.empleado = empleado;
        r.estado = EstadoReserva.PENDIENTE;
        r.fechaSolicitud = Instant.now(reloj);
        return r;
    }

    public void asignarEjemplar(Clock reloj) {
        this.estado = estado.alHaberEjemplar();       // valida y transita en una línea
        this.fechaLimiteRecogida = Instant.now(reloj).plus(HORAS_PARA_RECOGER, ChronoUnit.HOURS);
    }

    public void recoger() {
        this.estado = estado.alRecoger();
        this.fechaLimiteRecogida = null;
    }

    public void cancelar() {
        this.estado = estado.alCancelar();
        this.fechaLimiteRecogida = null;
    }

    /** Devuelve true si efectivamente caducó. Idempotente: si no procede, no hace nada. */
    public boolean caducarSiProcede(Clock reloj) {
        if (!estado.puedeCaducar()) return false;
        if (Instant.now(reloj).isBefore(fechaLimiteRecogida)) return false;
        this.estado = estado.alCaducar();
        this.fechaLimiteRecogida = null;
        return true;
    }

    public boolean ocupaPuestoEnCola() { return estado.ocupaPuestoEnCola(); }
}

Y el proceso periódico que caduca reservas queda trivial:

@Scheduled(cron = "0 */15 * * * *")     // cada 15 minutos
@Transactional
public void caducarReservasVencidas() {
    int caducadas = 0;
    for (Reserva r : repositorio.enEstado(EstadoReserva.DISPONIBLE)) {
        if (r.caducarSiProcede(reloj)) {
            caducadas++;
            eventos.publishEvent(new ReservaCaducada(r.getId()));   // Observador
        }
    }
    log.info("Reservas caducadas: {}", caducadas);
}

Lo que ha ganado el diseño: la máquina de estados vive en un único fichero de 40 líneas, cualquier transición inválida lanza una excepción clara con el estado de origen y la acción intentada, añadir un estado SUSPENDIDA es añadir una constante, y es imposible dejar una reserva en un estado incoherente desde cualquier punto del sistema.

Conclusión

Todas las deudas del curso están saldadas.

Empezaste con lo que de verdad importa: los principios. Los cinco de SOLID, cada uno con un antes y un después de BiblioTech: la responsabilidad única que partió aquel GestorPrestamos con siete razones para cambiar; el abierto/cerrado que explica por qué el polimorfismo de 03-06 era más que un truco de sintaxis; la sustitución de Liskov con su violación más didáctica —el MaterialDeReferencia que lanzaba UnsupportedOperationException— y su solución real, que no era arreglar la subclase sino rediseñar la jerarquía; la segregación de interfaces que ya practicabas desde 04-01 con Prestable y Notificable; y la inversión de dependencias, con el matiz que casi nadie explica: Spring te da DI e IoC, pero el DIP depende de dónde pongas tú la interfaz. Más DRY sobre conocimiento y no sobre texto, KISS, YAGNI y la ley de Demeter con su excepción para las APIs fluidas.

Después, el catálogo, siempre con el mismo esquema: problema, estructura, código real de BiblioTech y —lo que casi ningún material incluye— cuándo no usarlo. Los creacionales, con el Builder que se prometió en 03-04 por fin implementado para Prestamo y contrastado honestamente con record y con @Builder de Lombok, y con el Singleton clásico puesto en su sitio: una variable global disfrazada que el bean de Spring hace mejor en las siete dimensiones que importan. Los estructurales, con el Adaptador que aísla el ClienteMetadatos externo tras un puerto del dominio y sus tres traducciones —tipos, excepciones y semántica—; el Decorador prometido desde 07-03, con los flujos de E/S como caso canónico y el CatalogoConCache como caso propio, incluida la lección de que el orden de apilado es una decisión de diseño; y el Proxy, que cierra el círculo de 10-03 y explica de una vez por qué @Transactional no funciona en autoinvocaciones ni en métodos privados.

Y los de comportamiento, donde estaban las deudas más antiguas. La Estrategia que llevabas usando desde el módulo 4 con las ReglaTarifa y los Comparator, ahora con nombre y con la versión Spring que la hace casi invisible. El Template Method prometido en 04-02, con la comparación que importa: herencia frente a composición, y por qué la preferencia moderna es la segunda. El Observador con sus OyenteDevolucion, el try/catch dentro del bucle que casi todo el mundo olvida, y los eventos de aplicación de Spring con @TransactionalEventListener(AFTER_COMMIT) como su versión moderna y correcta. El Estado, resuelto con el enum de métodos abstractos que es la implementación más limpia que permite Java. El Comando, que ya tenías en la pila de deshacer de 05-08 y en cada Runnable del módulo 8. Y la Cadena de responsabilidad y el Iterador, que retoman 05-02 y que volverás a ver en la cadena de filtros de 12-07.

Conoces además los patrones de empresa que BiblioTech usa sin que los escribieras: Repositorio, Unidad de trabajo, DTO, Servicio de aplicación, Objeto de valor, Especificación. Y los antipatrones, incluido el debate honesto sobre el modelo anémico, en el que BiblioTech toma partido de forma explícita: entidades razonablemente ricas que protegen sus propios invariantes, servicios de aplicación para lo que cruza agregados, transacciones o infraestructura.

Por encima de todo el catálogo, queda la advertencia con la que empezó la lección, que es lo único que no debes olvidar: un patrón que no responde a un problema real es deuda técnica con buen nombre. El objetivo no es reconocer patrones en tu código; es que tu código sea fácil de cambiar. Los patrones son un medio, y a veces el medio correcto es un if.

BiblioTech tiene ahora arquitectura, módulos, fronteras verificadas por el compilador y un diseño interno con nombre propio. Le sigue faltando algo elemental: nadie puede usarlo. No hay ni una interfaz por la que Marta Ruiz pueda prestar un libro sin escribir código Java.

La siguiente lección lo arregla por el camino más directo y más subestimado: una aplicación de consola. No el menú con Scanner del módulo 2, sino una CLI profesional —con subcomandos, opciones tipadas, ayuda automática, códigos de salida con significado, salida componible con tuberías y cancelación limpia— que un administrador de sistemas de Nexus Software pueda meter en un cron sin quejarse.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados