La CLI de la lección anterior resolvió la automatización y a la gente técnica. No resuelve que Marta Ruiz consulte el catálogo desde su navegador, ni que la futura app móvil de Nexus Software cree préstamos, ni que el sistema de recursos humanos avise a BiblioTech cuando alguien deja la empresa.

Para eso hace falta una API web: un adaptador de entrada que hable HTTP, que cualquier cliente —navegador, móvil, otro servicio, un curl en un script— pueda consumir. Es el módulo bibliotech-web, y va a ser el segundo adaptador que se enchufa exactamente a los mismos casos de uso del módulo bibliotech-aplicacion. Ni una línea de lógica de negocio nueva.

Antes de empezar, conviene decir algo que cambia la forma de leer esta lección: ya sabes cómo funciona esto. En el módulo 9 construiste el ServidorCatalogo: un ServerSocket que aceptaba conexiones, leía bytes, parseaba un protocolo de texto, decidía qué hacer, componía una respuesta y la escribía. Un servidor web es exactamente eso, con el protocolo ya escrito por otros. Spring MVC no es magia: es tu servidor de sockets con treinta años de casos límite resueltos.

Al terminar esta lección entenderás el ciclo petición-respuesta y el papel de cada pieza, diseñarás una API REST con recursos, verbos y códigos de estado correctos, escribirás controladores con validación y manejo global de errores en formato estándar, paginarás y filtrarás, documentarás la API automáticamente, y probarás la capa web en los dos niveles que tienen sentido.

Dos cosas que no están aquí: la seguridad (autenticación, autorización, JWT) es la lección 12-07, y el despliegue es la 12-06. Aquí construimos la API; ya la protegeremos y la pondremos en producción.

Contenido

  1. Cómo funciona una aplicación web en Java
  2. El servidor embebido y el DispatcherServlet
  3. El flujo interno de una petición
  4. Del ServidorCatalogo de sockets a Spring MVC
  5. REST: recursos y representaciones
  6. Verbos HTTP y sus semánticas
  7. Diseño de URIs
  8. Códigos de estado por operación
  9. @RestController: el mapeo de peticiones
  10. Parámetros: ruta, consulta y cuerpo
  11. ResponseEntity y cuándo usarla
  12. DTOs de entrada y salida
  13. Validación con jakarta.validation
  14. Un validador propio para el ISBN
  15. Manejo global de errores y Problem Details
  16. Paginación y ordenación
  17. Filtros y búsqueda
  18. La API completa de BiblioTech
  19. Documentación automática con OpenAPI
  20. CORS
  21. La capa de servicio y las transacciones
  22. Pruebas de la capa web
  23. Interfaz de usuario: Thymeleaf o front-end separado
  24. Hilos virtuales en Spring Boot 3.2
  25. Errores Comunes y Consejos
  26. Ejercicios
  27. Conclusión

  1. Cómo funciona una aplicación web en Java

En su núcleo, todo se reduce a esto: un cliente abre una conexión TCP, envía un texto con un formato acordado, el servidor lo interpreta, hace algo y devuelve otro texto. El formato acordado es HTTP.

Una petición HTTP cruda, tal cual viaja por el socket:

GET /api/materiales?tipo=LIBRO&page=0&size=20 HTTP/1.1
Host: bibliotech.nexussoftware.com
Accept: application/json
User-Agent: curl/8.5.0

Y la respuesta:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 284

{"content":[{"isbn":"978-0000000001","titulo":"Java Efectivo","tipo":"LIBRO"}],
 "page":{"size":20,"number":0,"totalElements":3,"totalPages":1}}

Cuatro elementos en la petición (método, ruta, versión, cabeceras, y cuerpo opcional) y cuatro en la respuesta (versión, código de estado, cabeceras, cuerpo). Nada más. Todo lo que hace Spring MVC es evitarte manipular ese texto a mano.

La pila completa de una aplicación Spring Boot:

Capa Qué hace Quién la implementa
Socket TCP Acepta conexiones, mueve bytes El sistema operativo + la JVM
Servidor HTTP Parsea HTTP, gestiona conexiones e hilos Tomcat (embebido)
API Servlet Abstracción estándar de petición y respuesta jakarta.servlet
DispatcherServlet Enruta a tu código, convierte tipos Spring MVC
Tus controladores Lógica de la aplicación

  1. El servidor embebido y el DispatcherServlet

Servidor embebido. Hasta Spring Boot, desplegar una aplicación Java web era: construir un .war, instalar un Tomcat, copiar el war en webapps/, reiniciar. Spring Boot invirtió el modelo: el servidor va dentro de la aplicación, y el resultado es un jar que se ejecuta con java -jar.

@SpringBootApplication
public class BiblioTechApplication {
    public static void main(String[] args) {
        SpringApplication.run(BiblioTechApplication.class, args);
    }
}

Esas tres líneas arrancan un Tomcat en el puerto 8080. Ventajas: una unidad desplegable, la misma versión de servidor en desarrollo y producción, configuración en el mismo sitio que el resto, y encaja perfectamente con contenedores (12-06).

Alternativas, todas intercambiables cambiando una dependencia:

Servidor Modelo Cuándo
Tomcat Un hilo por petición Por defecto; el más conocido
Jetty Un hilo por petición Menor consumo; embebido en herramientas
Undertow No bloqueante bajo demanda Máximo rendimiento con muchas conexiones
Netty Reactivo Solo con Spring WebFlux

El DispatcherServlet es el front controller: un único servlet mapeado a / por el que pasan todas las peticiones, y que se encarga de decidir quién las atiende. Es el patrón Facade y el patrón Command (12-02) aplicados a la web.

  1. El flujo interno de una petición

Este es el recorrido completo de GET /api/materiales/978-0000000001:

sequenceDiagram
    autonumber
    participant N as Navegador
    participant T as Tomcat
    participant F as Cadena de filtros
    participant D as DispatcherServlet
    participant HM as HandlerMapping
    participant HA as HandlerAdapter
    participant C as CatalogoController
    participant S as CatalogoService
    participant R as Repositorio JPA
    participant MC as HttpMessageConverter

    N->>T: GET /api/materiales/978-0000000001
    T->>T: parsea HTTP, toma un hilo del pool
    T->>F: HttpServletRequest
    F->>F: filtros (CORS, MDC, seguridad en 12-07)
    F->>D: request
    D->>HM: quien atiende esta ruta?
    HM-->>D: CatalogoController.porIsbn
    D->>HA: invoca el metodo
    HA->>HA: convierte el PathVariable a Isbn
    HA->>C: porIsbn(Isbn)
    C->>S: buscarPorIsbn(isbn)
    S->>R: findByIsbn(isbn)
    R-->>S: Optional~Material~
    S-->>C: Material
    C-->>HA: MaterialResponse (DTO)
    HA->>MC: serializa a JSON (Jackson)
    MC-->>D: bytes
    D-->>T: HttpServletResponse 200
    T-->>N: HTTP/1.1 200 OK + JSON

Las piezas de Spring MVC que intervienen:

Componente Responsabilidad
Filter Trabajo transversal antes y después: CORS, correlación (MDC), seguridad
DispatcherServlet Orquesta todo el proceso
HandlerMapping Encuentra el método que atiende la URL y el verbo
HandlerAdapter Invoca el método resolviendo sus argumentos
HandlerMethodArgumentResolver Convierte @PathVariable, @RequestParam, @RequestBody
HttpMessageConverter Serializa y deserializa el cuerpo (Jackson para JSON)
HandlerExceptionResolver Convierte excepciones en respuestas HTTP

  1. Del ServidorCatalogo de sockets a Spring MVC

Merece la pena poner las dos cosas una al lado de la otra, porque esto es lo mismo, resuelto por ti hace tres módulos.

Tu servidor del módulo 9:

// ServidorCatalogo, módulo 9: lo escribiste tú, entero
public void atender(Socket cliente) throws IOException {
    try (var entrada = new BufferedReader(new InputStreamReader(cliente.getInputStream(), UTF_8));
         var salida = new PrintWriter(cliente.getOutputStream(), true)) {

        String peticion = entrada.readLine();          // "BUSCAR 978-0000000001"
        String[] partes = peticion.split(" ", 2);      // parseo del protocolo

        switch (partes[0]) {                           // enrutado
            case "BUSCAR" -> {
                Optional<Material> m = catalogo.porIsbn(partes[1]);
                if (m.isPresent()) {
                    salida.println("OK " + serializar(m.get()));    // serialización
                } else {
                    salida.println("ERROR 404 No encontrado");      // código de error
                }
            }
            case "LISTAR" -> salida.println("OK " + serializar(catalogo.todos()));
            default -> salida.println("ERROR 400 Comando desconocido");
        }
    }
}

Lo mismo con Spring MVC:

@RestController
@RequestMapping("/api/materiales")
public class CatalogoController {

    private final ConsultarCatalogo catalogo;

    @GetMapping("/{isbn}")
    public MaterialResponse porIsbn(@PathVariable Isbn isbn) {
        return catalogo.porIsbn(isbn)
                .map(MaterialResponse::desde)
                .orElseThrow(() -> new MaterialNoEncontradoException(isbn));
    }
}

La correspondencia, pieza a pieza:

Lo que hiciste a mano en el módulo 9 Quién lo hace ahora
ServerSocket.accept() en un bucle Tomcat
Un hilo por cliente (ExecutorService) El pool de hilos de Tomcat
readLine() y split(" ") El parseador HTTP de Tomcat
El switch sobre el comando HandlerMapping con @GetMapping
Integer.parseInt(partes[1]) HandlerMethodArgumentResolver
serializar(...) a mano Jackson vía HttpMessageConverter
"ERROR 404 ..." @RestControllerAdvice y códigos HTTP
Cerrar el socket en finally Tomcat
Tiempos de espera y conexiones a medias Tomcat
Codificación, Content-Length, keep-alive Tomcat

La conclusión que importa: no estás aprendiendo un framework mágico. Estás delegando en código probado exactamente el trabajo que ya sabes hacer, para poder dedicar tu atención a lo que solo tú puedes escribir: las reglas de BiblioTech. Y como sabes lo que hay debajo, cuando algo falle —una petición que se queda colgada, una codificación rara, un Content-Type que no cuadra— sabrás dónde mirar.

  1. REST: recursos y representaciones

REST (Representational State Transfer) es un estilo arquitectónico definido por Roy Fielding en 2000. Sus ideas centrales, aplicadas a BiblioTech:

Todo es un recurso, identificado por una URI. Un recurso es un sustantivo, no una acción:

Correcto Incorrecto
/api/materiales /api/obtenerMateriales
/api/materiales/978-0000000001 /api/getMaterialPorIsbn?isbn=…
/api/prestamos/42 /api/verPrestamo/42

Un recurso no es su representación. El préstamo 42 es un concepto; su representación puede ser JSON, XML o HTML según lo que pida el cliente en Accept.

Sin estado (stateless). Cada petición contiene todo lo necesario para atenderla. El servidor no guarda «en qué paso está» un cliente. Esta propiedad es la que permite escalar horizontalmente (12-06): cualquier instancia puede atender cualquier petición.

Interfaz uniforme. Los mismos verbos con la misma semántica para todos los recursos. Quien sabe usar /api/materiales sabe usar /api/prestamos.

Nota: el modelo de madurez de Richardson. Leonard Richardson clasificó las APIs en cuatro niveles. Nivel 0: un único endpoint que recibe todo (RPC sobre HTTP). Nivel 1: recursos con URIs propias, pero un solo verbo. Nivel 2: recursos + verbos HTTP + códigos de estado correctos. Nivel 3: además, HATEOAS — las respuestas incluyen enlaces a las acciones posibles. La inmensa mayoría de APIs de la industria están en el nivel 2, y es un objetivo perfectamente razonable: el nivel 3 aporta descubribilidad, pero añade complejidad que pocos clientes aprovechan. BiblioTech será nivel 2, con un apunte de HATEOAS donde aporta.

  1. Verbos HTTP y sus semánticas

