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
- Cómo funciona una aplicación web en Java
- El servidor embebido y el
DispatcherServlet - El flujo interno de una petición
- Del
ServidorCatalogode sockets a Spring MVC - REST: recursos y representaciones
- Verbos HTTP y sus semánticas
- Diseño de URIs
- Códigos de estado por operación
@RestController: el mapeo de peticiones- Parámetros: ruta, consulta y cuerpo
ResponseEntityy cuándo usarla- DTOs de entrada y salida
- Validación con
jakarta.validation - Un validador propio para el ISBN
- Manejo global de errores y Problem Details
- Paginación y ordenación
- Filtros y búsqueda
- La API completa de BiblioTech
- Documentación automática con OpenAPI
- CORS
- La capa de servicio y las transacciones
- Pruebas de la capa web
- Interfaz de usuario: Thymeleaf o front-end separado
- Hilos virtuales en Spring Boot 3.2
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- 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 | Tú |
- El servidor embebido y el
DispatcherServlet
DispatcherServletServidor 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.
- 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 |
- Del
ServidorCatalogo de sockets a Spring MVC
ServidorCatalogo de sockets a Spring MVCMerece 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.
- 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.
- 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 |
Sí | Sí | Leer | Consultar catálogo, préstamos |
POST |
No | No | Crear, o acciones no idempotentes | Crear préstamo |
PUT |
No | Sí | Reemplazar completo | Actualizar todos los datos de un material |
PATCH |
No | No necesariamente | Modificar parcialmente | Cambiar solo las unidades |
DELETE |
No | Sí | Borrar | Cancelar una reserva |
HEAD |
Sí | Sí | Como GET, sin cuerpo | Comprobar existencia |
OPTIONS |
Sí | Sí | 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.
- 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/devolverBiblioTech 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.
- 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 |
@RestController: el mapeo de peticiones
@RestController: el mapeo de peticionespackage 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
- 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.
ResponseEntity y cuándo usarla
ResponseEntity y cuándo usarlaDevolver el DTO directamente es lo más limpio, y es lo que se debe hacer por defecto:
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); }
- 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.
- Validación con
jakarta.validation
jakarta.validationDependencia:
<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.
- 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).
- 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 7807Y 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.
- 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,ascLimita 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: falseY 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.
- 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));
}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).
- 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:
- 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 JSONhttp://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):
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.
- 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-typeHTTP/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: 3600Configuració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 conallowCredentials(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.
- 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:
- La transacción quedaría abierta durante la serialización JSON, alargándola sin motivo y manteniendo ocupada una conexión del pool.
- La CLI (12-03) llama al mismo caso de uso sin pasar por el controlador: se quedaría sin transacción.
- 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:
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.
- 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 | Sí | Sí |
| Conversión de parámetros | Sí | Sí |
| 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) | Sí |
| 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.
- 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.
- 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:
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:
- 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.
synchronizedlos ancla. Un bloquesynchronizedque hace E/S dentro fija (pins) el hilo virtual a su portador, anulando la ventaja. Se sustituye porReentrantLock. En Java 24 esta limitación se elimina, pero en Java 21 hay que vigilarla.- Nunca los pongas en un pool. Su gracia es crear uno por tarea. Un pool de hilos virtuales no tiene sentido.
- 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.
- 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()));
}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.anioDesdeyanioHasta, validando quedesde <= hastacon 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
Locationy una cabeceraIdempotent-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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