Dos propiedades gobiernan el uso correcto de los verbos:

  • Seguro (safe): no modifica el estado del servidor. Un buscador puede invocarlo libremente.
  • Idempotente: ejecutarlo N veces tiene el mismo efecto que ejecutarlo una vez. Es lo que permite reintentar sin miedo.
Verbo Seguro Idempotente Uso En BiblioTech
GET Leer Consultar catálogo, préstamos
POST No No Crear, o acciones no idempotentes Crear préstamo
PUT No Reemplazar completo Actualizar todos los datos de un material
PATCH No No necesariamente Modificar parcialmente Cambiar solo las unidades
DELETE No Borrar Cancelar una reserva
HEAD Como GET, sin cuerpo Comprobar existencia
OPTIONS Verbos permitidos Petición previa de CORS

Por qué la idempotencia importa de verdad. Un cliente móvil envía POST /api/prestamos, la respuesta se pierde por un corte de red, y el cliente reintenta. Con POST no idempotente, se crean dos préstamos. Con PUT, no.

Soluciones para hacer idempotente una creación:

POST /api/prestamos HTTP/1.1
Idempotency-Key: 3f2504e0-4f89-11d3-9a0c-0305e82c3301
Content-Type: application/json

{"isbn":"978-0000000001","idEmpleado":1,"dias":15}

El servidor guarda la clave con su respuesta; si llega repetida, devuelve la respuesta original sin crear nada. Es lo que hacen las pasarelas de pago, y por buenas razones.

El caso de DELETE y la idempotencia. Borrar dos veces el mismo recurso: la primera devuelve 204, la segunda 404. ¿Sigue siendo idempotente? Sí: el estado del servidor es el mismo. La idempotencia habla del efecto, no del código de respuesta.

  1. Diseño de URIs

Reglas, con ejemplos de BiblioTech:

Regla Bien Mal
Sustantivos en plural /api/materiales /api/material, /api/getMateriales
Minúsculas y guiones /api/tipos-material /api/tiposMaterial, /api/Tipos_Material
Jerarquía para relaciones /api/empleados/1/prestamos /api/prestamosDeEmpleado?id=1
Sin extensión /api/materiales/978-… /api/materiales/978-….json
Sin verbo en la ruta POST /api/prestamos POST /api/crearPrestamo
Filtros en la consulta /api/materiales?tipo=LIBRO /api/materiales/tipo/LIBRO
Versión explícita /api/v1/materiales (sin versión; se retoma en 12-07)
Sin barra final /api/materiales /api/materiales/

El caso difícil: las acciones que no son CRUD. ¿Cómo se expresa «devolver un préstamo» en REST? Tres opciones:

# Opción A: subrecurso que representa el hecho. La preferida.
POST /api/prestamos/42/devolucion

# Opción B: PATCH sobre el estado
PATCH /api/prestamos/42
{"estado": "DEVUELTO"}

# Opción C: verbo en la ruta. Pragmática, y aceptable si se usa con moderación.
POST /api/prestamos/42/devolver

BiblioTech usa la A: POST /api/prestamos/42/devolucion crea el hecho «devolución» dentro del préstamo 42. Es conceptualmente limpia y permite que la devolución tenga sus propios datos (fecha, observaciones, estado del material).

Anidamiento: máximo dos niveles. /api/empleados/1/prestamos está bien; /api/sedes/2/departamentos/5/empleados/1/prestamos/42/multas es inmanejable. A partir de ahí, recurso de primer nivel con filtros: /api/multas?empleado=1.

  1. Códigos de estado por operación

Usar códigos correctos no es purismo: es lo que permite a un cliente reaccionar sin parsear mensajes.

Código Nombre Cuándo, en BiblioTech
200 OK GET con resultado; PUT/PATCH que devuelve el recurso
201 Created POST que crea. Con cabecera Location
202 Accepted Aceptado para proceso asíncrono (importación masiva)
204 No Content DELETE correcto; PUT sin cuerpo de respuesta
400 Bad Request JSON malformado, tipo incorrecto, validación fallida
401 Unauthorized Sin credenciales o inválidas (12-07)
403 Forbidden Autenticado, pero sin permiso (12-07)
404 Not Found El recurso no existe
405 Method Not Allowed DELETE sobre un recurso que no lo admite
409 Conflict Regla de negocio violada: ya tiene 3 préstamos; conflicto de versión (@Version)
410 Gone Existió y se eliminó permanentemente
415 Unsupported Media Type Content-Type que no se sabe leer
422 Unprocessable Entity Sintaxis correcta, semántica inválida
429 Too Many Requests Límite de tasa superado (12-07)
500 Internal Server Error Error no previsto. Nunca por una entrada del usuario
503 Service Unavailable Dependencia caída; con Retry-After

Errores clásicos que conviene no cometer:

Error Por qué está mal
Devolver 200 con {"error": "..."} El cliente tiene que parsear el cuerpo para saber si funcionó
Devolver 500 porque falta un campo Un 500 significa «he fallado yo»; falta un campo es 400
Devolver 404 cuando no hay resultados en una lista Una lista vacía es un resultado válido: 200 con []
Devolver 401 en lugar de 403 401 = «no sé quién eres»; 403 = «sé quién eres y no puedes»
Devolver 200 tras un POST que crea Debe ser 201 con Location

  1. @RestController: el mapeo de peticiones

package com.nexussoftware.bibliotech.web.catalogo;

@RestController                              // = @Controller + @ResponseBody
@RequestMapping("/api/materiales")           // prefijo común de todos los métodos
public class CatalogoController {

    private final ConsultarCatalogo catalogo;
    private final GestionarCatalogo gestion;

    public CatalogoController(ConsultarCatalogo catalogo, GestionarCatalogo gestion) {
        this.catalogo = catalogo;
        this.gestion = gestion;
    }

    @GetMapping
    public PageResponse<MaterialResponse> listar(@Valid CriterioMaterialesRequest criterio,
                                                 Pageable paginacion) { … }

    @GetMapping("/{isbn}")
    public MaterialResponse porIsbn(@PathVariable Isbn isbn) { … }

    @PostMapping
    public ResponseEntity<MaterialResponse> crear(@Valid @RequestBody CrearMaterialRequest peticion) { … }

    @PutMapping("/{isbn}")
    public MaterialResponse reemplazar(@PathVariable Isbn isbn,
                                       @Valid @RequestBody ActualizarMaterialRequest peticion) { … }

    @PatchMapping("/{isbn}/unidades")
    public MaterialResponse ajustarUnidades(@PathVariable Isbn isbn,
                                            @Valid @RequestBody AjusteUnidadesRequest peticion) { … }

    @DeleteMapping("/{isbn}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void eliminar(@PathVariable Isbn isbn) { … }
}

@RestController equivale a @Controller + @ResponseBody en todos los métodos: el valor devuelto es el cuerpo de la respuesta, serializado por Jackson. Sin él, Spring interpretaría un String devuelto como el nombre de una vista.

Restricciones útiles en el mapeo:

@GetMapping(value = "/{isbn}", produces = MediaType.APPLICATION_JSON_VALUE)
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@GetMapping(value = "/{isbn}", produces = "text/csv")          // negociación de contenido
@GetMapping(params = "formato=resumen")                        // según parámetro
@GetMapping(headers = "X-Api-Version=2")                       // según cabecera

  1. Parámetros: ruta, consulta y cuerpo

@GetMapping("/{isbn}/prestamos")
public List<PrestamoResponse> historial(

        // Parte de la ruta: identifica el recurso
        @PathVariable Isbn isbn,

        // Parámetro de consulta obligatorio
        @RequestParam EstadoPrestamo estado,

        // Opcional con valor por defecto
        @RequestParam(defaultValue = "10") int limite,

        // Opcional de verdad: Optional (10-04)
        @RequestParam Optional<LocalDate> desde,

        // Nombre distinto al del parámetro Java
        @RequestParam(name = "ordenar_por", defaultValue = "FECHA") CriterioOrden orden,

        // Repetible: ?etiqueta=java&etiqueta=diseño
        @RequestParam(required = false) List<String> etiqueta,

        // Cabecera
        @RequestHeader(value = "Accept-Language", defaultValue = "es") Locale idioma) { … }

Y para el cuerpo:

@PostMapping
public ResponseEntity<PrestamoResponse> crear(@Valid @RequestBody CrearPrestamoRequest peticion) { … }

Conversión de tipos propios. Que @PathVariable Isbn isbn funcione requiere decírselo a Spring, y es exactamente el mismo ITypeConverter de Picocli con otra interfaz:

@Component
public class ConvertidorIsbn implements Converter<String, Isbn> {

    @Override
    public Isbn convert(String texto) {
        try {
            return Isbn.de(texto);
        } catch (IsbnInvalidoException e) {
            // IllegalArgumentException → Spring lo traduce a 400, no a 500
            throw new IllegalArgumentException("ISBN inválido: " + texto, e);
        }
    }
}

La ganancia es la misma que en la CLI: el controlador trabaja con el tipo del dominio, y las entradas inválidas se rechazan antes de entrar en tu código.

Un detalle sobre los nombres de parámetro: desde Java 21 conviene compilar con -parameters (Spring Boot lo configura por defecto) para que Spring deduzca los nombres. Sin eso, @PathVariable sin nombre explícito falla en tiempo de ejecución.

  1. ResponseEntity y cuándo usarla

Devolver el DTO directamente es lo más limpio, y es lo que se debe hacer por defecto:

@GetMapping("/{isbn}")
public MaterialResponse porIsbn(@PathVariable Isbn isbn) { … }    // 200 + JSON

ResponseEntity da control total sobre estado y cabeceras, y se justifica en tres casos:

Caso 1: creación con Location (obligatorio para un 201 bien hecho).

@PostMapping
public ResponseEntity<PrestamoResponse> crear(@Valid @RequestBody CrearPrestamoRequest peticion) {
    Prestamo creado = gestor.prestar(peticion.isbn(), peticion.idEmpleado(), peticion.dias());

    URI ubicacion = ServletUriComponentsBuilder
            .fromCurrentRequest()          // http://host/api/prestamos
            .path("/{id}")
            .buildAndExpand(creado.getId())
            .toUri();                      // http://host/api/prestamos/42

    return ResponseEntity.created(ubicacion).body(PrestamoResponse.desde(creado));
}

Caso 2: el código depende del resultado.

@PutMapping("/{isbn}")
public ResponseEntity<MaterialResponse> reemplazar(@PathVariable Isbn isbn,
                                                   @Valid @RequestBody ActualizarMaterialRequest p) {
    ResultadoActualizacion resultado = gestion.crearOActualizar(isbn, p);

    return resultado.fueCreado()
            ? ResponseEntity.status(HttpStatus.CREATED).body(MaterialResponse.desde(resultado.material()))
            : ResponseEntity.ok(MaterialResponse.desde(resultado.material()));
}

Caso 3: cabeceras específicas, como el caché condicional.

@GetMapping("/{isbn}")
public ResponseEntity<MaterialResponse> porIsbn(@PathVariable Isbn isbn) {
    Material material = catalogo.porIsbn(isbn).orElseThrow(() -> new MaterialNoEncontradoException(isbn));

    return ResponseEntity.ok()
            .eTag("\"" + material.getVersion() + "\"")     // el @Version de JPA como ETag
            .cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
            .body(MaterialResponse.desde(material));
}

Con el ETag, un cliente que reenvía If-None-Match recibe un 304 Not Modified sin cuerpo. Es la optimización de ancho de banda más barata que existe.

Alternativa para el caso simple de fijar el código: @ResponseStatus.

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)      // 204
public void cancelar(@PathVariable Long id) { reservas.cancelar(id); }

  1. DTOs de entrada y salida

Esto retoma 12-01, y ahora con el detalle completo. La regla: las entidades JPA no cruzan la frontera de la API, ni de entrada ni de salida.

DTO de salida:

package com.nexussoftware.bibliotech.web.prestamos;

@Schema(description = "Información de un préstamo")          // documentación OpenAPI
public record PrestamoResponse(

        @Schema(example = "42") Long id,
        @Schema(example = "978-0000000001") String isbn,
        @Schema(example = "Java Efectivo") String tituloMaterial,
        @Schema(example = "Marta Ruiz") String nombreEmpleado,
        LocalDate fechaPrestamo,
        LocalDate fechaVencimiento,

        @Schema(description = "Nula si el material sigue prestado")
        LocalDate fechaDevolucion,

        @Schema(example = "ACTIVO") String estado,

        @Schema(description = "Negativo si está vencido", example = "5")
        long diasRestantes,

        @Schema(example = "2.50") BigDecimal multaAcumulada) {

    public static PrestamoResponse desde(Prestamo p, LocalDate hoy) {
        return new PrestamoResponse(
                p.getId(),
                p.getIsbn().valor(),
                p.tituloDelMaterial(),
                p.nombreDelEmpleado(),
                p.getFechaPrestamo(),
                p.getFechaVencimiento(),
                p.getFechaDevolucion().orElse(null),     // JSON no tiene Optional
                p.getEstado().name(),
                ChronoUnit.DAYS.between(hoy, p.getFechaVencimiento()),
                p.multaAcumulada(hoy).importe());
    }
}

DTO de entrada:

public record CrearPrestamoRequest(

        @NotBlank(message = "El ISBN es obligatorio")
        @IsbnValido
        @Schema(example = "978-0000000001")
        String isbn,

        @NotNull(message = "El identificador del empleado es obligatorio")
        @Positive
        Long idEmpleado,

        @Positive @Max(value = 90, message = "La duración máxima es de 90 días")
        @Schema(description = "Días de préstamo. Si se omite, el estándar del tipo de material")
        Integer dias) {
}

Fíjate en lo que no tiene: ni id, ni version, ni estado, ni fechaDevolucion. Un cliente malicioso no puede enviarlos porque el objeto en el que se deserializa no tiene esos componentes. La defensa es estructural, no una lista de campos que hay que acordarse de ignorar.

Un aviso concreto sobre record y Jackson: los records se deserializan sin problema desde Jackson 2.12, pero necesitan -parameters en la compilación o @JsonProperty en cada componente. Spring Boot lo activa por defecto; si tu build es propio, verifícalo.

  1. Validación con jakarta.validation

Dependencia:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Las anotaciones más usadas:

Anotación Valida Ejemplo
@NotNull No nulo (una cadena vacía pasa) Long idEmpleado
@NotBlank Cadena no nula, no vacía, no solo espacios String titulo
@NotEmpty Colección o cadena no vacía List<String> autores
@Size(min, max) Longitud @Size(max = 200) String titulo
@Min / @Max Rango numérico @Max(90) Integer dias
@Positive / @PositiveOrZero Signo @Positive int unidades
@Email Correo electrónico String correo
@Pattern(regexp) Expresión regular @Pattern(regexp = "97[89]-\\d{10}")
@Past / @Future Fechas @PastOrPresent LocalDate fecha
@Valid Cascada a objetos anidados @Valid DireccionRequest direccion

@Valid en el parámetro es lo que dispara la validación:

@PostMapping
public ResponseEntity<PrestamoResponse> crear(@Valid @RequestBody CrearPrestamoRequest peticion) {
    // Si llega aquí, la petición es sintácticamente válida.
    // Si no, Spring ya lanzó MethodArgumentNotValidException.
}

Validación en los parámetros de consulta requiere @Validated en la clase:

@RestController
@Validated                                   // habilita la validación de parámetros sueltos
@RequestMapping("/api/materiales")
public class CatalogoController {

    @GetMapping
    public List<MaterialResponse> listar(
            @RequestParam @Size(min = 2, message = "Al menos 2 caracteres") String titulo,
            @RequestParam @Max(100) int limite) { … }
}

Validación de reglas cruzadas con una anotación a nivel de clase:

@RangoFechasValido       // validador propio: desde <= hasta
public record ConsultaMultasRequest(
        @NotNull @PastOrPresent LocalDate desde,
        @NotNull @PastOrPresent LocalDate hasta,
        Long idEmpleado) {
}

Un límite importante que hay que tener claro: jakarta.validation valida la forma, no las reglas de negocio. Que el ISBN tenga el formato correcto es validación; que ese ISBN exista en el catálogo y tenga unidades libres es una regla de negocio, y vive en el dominio. Confundirlas lleva a poner consultas a la base de datos dentro de un validador, que es exactamente donde no deben estar.

  1. Un validador propio para el ISBN

Un ISBN-13 no es solo un formato: lleva un dígito de control calculado. Comprobarlo es validación de forma, y por tanto sí corresponde aquí.

package com.nexussoftware.bibliotech.web.validacion;

@Documented
@Constraint(validatedBy = ValidadorIsbn.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsbnValido {

    String message() default "ISBN-13 inválido: revisa el formato y el dígito de control";

    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};

    /** Si es true, acepta el ISBN con guiones y espacios. */
    boolean permitirSeparadores() default true;
}
public class ValidadorIsbn implements ConstraintValidator<IsbnValido, String> {

    private static final Pattern CON_SEPARADORES = Pattern.compile("^97[89][- ]?\\d{1,5}[- ]?\\d+[- ]?\\d+[- ]?\\d$");
    private static final Pattern SIN_SEPARADORES = Pattern.compile("^97[89]\\d{10}$");

    private boolean permitirSeparadores;

    @Override
    public void initialize(IsbnValido anotacion) {
        this.permitirSeparadores = anotacion.permitirSeparadores();
    }

    @Override
    public boolean isValid(String valor, ConstraintValidatorContext contexto) {
        // Un valor nulo se considera válido: la obligatoriedad la impone @NotNull.
        // Separar responsabilidades entre validadores es lo correcto.
        if (valor == null) return true;

        String normalizado = permitirSeparadores ? valor.replaceAll("[- ]", "") : valor;

        if (!SIN_SEPARADORES.matcher(normalizado).matches()) {
            mensaje(contexto, "El ISBN debe empezar por 978 o 979 y tener 13 dígitos");
            return false;
        }
        if (!digitoDeControlCorrecto(normalizado)) {
            mensaje(contexto, "El dígito de control del ISBN no es correcto");
            return false;
        }
        return true;
    }

    /**
     * Algoritmo oficial del ISBN-13: se multiplican los 12 primeros dígitos
     * alternando 1 y 3, se suman, y el dígito de control es lo que falta
     * para el siguiente múltiplo de 10.
     */
    private boolean digitoDeControlCorrecto(String isbn) {
        int suma = 0;
        for (int i = 0; i < 12; i++) {
            int digito = isbn.charAt(i) - '0';
            suma += (i % 2 == 0) ? digito : digito * 3;
        }
        int control = (10 - (suma % 10)) % 10;
        return control == (isbn.charAt(12) - '0');
    }

    /** Sustituye el mensaje genérico por uno específico del fallo concreto. */
    private void mensaje(ConstraintValidatorContext contexto, String texto) {
        contexto.disableDefaultConstraintViolation();
        contexto.buildConstraintViolationWithTemplate(texto).addConstraintViolation();
    }
}

Ese mensaje específico es la diferencia entre «ISBN inválido» (que no ayuda) y «el dígito de control no es correcto» (que le dice a quien integra dónde está el fallo).

  1. Manejo global de errores y Problem Details

Sin manejo global, una excepción produce una respuesta genérica con traza incluida, que es a la vez inútil para el cliente y una filtración de información (12-07).

RFC 7807 (Problem Details) define un formato estándar para errores HTTP, y Spring 6 lo soporta de serie con la clase ProblemDetail:

{
  "type": "https://bibliotech.nexussoftware.com/errores/limite-prestamos",
  "title": "Límite de préstamos excedido",
  "status": 409,
  "detail": "El empleado Diego Alonso ya tiene 3 préstamos activos (máximo: 3).",
  "instance": "/api/prestamos",
  "timestamp": "2026-08-05T10:23:45Z",
  "traceId": "a7f3e91c4b2d",
  "prestamosActivos": [12, 27, 38]
}

Los cinco campos estándar son type, title, status, detail e instance; el resto son extensiones propias.

Activación del formato por defecto de Spring:

spring:
  mvc:
    problemdetails:
      enabled: true      # las excepciones estándar de Spring ya salen en formato RFC 7807

Y el manejador para nuestras excepciones:

package com.nexussoftware.bibliotech.web.error;

@RestControllerAdvice
public class ManejadorGlobalErrores {

    private static final Logger log = LoggerFactory.getLogger(ManejadorGlobalErrores.class);
    private static final String BASE_TIPOS = "https://bibliotech.nexussoftware.com/errores/";

    // ---------- 404 ----------
    @ExceptionHandler({MaterialNoEncontradoException.class,
                       PrestamoNoEncontradoException.class,
                       EmpleadoNoEncontradoException.class})
    public ProblemDetail noEncontrado(RecursoNoEncontradoException e, HttpServletRequest peticion) {
        log.info("Recurso no encontrado: {}", e.getMessage());     // INFO: no es un fallo del sistema
        return problema(HttpStatus.NOT_FOUND, "recurso-no-encontrado",
                        "Recurso no encontrado", e.getMessage(), peticion);
    }

    // ---------- 409: reglas de negocio ----------
    @ExceptionHandler(LimiteDePrestamosExcedidoException.class)
    public ProblemDetail limiteExcedido(LimiteDePrestamosExcedidoException e, HttpServletRequest p) {
        ProblemDetail detalle = problema(HttpStatus.CONFLICT, "limite-prestamos",
                "Límite de préstamos excedido", e.getMessage(), p);
        detalle.setProperty("prestamosActivos", e.getIdsPrestamosActivos());
        detalle.setProperty("maximoPermitido", e.getMaximo());
        return detalle;
    }

    @ExceptionHandler(MaterialNoDisponibleException.class)
    public ProblemDetail noDisponible(MaterialNoDisponibleException e, HttpServletRequest p) {
        ProblemDetail detalle = problema(HttpStatus.CONFLICT, "material-no-disponible",
                "Material no disponible", e.getMessage(), p);
        e.getFechaPrevistaDisponibilidad()
         .ifPresent(f -> detalle.setProperty("disponiblePrevisiblementeEl", f.toString()));
        return detalle;
    }

    // ---------- 409: conflicto de concurrencia (el @Version de 11-03) ----------
    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    public ProblemDetail conflictoDeVersion(ObjectOptimisticLockingFailureException e,
                                            HttpServletRequest p) {
        log.warn("Conflicto de bloqueo optimista en {}", p.getRequestURI());
        return problema(HttpStatus.CONFLICT, "conflicto-concurrencia",
                "Conflicto de concurrencia",
                "Otro usuario modificó este recurso mientras lo editabas. Recárgalo y reintenta.", p);
    }

    // ---------- 400: validación del cuerpo ----------
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail validacion(MethodArgumentNotValidException e, HttpServletRequest p) {
        List<ErrorCampo> errores = e.getBindingResult().getFieldErrors().stream()
                .map(f -> new ErrorCampo(f.getField(), f.getDefaultMessage(), f.getRejectedValue()))
                .toList();

        ProblemDetail detalle = problema(HttpStatus.BAD_REQUEST, "validacion",
                "Error de validación",
                "La petición contiene %d campo(s) inválido(s).".formatted(errores.size()), p);
        detalle.setProperty("errores", errores);     // ESTO es lo que hace útil un 400
        return detalle;
    }

    // ---------- 400: parámetros y tipos ----------
    @ExceptionHandler(ConstraintViolationException.class)
    public ProblemDetail parametrosInvalidos(ConstraintViolationException e, HttpServletRequest p) {
        List<ErrorCampo> errores = e.getConstraintViolations().stream()
                .map(v -> new ErrorCampo(v.getPropertyPath().toString(), v.getMessage(), v.getInvalidValue()))
                .toList();
        ProblemDetail detalle = problema(HttpStatus.BAD_REQUEST, "parametros-invalidos",
                "Parámetros inválidos", "Revisa los parámetros de la petición.", p);
        detalle.setProperty("errores", errores);
        return detalle;
    }

    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ProblemDetail tipoIncorrecto(MethodArgumentTypeMismatchException e, HttpServletRequest p) {
        String esperado = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "válido";
        return problema(HttpStatus.BAD_REQUEST, "tipo-incorrecto", "Tipo de dato incorrecto",
                "El parámetro '%s' con valor '%s' no es un %s."
                        .formatted(e.getName(), e.getValue(), esperado), p);
    }

    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ProblemDetail cuerpoIlegible(HttpMessageNotReadableException e, HttpServletRequest p) {
        // NO exponemos e.getMessage(): revela la estructura interna de las clases
        return problema(HttpStatus.BAD_REQUEST, "cuerpo-invalido", "Cuerpo de la petición inválido",
                "El cuerpo no es un JSON válido o no encaja con el formato esperado.", p);
    }

    // ---------- 503: dependencia externa ----------
    @ExceptionHandler(ServicioExternoNoDisponibleException.class)
    public ResponseEntity<ProblemDetail> servicioCaido(ServicioExternoNoDisponibleException e,
                                                       HttpServletRequest p) {
        log.error("Servicio externo no disponible: {}", e.getServicio(), e);
        ProblemDetail detalle = problema(HttpStatus.SERVICE_UNAVAILABLE, "servicio-no-disponible",
                "Servicio temporalmente no disponible",
                "No se pudo completar la operación. Inténtalo de nuevo en unos minutos.", p);
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                .header(HttpHeaders.RETRY_AFTER, "60")     // el cliente sabe cuándo reintentar
                .body(detalle);
    }

    // ---------- 500: la red de seguridad ----------
    @ExceptionHandler(Exception.class)
    public ProblemDetail errorNoPrevisto(Exception e, HttpServletRequest p) {
        String idIncidencia = UUID.randomUUID().toString().substring(0, 12);
        // La traza COMPLETA al log; al cliente, solo el identificador (06-07 y 12-07)
        log.error("Error no previsto [id={}] en {} {}", idIncidencia, p.getMethod(), p.getRequestURI(), e);

        ProblemDetail detalle = problema(HttpStatus.INTERNAL_SERVER_ERROR, "error-interno",
                "Error interno",
                "Se ha producido un error inesperado. Si el problema persiste, "
                + "contacta con soporte indicando el identificador de incidencia.", p);
        detalle.setProperty("idIncidencia", idIncidencia);
        return detalle;
    }

    // ---------- Constructor común ----------
    private ProblemDetail problema(HttpStatus estado, String tipo, String titulo,
                                   String detalleTexto, HttpServletRequest peticion) {
        ProblemDetail detalle = ProblemDetail.forStatusAndDetail(estado, detalleTexto);
        detalle.setType(URI.create(BASE_TIPOS + tipo));
        detalle.setTitle(titulo);
        detalle.setInstance(URI.create(peticion.getRequestURI()));
        detalle.setProperty("timestamp", Instant.now().toString());
        // El identificador de correlación que MDC puso en 11-07: une la respuesta con el log
        detalle.setProperty("traceId", MDC.get("traceId"));
        return detalle;
    }

    public record ErrorCampo(String campo, String mensaje, Object valorRechazado) { }
}

Tabla de traducción completa de la jerarquía del módulo 6:

Excepción de BiblioTech HTTP Tipo de problema Se registra como
MaterialNoEncontradoException 404 recurso-no-encontrado INFO
PrestamoNoEncontradoException 404 recurso-no-encontrado INFO
LimiteDePrestamosExcedidoException 409 limite-prestamos INFO
MaterialNoDisponibleException 409 material-no-disponible INFO
PrestamoYaDevueltoException 409 prestamo-ya-devuelto INFO
ObjectOptimisticLockingFailureException 409 conflicto-concurrencia WARN
MethodArgumentNotValidException 400 validacion DEBUG
IsbnInvalidoException 400 isbn-invalido DEBUG
ServicioExternoNoDisponibleException 503 servicio-no-disponible ERROR
Exception (cualquier otra) 500 error-interno ERROR

El criterio de nivel de log es importante y casi nadie lo aplica: un 404 no es un error del sistema, es un cliente pidiendo algo que no existe. Si lo registras como ERROR, tu panel de alertas se llenará de ruido y dejarás de mirarlo.

  1. Paginación y ordenación

Devolver List<Material> con 50.000 elementos es un problema de memoria, de red y de tiempo de respuesta. Spring Data resuelve la paginación de serie:

@GetMapping
public PageResponse<MaterialResponse> listar(
        @PageableDefault(size = 20, sort = "titulo", direction = Sort.Direction.ASC)
        Pageable paginacion) {

    Page<Material> pagina = catalogo.buscar(paginacion);
    return PageResponse.desde(pagina.map(MaterialResponse::desde));
}
GET /api/materiales?page=0&size=20&sort=titulo,asc
GET /api/materiales?page=2&size=50&sort=anioPublicacion,desc&sort=titulo,asc

Limita el tamaño máximo de página, o alguien pedirá size=1000000:

spring:
  data:
    web:
      pageable:
        default-page-size: 20
        max-page-size: 100          # Spring recorta silenciosamente por encima
        one-indexed-parameters: false

Y un DTO de respuesta paginada propio, para no exponer la estructura interna de Page (que ha cambiado entre versiones de Spring Data, rompiendo clientes):

public record PageResponse<T>(List<T> contenido, MetadatosPagina pagina) {

    public static <T> PageResponse<T> desde(Page<T> page) {
        return new PageResponse<>(page.getContent(), new MetadatosPagina(
                page.getNumber(), page.getSize(), page.getTotalElements(),
                page.getTotalPages(), page.isFirst(), page.isLast()));
    }

    public record MetadatosPagina(int numero, int tamano, long totalElementos,
                                  int totalPaginas, boolean primera, boolean ultima) { }
}
{
  "contenido": [ { "isbn": "978-0000000001", "titulo": "Java Efectivo", "…": "…" } ],
  "pagina": { "numero": 0, "tamano": 20, "totalElementos": 3,
              "totalPaginas": 1, "primera": true, "ultima": true }
}

Un aviso de rendimiento. La paginación por desplazamiento (OFFSET) se degrada con páginas altas: OFFSET 100000 obliga a la base de datos a recorrer y descartar cien mil filas. Para catálogos grandes existe la paginación por cursor (WHERE id > :ultimoId ORDER BY id LIMIT 20), que es constante en tiempo. Para BiblioTech, con unos miles de materiales, el desplazamiento sobra.

  1. Filtros y búsqueda

Un objeto de criterios agrupado es mejor que ocho @RequestParam sueltos:

public record CriterioMaterialesRequest(
        @Size(min = 2, max = 100) String titulo,
        @Size(min = 2, max = 100) String autor,
        TipoMaterial tipo,
        @Min(1450) Integer anioDesde,          // año de la imprenta: un límite razonable
        @Max(2100) Integer anioHasta,
        Boolean soloDisponibles) {

    public CriterioBusqueda aDominio() {
        return CriterioBusqueda.builder()      // el Builder de 12-02
                .titulo(titulo).autor(autor).tipo(tipo)
                .entre(anioDesde, anioHasta)
                .soloDisponibles(Boolean.TRUE.equals(soloDisponibles))
                .construir();
    }
}
@GetMapping
public PageResponse<MaterialResponse> listar(@Valid CriterioMaterialesRequest criterio,
                                             @PageableDefault(size = 20) Pageable paginacion) {
    Page<Material> resultados = catalogo.buscar(criterio.aDominio(), paginacion);
    return PageResponse.desde(resultados.map(MaterialResponse::desde));
}
GET /api/materiales?titulo=java&tipo=LIBRO&soloDisponibles=true&page=0&size=10

En la implementación, Specification de Spring Data compone los criterios dinámicamente. Es el patrón Especificación (12-02):

public Page<Material> buscar(CriterioBusqueda criterio, Pageable paginacion) {
    Specification<Material> spec = Specification.where(null);

    if (criterio.titulo() != null) {
        spec = spec.and((raiz, consulta, cb) ->
                cb.like(cb.lower(raiz.get("titulo")), "%" + criterio.titulo().toLowerCase() + "%"));
    }
    if (criterio.tipo() != null) {
        spec = spec.and((raiz, consulta, cb) -> cb.equal(raiz.get("tipo"), criterio.tipo()));
    }
    if (criterio.soloDisponibles()) {
        spec = spec.and((raiz, consulta, cb) -> cb.greaterThan(raiz.get("unidadesDisponibles"), 0));
    }
    return repositorio.findAll(spec, paginacion);
}

Esto no es concatenación de SQL: CriteriaBuilder genera consultas parametrizadas, inmunes a inyección (se retoma en 12-07).

  1. La API completa de BiblioTech

Método Ruta Descripción Éxito Errores
GET /api/materiales Lista paginada con filtros 200 400
GET /api/materiales/{isbn} Detalle de un material 200 400, 404
POST /api/materiales Alta de material 201 400, 409
PUT /api/materiales/{isbn} Reemplazo completo 200, 201 400, 404
PATCH /api/materiales/{isbn}/unidades Ajuste de unidades 200 400, 404, 409
DELETE /api/materiales/{isbn} Baja de material 204 404, 409
GET /api/prestamos Lista paginada 200 400
GET /api/prestamos/{id} Detalle 200 404
POST /api/prestamos Crear préstamo 201 400, 404, 409
POST /api/prestamos/{id}/devolucion Registrar devolución 200 404, 409
POST /api/prestamos/{id}/renovacion Renovar 200 404, 409
GET /api/empleados/{id}/prestamos Préstamos de un empleado 200 404
GET /api/empleados/{id}/multas Multas de un empleado 200 404
POST /api/reservas Crear reserva 201 400, 404, 409
DELETE /api/reservas/{id} Cancelar reserva 204 404, 409
GET /api/estadisticas/uso Informe de uso 200 400
GET /actuator/health Estado del servicio 200 503

Ejemplos con curl y sus respuestas.

Crear un préstamo:

curl -i -X POST http://localhost:8080/api/prestamos \
  -H "Content-Type: application/json" \
  -d '{"isbn":"978-0000000001","idEmpleado":1,"dias":15}'
HTTP/1.1 201 Created
Location: http://localhost:8080/api/prestamos/42
Content-Type: application/json

{
  "id": 42,
  "isbn": "978-0000000001",
  "tituloMaterial": "Java Efectivo",
  "nombreEmpleado": "Marta Ruiz",
  "fechaPrestamo": "2026-08-05",
  "fechaVencimiento": "2026-08-20",
  "fechaDevolucion": null,
  "estado": "ACTIVO",
  "diasRestantes": 15,
  "multaAcumulada": 0.00
}

Superar el límite de préstamos:

curl -i -X POST http://localhost:8080/api/prestamos \
  -H "Content-Type: application/json" \
  -d '{"isbn":"978-0000000003","idEmpleado":2,"dias":15}'
HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://bibliotech.nexussoftware.com/errores/limite-prestamos",
  "title": "Límite de préstamos excedido",
  "status": 409,
  "detail": "El empleado Diego Alonso ya tiene 3 préstamos activos (máximo: 3).",
  "instance": "/api/prestamos",
  "timestamp": "2026-08-05T10:23:45Z",
  "traceId": "a7f3e91c4b2d",
  "prestamosActivos": [12, 27, 38],
  "maximoPermitido": 3
}

Petición con varios errores de validación:

curl -i -X POST http://localhost:8080/api/prestamos \
  -H "Content-Type: application/json" \
  -d '{"isbn":"1234","idEmpleado":null,"dias":365}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{
  "type": "https://bibliotech.nexussoftware.com/errores/validacion",
  "title": "Error de validación",
  "status": 400,
  "detail": "La petición contiene 3 campo(s) inválido(s).",
  "instance": "/api/prestamos",
  "errores": [
    {"campo": "isbn", "mensaje": "El ISBN debe empezar por 978 o 979 y tener 13 dígitos",
     "valorRechazado": "1234"},
    {"campo": "idEmpleado", "mensaje": "El identificador del empleado es obligatorio",
     "valorRechazado": null},
    {"campo": "dias", "mensaje": "La duración máxima es de 90 días", "valorRechazado": 365}
  ]
}

Que se devuelvan los tres errores a la vez —y no el primero— es lo que evita al cliente tres viajes de ida y vuelta.

Registrar la devolución:

curl -i -X POST http://localhost:8080/api/prestamos/42/devolucion \
  -H "Content-Type: application/json" -d '{"fecha":"2026-08-25"}'
HTTP/1.1 200 OK

{
  "idPrestamo": 42,
  "fechaDevolucion": "2026-08-25",
  "diasDeRetraso": 5,
  "multa": 2.50,
  "estado": "DEVUELTO"
}

Búsqueda paginada:

curl "http://localhost:8080/api/materiales?titulo=dise&tipo=LIBRO&page=0&size=5&sort=titulo,asc"

  1. Documentación automática con OpenAPI

OpenAPI (antes Swagger) describe una API en un documento JSON o YAML legible por máquinas. A partir de él se genera documentación navegable, clientes en cualquier lenguaje y pruebas de contrato.

<dependency>
  <groupId>org.springdoc</groupId>
  <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>

Con solo eso ya tienes:

  • http://localhost:8080/v3/api-docs — el documento OpenAPI en JSON
  • http://localhost:8080/swagger-ui.html — la interfaz navegable, con botón «Try it out»

Y se enriquece con anotaciones:

@RestController
@RequestMapping("/api/prestamos")
@Tag(name = "Préstamos", description = "Gestión de préstamos de materiales a empleados")
public class PrestamoController {

    @Operation(
        summary = "Crea un préstamo",
        description = """
                Presta un material a un empleado. Comprueba que haya unidades disponibles
                y que el empleado no supere el límite de préstamos activos.

                Si no se indica la duración, se usa la estándar del tipo de material:
                15 días para libros, 7 para revistas y 3 para DVD.""")
    @ApiResponses({
        @ApiResponse(responseCode = "201", description = "Préstamo creado",
            content = @Content(schema = @Schema(implementation = PrestamoResponse.class))),
        @ApiResponse(responseCode = "400", description = "Petición inválida",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
        @ApiResponse(responseCode = "404", description = "Material o empleado inexistente",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
        @ApiResponse(responseCode = "409", description = "Sin unidades libres o límite excedido",
            content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
    })
    @PostMapping
    public ResponseEntity<PrestamoResponse> crear(@Valid @RequestBody CrearPrestamoRequest peticion) { … }
}

Información general de la API:

@Configuration
public class ConfiguracionOpenApi {

    @Bean
    OpenAPI apiBiblioTech(@Value("${bibliotech.version}") String version) {
        return new OpenAPI()
                .info(new Info()
                        .title("API de BiblioTech")
                        .version(version)
                        .description("Gestión de la biblioteca técnica interna de Nexus Software.")
                        .contact(new Contact().name("Equipo de plataforma")
                                              .email("[email protected]")))
                .servers(List.of(
                        new Server().url("http://localhost:8080").description("Desarrollo"),
                        new Server().url("https://bibliotech.nexussoftware.com").description("Producción")));
    }
}

Y en producción, se desactiva la interfaz pero se conserva el documento (o se protege, 12-07):

springdoc:
  swagger-ui:
    enabled: false        # en prod
  api-docs:
    path: /v3/api-docs

El valor real de OpenAPI aparece en la integración: un cliente TypeScript, Java o Python se genera desde el documento con un comando, y no hay que escribir a mano un solo DTO.

  1. CORS

Los navegadores aplican la política del mismo origen: JavaScript servido desde https://intranet.nexussoftware.com no puede llamar a https://bibliotech.nexussoftware.com salvo que el servidor lo autorice explícitamente. CORS (Cross-Origin Resource Sharing) es ese mecanismo de autorización.

El flujo para peticiones «no simples» (con Content-Type: application/json, por ejemplo) incluye una petición previa:

OPTIONS /api/prestamos HTTP/1.1
Origin: https://intranet.nexussoftware.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://intranet.nexussoftware.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE
Access-Control-Allow-Headers: content-type
Access-Control-Max-Age: 3600

Configuración global:

@Configuration
public class ConfiguracionCors implements WebMvcConfigurer {

    private final List<String> origenesPermitidos;    // desde application.yml, por entorno

    @Override
    public void addCorsMappings(CorsRegistry registro) {
        registro.addMapping("/api/**")
                .allowedOrigins(origenesPermitidos.toArray(String[]::new))
                .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
                .allowedHeaders("Content-Type", "Authorization")
                .exposedHeaders("Location")            // para que el cliente pueda leerla tras un 201
                .allowCredentials(true)
                .maxAge(3600);                         // cachear la respuesta previa 1 hora
    }
}
# dev
bibliotech:
  cors:
    origenes: ["http://localhost:5173", "http://localhost:3000"]
# prod
bibliotech:
  cors:
    origenes: ["https://intranet.nexussoftware.com"]

Aviso. allowedOrigins("*") junto con allowCredentials(true) es una combinación que la especificación prohíbe y que Spring rechaza en tiempo de ejecución. Y "*" a secas en una API interna es abrir la puerta a que cualquier página web del mundo haga peticiones desde el navegador de tus usuarios. Lista explícita de orígenes, siempre.

Y una aclaración que ahorra horas de depuración: CORS es una protección del navegador, no del servidor. Un curl o un cliente Java ignoran CORS por completo. No es un mecanismo de seguridad de tu API; la seguridad es 12-07.

  1. La capa de servicio y las transacciones

El controlador no lleva @Transactional. La transacción pertenece al caso de uso, y esto no es una preferencia estética:

// MAL: transacción en el controlador
@RestController
public class PrestamoController {
    @PostMapping
    @Transactional                          // ← no
    public PrestamoResponse crear(@RequestBody CrearPrestamoRequest p) { … }
}

Razones concretas:

  1. La transacción quedaría abierta durante la serialización JSON, alargándola sin motivo y manteniendo ocupada una conexión del pool.
  2. La CLI (12-03) llama al mismo caso de uso sin pasar por el controlador: se quedaría sin transacción.
  3. Mezcla una decisión de infraestructura de datos con la capa de presentación.
// BIEN: la transacción, en el caso de uso
@Service
public class GestorPrestamos implements GestionarPrestamos {

    @Override
    @Transactional                                  // escritura
    public Prestamo prestar(Isbn isbn, Long idEmpleado, Integer dias) { … }

    @Override
    @Transactional(readOnly = true)                 // lectura: Hibernate omite el dirty checking
    public Optional<Prestamo> buscar(Long id) { … }
}
// Y el controlador solo traduce HTTP
@PostMapping
public ResponseEntity<PrestamoResponse> crear(@Valid @RequestBody CrearPrestamoRequest p) {
    Prestamo creado = gestor.prestar(p.isbn(), p.idEmpleado(), p.dias());
    return ResponseEntity.created(uriDe(creado)).body(PrestamoResponse.desde(creado, hoy()));
}

Recuerda de 12-01 la propiedad que hace esto obligatorio:

spring:
  jpa:
    open-in-view: false

Con open-in-view: true (el valor por defecto de Spring Boot), la sesión de Hibernate sigue abierta durante el renderizado, lo que hace que las relaciones perezosas se resuelvan desde el controlador, generando N+1 invisibles. Con false, si tu DTO accede a una relación no cargada, obtienes LazyInitializationException en desarrollo, que es exactamente lo que quieres: un error ruidoso en lugar de un problema de rendimiento silencioso.

  1. Pruebas de la capa web

Dos niveles, con propósitos distintos:

Nivel Anotación Qué levanta Velocidad Qué prueba
Rebanada web @WebMvcTest Solo la capa MVC ~1 s Mapeo, validación, serialización, códigos de estado
Extremo a extremo @SpringBootTest(RANDOM_PORT) Todo, con servidor real ~5-15 s El flujo completo, incluida la base de datos

Nivel 1: @WebMvcTest con MockMvc. No hay base de datos, no hay servidor: solo el controlador y la infraestructura MVC.

@WebMvcTest(PrestamoController.class)
class PrestamoControllerTest {

    @Autowired MockMvc mvc;
    @Autowired ObjectMapper json;

    @MockitoBean GestionarPrestamos gestor;      // Spring Boot 3.4+; antes era @MockBean

    @Test
    void devuelve201YLocationAlCrearUnPrestamo() throws Exception {
        var creado = unPrestamo(42L, "978-0000000001", "Marta Ruiz");
        when(gestor.prestar(any(), eq(1L), eq(15))).thenReturn(creado);

        mvc.perform(post("/api/prestamos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"978-0000000001","idEmpleado":1,"dias":15}"""))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", endsWith("/api/prestamos/42")))
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.tituloMaterial").value("Java Efectivo"))
                .andExpect(jsonPath("$.estado").value("ACTIVO"));
    }

    @Test
    void devuelve400ConElDetalleDeCadaCampoInvalido() throws Exception {
        mvc.perform(post("/api/prestamos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"1234","idEmpleado":null,"dias":365}"""))
                .andExpect(status().isBadRequest())
                .andExpect(content().contentTypeCompatibleWith("application/problem+json"))
                .andExpect(jsonPath("$.title").value("Error de validación"))
                .andExpect(jsonPath("$.errores", hasSize(3)))
                .andExpect(jsonPath("$.errores[*].campo",
                        containsInAnyOrder("isbn", "idEmpleado", "dias")));

        // Con datos inválidos, el caso de uso NO debe haberse invocado
        verifyNoInteractions(gestor);
    }

    @Test
    void devuelve409ConDetalleCuandoSeSuperaElLimite() throws Exception {
        when(gestor.prestar(any(), eq(2L), any()))
                .thenThrow(new LimiteDePrestamosExcedidoException(2L, "Diego Alonso", 3,
                                                                  List.of(12L, 27L, 38L)));

        mvc.perform(post("/api/prestamos")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"isbn":"978-0000000003","idEmpleado":2,"dias":15}"""))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.type").value(endsWith("/errores/limite-prestamos")))
                .andExpect(jsonPath("$.detail").value(containsString("Diego Alonso")))
                .andExpect(jsonPath("$.prestamosActivos", hasSize(3)));
    }

    @Test
    void devuelve404CuandoElPrestamoNoExiste() throws Exception {
        when(gestor.buscar(9999L)).thenReturn(Optional.empty());

        mvc.perform(get("/api/prestamos/9999"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(404));
    }

    @Test
    void devuelve400CuandoElIsbnDeLaRutaEsInvalido() throws Exception {
        mvc.perform(get("/api/materiales/no-es-un-isbn"))
                .andExpect(status().isBadRequest());
    }
}

Nivel 2: @SpringBootTest con TestRestTemplate. Servidor real en un puerto aleatorio, base de datos real, todo el flujo:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PrestamoApiIT {

    @Autowired TestRestTemplate cliente;
    @Autowired PrestamoRepository repositorio;

    @Test
    void cicloCompletoDePrestamoYDevolucion() {
        // 1. Crear
        var peticion = new CrearPrestamoRequest("978-0000000001", 1L, 15);
        ResponseEntity<PrestamoResponse> creacion =
                cliente.postForEntity("/api/prestamos", peticion, PrestamoResponse.class);

        assertThat(creacion.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(creacion.getHeaders().getLocation()).isNotNull();
        Long id = creacion.getBody().id();

        // 2. Consultar por la URI que devolvió Location
        ResponseEntity<PrestamoResponse> consulta =
                cliente.getForEntity(creacion.getHeaders().getLocation(), PrestamoResponse.class);
        assertThat(consulta.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(consulta.getBody().estado()).isEqualTo("ACTIVO");

        // 3. Devolver
        ResponseEntity<DevolucionResponse> devolucion = cliente.postForEntity(
                "/api/prestamos/{id}/devolucion", new DevolucionRequest(null),
                DevolucionResponse.class, id);
        assertThat(devolucion.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(devolucion.getBody().multa()).isEqualByComparingTo("0.00");

        // 4. Verificar que la persistencia refleja el cambio
        assertThat(repositorio.findById(id))
                .get()
                .extracting(Prestamo::getEstado)
                .isEqualTo(EstadoPrestamo.DEVUELTO);
    }

    @Test
    void devolverDosVecesDevuelve409() {
        Long id = crearYDevolver();

        ResponseEntity<ProblemDetail> segunda = cliente.postForEntity(
                "/api/prestamos/{id}/devolucion", new DevolucionRequest(null),
                ProblemDetail.class, id);

        assertThat(segunda.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
        assertThat(segunda.getBody().getDetail()).contains("ya fue devuelto");
    }
}

Qué se prueba en cada nivel (la estrategia completa es la lección 12-05):

Aspecto @WebMvcTest @SpringBootTest
Ruta y verbo correctos
Conversión de parámetros
Validación de la entrada Sí, aquí Redundante
Códigos de estado y errores Sí, aquí Los principales
Forma del JSON Sí, aquí No hace falta
Reglas de negocio No (mockeadas)
Persistencia real No Sí, aquí
Transacciones y rollback No Sí, aquí

Regla práctica: muchas pruebas de rebanada (rápidas, exhaustivas en casos límite) y pocas de extremo a extremo (lentas, para los flujos principales). Invertir esa proporción es la forma más común de acabar con una suite de pruebas que tarda veinte minutos.

  1. Interfaz de usuario: Thymeleaf o front-end separado

La API está hecha. Falta decidir qué ve Marta Ruiz en su navegador. Dos caminos:

Opción A: HTML servido desde Spring, con Thymeleaf.

@Controller                                   // ¡NO @RestController!
@RequestMapping("/catalogo")
public class CatalogoVistaController {

    @GetMapping
    public String listar(@RequestParam(required = false) String titulo, Model modelo) {
        modelo.addAttribute("materiales", catalogo.buscar(titulo));
        modelo.addAttribute("filtro", titulo);
        return "catalogo/lista";              // resuelve a templates/catalogo/lista.html
    }
}
<!-- src/main/resources/templates/catalogo/lista.html -->
<table>
  <tr th:each="m : ${materiales}">
    <td th:text="${m.isbn}">978-…</td>
    <td th:text="${m.titulo}">Título</td>
    <td th:text="${m.unidadesDisponibles}">0</td>
  </tr>
</table>

Opción B: front-end separado (React, Vue, Angular) que consume la API REST, desplegado como estáticos en un CDN o servidor web.

Criterio Thymeleaf (HTML servido) Front-end separado
Complejidad de despliegue Una unidad Dos proyectos, dos despliegues
SEO Excelente Requiere renderizado en servidor
Interactividad rica Limitada Excelente
Equipo necesario Solo Java Java + JavaScript
Reutilización de la API La vista no usa la API La misma API sirve a web y móvil
Tiempo hasta la primera versión Menor Mayor
CORS, autenticación por token No hacen falta Hay que resolverlos

Recomendación para BiblioTech: la API REST es la interfaz principal, porque tiene que servir también a la CLI, a la app móvil prevista y a la integración con recursos humanos. Para la interfaz de los empleados, Thymeleaf es la elección pragmática: el equipo de Nexus Software es de Java, la interactividad requerida es baja (buscar, listar, un botón de reservar), y evita el coste de mantener un proyecto de front-end separado con su propio ciclo de despliegue.

Y no es una decisión irreversible: la API queda intacta si más adelante se añade un front-end moderno. Esa es precisamente la ventaja de tener la API como pieza central.

  1. Hilos virtuales en Spring Boot 3.2

Aquí se cierra el círculo con 10-06.

El modelo tradicional de Tomcat es un hilo de plataforma por petición, con un pool de 200 por defecto. Cada hilo consume ~1 MB de pila. Cuando una petición espera a la base de datos o a la API de metadatos, su hilo está bloqueado sin hacer nada, y con 200 peticiones lentas concurrentes el pool se agota: las siguientes esperan en cola aunque la CPU esté al 5 %.

Los hilos virtuales de Java 21 (Project Loom) resuelven esto: son hilos gestionados por la JVM, cuestan unos cientos de bytes, y cuando se bloquean en E/S liberan el hilo de plataforma subyacente.

Activarlos en Spring Boot 3.2+ es una línea:

spring:
  threads:
    virtual:
      enabled: true

Eso hace que Tomcat atienda cada petición en un hilo virtual, y que @Async y las tareas programadas también los usen.

Aspecto Hilos de plataforma Hilos virtuales
Coste de memoria ~1 MB de pila Cientos de bytes
Máximo práctico Miles Millones
Al bloquearse en E/S El hilo del SO queda ocupado Se libera el portador
Coste de creación Alto (por eso los pools) Muy bajo
Cambios en tu código Ninguno
Beneficio Grande si hay mucha E/S

Lo que hay que saber antes de activarlos:

  1. No aceleran la CPU. Si el cuello de botella es cálculo, no cambian nada. El beneficio es en concurrencia bloqueada por E/S, que es el caso típico de una API.
  2. synchronized los ancla. Un bloque synchronized que hace E/S dentro fija (pins) el hilo virtual a su portador, anulando la ventaja. Se sustituye por ReentrantLock. En Java 24 esta limitación se elimina, pero en Java 21 hay que vigilarla.
  3. Nunca los pongas en un pool. Su gracia es crear uno por tarea. Un pool de hilos virtuales no tiene sentido.
  4. El pool de conexiones sigue siendo el límite real. Puedes tener un millón de hilos virtuales y 20 conexiones a la base de datos: el cuello de botella se mueve, no desaparece. Ajusta HikariCP en consecuencia.
  5. Cuidado con los ThreadLocal. Millones de hilos virtuales, cada uno con su copia, consumen memoria. Con MDC (11-07) esto está controlado, pero conviene saberlo.

Comprobación de que están activos:

@GetMapping("/api/diagnostico/hilo")
public Map<String, Object> hiloActual() {
    Thread hilo = Thread.currentThread();
    return Map.of("nombre", hilo.getName(),
                  "virtual", hilo.isVirtual(),
                  "grupo", String.valueOf(hilo.threadId()));
}
{"nombre":"","virtual":true,"grupo":"142"}

Y así se cierra el arco que empezó en el módulo 8 con Thread, siguió en 08-05 con ExecutorService, se generalizó en 10-06 con los hilos virtuales, y termina aquí: una línea de configuración que multiplica por mil la concurrencia de la API, sin tocar una sola línea de código de BiblioTech.

Errores Comunes y Consejos

1. Exponer entidades JPA en la API. Fuga de datos sensibles, LazyInitializationException, ciclos infinitos, y el esquema de la base de datos convertido en contrato público. DTOs siempre, desde el primer endpoint.

2. Devolver 200 con un cuerpo de error. El cliente no puede distinguir éxito de fallo sin parsear el cuerpo. Usa el código de estado: para eso existe.

3. Poner @Transactional en el controlador. Alarga la transacción hasta la serialización, ocupa una conexión de más y deja sin transacción a los demás adaptadores.

4. Dejar open-in-view a true. Es el valor por defecto y genera N+1 invisibles ejecutados desde la capa de vista. Ponlo a false y arregla lo que se rompa.

5. No paginar. Funciona con 50 materiales y tumba el servidor con 50.000. Pagina desde el principio y limita el tamaño máximo.

6. Devolver 500 por errores del cliente. Un campo que falta es 400, un recurso inexistente es 404, una regla de negocio violada es 409. El 500 es «he fallado yo», y debería disparar una alerta.

7. Filtrar detalles internos en los mensajes de error. Trazas de pila, nombres de clase, consultas SQL o versiones de librería en la respuesta HTTP son información gratis para un atacante. Al cliente, mensaje y identificador; al log, todo lo demás.

8. allowedOrigins("*") en producción. Cualquier página del mundo podría llamar a tu API desde el navegador de tus usuarios. Lista explícita.

9. Verbos en las rutas. POST /api/crearPrestamo no es REST, es RPC con otra sintaxis. POST /api/prestamos.

10. No documentar la API. Sin OpenAPI, cada integración empieza con una cadena de correos. El coste es una dependencia y unas anotaciones.

11. Probar solo con @SpringBootTest. Suites de veinte minutos que nadie ejecuta antes de subir código. Muchas pruebas de rebanada, pocas de extremo a extremo.

12. Olvidar la cabecera Location en un 201. El cliente no sabe dónde ha quedado el recurso que acaba de crear y tiene que adivinarlo.

Consejo final: diseña la API antes de implementarla. Escribe la tabla de endpoints con sus códigos de estado, revísala, y solo entonces escribe código. Cambiar una API publicada es caro; cambiar una tabla en un documento no cuesta nada.

Ejercicios

Los ejercicios asumen el proyecto tal y como ha quedado en esta lección.

Ejercicio 1: endpoint de renovación

Implementa POST /api/prestamos/{id}/renovacion con estas reglas:

  • Cuerpo opcional con dias (1 a 30); si se omite, se usa la duración estándar del material.
  • Solo se puede renovar una vez (estado ACTIVO; ver el patrón Estado de 12-02).
  • No se puede renovar si el préstamo está vencido.
  • No se puede renovar si hay reservas pendientes de ese material.
  • Códigos: 200 correcto, 404 no existe, 409 en cada una de las tres violaciones, 400 días fuera de rango.

Escribe el DTO de petición y respuesta, el método del controlador, los manejadores de error necesarios y las pruebas @WebMvcTest de los cinco escenarios.

Ejercicio 2: búsqueda avanzada con paginación

Implementa GET /api/materiales/busqueda que acepte:

  • q: texto libre que busca en título y autor (mínimo 2 caracteres).
  • tipo: repetible (?tipo=LIBRO&tipo=DVD).
  • disponible: booleano.
  • anioDesde y anioHasta, validando que desde <= hasta con una anotación de clase.
  • Paginación y ordenación, con máximo 50 por página.
  • Debe devolver, además de los resultados, cuántos hay por tipo (facetas).

Incluye el DTO de criterios con su validación cruzada, la respuesta con facetas y las pruebas.

Ejercicio 3: idempotencia en la creación de préstamos

Implementa el soporte de la cabecera Idempotency-Key en POST /api/prestamos:

  • Si la cabecera está presente y la clave no se ha visto, se procesa normalmente y se guarda la respuesta asociada a la clave.
  • Si la clave ya se procesó, se devuelve la respuesta original con la misma cabecera Location y una cabecera Idempotent-Replay: true, sin crear nada.
  • Si la clave está en curso, se devuelve 409.
  • Las claves caducan a las 24 horas.

Usa un filtro o un interceptor, y explica las implicaciones de concurrencia.


Soluciones

Solución 1

DTOs:

public record RenovarPrestamoRequest(
        @Min(value = 1, message = "La renovación debe ser de al menos 1 día")
        @Max(value = 30, message = "La renovación no puede superar los 30 días")
        Integer dias) {
}

public record RenovacionResponse(
        Long idPrestamo,
        String tituloMaterial,
        LocalDate vencimientoAnterior,
        LocalDate vencimientoNuevo,
        int diasAnadidos,
        boolean puedeRenovarseDeNuevo) {

    public static RenovacionResponse desde(Prestamo p, LocalDate anterior) {
        return new RenovacionResponse(
                p.getId(), p.tituloDelMaterial(), anterior, p.getFechaVencimiento(),
                (int) ChronoUnit.DAYS.between(anterior, p.getFechaVencimiento()),
                p.getEstado().permiteRenovar());
    }
}

Controlador:

@PostMapping("/{id}/renovacion")
@Operation(summary = "Renueva un préstamo",
           description = "Amplía la fecha de vencimiento. Solo se permite una renovación por préstamo.")
@ApiResponses({
    @ApiResponse(responseCode = "200", description = "Renovado"),
    @ApiResponse(responseCode = "404", description = "El préstamo no existe"),
    @ApiResponse(responseCode = "409", description = "Ya renovado, vencido, o con reservas pendientes")
})
public RenovacionResponse renovar(
        @PathVariable Long id,
        @RequestBody(required = false) @Valid RenovarPrestamoRequest peticion) {

    Integer dias = (peticion != null) ? peticion.dias() : null;
    ResultadoRenovacion resultado = gestor.renovar(id, dias);
    return RenovacionResponse.desde(resultado.prestamo(), resultado.vencimientoAnterior());
}

Caso de uso, donde viven de verdad las tres reglas:

@Override
@Transactional
public ResultadoRenovacion renovar(Long id, Integer dias) {
    Prestamo prestamo = repositorio.buscarPorId(id)
            .orElseThrow(() -> new PrestamoNoEncontradoException(id));

    LocalDate hoy = LocalDate.now(reloj);

    // Regla 1: el estado gobierna (patrón Estado, 12-02)
    if (!prestamo.getEstado().permiteRenovar()) {
        throw new RenovacionNoPermitidaException(id, prestamo.getEstado(),
                "Este préstamo ya fue renovado o no admite renovación.");
    }

    // Regla 2: vencido
    if (prestamo.estaVencidoA(hoy)) {
        throw new RenovacionNoPermitidaException(id, prestamo.getEstado(),
                "El préstamo venció el %s. Devuélvelo y vuelve a prestarlo."
                        .formatted(prestamo.getFechaVencimiento()));
    }

    // Regla 3: reservas de otros
    long pendientes = reservas.contarPendientesDe(prestamo.getIsbn());
    if (pendientes > 0) {
        throw new MaterialConReservasException(prestamo.getIsbn(), pendientes);
    }

    LocalDate anterior = prestamo.getFechaVencimiento();
    int diasEfectivos = (dias != null) ? dias : prestamo.diasEstandarDelMaterial();
    prestamo.renovar(diasEfectivos);          // valida y transita el estado

    return new ResultadoRenovacion(prestamo, anterior);
}

Manejadores:

@ExceptionHandler(RenovacionNoPermitidaException.class)
public ProblemDetail renovacionNoPermitida(RenovacionNoPermitidaException e, HttpServletRequest p) {
    ProblemDetail d = problema(HttpStatus.CONFLICT, "renovacion-no-permitida",
            "Renovación no permitida", e.getMessage(), p);
    d.setProperty("idPrestamo", e.getIdPrestamo());
    d.setProperty("estadoActual", e.getEstado().name());
    return d;
}

@ExceptionHandler(MaterialConReservasException.class)
public ProblemDetail conReservas(MaterialConReservasException e, HttpServletRequest p) {
    ProblemDetail d = problema(HttpStatus.CONFLICT, "material-con-reservas",
            "Material con reservas pendientes",
            "No se puede renovar: hay %d empleado(s) esperando este material."
                    .formatted(e.getReservasPendientes()), p);
    d.setProperty("reservasPendientes", e.getReservasPendientes());
    return d;
}

Pruebas de los cinco escenarios:

@WebMvcTest(PrestamoController.class)
class RenovacionControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean GestionarPrestamos gestor;

    @Test
    void renuevaConLosDiasIndicados() throws Exception {
        var prestamo = unPrestamo(42L).conVencimiento(LocalDate.of(2026, 8, 20));
        when(gestor.renovar(42L, 10)).thenReturn(
                new ResultadoRenovacion(prestamo.renovadoHasta(LocalDate.of(2026, 8, 30)),
                                        LocalDate.of(2026, 8, 20)));

        mvc.perform(post("/api/prestamos/42/renovacion")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"dias":10}"""))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.vencimientoAnterior").value("2026-08-20"))
                .andExpect(jsonPath("$.vencimientoNuevo").value("2026-08-30"))
                .andExpect(jsonPath("$.diasAnadidos").value(10))
                .andExpect(jsonPath("$.puedeRenovarseDeNuevo").value(false));
    }

    @Test
    void renuevaSinCuerpoUsandoLaDuracionEstandar() throws Exception {
        when(gestor.renovar(42L, null)).thenReturn(unaRenovacionDe(15));

        mvc.perform(post("/api/prestamos/42/renovacion"))     // sin cuerpo
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.diasAnadidos").value(15));
    }

    @Test
    void devuelve404SiElPrestamoNoExiste() throws Exception {
        when(gestor.renovar(eq(9999L), any())).thenThrow(new PrestamoNoEncontradoException(9999L));

        mvc.perform(post("/api/prestamos/9999/renovacion"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.type").value(endsWith("/errores/recurso-no-encontrado")));
    }

    @Test
    void devuelve409SiYaFueRenovado() throws Exception {
        when(gestor.renovar(eq(42L), any())).thenThrow(
                new RenovacionNoPermitidaException(42L, EstadoPrestamo.RENOVADO,
                        "Este préstamo ya fue renovado o no admite renovación."));

        mvc.perform(post("/api/prestamos/42/renovacion"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.estadoActual").value("RENOVADO"))
                .andExpect(jsonPath("$.detail").value(containsString("ya fue renovado")));
    }

    @Test
    void devuelve409SiHayReservasPendientes() throws Exception {
        when(gestor.renovar(eq(42L), any()))
                .thenThrow(new MaterialConReservasException(Isbn.de("978-0000000001"), 2));

        mvc.perform(post("/api/prestamos/42/renovacion"))
                .andExpect(status().isConflict())
                .andExpect(jsonPath("$.reservasPendientes").value(2));
    }

    @ParameterizedTest
    @ValueSource(ints = {0, -5, 31, 100})
    void devuelve400SiLosDiasEstanFueraDeRango(int dias) throws Exception {
        mvc.perform(post("/api/prestamos/42/renovacion")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"dias\":%d}".formatted(dias)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errores[0].campo").value("dias"));

        verifyNoInteractions(gestor);      // ni siquiera se llamó al caso de uso
    }
}

Solución 2

Validación cruzada con anotación de clase:

@Documented
@Constraint(validatedBy = ValidadorRangoAnios.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RangoAniosValido {
    String message() default "anioDesde no puede ser posterior a anioHasta";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class ValidadorRangoAnios implements ConstraintValidator<RangoAniosValido, BusquedaRequest> {
    @Override
    public boolean isValid(BusquedaRequest r, ConstraintValidatorContext ctx) {
        if (r.anioDesde() == null || r.anioHasta() == null) return true;   // @NotNull no es cosa nuestra
        if (r.anioDesde() <= r.anioHasta()) return true;

        ctx.disableDefaultConstraintViolation();
        ctx.buildConstraintViolationWithTemplate(
                "anioDesde (%d) no puede ser posterior a anioHasta (%d)"
                        .formatted(r.anioDesde(), r.anioHasta()))
           .addPropertyNode("anioDesde")        // el error se asocia al campo correcto
           .addConstraintViolation();
        return false;
    }
}

DTO de criterios:

@RangoAniosValido
public record BusquedaRequest(
        @NotBlank(message = "El texto de búsqueda es obligatorio")
        @Size(min = 2, max = 100, message = "Entre 2 y 100 caracteres")
        String q,

        List<TipoMaterial> tipo,          // repetible: ?tipo=LIBRO&tipo=DVD

        Boolean disponible,

        @Min(1450) @Max(2100) Integer anioDesde,
        @Min(1450) @Max(2100) Integer anioHasta) {

    /** Normalización: null → lista vacía, para no repetir comprobaciones aguas abajo. */
    public BusquedaRequest {
        tipo = (tipo == null) ? List.of() : List.copyOf(tipo);
    }

    public CriterioBusqueda aDominio() {
        return CriterioBusqueda.builder()
                .texto(q)
                .tipos(tipo)
                .soloDisponibles(Boolean.TRUE.equals(disponible))
                .entre(anioDesde, anioHasta)
                .construir();
    }
}

Respuesta con facetas:

public record ResultadoBusquedaResponse(
        List<MaterialResponse> resultados,
        PageResponse.MetadatosPagina pagina,
        Map<String, Long> facetasPorTipo,
        long totalGlobal,
        String consulta) {

    public static ResultadoBusquedaResponse desde(Page<Material> pagina,
                                                  Map<TipoMaterial, Long> facetas,
                                                  String consulta) {
        return new ResultadoBusquedaResponse(
                pagina.getContent().stream().map(MaterialResponse::desde).toList(),
                new PageResponse.MetadatosPagina(pagina.getNumber(), pagina.getSize(),
                        pagina.getTotalElements(), pagina.getTotalPages(),
                        pagina.isFirst(), pagina.isLast()),
                facetas.entrySet().stream()
                        .collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue,
                                                  (a, b) -> a, LinkedHashMap::new)),
                facetas.values().stream().mapToLong(Long::longValue).sum(),
                consulta);
    }
}

Controlador:

@GetMapping("/busqueda")
@Operation(summary = "Búsqueda avanzada con facetas por tipo de material")
public ResultadoBusquedaResponse buscar(
        @Valid BusquedaRequest criterio,
        @PageableDefault(size = 20, sort = "titulo") Pageable paginacion) {

    // Defensa en profundidad: aunque max-page-size esté configurado, no confíes
    if (paginacion.getPageSize() > 50) {
        paginacion = PageRequest.of(paginacion.getPageNumber(), 50, paginacion.getSort());
    }

    CriterioBusqueda dominio = criterio.aDominio();
    Page<Material> pagina = catalogo.buscar(dominio, paginacion);
    Map<TipoMaterial, Long> facetas = catalogo.contarPorTipo(dominio);   // consulta de agregación

    return ResultadoBusquedaResponse.desde(pagina, facetas, criterio.q());
}

Las facetas en el repositorio, con una sola consulta de agregación en lugar de N consultas:

@Query("""
       select m.tipo as tipo, count(m) as total
       from Material m
       where (lower(m.titulo) like lower(concat('%', :texto, '%'))
              or lower(m.autor) like lower(concat('%', :texto, '%')))
         and (:soloDisponibles = false or m.unidadesDisponibles > 0)
       group by m.tipo
       """)
List<FacetaTipo> contarPorTipo(@Param("texto") String texto,
                               @Param("soloDisponibles") boolean soloDisponibles);

Pruebas:

@Test
void devuelveResultadosConFacetasPorTipo() throws Exception {
    when(catalogo.buscar(any(), any())).thenReturn(unaPaginaCon(2, "Java Efectivo", "Refactorización"));
    when(catalogo.contarPorTipo(any())).thenReturn(
            new LinkedHashMap<>(Map.of(TipoMaterial.LIBRO, 5L, TipoMaterial.DVD, 2L)));

    mvc.perform(get("/api/materiales/busqueda").param("q", "java").param("tipo", "LIBRO"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.resultados", hasSize(2)))
            .andExpect(jsonPath("$.facetasPorTipo.LIBRO").value(5))
            .andExpect(jsonPath("$.totalGlobal").value(7))
            .andExpect(jsonPath("$.consulta").value("java"));
}

@Test
void rechazaConsultasDemasiadoCortas() throws Exception {
    mvc.perform(get("/api/materiales/busqueda").param("q", "j"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errores[0].campo").value("q"));
}

@Test
void rechazaUnRangoDeAniosInvertido() throws Exception {
    mvc.perform(get("/api/materiales/busqueda")
                    .param("q", "java").param("anioDesde", "2020").param("anioHasta", "2010"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.errores[0].campo").value("anioDesde"))
            .andExpect(jsonPath("$.errores[0].mensaje").value(containsString("no puede ser posterior")));
}

@Test
void recortaElTamanoDePaginaAlMaximo() throws Exception {
    mvc.perform(get("/api/materiales/busqueda").param("q", "java").param("size", "500"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.pagina.tamano").value(lessThanOrEqualTo(50)));
}

Solución 3

Almacén de claves de idempotencia:

@Entity
@Table(name = "claves_idempotencia",
       indexes = @Index(name = "idx_caducidad", columnList = "caducaEn"))
public class ClaveIdempotencia {

    @Id
    @Column(length = 100)
    private String clave;

    @Column(nullable = false, length = 64)
    private String huellaPeticion;     // hash del cuerpo: detecta reutilización con datos distintos

    @Enumerated(EnumType.STRING)
    private EstadoClave estado;        // EN_CURSO, COMPLETADA

    private Integer codigoRespuesta;

    @Column(columnDefinition = "text")
    private String cuerpoRespuesta;

    private String ubicacion;
    private Instant creadaEn;
    private Instant caducaEn;

    public enum EstadoClave { EN_CURSO, COMPLETADA }
}

El interceptor, que envuelve la ejecución del controlador:

@Component
public class InterceptorIdempotencia implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(InterceptorIdempotencia.class);
    private static final String CABECERA = "Idempotency-Key";
    private static final Duration VIGENCIA = Duration.ofHours(24);

    private final RepositorioClavesIdempotencia repositorio;
    private final ObjectMapper json;
    private final Clock reloj;

    @Override
    public boolean preHandle(HttpServletRequest peticion, HttpServletResponse respuesta,
                             Object manejador) throws IOException {

        // Solo aplica a métodos no idempotentes por naturaleza
        if (!"POST".equals(peticion.getMethod())) return true;

        String clave = peticion.getHeader(CABECERA);
        if (clave == null || clave.isBlank()) return true;     // cabecera opcional

        String huella = huellaDe(peticion);

        // INSERT condicional: la restricción de clave primaria resuelve la carrera
        // entre dos peticiones simultáneas con la misma clave. NO uses "SELECT y luego INSERT".
        Optional<ClaveIdempotencia> existente = repositorio.reservarSiNoExiste(
                clave, huella, Instant.now(reloj), Instant.now(reloj).plus(VIGENCIA));

        if (existente.isEmpty()) {
            // La hemos reservado nosotros: seguimos con el procesamiento normal
            peticion.setAttribute("idempotencia.clave", clave);
            return true;
        }

        ClaveIdempotencia previa = existente.get();

        // Misma clave, cuerpo distinto: el cliente se ha equivocado
        if (!previa.getHuellaPeticion().equals(huella)) {
            escribirProblema(respuesta, HttpStatus.UNPROCESSABLE_ENTITY,
                    "La clave de idempotencia ya se usó con un cuerpo diferente.");
            return false;
        }

        if (previa.getEstado() == EstadoClave.EN_CURSO) {
            // Otra petición idéntica se está procesando ahora mismo
            respuesta.setHeader(HttpHeaders.RETRY_AFTER, "2");
            escribirProblema(respuesta, HttpStatus.CONFLICT,
                    "Ya hay una petición en curso con esta clave de idempotencia.");
            return false;
        }

        // COMPLETADA: reproducimos la respuesta original sin ejecutar nada
        log.info("Reproduciendo respuesta idempotente para la clave {}", clave);
        respuesta.setStatus(previa.getCodigoRespuesta());
        respuesta.setContentType(MediaType.APPLICATION_JSON_VALUE);
        respuesta.setHeader("Idempotent-Replay", "true");
        if (previa.getUbicacion() != null) {
            respuesta.setHeader(HttpHeaders.LOCATION, previa.getUbicacion());
        }
        respuesta.getWriter().write(previa.getCuerpoRespuesta());
        return false;                       // NO se llama al controlador
    }

    private String huellaDe(HttpServletRequest peticion) throws IOException {
        // Requiere ContentCachingRequestWrapper: el cuerpo solo se puede leer una vez
        byte[] cuerpo = ((ContentCachingRequestWrapper) peticion).getContentAsByteArray();
        return HexFormat.of().formatHex(
                MessageDigest.getInstance("SHA-256").digest(cuerpo));
    }
}

Guardado de la respuesta, en un filtro que envuelve todo:

@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class FiltroIdempotencia extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest peticion, HttpServletResponse respuesta,
                                    FilterChain cadena) throws ServletException, IOException {

        var peticionCacheada = new ContentCachingRequestWrapper(peticion);
        var respuestaCacheada = new ContentCachingResponseWrapper(respuesta);

        try {
            cadena.doFilter(peticionCacheada, respuestaCacheada);

            String clave = (String) peticion.getAttribute("idempotencia.clave");
            if (clave != null) {
                int estado = respuestaCacheada.getStatus();
                if (estado >= 200 && estado < 300) {
                    // Solo se memoriza el éxito: un 500 debe poder reintentarse
                    repositorio.completar(clave, estado,
                            new String(respuestaCacheada.getContentAsByteArray(), UTF_8),
                            respuestaCacheada.getHeader(HttpHeaders.LOCATION));
                } else {
                    repositorio.liberar(clave);    // libera la clave para un reintento legítimo
                }
            }
        } finally {
            respuestaCacheada.copyBodyToResponse();   // IMPRESCINDIBLE: sin esto no llega nada al cliente
        }
    }
}

La reserva atómica, que es el punto delicado:

@Repository
public class RepositorioClavesIdempotencia {

    private final JdbcTemplate jdbc;

    /**
     * Devuelve Optional.empty() si la clave se ha reservado ahora (somos los primeros),
     * o la fila existente si ya estaba.
     *
     * ON CONFLICT DO NOTHING hace la operación ATÓMICA en la base de datos:
     * dos peticiones simultáneas con la misma clave no pueden reservarla ambas.
     * Un "SELECT y luego INSERT" en Java tendría una ventana de carrera.
     */
    public Optional<ClaveIdempotencia> reservarSiNoExiste(String clave, String huella,
                                                          Instant ahora, Instant caduca) {
        int filas = jdbc.update("""
                insert into claves_idempotencia
                       (clave, huella_peticion, estado, creada_en, caduca_en)
                values (?, ?, 'EN_CURSO', ?, ?)
                on conflict (clave) do nothing
                """, clave, huella, Timestamp.from(ahora), Timestamp.from(caduca));

        if (filas == 1) return Optional.empty();      // la hemos reservado nosotros

        return jdbc.query("select * from claves_idempotencia where clave = ?",
                          this::mapear, clave).stream().findFirst();
    }
}

Limpieza periódica:

@Scheduled(cron = "0 0 3 * * *")     // cada día a las 3:00
@Transactional
public void purgarClavesCaducadas() {
    int borradas = repositorio.borrarCaducadasAntesDe(Instant.now(reloj));
    log.info("Claves de idempotencia purgadas: {}", borradas);
}

Implicaciones de concurrencia, que es lo que evalúa el ejercicio:

Escenario Sin protección Con esta implementación
Reintento tras un timeout de red Dos préstamos creados Se devuelve la respuesta original
Dos peticiones simultáneas, misma clave Dos préstamos Una procesa, la otra recibe 409
Misma clave, cuerpo distinto Comportamiento indefinido 422 explícito
Fallo del servidor a mitad Clave bloqueada para siempre liberar() en el filtro + caducidad
Dos instancias de la aplicación La memoria local no sirve La base de datos es el punto de acuerdo

La decisión clave es usar INSERT ... ON CONFLICT DO NOTHING en lugar de comprobar y luego insertar. Entre el SELECT y el INSERT de la versión ingenua hay una ventana en la que otra petición puede colarse, y con dos instancias de la aplicación (escalado horizontal, 12-06) esa ventana se abre constantemente. La atomicidad tiene que estar en la base de datos, que es el único recurso compartido.

Uso desde el cliente:

CLAVE=$(uuidgen)
curl -i -X POST http://localhost:8080/api/prestamos \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $CLAVE" \
  -d '{"isbn":"978-0000000001","idEmpleado":1,"dias":15}'
# → 201 Created, Location: /api/prestamos/42

# Reintento con la misma clave
curl -i -X POST … -H "Idempotency-Key: $CLAVE" -d '{…}'
# → 201 Created, Location: /api/prestamos/42, Idempotent-Replay: true
#   (y NO se ha creado un segundo préstamo)

Conclusión

BiblioTech tiene API.

Entiendes cómo funciona una aplicación web en Java de verdad, no como una caja negra: la petición HTTP es texto sobre un socket, el servidor embebido de Tomcat lo parsea, el DispatcherServlet actúa de front controller, los HandlerMapping enrutan, los ArgumentResolver convierten los parámetros, los HttpMessageConverter serializan con Jackson y los HandlerExceptionResolver traducen excepciones. Y sobre todo has visto la tabla que lo pone todo en perspectiva: cada pieza de Spring MVC corresponde a algo que tú escribiste a mano en el ServidorCatalogo del módulo 9. No estás usando magia; estás delegando trabajo que ya sabes hacer.

Diseñas REST con criterio: recursos como sustantivos, verbos con su semántica de seguridad e idempotencia —incluida la razón práctica por la que la idempotencia importa, que es poder reintentar sin duplicar—, URIs con jerarquía de máximo dos niveles, el caso difícil de las acciones que no son CRUD resuelto con subrecursos (POST /api/prestamos/42/devolucion), y la tabla de códigos de estado con los cinco errores clásicos que hay que evitar. Con el nivel 2 de Richardson como objetivo honesto y realista.

Escribes controladores que solo traducen HTTP: reciben, delegan en el caso de uso y devuelven. Con @PathVariable, @RequestParam y @RequestBody; con convertidores propios que hacen que el controlador trabaje con Isbn y no con String; con ResponseEntity únicamente en los tres casos que la justifican —el Location de un 201, el código que depende del resultado, y las cabeceras de caché con ETag—; y con DTOs record de entrada y salida, cuya asimetría es una defensa estructural: el cliente no puede enviar id, version ni estado porque el objeto no los tiene.

Validas en el sitio correcto: jakarta.validation para la forma —incluido un validador propio de ISBN-13 que comprueba el dígito de control y da mensajes específicos— y el dominio para las reglas de negocio, sin confundir las dos cosas. Y manejas los errores globalmente con @RestControllerAdvice y el formato RFC 7807 que Spring 6 trae de serie, con la tabla completa de traducción de la jerarquía BiblioTechException del módulo 6, la lista de campos inválidos que ahorra tres viajes al cliente, el traceId del MDC de 11-07 que une la respuesta con el log, y el criterio de nivel de registro que evita que un 404 dispare una alerta.

Paginas con Pageable y un DTO propio que no expone la estructura interna de Page, con el tamaño máximo limitado y el aviso sobre la degradación del OFFSET. Filtras con un objeto de criterios y Specification, que es el patrón Especificación de 12-02 y genera consultas parametrizadas. Tienes la API completa documentada endpoint por endpoint, con ejemplos curl y sus respuestas reales, documentación automática con springdoc-openapi y Swagger UI, y CORS configurado con lista explícita de orígenes y la aclaración de que CORS protege al navegador, no a tu API.

Pusiste la transacción donde va —en el caso de uso, nunca en el controlador— con las tres razones concretas, y open-in-view a false. Y pruebas la capa web en los dos niveles con propósitos distintos: @WebMvcTest con MockMvc y @MockitoBean para mapeo, validación, códigos y forma del JSON, rápido y exhaustivo; y @SpringBootTest(RANDOM_PORT) con TestRestTemplate para los flujos completos con persistencia real. Muchas de las primeras, pocas de las segundas.

Elegiste la interfaz de usuario con criterio: la API REST como pieza central porque debe servir a la CLI, a la app móvil y a la integración con recursos humanos, y Thymeleaf para la interfaz de los empleados porque el equipo es de Java y la interactividad requerida es baja — sin cerrar la puerta a un front-end separado más adelante.

Y cerraste el círculo de 10-06 con los hilos virtuales: una línea de configuración, spring.threads.virtual.enabled=true, que convierte cada petición en un hilo virtual de Java 21 y multiplica la concurrencia sin tocar una sola línea del código de BiblioTech. Con las cinco advertencias que hay que conocer antes de activarlos, especialmente que synchronized los ancla y que el pool de conexiones sigue siendo el límite real.

BiblioTech tiene ahora arquitectura, patrones, CLI y API. Y una pregunta incómoda: ¿funciona de verdad?

Hay cuarenta y una pruebas heredadas del módulo 11 y unas cuantas nuevas de esta lección. No hay medida de cobertura, ni idea de si esas pruebas comprueban algo o solo ejecutan código. Las pruebas de repositorio corren sobre H2, que no es la base de datos de producción. Nadie ha ejecutado un análisis estático. No hay integración continua: si Diego Alonso rompe el cálculo de multas, nadie se entera hasta que un empleado se queja.

La siguiente lección convierte «tengo pruebas» en «tengo una estrategia de calidad»: qué probar en cada nivel y con qué tiempos objetivo, Testcontainers con PostgreSQL real porque H2 miente, cobertura con JaCoCo y su interpretación honesta, pruebas de mutación con PIT como la única medida que evalúa tus aserciones, análisis estático, refactorización segura, TDD desarrollado paso a paso con una regla nueva de BiblioTech, revisión de código, e integración continua con GitHub Actions que rompe el PR cuando algo falla.

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