La CLI de la lliçó anterior va resoldre l'automatització i la gent tècnica. No resol que la Marta Ruiz consulti el catàleg des del navegador, ni que la futura app mòbil de Nexus Software creï préstecs, ni que el sistema de recursos humans avisi BiblioTech quan algú deixa l'empresa.
Per a això cal una API web: un adaptador d'entrada que parli HTTP, que qualsevol client —navegador, mòbil, un altre servei, un curl en un script— pugui consumir. És el mòdul bibliotech-web, i serà el segon adaptador que s'endolla exactament als mateixos casos d'ús del mòdul bibliotech-aplicacio. Ni una línia de lògica de negoci nova.
Abans de començar, convé dir una cosa que canvia la manera de llegir aquesta lliçó: ja saps com funciona això. Al mòdul 9 vas construir el ServidorCataleg: un ServerSocket que acceptava connexions, llegia bytes, analitzava un protocol de text, decidia què fer, componia una resposta i l'escrivia. Un servidor web és exactament això, amb el protocol ja escrit per altres. Spring MVC no és màgia: és el teu servidor de sockets amb trenta anys de casos límit resolts.
En acabar aquesta lliçó entendràs el cicle petició-resposta i el paper de cada peça, dissenyaràs una API REST amb recursos, verbs i codis d'estat correctes, escriuràs controladors amb validació i gestió global d'errors en format estàndard, paginaràs i filtraràs, documentaràs l'API automàticament, i provaràs la capa web en els dos nivells que tenen sentit.
Dues coses que no hi són: la seguretat (autenticació, autorització, JWT) és la lliçó 12-07, i el desplegament és la 12-06. Aquí construïm l'API; ja la protegirem i la posarem en producció.
Contingut
- Com funciona una aplicació web en Java
- El servidor incrustat i el
DispatcherServlet - El flux intern d'una petició
- Del
ServidorCatalegde sockets a Spring MVC - REST: recursos i representacions
- Verbs HTTP i les seves semàntiques
- Disseny d'URI
- Codis d'estat per operació
@RestController: el mapatge de peticions- Paràmetres: camí, consulta i cos
ResponseEntityi quan fer-la servir- DTO d'entrada i de sortida
- Validació amb
jakarta.validation - Un validador propi per a l'ISBN
- Gestió global d'errors i Problem Details
- Paginació i ordenació
- Filtres i cerca
- L'API completa de BiblioTech
- Documentació automàtica amb OpenAPI
- CORS
- La capa de servei i les transaccions
- Proves de la capa web
- Interfície d'usuari: Thymeleaf o front-end separat
- Fils virtuals a Spring Boot 3.2
- Errors Comuns i Consells
- Exercicis
- Conclusió
- Com funciona una aplicació web en Java
Al seu nucli, tot es redueix a això: un client obre una connexió TCP, envia un text amb un format acordat, el servidor l'interpreta, fa alguna cosa i retorna un altre text. El format acordat és HTTP.
Una petició HTTP crua, tal com viatja pel socket:
GET /api/materials?tipus=LLIBRE&page=0&size=20 HTTP/1.1
Host: bibliotech.nexussoftware.com
Accept: application/json
User-Agent: curl/8.5.0
I la resposta:
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 283
{"content":[{"isbn":"978-0000000001","titol":"Java Eficac","tipus":"LLIBRE"}],
"page":{"size":20,"number":0,"totalElements":3,"totalPages":1}}Quatre elements a la petició (mètode, camí, versió, capçaleres, i cos opcional) i quatre a la resposta (versió, codi d'estat, capçaleres, cos). Res més. Tot el que fa Spring MVC és estalviar-te manipular aquest text a mà.
La pila completa d'una aplicació Spring Boot:
| Capa | Què fa | Qui la implementa |
|---|---|---|
| Socket TCP | Accepta connexions, mou bytes | El sistema operatiu + la JVM |
| Servidor HTTP | Analitza HTTP, gestiona connexions i fils | Tomcat (incrustat) |
| API Servlet | Abstracció estàndard de petició i resposta | jakarta.servlet |
DispatcherServlet |
Encamina al teu codi, converteix tipus | Spring MVC |
| Els teus controladors | Lògica de l'aplicació | Tu |
- El servidor incrustat i el
DispatcherServlet
DispatcherServletServidor incrustat. Fins a Spring Boot, desplegar una aplicació Java web era: construir un .war, instal·lar un Tomcat, copiar el war a webapps/, reiniciar. Spring Boot va invertir el model: el servidor va dins de l'aplicació, i el resultat és un jar que s'executa amb java -jar.
@SpringBootApplication
public class BiblioTechApplication {
public static void main(String[] args) {
SpringApplication.run(BiblioTechApplication.class, args);
}
}Aquestes tres línies arrenquen un Tomcat al port 8080. Avantatges: una unitat desplegable, la mateixa versió de servidor en desenvolupament i producció, configuració al mateix lloc que la resta, i encaixa perfectament amb contenidors (12-06).
Alternatives, totes intercanviables canviant una dependència:
| Servidor | Model | Quan |
|---|---|---|
| Tomcat | Un fil per petició | Per defecte; el més conegut |
| Jetty | Un fil per petició | Menys consum; incrustat en eines |
| Undertow | No bloquejant sota demanda | Màxim rendiment amb moltes connexions |
| Netty | Reactiu | Només amb Spring WebFlux |
El DispatcherServlet és el front controller: un únic servlet mapat a / pel qual passen totes les peticions, i que s'encarrega de decidir qui les atén. És el patró Façana i el patró Ordre (12-02) aplicats al web.
- El flux intern d'una petició
Aquest és el recorregut complet de GET /api/materials/978-0000000001:
sequenceDiagram
autonumber
participant N as Navegador
participant T as Tomcat
participant F as Cadena de filtres
participant D as DispatcherServlet
participant HM as HandlerMapping
participant HA as HandlerAdapter
participant C as CatalegController
participant S as CatalegService
participant R as Repositori JPA
participant MC as HttpMessageConverter
N->>T: GET /api/materials/978-0000000001
T->>T: analitza HTTP, pren un fil del pool
T->>F: HttpServletRequest
F->>F: filtres (CORS, MDC, seguretat a 12-07)
F->>D: request
D->>HM: qui aten aquest cami?
HM-->>D: CatalegController.perIsbn
D->>HA: invoca el metode
HA->>HA: converteix el PathVariable a Isbn
HA->>C: perIsbn(Isbn)
C->>S: cercarPerIsbn(isbn)
S->>R: findByIsbn(isbn)
R-->>S: Optional~Material~
S-->>C: Material
C-->>HA: MaterialResponse (DTO)
HA->>MC: serialitza a JSON (Jackson)
MC-->>D: bytes
D-->>T: HttpServletResponse 200
T-->>N: HTTP/1.1 200 OK + JSON
Les peces de Spring MVC que hi intervenen:
| Component | Responsabilitat |
|---|---|
Filter |
Feina transversal abans i després: CORS, correlació (MDC), seguretat |
DispatcherServlet |
Orquestra tot el procés |
HandlerMapping |
Troba el mètode que atén la URL i el verb |
HandlerAdapter |
Invoca el mètode resolent-ne els arguments |
HandlerMethodArgumentResolver |
Converteix @PathVariable, @RequestParam, @RequestBody |
HttpMessageConverter |
Serialitza i deserialitza el cos (Jackson per a JSON) |
HandlerExceptionResolver |
Converteix excepcions en respostes HTTP |
- Del
ServidorCataleg de sockets a Spring MVC
ServidorCataleg de sockets a Spring MVCVal la pena posar les dues coses una al costat de l'altra, perquè això és el mateix, resolt per tu fa tres mòduls.
El teu servidor del mòdul 9:
// ServidorCataleg, modul 9: el vas escriure tu, sencer
public void atendre(Socket client) throws IOException {
try (var entrada = new BufferedReader(new InputStreamReader(client.getInputStream(), UTF_8));
var sortida = new PrintWriter(client.getOutputStream(), true)) {
String peticio = entrada.readLine(); // "CERCAR 978-0000000001"
String[] parts = peticio.split(" ", 2); // analisi del protocol
switch (parts[0]) { // encaminament
case "CERCAR" -> {
Optional<Material> m = cataleg.perIsbn(parts[1]);
if (m.isPresent()) {
sortida.println("OK " + serialitzar(m.get())); // serialitzacio
} else {
sortida.println("ERROR 404 No trobat"); // codi d'error
}
}
case "LLISTAR" -> sortida.println("OK " + serialitzar(cataleg.tots()));
default -> sortida.println("ERROR 400 Ordre desconeguda");
}
}
}El mateix amb Spring MVC:
@RestController
@RequestMapping("/api/materials")
public class CatalegController {
private final ConsultarCataleg cataleg;
@GetMapping("/{isbn}")
public MaterialResponse perIsbn(@PathVariable Isbn isbn) {
return cataleg.perIsbn(isbn)
.map(MaterialResponse::desDe)
.orElseThrow(() -> new MaterialNoTrobatException(isbn));
}
}La correspondència, peça a peça:
| El que vas fer a mà al mòdul 9 | Qui ho fa ara |
|---|---|
ServerSocket.accept() en un bucle |
Tomcat |
Un fil per client (ExecutorService) |
El pool de fils de Tomcat |
readLine() i split(" ") |
L'analitzador HTTP de Tomcat |
El switch sobre l'ordre |
HandlerMapping amb @GetMapping |
Integer.parseInt(parts[1]) |
HandlerMethodArgumentResolver |
serialitzar(...) a mà |
Jackson via HttpMessageConverter |
"ERROR 404 ..." |
@RestControllerAdvice i codis HTTP |
Tancar el socket al finally |
Tomcat |
| Temps d'espera i connexions a mitges | Tomcat |
Codificació, Content-Length, keep-alive |
Tomcat |
La conclusió que importa: no estàs aprenent un framework màgic. Estàs delegant en codi provat exactament la feina que ja saps fer, per poder dedicar la teva atenció al que només tu pots escriure: les regles de BiblioTech. I com que saps què hi ha a sota, quan alguna cosa falli —una petició que es queda penjada, una codificació estranya, un Content-Type que no quadra— sabràs on mirar.
- REST: recursos i representacions
REST (Representational State Transfer) és un estil arquitectònic definit per Roy Fielding l'any 2000. Les seves idees centrals, aplicades a BiblioTech:
Tot és un recurs, identificat per un URI. Un recurs és un substantiu, no una acció:
| Correcte | Incorrecte |
|---|---|
/api/materials |
/api/obtenirMaterials |
/api/materials/978-0000000001 |
/api/getMaterialPerIsbn?isbn=… |
/api/prestecs/42 |
/api/veurePrestec/42 |
Un recurs no és la seva representació. El préstec 42 és un concepte; la seva representació pot ser JSON, XML o HTML segons el que demani el client a Accept.
Sense estat (stateless). Cada petició conté tot el que cal per atendre-la. El servidor no guarda «en quin pas està» un client. Aquesta propietat és la que permet escalar horitzontalment (12-06): qualsevol instància pot atendre qualsevol petició.
Interfície uniforme. Els mateixos verbs amb la mateixa semàntica per a tots els recursos. Qui sap fer servir /api/materials sap fer servir /api/prestecs.
Nota: el model de maduresa de Richardson. Leonard Richardson va classificar les API en quatre nivells. Nivell 0: un únic endpoint que ho rep tot (RPC sobre HTTP). Nivell 1: recursos amb URI propis, però un sol verb. Nivell 2: recursos + verbs HTTP + codis d'estat correctes. Nivell 3: a més, HATEOAS — les respostes inclouen enllaços a les accions possibles. La immensa majoria d'API de la indústria són de nivell 2, i és un objectiu perfectament raonable: el nivell 3 aporta descobribilitat, però afegeix complexitat que pocs clients aprofiten. BiblioTech serà de nivell 2, amb un apunt d'HATEOAS on aporti.
- Verbs HTTP i les seves semàntiques
Dues propietats governen l'ús correcte dels verbs:
- Segur (safe): no modifica l'estat del servidor. Un cercador el pot invocar lliurement.
- Idempotent: executar-lo N vegades té el mateix efecte que executar-lo una vegada. És el que permet reintentar sense por.
| Verb | Segur | Idempotent | Ús | A BiblioTech |
|---|---|---|---|---|
GET |
Sí | Sí | Llegir | Consultar catàleg, préstecs |
POST |
No | No | Crear, o accions no idempotents | Crear préstec |
PUT |
No | Sí | Reemplaçar complet | Actualitzar totes les dades d'un material |
PATCH |
No | No necessàriament | Modificar parcialment | Canviar només les unitats |
DELETE |
No | Sí | Esborrar | Cancel·lar una reserva |
HEAD |
Sí | Sí | Com GET, sense cos | Comprovar existència |
OPTIONS |
Sí | Sí | Verbs permesos | Petició prèvia de CORS |
Per què la idempotència importa de debò. Un client mòbil envia POST /api/prestecs, la resposta es perd per un tall de xarxa, i el client ho reintenta. Amb POST no idempotent, es creen dos préstecs. Amb PUT, no.
Solucions per fer idempotent una creació:
POST /api/prestecs HTTP/1.1
Idempotency-Key: 3f2504e0-4f89-11d3-9a0c-0305e82c3301
Content-Type: application/json
{"isbn":"978-0000000001","idEmpleat":1,"dies":15}El servidor desa la clau amb la seva resposta; si arriba repetida, retorna la resposta original sense crear res. És el que fan les passareles de pagament, i per bones raons.
El cas de DELETE i la idempotència. Esborrar dues vegades el mateix recurs: la primera retorna 204, la segona 404. Continua sent idempotent? Sí: l'estat del servidor és el mateix. La idempotència parla de l'efecte, no del codi de resposta.
- Disseny d'URI
Regles, amb exemples de BiblioTech:
| Regla | Bé | Malament |
|---|---|---|
| Substantius en plural | /api/materials |
/api/material, /api/getMaterials |
| Minúscules i guions | /api/tipus-material |
/api/tipusMaterial, /api/Tipus_Material |
| Jerarquia per a relacions | /api/empleats/1/prestecs |
/api/prestecsDeEmpleat?id=1 |
| Sense extensió | /api/materials/978-… |
/api/materials/978-….json |
| Sense verb al camí | POST /api/prestecs |
POST /api/crearPrestec |
| Filtres a la consulta | /api/materials?tipus=LLIBRE |
/api/materials/tipus/LLIBRE |
| Versió explícita | /api/v1/materials |
(sense versió; es reprèn a 12-07) |
| Sense barra final | /api/materials |
/api/materials/ |
El cas difícil: les accions que no són CRUD. Com s'expressa «retornar un préstec» en REST? Tres opcions:
# Opcio A: subrecurs que representa el fet. La preferida.
POST /api/prestecs/42/devolucio
# Opcio B: PATCH sobre l'estat
PATCH /api/prestecs/42
{"estat": "RETORNAT"}
# Opcio C: verb al cami. Pragmatica, i acceptable si es fa servir amb moderacio.
POST /api/prestecs/42/retornarBiblioTech fa servir la A: POST /api/prestecs/42/devolucio crea el fet «devolució» dins del préstec 42. És conceptualment neta i permet que la devolució tingui les seves pròpies dades (data, observacions, estat del material).
Imbricació: màxim dos nivells. /api/empleats/1/prestecs està bé; /api/seus/2/departaments/5/empleats/1/prestecs/42/multes és inmanejable. A partir d'aquí, recurs de primer nivell amb filtres: /api/multes?empleat=1.
- Codis d'estat per operació
Fer servir codis correctes no és purisme: és el que permet a un client reaccionar sense analitzar missatges.
| Codi | Nom | Quan, a BiblioTech |
|---|---|---|
| 200 | OK | GET amb resultat; PUT/PATCH que retorna el recurs |
| 201 | Created | POST que crea. Amb capçalera Location |
| 202 | Accepted | Acceptat per a procés asíncron (importació massiva) |
| 204 | No Content | DELETE correcte; PUT sense cos de resposta |
| 400 | Bad Request | JSON mal format, tipus incorrecte, validació fallida |
| 401 | Unauthorized | Sense credencials o no vàlides (12-07) |
| 403 | Forbidden | Autenticat, però sense permís (12-07) |
| 404 | Not Found | El recurs no existeix |
| 405 | Method Not Allowed | DELETE sobre un recurs que no l'admet |
| 409 | Conflict | Regla de negoci violada: ja té 3 préstecs; conflicte de versió (@Version) |
| 410 | Gone | Va existir i s'ha eliminat permanentment |
| 415 | Unsupported Media Type | Content-Type que no se sap llegir |
| 422 | Unprocessable Entity | Sintaxi correcta, semàntica no vàlida |
| 429 | Too Many Requests | Límit de taxa superat (12-07) |
| 500 | Internal Server Error | Error no previst. Mai per una entrada de l'usuari |
| 503 | Service Unavailable | Dependència caiguda; amb Retry-After |
Errors clàssics que convé no cometre:
| Error | Per què està malament |
|---|---|
Retornar 200 amb {"error": "..."} |
El client ha d'analitzar el cos per saber si ha funcionat |
| Retornar 500 perquè falta un camp | Un 500 significa «he fallat jo»; que falti un camp és 400 |
| Retornar 404 quan no hi ha resultats en una llista | Una llista buida és un resultat vàlid: 200 amb [] |
| Retornar 401 en lloc de 403 | 401 = «no sé qui ets»; 403 = «sé qui ets i no pots» |
Retornar 200 després d'un POST que crea |
Ha de ser 201 amb Location |
@RestController: el mapatge de peticions
@RestController: el mapatge de peticionspackage com.nexussoftware.bibliotech.web.cataleg;
@RestController // = @Controller + @ResponseBody
@RequestMapping("/api/materials") // prefix comu de tots els metodes
public class CatalegController {
private final ConsultarCataleg cataleg;
private final GestionarCataleg gestio;
public CatalegController(ConsultarCataleg cataleg, GestionarCataleg gestio) {
this.cataleg = cataleg;
this.gestio = gestio;
}
@GetMapping
public PageResponse<MaterialResponse> llistar(@Valid CriteriMaterialsRequest criteri,
Pageable paginacio) { … }
@GetMapping("/{isbn}")
public MaterialResponse perIsbn(@PathVariable Isbn isbn) { … }
@PostMapping
public ResponseEntity<MaterialResponse> crear(@Valid @RequestBody CrearMaterialRequest peticio) { … }
@PutMapping("/{isbn}")
public MaterialResponse reemplacar(@PathVariable Isbn isbn,
@Valid @RequestBody ActualitzarMaterialRequest peticio) { … }
@PatchMapping("/{isbn}/unitats")
public MaterialResponse ajustarUnitats(@PathVariable Isbn isbn,
@Valid @RequestBody AjustUnitatsRequest peticio) { … }
@DeleteMapping("/{isbn}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void eliminar(@PathVariable Isbn isbn) { … }
}@RestController equival a @Controller + @ResponseBody a tots els mètodes: el valor retornat és el cos de la resposta, serialitzat per Jackson. Sense ell, Spring interpretaria un String retornat com el nom d'una vista.
Restriccions útils al mapatge:
@GetMapping(value = "/{isbn}", produces = MediaType.APPLICATION_JSON_VALUE)
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
@GetMapping(value = "/{isbn}", produces = "text/csv") // negociacio de contingut
@GetMapping(params = "format=resum") // segons parametre
@GetMapping(headers = "X-Api-Version=2") // segons capcalera
- Paràmetres: camí, consulta i cos
@GetMapping("/{isbn}/prestecs")
public List<PrestecResponse> historial(
// Part del cami: identifica el recurs
@PathVariable Isbn isbn,
// Parametre de consulta obligatori
@RequestParam EstatPrestec estat,
// Opcional amb valor per defecte
@RequestParam(defaultValue = "10") int limit,
// Opcional de debo: Optional (10-04)
@RequestParam Optional<LocalDate> desDe,
// Nom diferent del del parametre Java
@RequestParam(name = "ordenar_per", defaultValue = "DATA") CriteriOrdre ordre,
// Repetible: ?etiqueta=java&etiqueta=disseny
@RequestParam(required = false) List<String> etiqueta,
// Capcalera
@RequestHeader(value = "Accept-Language", defaultValue = "ca") Locale idioma) { … }I per al cos:
@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest peticio) { … }Conversió de tipus propis. Que @PathVariable Isbn isbn funcioni requereix dir-l'hi a Spring, i és exactament el mateix ITypeConverter de Picocli amb una altra interfície:
@Component
public class ConvertidorIsbn implements Converter<String, Isbn> {
@Override
public Isbn convert(String text) {
try {
return Isbn.de(text);
} catch (IsbnInvalidException e) {
// IllegalArgumentException → Spring ho tradueix a 400, no a 500
throw new IllegalArgumentException("ISBN invalid: " + text, e);
}
}
}El guany és el mateix que a la CLI: el controlador treballa amb el tipus del domini, i les entrades no vàlides es rebutgen abans d'entrar al teu codi.
Un detall sobre els noms de paràmetre: des de Java 21 convé compilar amb -parameters (Spring Boot ho configura per defecte) perquè Spring dedueixi els noms. Sense això, @PathVariable sense nom explícit falla en temps d'execució.
ResponseEntity i quan fer-la servir
ResponseEntity i quan fer-la servirRetornar el DTO directament és el més net, i és el que s'ha de fer per defecte:
ResponseEntity dona control total sobre estat i capçaleres, i es justifica en tres casos:
Cas 1: creació amb Location (obligatori per a un 201 ben fet).
@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest peticio) {
Prestec creat = gestor.prestar(peticio.isbn(), peticio.idEmpleat(), peticio.dies());
URI ubicacio = ServletUriComponentsBuilder
.fromCurrentRequest() // http://host/api/prestecs
.path("/{id}")
.buildAndExpand(creat.getId())
.toUri(); // http://host/api/prestecs/42
return ResponseEntity.created(ubicacio).body(PrestecResponse.desDe(creat));
}Cas 2: el codi depèn del resultat.
@PutMapping("/{isbn}")
public ResponseEntity<MaterialResponse> reemplacar(@PathVariable Isbn isbn,
@Valid @RequestBody ActualitzarMaterialRequest p) {
ResultatActualitzacio resultat = gestio.crearOActualitzar(isbn, p);
return resultat.haEstatCreat()
? ResponseEntity.status(HttpStatus.CREATED).body(MaterialResponse.desDe(resultat.material()))
: ResponseEntity.ok(MaterialResponse.desDe(resultat.material()));
}Cas 3: capçaleres específiques, com la memòria cau condicional.
@GetMapping("/{isbn}")
public ResponseEntity<MaterialResponse> perIsbn(@PathVariable Isbn isbn) {
Material material = cataleg.perIsbn(isbn).orElseThrow(() -> new MaterialNoTrobatException(isbn));
return ResponseEntity.ok()
.eTag("\"" + material.getVersion() + "\"") // el @Version de JPA com a ETag
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(MaterialResponse.desDe(material));
}Amb l'ETag, un client que reenvia If-None-Match rep un 304 Not Modified sense cos. És l'optimització d'amplada de banda més barata que existeix.
Alternativa per al cas simple de fixar el codi: @ResponseStatus.
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 204
public void cancellar(@PathVariable Long id) { reserves.cancellar(id); }
- DTO d'entrada i de sortida
Això reprèn 12-01, i ara amb el detall complet. La regla: les entitats JPA no creuen la frontera de l'API, ni d'entrada ni de sortida.
DTO de sortida:
package com.nexussoftware.bibliotech.web.prestecs;
@Schema(description = "Informacio d'un prestec") // documentacio OpenAPI
public record PrestecResponse(
@Schema(example = "42") Long id,
@Schema(example = "978-0000000001") String isbn,
@Schema(example = "Java Eficac") String titolMaterial,
@Schema(example = "Marta Ruiz") String nomEmpleat,
LocalDate dataPrestec,
LocalDate dataVenciment,
@Schema(description = "Nulla si el material continua prestat")
LocalDate dataDevolucio,
@Schema(example = "ACTIU") String estat,
@Schema(description = "Negatiu si esta vencut", example = "5")
long diesRestants,
@Schema(example = "2.50") BigDecimal multaAcumulada) {
public static PrestecResponse desDe(Prestec p, LocalDate avui) {
return new PrestecResponse(
p.getId(),
p.getIsbn().valor(),
p.titolDelMaterial(),
p.nomDeLempleat(),
p.getDataPrestec(),
p.getDataVenciment(),
p.getDataDevolucio().orElse(null), // JSON no te Optional
p.getEstat().name(),
ChronoUnit.DAYS.between(avui, p.getDataVenciment()),
p.multaAcumulada(avui).quantitat());
}
}DTO d'entrada:
public record CrearPrestecRequest(
@NotBlank(message = "L'ISBN es obligatori")
@IsbnValid
@Schema(example = "978-0000000001")
String isbn,
@NotNull(message = "L'identificador de l'empleat es obligatori")
@Positive
Long idEmpleat,
@Positive @Max(value = 90, message = "La durada maxima es de 90 dies")
@Schema(description = "Dies de prestec. Si s'omet, l'estandard del tipus de material")
Integer dies) {
}Fixa't en el que no té: ni id, ni version, ni estat, ni dataDevolucio. Un client maliciós no els pot enviar perquè l'objecte on es deserialitza no té aquests components. La defensa és estructural, no una llista de camps que cal recordar-se d'ignorar.
Un avís concret sobre record i Jackson: els records es deserialitzen sense problema des de Jackson 2.12, però necessiten -parameters en la compilació o @JsonProperty a cada component. Spring Boot ho activa per defecte; si el teu build és propi, verifica-ho.
- Validació amb
jakarta.validation
jakarta.validationDependència:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>Les anotacions més usades:
| Anotació | Valida | Exemple |
|---|---|---|
@NotNull |
No nul (una cadena buida passa) | Long idEmpleat |
@NotBlank |
Cadena no nul·la, no buida, no només espais | String titol |
@NotEmpty |
Col·lecció o cadena no buida | List<String> autors |
@Size(min, max) |
Longitud | @Size(max = 200) String titol |
@Min / @Max |
Rang numèric | @Max(90) Integer dies |
@Positive / @PositiveOrZero |
Signe | @Positive int unitats |
@Email |
Correu electrònic | String correu |
@Pattern(regexp) |
Expressió regular | @Pattern(regexp = "97[89]-\\d{10}") |
@Past / @Future |
Dates | @PastOrPresent LocalDate data |
@Valid |
Cascada a objectes imbricats | @Valid AdrecaRequest adreca |
@Valid al paràmetre és el que dispara la validació:
@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest peticio) {
// Si arriba aqui, la peticio es sintacticament valida.
// Si no, Spring ja ha llancat MethodArgumentNotValidException.
}Validació als paràmetres de consulta requereix @Validated a la classe:
@RestController
@Validated // habilita la validacio de parametres solts
@RequestMapping("/api/materials")
public class CatalegController {
@GetMapping
public List<MaterialResponse> llistar(
@RequestParam @Size(min = 2, message = "Almenys 2 caracters") String titol,
@RequestParam @Max(100) int limit) { … }
}Validació de regles creuades amb una anotació a nivell de classe:
@RangDatesValid // validador propi: desDe <= finsA
public record ConsultaMultesRequest(
@NotNull @PastOrPresent LocalDate desDe,
@NotNull @PastOrPresent LocalDate finsA,
Long idEmpleat) {
}Un límit important que cal tenir clar: jakarta.validation valida la forma, no les regles de negoci. Que l'ISBN tingui el format correcte és validació; que aquest ISBN existeixi al catàleg i tingui unitats lliures és una regla de negoci, i viu al domini. Confondre-les porta a posar consultes a la base de dades dins d'un validador, que és exactament on no han d'estar.
- Un validador propi per a l'ISBN
Un ISBN-13 no és només un format: porta un dígit de control calculat. Comprovar-lo és validació de forma, i per tant sí que correspon aquí.
package com.nexussoftware.bibliotech.web.validacio;
@Documented
@Constraint(validatedBy = ValidadorIsbn.class)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
public @interface IsbnValid {
String message() default "ISBN-13 invalid: revisa el format i el digit de control";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/** Si es true, accepta l'ISBN amb guions i espais. */
boolean permetreSeparadors() default true;
}public class ValidadorIsbn implements ConstraintValidator<IsbnValid, String> {
private static final Pattern AMB_SEPARADORS = Pattern.compile("^97[89][- ]?\\d{1,5}[- ]?\\d+[- ]?\\d+[- ]?\\d$");
private static final Pattern SENSE_SEPARADORS = Pattern.compile("^97[89]\\d{10}$");
private boolean permetreSeparadors;
@Override
public void initialize(IsbnValid anotacio) {
this.permetreSeparadors = anotacio.permetreSeparadors();
}
@Override
public boolean isValid(String valor, ConstraintValidatorContext context) {
// Un valor nul es considera valid: l'obligatorietat la imposa @NotNull.
// Separar responsabilitats entre validadors es el correcte.
if (valor == null) return true;
String normalitzat = permetreSeparadors ? valor.replaceAll("[- ]", "") : valor;
if (!SENSE_SEPARADORS.matcher(normalitzat).matches()) {
missatge(context, "L'ISBN ha de comencar per 978 o 979 i tenir 13 digits");
return false;
}
if (!digitDeControlCorrecte(normalitzat)) {
missatge(context, "El digit de control de l'ISBN no es correcte");
return false;
}
return true;
}
/**
* Algoritme oficial de l'ISBN-13: es multipliquen els 12 primers digits
* alternant 1 i 3, se sumen, i el digit de control es el que falta
* per al seguent multiple de 10.
*/
private boolean digitDeControlCorrecte(String isbn) {
int suma = 0;
for (int i = 0; i < 12; i++) {
int digit = isbn.charAt(i) - '0';
suma += (i % 2 == 0) ? digit : digit * 3;
}
int control = (10 - (suma % 10)) % 10;
return control == (isbn.charAt(12) - '0');
}
/** Substitueix el missatge generic per un d'especific de la fallada concreta. */
private void missatge(ConstraintValidatorContext context, String text) {
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(text).addConstraintViolation();
}
}Aquest missatge específic és la diferència entre «ISBN no vàlid» (que no ajuda) i «el dígit de control no és correcte» (que li diu a qui integra on és la fallada).
- Gestió global d'errors i Problem Details
Sense gestió global, una excepció produeix una resposta genèrica amb traça inclosa, que és alhora inútil per al client i una filtració d'informació (12-07).
RFC 7807 (Problem Details) defineix un format estàndard per a errors HTTP, i Spring 6 el suporta de sèrie amb la classe ProblemDetail:
{
"type": "https://bibliotech.nexussoftware.com/errors/limit-prestecs",
"title": "Limit de prestecs excedit",
"status": 409,
"detail": "L'empleat Diego Alonso ja te 3 prestecs actius (maxim: 3).",
"instance": "/api/prestecs",
"timestamp": "2026-08-05T10:23:45Z",
"traceId": "a7f3e91c4b2d",
"prestecsActius": [12, 27, 38]
}Els cinc camps estàndard són type, title, status, detail i instance; la resta són extensions pròpies.
Activació del format per defecte de Spring:
spring:
mvc:
problemdetails:
enabled: true # les excepcions estandard de Spring ja surten en format RFC 7807I el gestor per a les nostres excepcions:
package com.nexussoftware.bibliotech.web.error;
@RestControllerAdvice
public class GestorGlobalErrors {
private static final Logger log = LoggerFactory.getLogger(GestorGlobalErrors.class);
private static final String BASE_TIPUS = "https://bibliotech.nexussoftware.com/errors/";
// ---------- 404 ----------
@ExceptionHandler({MaterialNoTrobatException.class,
PrestecNoTrobatException.class,
EmpleatNoTrobatException.class})
public ProblemDetail noTrobat(RecursNoTrobatException e, HttpServletRequest peticio) {
log.info("Recurs no trobat: {}", e.getMessage()); // INFO: no es una fallada del sistema
return problema(HttpStatus.NOT_FOUND, "recurs-no-trobat",
"Recurs no trobat", e.getMessage(), peticio);
}
// ---------- 409: regles de negoci ----------
@ExceptionHandler(LimitPrestecsExceditException.class)
public ProblemDetail limitExcedit(LimitPrestecsExceditException e, HttpServletRequest p) {
ProblemDetail detall = problema(HttpStatus.CONFLICT, "limit-prestecs",
"Limit de prestecs excedit", e.getMessage(), p);
detall.setProperty("prestecsActius", e.getIdsPrestecsActius());
detall.setProperty("maximPermes", e.getMaxim());
return detall;
}
@ExceptionHandler(MaterialNoDisponibleException.class)
public ProblemDetail noDisponible(MaterialNoDisponibleException e, HttpServletRequest p) {
ProblemDetail detall = problema(HttpStatus.CONFLICT, "material-no-disponible",
"Material no disponible", e.getMessage(), p);
e.getDataPrevistaDisponibilitat()
.ifPresent(f -> detall.setProperty("disponiblePrevisiblementEl", f.toString()));
return detall;
}
// ---------- 409: conflicte de concurrencia (el @Version d'11-03) ----------
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
public ProblemDetail conflicteDeVersio(ObjectOptimisticLockingFailureException e,
HttpServletRequest p) {
log.warn("Conflicte de bloqueig optimista a {}", p.getRequestURI());
return problema(HttpStatus.CONFLICT, "conflicte-concurrencia",
"Conflicte de concurrencia",
"Un altre usuari ha modificat aquest recurs mentre l'editaves. Recarrega'l i reintenta.", p);
}
// ---------- 400: validacio del cos ----------
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail validacio(MethodArgumentNotValidException e, HttpServletRequest p) {
List<ErrorCamp> errors = e.getBindingResult().getFieldErrors().stream()
.map(f -> new ErrorCamp(f.getField(), f.getDefaultMessage(), f.getRejectedValue()))
.toList();
ProblemDetail detall = problema(HttpStatus.BAD_REQUEST, "validacio",
"Error de validacio",
"La peticio conte %d camp(s) invalid(s).".formatted(errors.size()), p);
detall.setProperty("errors", errors); // AIXO es el que fa util un 400
return detall;
}
// ---------- 400: parametres i tipus ----------
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail parametresInvalids(ConstraintViolationException e, HttpServletRequest p) {
List<ErrorCamp> errors = e.getConstraintViolations().stream()
.map(v -> new ErrorCamp(v.getPropertyPath().toString(), v.getMessage(), v.getInvalidValue()))
.toList();
ProblemDetail detall = problema(HttpStatus.BAD_REQUEST, "parametres-invalids",
"Parametres invalids", "Revisa els parametres de la peticio.", p);
detall.setProperty("errors", errors);
return detall;
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ProblemDetail tipusIncorrecte(MethodArgumentTypeMismatchException e, HttpServletRequest p) {
String esperat = e.getRequiredType() != null ? e.getRequiredType().getSimpleName() : "valid";
return problema(HttpStatus.BAD_REQUEST, "tipus-incorrecte", "Tipus de dada incorrecte",
"El parametre '%s' amb valor '%s' no es un %s."
.formatted(e.getName(), e.getValue(), esperat), p);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail cosIllegible(HttpMessageNotReadableException e, HttpServletRequest p) {
// NO exposem e.getMessage(): revela l'estructura interna de les classes
return problema(HttpStatus.BAD_REQUEST, "cos-invalid", "Cos de la peticio invalid",
"El cos no es un JSON valid o no encaixa amb el format esperat.", p);
}
// ---------- 503: dependencia externa ----------
@ExceptionHandler(ServeiExternNoDisponibleException.class)
public ResponseEntity<ProblemDetail> serveiCaigut(ServeiExternNoDisponibleException e,
HttpServletRequest p) {
log.error("Servei extern no disponible: {}", e.getServei(), e);
ProblemDetail detall = problema(HttpStatus.SERVICE_UNAVAILABLE, "servei-no-disponible",
"Servei temporalment no disponible",
"No s'ha pogut completar l'operacio. Torna-ho a provar d'aqui a uns minuts.", p);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, "60") // el client sap quan reintentar
.body(detall);
}
// ---------- 500: la xarxa de seguretat ----------
@ExceptionHandler(Exception.class)
public ProblemDetail errorNoPrevist(Exception e, HttpServletRequest p) {
String idIncidencia = UUID.randomUUID().toString().substring(0, 12);
// La traca COMPLETA al log; al client, nomes l'identificador (06-07 i 12-07)
log.error("Error no previst [id={}] a {} {}", idIncidencia, p.getMethod(), p.getRequestURI(), e);
ProblemDetail detall = problema(HttpStatus.INTERNAL_SERVER_ERROR, "error-intern",
"Error intern",
"S'ha produit un error inesperat. Si el problema persisteix, "
+ "contacta amb suport indicant l'identificador d'incidencia.", p);
detall.setProperty("idIncidencia", idIncidencia);
return detall;
}
// ---------- Constructor comu ----------
private ProblemDetail problema(HttpStatus estat, String tipus, String titol,
String detallText, HttpServletRequest peticio) {
ProblemDetail detall = ProblemDetail.forStatusAndDetail(estat, detallText);
detall.setType(URI.create(BASE_TIPUS + tipus));
detall.setTitle(titol);
detall.setInstance(URI.create(peticio.getRequestURI()));
detall.setProperty("timestamp", Instant.now().toString());
// L'identificador de correlacio que MDC va posar a 11-07: uneix la resposta amb el log
detall.setProperty("traceId", MDC.get("traceId"));
return detall;
}
public record ErrorCamp(String camp, String missatge, Object valorRebutjat) { }
}Taula de traducció completa de la jerarquia del mòdul 6:
| Excepció de BiblioTech | HTTP | Tipus de problema | Es registra com |
|---|---|---|---|
MaterialNoTrobatException |
404 | recurs-no-trobat |
INFO |
PrestecNoTrobatException |
404 | recurs-no-trobat |
INFO |
LimitPrestecsExceditException |
409 | limit-prestecs |
INFO |
MaterialNoDisponibleException |
409 | material-no-disponible |
INFO |
PrestecJaRetornatException |
409 | prestec-ja-retornat |
INFO |
ObjectOptimisticLockingFailureException |
409 | conflicte-concurrencia |
WARN |
MethodArgumentNotValidException |
400 | validacio |
DEBUG |
IsbnInvalidException |
400 | isbn-invalid |
DEBUG |
ServeiExternNoDisponibleException |
503 | servei-no-disponible |
ERROR |
Exception (qualsevol altra) |
500 | error-intern |
ERROR |
El criteri de nivell de log és important i gairebé ningú l'aplica: un 404 no és un error del sistema, és un client demanant una cosa que no existeix. Si el registres com a ERROR, el teu panell d'alertes s'omplirà de soroll i deixaràs de mirar-lo.
- Paginació i ordenació
Retornar List<Material> amb 50.000 elements és un problema de memòria, de xarxa i de temps de resposta. Spring Data resol la paginació de sèrie:
@GetMapping
public PageResponse<MaterialResponse> llistar(
@PageableDefault(size = 20, sort = "titol", direction = Sort.Direction.ASC)
Pageable paginacio) {
Page<Material> pagina = cataleg.cercar(paginacio);
return PageResponse.desDe(pagina.map(MaterialResponse::desDe));
}GET /api/materials?page=0&size=20&sort=titol,asc
GET /api/materials?page=2&size=50&sort=anyPublicacio,desc&sort=titol,ascLimita la mida màxima de pàgina, o algú demanarà size=1000000:
spring:
data:
web:
pageable:
default-page-size: 20
max-page-size: 100 # Spring retalla silenciosament per damunt
one-indexed-parameters: falseI un DTO de resposta paginada propi, per no exposar l'estructura interna de Page (que ha canviat entre versions de Spring Data, trencant clients):
public record PageResponse<T>(List<T> contingut, MetadadesPagina pagina) {
public static <T> PageResponse<T> desDe(Page<T> page) {
return new PageResponse<>(page.getContent(), new MetadadesPagina(
page.getNumber(), page.getSize(), page.getTotalElements(),
page.getTotalPages(), page.isFirst(), page.isLast()));
}
public record MetadadesPagina(int numero, int mida, long totalElements,
int totalPagines, boolean primera, boolean ultima) { }
}{
"contingut": [ { "isbn": "978-0000000001", "titol": "Java Eficac", "…": "…" } ],
"pagina": { "numero": 0, "mida": 20, "totalElements": 3,
"totalPagines": 1, "primera": true, "ultima": true }
}Un avís de rendiment. La paginació per desplaçament (OFFSET) es degrada amb pàgines altes: OFFSET 100000 obliga la base de dades a recórrer i descartar cent mil files. Per a catàlegs grans existeix la paginació per cursor (WHERE id > :ultimId ORDER BY id LIMIT 20), que és constant en temps. Per a BiblioTech, amb uns quants milers de materials, el desplaçament fa el fet.
- Filtres i cerca
Un objecte de criteris agrupat és millor que vuit @RequestParam solts:
public record CriteriMaterialsRequest(
@Size(min = 2, max = 100) String titol,
@Size(min = 2, max = 100) String autor,
TipusMaterial tipus,
@Min(1450) Integer anyDesDe, // any de la impremta: un limit raonable
@Max(2100) Integer anyFinsA,
Boolean nomesDisponibles) {
public CriteriCerca aDomini() {
return CriteriCerca.builder() // el Builder de 12-02
.titol(titol).autor(autor).tipus(tipus)
.entre(anyDesDe, anyFinsA)
.nomesDisponibles(Boolean.TRUE.equals(nomesDisponibles))
.construir();
}
}@GetMapping
public PageResponse<MaterialResponse> llistar(@Valid CriteriMaterialsRequest criteri,
@PageableDefault(size = 20) Pageable paginacio) {
Page<Material> resultats = cataleg.cercar(criteri.aDomini(), paginacio);
return PageResponse.desDe(resultats.map(MaterialResponse::desDe));
}A la implementació, Specification de Spring Data compon els criteris dinàmicament. És el patró Especificació (12-02):
public Page<Material> cercar(CriteriCerca criteri, Pageable paginacio) {
Specification<Material> spec = Specification.where(null);
if (criteri.titol() != null) {
spec = spec.and((arrel, consulta, cb) ->
cb.like(cb.lower(arrel.get("titol")), "%" + criteri.titol().toLowerCase() + "%"));
}
if (criteri.tipus() != null) {
spec = spec.and((arrel, consulta, cb) -> cb.equal(arrel.get("tipus"), criteri.tipus()));
}
if (criteri.nomesDisponibles()) {
spec = spec.and((arrel, consulta, cb) -> cb.greaterThan(arrel.get("unitatsDisponibles"), 0));
}
return repositori.findAll(spec, paginacio);
}Això no és concatenació de SQL: CriteriaBuilder genera consultes parametritzades, immunes a injecció (es reprèn a 12-07).
- L'API completa de BiblioTech
| Mètode | Camí | Descripció | Èxit | Errors |
|---|---|---|---|---|
GET |
/api/materials |
Llista paginada amb filtres | 200 | 400 |
GET |
/api/materials/{isbn} |
Detall d'un material | 200 | 400, 404 |
POST |
/api/materials |
Alta de material | 201 | 400, 409 |
PUT |
/api/materials/{isbn} |
Reemplaçament complet | 200, 201 | 400, 404 |
PATCH |
/api/materials/{isbn}/unitats |
Ajust d'unitats | 200 | 400, 404, 409 |
DELETE |
/api/materials/{isbn} |
Baixa de material | 204 | 404, 409 |
GET |
/api/prestecs |
Llista paginada | 200 | 400 |
GET |
/api/prestecs/{id} |
Detall | 200 | 404 |
POST |
/api/prestecs |
Crear préstec | 201 | 400, 404, 409 |
POST |
/api/prestecs/{id}/devolucio |
Registrar devolució | 200 | 404, 409 |
POST |
/api/prestecs/{id}/renovacio |
Renovar | 200 | 404, 409 |
GET |
/api/empleats/{id}/prestecs |
Préstecs d'un empleat | 200 | 404 |
GET |
/api/empleats/{id}/multes |
Multes d'un empleat | 200 | 404 |
POST |
/api/reserves |
Crear reserva | 201 | 400, 404, 409 |
DELETE |
/api/reserves/{id} |
Cancel·lar reserva | 204 | 404, 409 |
GET |
/api/estadistiques/us |
Informe d'ús | 200 | 400 |
GET |
/actuator/health |
Estat del servei | 200 | 503 |
Exemples amb curl i les seves respostes.
Crear un préstec:
curl -i -X POST http://localhost:8080/api/prestecs \
-H "Content-Type: application/json" \
-d '{"isbn":"978-0000000001","idEmpleat":1,"dies":15}'HTTP/1.1 201 Created
Location: http://localhost:8080/api/prestecs/42
Content-Type: application/json
{
"id": 42,
"isbn": "978-0000000001",
"titolMaterial": "Java Eficac",
"nomEmpleat": "Marta Ruiz",
"dataPrestec": "2026-08-05",
"dataVenciment": "2026-08-20",
"dataDevolucio": null,
"estat": "ACTIU",
"diesRestants": 15,
"multaAcumulada": 0.00
}Superar el límit de préstecs:
curl -i -X POST http://localhost:8080/api/prestecs \
-H "Content-Type: application/json" \
-d '{"isbn":"978-0000000003","idEmpleat":2,"dies":15}'HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://bibliotech.nexussoftware.com/errors/limit-prestecs",
"title": "Limit de prestecs excedit",
"status": 409,
"detail": "L'empleat Diego Alonso ja te 3 prestecs actius (maxim: 3).",
"instance": "/api/prestecs",
"timestamp": "2026-08-05T10:23:45Z",
"traceId": "a7f3e91c4b2d",
"prestecsActius": [12, 27, 38],
"maximPermes": 3
}Petició amb diversos errors de validació:
curl -i -X POST http://localhost:8080/api/prestecs \
-H "Content-Type: application/json" \
-d '{"isbn":"1234","idEmpleat":null,"dies":365}'HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"type": "https://bibliotech.nexussoftware.com/errors/validacio",
"title": "Error de validacio",
"status": 400,
"detail": "La peticio conte 3 camp(s) invalid(s).",
"instance": "/api/prestecs",
"errors": [
{"camp": "isbn", "missatge": "L'ISBN ha de comencar per 978 o 979 i tenir 13 digits",
"valorRebutjat": "1234"},
{"camp": "idEmpleat", "missatge": "L'identificador de l'empleat es obligatori",
"valorRebutjat": null},
{"camp": "dies", "missatge": "La durada maxima es de 90 dies", "valorRebutjat": 365}
]
}Que es retornin els tres errors alhora —i no el primer— és el que estalvia al client tres viatges d'anada i tornada.
Registrar la devolució:
curl -i -X POST http://localhost:8080/api/prestecs/42/devolucio \
-H "Content-Type: application/json" -d '{"data":"2026-08-25"}'HTTP/1.1 200 OK
{
"idPrestec": 42,
"dataDevolucio": "2026-08-25",
"diesDeRetard": 5,
"multa": 2.50,
"estat": "RETORNAT"
}Cerca paginada:
- Documentació automàtica amb OpenAPI
OpenAPI (abans Swagger) descriu una API en un document JSON o YAML llegible per màquines. A partir d'ell es genera documentació navegable, clients en qualsevol llenguatge i proves de contracte.
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>Només amb això ja tens:
http://localhost:8080/v3/api-docs— el document OpenAPI en JSONhttp://localhost:8080/swagger-ui.html— la interfície navegable, amb botó «Try it out»
I s'enriqueix amb anotacions:
@RestController
@RequestMapping("/api/prestecs")
@Tag(name = "Prestecs", description = "Gestio de prestecs de materials a empleats")
public class PrestecController {
@Operation(
summary = "Crea un prestec",
description = """
Presta un material a un empleat. Comprova que hi hagi unitats disponibles
i que l'empleat no superi el limit de prestecs actius.
Si no s'indica la durada, es fa servir l'estandard del tipus de material:
15 dies per a llibres, 7 per a revistes i 3 per a DVD.""")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Prestec creat",
content = @Content(schema = @Schema(implementation = PrestecResponse.class))),
@ApiResponse(responseCode = "400", description = "Peticio invalida",
content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
@ApiResponse(responseCode = "404", description = "Material o empleat inexistent",
content = @Content(schema = @Schema(implementation = ProblemDetail.class))),
@ApiResponse(responseCode = "409", description = "Sense unitats lliures o limit excedit",
content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
})
@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest peticio) { … }
}Informació general de l'API:
@Configuration
public class ConfiguracioOpenApi {
@Bean
OpenAPI apiBiblioTech(@Value("${bibliotech.version}") String versio) {
return new OpenAPI()
.info(new Info()
.title("API de BiblioTech")
.version(versio)
.description("Gestio de la biblioteca tecnica interna de Nexus Software.")
.contact(new Contact().name("Equip de plataforma")
.email("[email protected]")))
.servers(List.of(
new Server().url("http://localhost:8080").description("Desenvolupament"),
new Server().url("https://bibliotech.nexussoftware.com").description("Produccio")));
}
}I en producció, es desactiva la interfície però es conserva el document (o es protegeix, 12-07):
El valor real d'OpenAPI apareix en la integració: un client TypeScript, Java o Python es genera des del document amb una ordre, i no cal escriure a mà ni un sol DTO.
- CORS
Els navegadors apliquen la política del mateix origen: JavaScript servit des de https://intranet.nexussoftware.com no pot cridar https://bibliotech.nexussoftware.com tret que el servidor ho autoritzi explícitament. CORS (Cross-Origin Resource Sharing) és aquest mecanisme d'autorització.
El flux per a peticions «no simples» (amb Content-Type: application/json, per exemple) inclou una petició prèvia:
OPTIONS /api/prestecs 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ó global:
@Configuration
public class ConfiguracioCors implements WebMvcConfigurer {
private final List<String> origensPermesos; // des d'application.yml, per entorn
@Override
public void addCorsMappings(CorsRegistry registre) {
registre.addMapping("/api/**")
.allowedOrigins(origensPermesos.toArray(String[]::new))
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("Content-Type", "Authorization")
.exposedHeaders("Location") // perque el client la pugui llegir despres d'un 201
.allowCredentials(true)
.maxAge(3600); // desar la resposta previa 1 hora
}
}# dev
bibliotech:
cors:
origens: ["http://localhost:5173", "http://localhost:3000"]
# prod
bibliotech:
cors:
origens: ["https://intranet.nexussoftware.com"]Avís.
allowedOrigins("*")juntament amballowCredentials(true)és una combinació que l'especificació prohibeix i que Spring rebutja en temps d'execució. I"*"a seques en una API interna és obrir la porta que qualsevol pàgina web del món faci peticions des del navegador dels teus usuaris. Llista explícita d'orígens, sempre.
I un aclariment que estalvia hores de depuració: CORS és una protecció del navegador, no del servidor. Un curl o un client Java ignoren CORS del tot. No és un mecanisme de seguretat de la teva API; la seguretat és 12-07.
- La capa de servei i les transaccions
El controlador no porta @Transactional. La transacció pertany al cas d'ús, i això no és una preferència estètica:
// MALAMENT: transaccio al controlador
@RestController
public class PrestecController {
@PostMapping
@Transactional // ← no
public PrestecResponse crear(@RequestBody CrearPrestecRequest p) { … }
}Raons concretes:
- La transacció quedaria oberta durant la serialització JSON, allargant-la sense motiu i mantenint ocupada una connexió del pool.
- La CLI (12-03) crida el mateix cas d'ús sense passar pel controlador: es quedaria sense transacció.
- Barreja una decisió d'infraestructura de dades amb la capa de presentació.
// BE: la transaccio, al cas d'us
@Service
public class GestorPrestecs implements GestionarPrestecs {
@Override
@Transactional // escriptura
public Prestec prestar(Isbn isbn, Long idEmpleat, Integer dies) { … }
@Override
@Transactional(readOnly = true) // lectura: Hibernate omet el dirty checking
public Optional<Prestec> cercar(Long id) { … }
}// I el controlador nomes tradueix HTTP
@PostMapping
public ResponseEntity<PrestecResponse> crear(@Valid @RequestBody CrearPrestecRequest p) {
Prestec creat = gestor.prestar(p.isbn(), p.idEmpleat(), p.dies());
return ResponseEntity.created(uriDe(creat)).body(PrestecResponse.desDe(creat, avui()));
}Recorda de 12-01 la propietat que fa això obligatori:
Amb open-in-view: true (el valor per defecte de Spring Boot), la sessió d'Hibernate continua oberta durant la representació, cosa que fa que les relacions mandroses es resolguin des del controlador, generant N+1 invisibles. Amb false, si el teu DTO accedeix a una relació no carregada, obtens LazyInitializationException en desenvolupament, que és exactament el que vols: un error sorollós en lloc d'un problema de rendiment silenciós.
- Proves de la capa web
Dos nivells, amb propòsits diferents:
| Nivell | Anotació | Què aixeca | Velocitat | Què prova |
|---|---|---|---|---|
| Llesca web | @WebMvcTest |
Només la capa MVC | ~1 s | Mapatge, validació, serialització, codis d'estat |
| Extrem a extrem | @SpringBootTest(RANDOM_PORT) |
Tot, amb servidor real | ~5-15 s | El flux complet, inclosa la base de dades |
Nivell 1: @WebMvcTest amb MockMvc. No hi ha base de dades, no hi ha servidor: només el controlador i la infraestructura MVC.
@WebMvcTest(PrestecController.class)
class PrestecControllerTest {
@Autowired MockMvc mvc;
@Autowired ObjectMapper json;
@MockitoBean GestionarPrestecs gestor; // Spring Boot 3.4+; abans era @MockBean
@Test
void retorna201ILocationEnCrearUnPrestec() throws Exception {
var creat = unPrestec(42L, "978-0000000001", "Marta Ruiz");
when(gestor.prestar(any(), eq(1L), eq(15))).thenReturn(creat);
mvc.perform(post("/api/prestecs")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"isbn":"978-0000000001","idEmpleat":1,"dies":15}"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", endsWith("/api/prestecs/42")))
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.titolMaterial").value("Java Eficac"))
.andExpect(jsonPath("$.estat").value("ACTIU"));
}
@Test
void retorna400AmbElDetallDeCadaCampInvalid() throws Exception {
mvc.perform(post("/api/prestecs")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"isbn":"1234","idEmpleat":null,"dies":365}"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith("application/problem+json"))
.andExpect(jsonPath("$.title").value("Error de validacio"))
.andExpect(jsonPath("$.errors", hasSize(3)))
.andExpect(jsonPath("$.errors[*].camp",
containsInAnyOrder("isbn", "idEmpleat", "dies")));
// Amb dades invalides, el cas d'us NO ha d'haver-se invocat
verifyNoInteractions(gestor);
}
@Test
void retorna409AmbDetallQuanSeSuperaElLimit() throws Exception {
when(gestor.prestar(any(), eq(2L), any()))
.thenThrow(new LimitPrestecsExceditException(2L, "Diego Alonso", 3,
List.of(12L, 27L, 38L)));
mvc.perform(post("/api/prestecs")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"isbn":"978-0000000003","idEmpleat":2,"dies":15}"""))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.type").value(endsWith("/errors/limit-prestecs")))
.andExpect(jsonPath("$.detail").value(containsString("Diego Alonso")))
.andExpect(jsonPath("$.prestecsActius", hasSize(3)));
}
@Test
void retorna404QuanElPrestecNoExisteix() throws Exception {
when(gestor.cercar(9999L)).thenReturn(Optional.empty());
mvc.perform(get("/api/prestecs/9999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404));
}
@Test
void retorna400QuanLIsbnDelCamiEsInvalid() throws Exception {
mvc.perform(get("/api/materials/no-es-un-isbn"))
.andExpect(status().isBadRequest());
}
}Nivell 2: @SpringBootTest amb TestRestTemplate. Servidor real en un port aleatori, base de dades real, tot el flux:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PrestecApiIT {
@Autowired TestRestTemplate client;
@Autowired PrestecRepository repositori;
@Test
void cicleCompletDePrestecIDevolucio() {
// 1. Crear
var peticio = new CrearPrestecRequest("978-0000000001", 1L, 15);
ResponseEntity<PrestecResponse> creacio =
client.postForEntity("/api/prestecs", peticio, PrestecResponse.class);
assertThat(creacio.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(creacio.getHeaders().getLocation()).isNotNull();
Long id = creacio.getBody().id();
// 2. Consultar per l'URI que va retornar Location
ResponseEntity<PrestecResponse> consulta =
client.getForEntity(creacio.getHeaders().getLocation(), PrestecResponse.class);
assertThat(consulta.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(consulta.getBody().estat()).isEqualTo("ACTIU");
// 3. Retornar
ResponseEntity<DevolucioResponse> devolucio = client.postForEntity(
"/api/prestecs/{id}/devolucio", new DevolucioRequest(null),
DevolucioResponse.class, id);
assertThat(devolucio.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(devolucio.getBody().multa()).isEqualByComparingTo("0.00");
// 4. Verificar que la persistencia reflecteix el canvi
assertThat(repositori.findById(id))
.get()
.extracting(Prestec::getEstat)
.isEqualTo(EstatPrestec.RETORNAT);
}
@Test
void retornarDuesVegadesRetorna409() {
Long id = crearIRetornar();
ResponseEntity<ProblemDetail> segona = client.postForEntity(
"/api/prestecs/{id}/devolucio", new DevolucioRequest(null),
ProblemDetail.class, id);
assertThat(segona.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
assertThat(segona.getBody().getDetail()).contains("ja ha estat retornat");
}
}Què es prova a cada nivell (l'estratègia completa és la lliçó 12-05):
| Aspecte | @WebMvcTest |
@SpringBootTest |
|---|---|---|
| Camí i verb correctes | Sí | Sí |
| Conversió de paràmetres | Sí | Sí |
| Validació de l'entrada | Sí, aquí | Redundant |
| Codis d'estat i errors | Sí, aquí | Els principals |
| Forma del JSON | Sí, aquí | No cal |
| Regles de negoci | No (simulades) | Sí |
| Persistència real | No | Sí, aquí |
| Transaccions i rollback | No | Sí, aquí |
Regla pràctica: moltes proves de llesca (ràpides, exhaustives en casos límit) i poques d'extrem a extrem (lentes, per als fluxos principals). Invertir aquesta proporció és la manera més comuna d'acabar amb una suite de proves que triga vint minuts.
- Interfície d'usuari: Thymeleaf o front-end separat
L'API està feta. Falta decidir què veu la Marta Ruiz al seu navegador. Dos camins:
Opció A: HTML servit des de Spring, amb Thymeleaf.
@Controller // NO @RestController!
@RequestMapping("/cataleg")
public class CatalegVistaController {
@GetMapping
public String llistar(@RequestParam(required = false) String titol, Model model) {
model.addAttribute("materials", cataleg.cercar(titol));
model.addAttribute("filtre", titol);
return "cataleg/llista"; // resol a templates/cataleg/llista.html
}
}<!-- src/main/resources/templates/cataleg/llista.html -->
<table>
<tr th:each="m : ${materials}">
<td th:text="${m.isbn}">978-…</td>
<td th:text="${m.titol}">Titol</td>
<td th:text="${m.unitatsDisponibles}">0</td>
</tr>
</table>Opció B: front-end separat (React, Vue, Angular) que consumeix l'API REST, desplegat com a estàtics en un CDN o servidor web.
| Criteri | Thymeleaf (HTML servit) | Front-end separat |
|---|---|---|
| Complexitat de desplegament | Una unitat | Dos projectes, dos desplegaments |
| SEO | Excel·lent | Requereix representació al servidor |
| Interactivitat rica | Limitada | Excel·lent |
| Equip necessari | Només Java | Java + JavaScript |
| Reutilització de l'API | La vista no fa servir l'API | La mateixa API serveix web i mòbil |
| Temps fins a la primera versió | Menor | Major |
| CORS, autenticació per testimoni | No calen | S'han de resoldre |
Recomanació per a BiblioTech: l'API REST és la interfície principal, perquè ha de servir també la CLI, l'app mòbil prevista i la integració amb recursos humans. Per a la interfície dels empleats, Thymeleaf és l'elecció pragmàtica: l'equip de Nexus Software és de Java, la interactivitat requerida és baixa (cercar, llistar, un botó de reservar), i evita el cost de mantenir un projecte de front-end separat amb el seu propi cicle de desplegament.
I no és una decisió irreversible: l'API queda intacta si més endavant s'afegeix un front-end modern. Aquest és precisament l'avantatge de tenir l'API com a peça central.
- Fils virtuals a Spring Boot 3.2
Aquí es tanca el cercle amb 10-06.
El model tradicional de Tomcat és un fil de plataforma per petició, amb un pool de 200 per defecte. Cada fil consumeix ~1 MB de pila. Quan una petició espera la base de dades o l'API de metadades, el seu fil està bloquejat sense fer res, i amb 200 peticions lentes concurrents el pool s'esgota: les següents esperen a la cua encara que la CPU estigui al 5 %.
Els fils virtuals de Java 21 (Project Loom) resolen això: són fils gestionats per la JVM, costen uns centenars de bytes, i quan es bloquegen en E/S alliberen el fil de plataforma subjacent.
Activar-los a Spring Boot 3.2+ és una línia:
Això fa que Tomcat atengui cada petició en un fil virtual, i que @Async i les tasques programades també els facin servir.
| Aspecte | Fils de plataforma | Fils virtuals |
|---|---|---|
| Cost de memòria | ~1 MB de pila | Centenars de bytes |
| Màxim pràctic | Milers | Milions |
| En bloquejar-se en E/S | El fil del SO queda ocupat | S'allibera el portador |
| Cost de creació | Alt (per això els pools) | Molt baix |
| Canvis al teu codi | — | Cap |
| Benefici | — | Gran si hi ha molta E/S |
El que cal saber abans d'activar-los:
- No acceleren la CPU. Si el coll d'ampolla és càlcul, no canvien res. El benefici és en concurrència bloquejada per E/S, que és el cas típic d'una API.
synchronizedels ancora. Un blocsynchronizedque fa E/S a dins fixa (pins) el fil virtual al seu portador, anul·lant l'avantatge. Se substitueix perReentrantLock. A Java 24 aquesta limitació s'elimina, però a Java 21 cal vigilar-la.- No els posis mai en un pool. La seva gràcia és crear-ne un per tasca. Un pool de fils virtuals no té sentit.
- El pool de connexions continua sent el límit real. Pots tenir un milió de fils virtuals i 20 connexions a la base de dades: el coll d'ampolla es mou, no desapareix. Ajusta HikariCP en conseqüència.
- Compte amb els
ThreadLocal. Milions de fils virtuals, cadascun amb la seva còpia, consumeixen memòria. Amb MDC (11-07) això està controlat, però convé saber-ho.
Comprovació que estan actius:
@GetMapping("/api/diagnostic/fil")
public Map<String, Object> filActual() {
Thread fil = Thread.currentThread();
return Map.of("nom", fil.getName(),
"virtual", fil.isVirtual(),
"grup", String.valueOf(fil.threadId()));
}I així es tanca l'arc que va començar al mòdul 8 amb Thread, va continuar a 08-05 amb ExecutorService, es va generalitzar a 10-06 amb els fils virtuals, i acaba aquí: una línia de configuració que multiplica per mil la concurrència de l'API, sense tocar ni una sola línia de codi de BiblioTech.
Errors Comuns i Consells
1. Exposar entitats JPA a l'API. Fuita de dades sensibles, LazyInitializationException, cicles infinits, i l'esquema de la base de dades convertit en contracte públic. DTO sempre, des del primer endpoint.
2. Retornar 200 amb un cos d'error. El client no pot distingir èxit de fallada sense analitzar el cos. Fes servir el codi d'estat: per a això existeix.
3. Posar @Transactional al controlador. Allarga la transacció fins a la serialització, ocupa una connexió de més i deixa sense transacció els altres adaptadors.
4. Deixar open-in-view a true. És el valor per defecte i genera N+1 invisibles executats des de la capa de vista. Posa'l a false i arregla el que es trenqui.
5. No paginar. Funciona amb 50 materials i tomba el servidor amb 50.000. Pagina des del principi i limita la mida màxima.
6. Retornar 500 per errors del client. Un camp que falta és 400, un recurs inexistent és 404, una regla de negoci violada és 409. El 500 és «he fallat jo», i hauria de disparar una alerta.
7. Filtrar detalls interns als missatges d'error. Traces de pila, noms de classe, consultes SQL o versions de biblioteca a la resposta HTTP són informació de franc per a un atacant. Al client, missatge i identificador; al log, tota la resta.
8. allowedOrigins("*") en producció. Qualsevol pàgina del món podria cridar la teva API des del navegador dels teus usuaris. Llista explícita.
9. Verbs als camins. POST /api/crearPrestec no és REST, és RPC amb una altra sintaxi. POST /api/prestecs.
10. No documentar l'API. Sense OpenAPI, cada integració comença amb una cadena de correus. El cost és una dependència i unes anotacions.
11. Provar només amb @SpringBootTest. Suites de vint minuts que ningú executa abans de pujar codi. Moltes proves de llesca, poques d'extrem a extrem.
12. Oblidar la capçalera Location en un 201. El client no sap on ha quedat el recurs que acaba de crear i l'ha d'endevinar.
Consell final: dissenya l'API abans d'implementar-la. Escriu la taula d'endpoints amb els seus codis d'estat, revisa-la, i només llavors escriu codi. Canviar una API publicada és car; canviar una taula en un document no costa res.
Exercicis
Els exercicis assumeixen el projecte tal com ha quedat en aquesta lliçó.
Exercici 1: endpoint de renovació
Implementa POST /api/prestecs/{id}/renovacio amb aquestes regles:
- Cos opcional amb
dies(1 a 30); si s'omet, es fa servir la durada estàndard del material. - Només es pot renovar una vegada (estat
ACTIU; vegeu el patró Estat de 12-02). - No es pot renovar si el préstec està vençut.
- No es pot renovar si hi ha reserves pendents d'aquest material.
- Codis: 200 correcte, 404 no existeix, 409 en cadascuna de les tres violacions, 400 dies fora de rang.
Escriu el DTO de petició i resposta, el mètode del controlador, els gestors d'error necessaris i les proves @WebMvcTest dels cinc escenaris.
Exercici 2: cerca avançada amb paginació
Implementa GET /api/materials/cerca que accepti:
q: text lliure que cerca en títol i autor (mínim 2 caràcters).tipus: repetible (?tipus=LLIBRE&tipus=DVD).disponible: booleà.anyDesDeianyFinsA, validant quedesDe <= finsAamb una anotació de classe.- Paginació i ordenació, amb màxim 50 per pàgina.
- Ha de retornar, a més dels resultats, quants n'hi ha per tipus (facetes).
Inclou el DTO de criteris amb la seva validació creuada, la resposta amb facetes i les proves.
Exercici 3: idempotència en la creació de préstecs
Implementa el suport de la capçalera Idempotency-Key a POST /api/prestecs:
- Si la capçalera hi és i la clau no s'ha vist, es processa normalment i es desa la resposta associada a la clau.
- Si la clau ja s'ha processat, es retorna la resposta original amb la mateixa capçalera
Locationi una capçaleraIdempotent-Replay: true, sense crear res. - Si la clau està en curs, es retorna 409.
- Les claus caduquen a les 24 hores.
Fes servir un filtre o un interceptor, i explica les implicacions de concurrència.
Solucions
Solució 1
DTO:
public record RenovarPrestecRequest(
@Min(value = 1, message = "La renovacio ha de ser d'almenys 1 dia")
@Max(value = 30, message = "La renovacio no pot superar els 30 dies")
Integer dies) {
}
public record RenovacioResponse(
Long idPrestec,
String titolMaterial,
LocalDate vencimentAnterior,
LocalDate vencimentNou,
int diesAfegits,
boolean potRenovarseDeNou) {
public static RenovacioResponse desDe(Prestec p, LocalDate anterior) {
return new RenovacioResponse(
p.getId(), p.titolDelMaterial(), anterior, p.getDataVenciment(),
(int) ChronoUnit.DAYS.between(anterior, p.getDataVenciment()),
p.getEstat().permetRenovar());
}
}Controlador:
@PostMapping("/{id}/renovacio")
@Operation(summary = "Renova un prestec",
description = "Amplia la data de venciment. Nomes es permet una renovacio per prestec.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Renovat"),
@ApiResponse(responseCode = "404", description = "El prestec no existeix"),
@ApiResponse(responseCode = "409", description = "Ja renovat, vencut, o amb reserves pendents")
})
public RenovacioResponse renovar(
@PathVariable Long id,
@RequestBody(required = false) @Valid RenovarPrestecRequest peticio) {
Integer dies = (peticio != null) ? peticio.dies() : null;
ResultatRenovacio resultat = gestor.renovar(id, dies);
return RenovacioResponse.desDe(resultat.prestec(), resultat.vencimentAnterior());
}Cas d'ús, on viuen de debò les tres regles:
@Override
@Transactional
public ResultatRenovacio renovar(Long id, Integer dies) {
Prestec prestec = repositori.cercarPerId(id)
.orElseThrow(() -> new PrestecNoTrobatException(id));
LocalDate avui = LocalDate.now(rellotge);
// Regla 1: l'estat governa (patro Estat, 12-02)
if (!prestec.getEstat().permetRenovar()) {
throw new RenovacioNoPermesaException(id, prestec.getEstat(),
"Aquest prestec ja ha estat renovat o no admet renovacio.");
}
// Regla 2: vencut
if (prestec.estaVencutA(avui)) {
throw new RenovacioNoPermesaException(id, prestec.getEstat(),
"El prestec va vencer el %s. Retorna'l i torna a prestar-lo."
.formatted(prestec.getDataVenciment()));
}
// Regla 3: reserves d'altri
long pendents = reserves.comptarPendentsDe(prestec.getIsbn());
if (pendents > 0) {
throw new MaterialAmbReservesException(prestec.getIsbn(), pendents);
}
LocalDate anterior = prestec.getDataVenciment();
int diesEfectius = (dies != null) ? dies : prestec.diesEstandardDelMaterial();
prestec.renovar(diesEfectius); // valida i transita l'estat
return new ResultatRenovacio(prestec, anterior);
}Gestors:
@ExceptionHandler(RenovacioNoPermesaException.class)
public ProblemDetail renovacioNoPermesa(RenovacioNoPermesaException e, HttpServletRequest p) {
ProblemDetail d = problema(HttpStatus.CONFLICT, "renovacio-no-permesa",
"Renovacio no permesa", e.getMessage(), p);
d.setProperty("idPrestec", e.getIdPrestec());
d.setProperty("estatActual", e.getEstat().name());
return d;
}
@ExceptionHandler(MaterialAmbReservesException.class)
public ProblemDetail ambReserves(MaterialAmbReservesException e, HttpServletRequest p) {
ProblemDetail d = problema(HttpStatus.CONFLICT, "material-amb-reserves",
"Material amb reserves pendents",
"No es pot renovar: hi ha %d empleat(s) esperant aquest material."
.formatted(e.getReservesPendents()), p);
d.setProperty("reservesPendents", e.getReservesPendents());
return d;
}Proves dels cinc escenaris:
@WebMvcTest(PrestecController.class)
class RenovacioControllerTest {
@Autowired MockMvc mvc;
@MockitoBean GestionarPrestecs gestor;
@Test
void renovaAmbElsDiesIndicats() throws Exception {
var prestec = unPrestec(42L).ambVenciment(LocalDate.of(2026, 8, 20));
when(gestor.renovar(42L, 10)).thenReturn(
new ResultatRenovacio(prestec.renovatFinsA(LocalDate.of(2026, 8, 30)),
LocalDate.of(2026, 8, 20)));
mvc.perform(post("/api/prestecs/42/renovacio")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"dies":10}"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.vencimentAnterior").value("2026-08-20"))
.andExpect(jsonPath("$.vencimentNou").value("2026-08-30"))
.andExpect(jsonPath("$.diesAfegits").value(10))
.andExpect(jsonPath("$.potRenovarseDeNou").value(false));
}
@Test
void renovaSenseCosAmbLaDuradaEstandard() throws Exception {
when(gestor.renovar(42L, null)).thenReturn(unaRenovacioDe(15));
mvc.perform(post("/api/prestecs/42/renovacio")) // sense cos
.andExpect(status().isOk())
.andExpect(jsonPath("$.diesAfegits").value(15));
}
@Test
void retorna404SiElPrestecNoExisteix() throws Exception {
when(gestor.renovar(eq(9999L), any())).thenThrow(new PrestecNoTrobatException(9999L));
mvc.perform(post("/api/prestecs/9999/renovacio"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.type").value(endsWith("/errors/recurs-no-trobat")));
}
@Test
void retorna409SiJaVaSerRenovat() throws Exception {
when(gestor.renovar(eq(42L), any())).thenThrow(
new RenovacioNoPermesaException(42L, EstatPrestec.RENOVAT,
"Aquest prestec ja ha estat renovat o no admet renovacio."));
mvc.perform(post("/api/prestecs/42/renovacio"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.estatActual").value("RENOVAT"))
.andExpect(jsonPath("$.detail").value(containsString("ja ha estat renovat")));
}
@Test
void retorna409SiHiHaReservesPendents() throws Exception {
when(gestor.renovar(eq(42L), any()))
.thenThrow(new MaterialAmbReservesException(Isbn.de("978-0000000001"), 2));
mvc.perform(post("/api/prestecs/42/renovacio"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.reservesPendents").value(2));
}
@ParameterizedTest
@ValueSource(ints = {0, -5, 31, 100})
void retorna400SiElsDiesEstanForaDeRang(int dies) throws Exception {
mvc.perform(post("/api/prestecs/42/renovacio")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"dies\":%d}".formatted(dies)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].camp").value("dies"));
verifyNoInteractions(gestor); // ni tan sols s'ha cridat el cas d'us
}
}Solució 2
Validació creuada amb anotació de classe:
@Documented
@Constraint(validatedBy = ValidadorRangAnys.class)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface RangAnysValid {
String message() default "anyDesDe no pot ser posterior a anyFinsA";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class ValidadorRangAnys implements ConstraintValidator<RangAnysValid, CercaRequest> {
@Override
public boolean isValid(CercaRequest r, ConstraintValidatorContext ctx) {
if (r.anyDesDe() == null || r.anyFinsA() == null) return true; // @NotNull no es cosa nostra
if (r.anyDesDe() <= r.anyFinsA()) return true;
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(
"anyDesDe (%d) no pot ser posterior a anyFinsA (%d)"
.formatted(r.anyDesDe(), r.anyFinsA()))
.addPropertyNode("anyDesDe") // l'error s'associa al camp correcte
.addConstraintViolation();
return false;
}
}DTO de criteris:
@RangAnysValid
public record CercaRequest(
@NotBlank(message = "El text de cerca es obligatori")
@Size(min = 2, max = 100, message = "Entre 2 i 100 caracters")
String q,
List<TipusMaterial> tipus, // repetible: ?tipus=LLIBRE&tipus=DVD
Boolean disponible,
@Min(1450) @Max(2100) Integer anyDesDe,
@Min(1450) @Max(2100) Integer anyFinsA) {
/** Normalitzacio: null → llista buida, per no repetir comprovacions aigues avall. */
public CercaRequest {
tipus = (tipus == null) ? List.of() : List.copyOf(tipus);
}
public CriteriCerca aDomini() {
return CriteriCerca.builder()
.text(q)
.tipus(tipus)
.nomesDisponibles(Boolean.TRUE.equals(disponible))
.entre(anyDesDe, anyFinsA)
.construir();
}
}Resposta amb facetes:
public record ResultatCercaResponse(
List<MaterialResponse> resultats,
PageResponse.MetadadesPagina pagina,
Map<String, Long> facetesPerTipus,
long totalGlobal,
String consulta) {
public static ResultatCercaResponse desDe(Page<Material> pagina,
Map<TipusMaterial, Long> facetes,
String consulta) {
return new ResultatCercaResponse(
pagina.getContent().stream().map(MaterialResponse::desDe).toList(),
new PageResponse.MetadadesPagina(pagina.getNumber(), pagina.getSize(),
pagina.getTotalElements(), pagina.getTotalPages(),
pagina.isFirst(), pagina.isLast()),
facetes.entrySet().stream()
.collect(Collectors.toMap(e -> e.getKey().name(), Map.Entry::getValue,
(a, b) -> a, LinkedHashMap::new)),
facetes.values().stream().mapToLong(Long::longValue).sum(),
consulta);
}
}Controlador:
@GetMapping("/cerca")
@Operation(summary = "Cerca avancada amb facetes per tipus de material")
public ResultatCercaResponse cercar(
@Valid CercaRequest criteri,
@PageableDefault(size = 20, sort = "titol") Pageable paginacio) {
// Defensa en profunditat: encara que max-page-size estigui configurat, no te'n fiis
if (paginacio.getPageSize() > 50) {
paginacio = PageRequest.of(paginacio.getPageNumber(), 50, paginacio.getSort());
}
CriteriCerca domini = criteri.aDomini();
Page<Material> pagina = cataleg.cercar(domini, paginacio);
Map<TipusMaterial, Long> facetes = cataleg.comptarPerTipus(domini); // consulta d'agregacio
return ResultatCercaResponse.desDe(pagina, facetes, criteri.q());
}Les facetes al repositori, amb una sola consulta d'agregació en lloc de N consultes:
@Query("""
select m.tipus as tipus, count(m) as total
from Material m
where (lower(m.titol) like lower(concat('%', :text, '%'))
or lower(m.autor) like lower(concat('%', :text, '%')))
and (:nomesDisponibles = false or m.unitatsDisponibles > 0)
group by m.tipus
""")
List<FacetaTipus> comptarPerTipus(@Param("text") String text,
@Param("nomesDisponibles") boolean nomesDisponibles);Proves:
@Test
void retornaResultatsAmbFacetesPerTipus() throws Exception {
when(cataleg.cercar(any(), any())).thenReturn(unaPaginaAmb(2, "Java Eficac", "Refactoritzacio"));
when(cataleg.comptarPerTipus(any())).thenReturn(
new LinkedHashMap<>(Map.of(TipusMaterial.LLIBRE, 5L, TipusMaterial.DVD, 2L)));
mvc.perform(get("/api/materials/cerca").param("q", "java").param("tipus", "LLIBRE"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.resultats", hasSize(2)))
.andExpect(jsonPath("$.facetesPerTipus.LLIBRE").value(5))
.andExpect(jsonPath("$.totalGlobal").value(7))
.andExpect(jsonPath("$.consulta").value("java"));
}
@Test
void rebutjaConsultesMassaCurtes() throws Exception {
mvc.perform(get("/api/materials/cerca").param("q", "j"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].camp").value("q"));
}
@Test
void rebutjaUnRangDAnysInvertit() throws Exception {
mvc.perform(get("/api/materials/cerca")
.param("q", "java").param("anyDesDe", "2020").param("anyFinsA", "2010"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].camp").value("anyDesDe"))
.andExpect(jsonPath("$.errors[0].missatge").value(containsString("no pot ser posterior")));
}
@Test
void retallaLaMidaDePaginaAlMaxim() throws Exception {
mvc.perform(get("/api/materials/cerca").param("q", "java").param("size", "500"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.pagina.mida").value(lessThanOrEqualTo(50)));
}Solució 3
Magatzem de claus d'idempotència:
@Entity
@Table(name = "claus_idempotencia",
indexes = @Index(name = "idx_caducitat", columnList = "caducaEl"))
public class ClauIdempotencia {
@Id
@Column(length = 100)
private String clau;
@Column(nullable = false, length = 64)
private String empremtaPeticio; // hash del cos: detecta reutilitzacio amb dades diferents
@Enumerated(EnumType.STRING)
private EstatClau estat; // EN_CURS, COMPLETADA
private Integer codiResposta;
@Column(columnDefinition = "text")
private String cosResposta;
private String ubicacio;
private Instant creadaEl;
private Instant caducaEl;
public enum EstatClau { EN_CURS, COMPLETADA }
}L'interceptor, que embolcalla l'execució del controlador:
@Component
public class InterceptorIdempotencia implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(InterceptorIdempotencia.class);
private static final String CAPCALERA = "Idempotency-Key";
private static final Duration VIGENCIA = Duration.ofHours(24);
private final RepositoriClausIdempotencia repositori;
private final ObjectMapper json;
private final Clock rellotge;
@Override
public boolean preHandle(HttpServletRequest peticio, HttpServletResponse resposta,
Object gestor) throws IOException {
// Nomes aplica a metodes no idempotents per naturalesa
if (!"POST".equals(peticio.getMethod())) return true;
String clau = peticio.getHeader(CAPCALERA);
if (clau == null || clau.isBlank()) return true; // capcalera opcional
String empremta = empremtaDe(peticio);
// INSERT condicional: la restriccio de clau primaria resol la cursa
// entre dues peticions simultanies amb la mateixa clau. NO facis "SELECT i despres INSERT".
Optional<ClauIdempotencia> existent = repositori.reservarSiNoExisteix(
clau, empremta, Instant.now(rellotge), Instant.now(rellotge).plus(VIGENCIA));
if (existent.isEmpty()) {
// L'hem reservada nosaltres: continuem amb el processament normal
peticio.setAttribute("idempotencia.clau", clau);
return true;
}
ClauIdempotencia previa = existent.get();
// Mateixa clau, cos diferent: el client s'ha equivocat
if (!previa.getEmpremtaPeticio().equals(empremta)) {
escriureProblema(resposta, HttpStatus.UNPROCESSABLE_ENTITY,
"La clau d'idempotencia ja s'ha fet servir amb un cos diferent.");
return false;
}
if (previa.getEstat() == EstatClau.EN_CURS) {
// Una altra peticio identica s'esta processant ara mateix
resposta.setHeader(HttpHeaders.RETRY_AFTER, "2");
escriureProblema(resposta, HttpStatus.CONFLICT,
"Ja hi ha una peticio en curs amb aquesta clau d'idempotencia.");
return false;
}
// COMPLETADA: reproduim la resposta original sense executar res
log.info("Reproduint resposta idempotent per a la clau {}", clau);
resposta.setStatus(previa.getCodiResposta());
resposta.setContentType(MediaType.APPLICATION_JSON_VALUE);
resposta.setHeader("Idempotent-Replay", "true");
if (previa.getUbicacio() != null) {
resposta.setHeader(HttpHeaders.LOCATION, previa.getUbicacio());
}
resposta.getWriter().write(previa.getCosResposta());
return false; // NO es crida el controlador
}
private String empremtaDe(HttpServletRequest peticio) throws IOException {
// Requereix ContentCachingRequestWrapper: el cos nomes es pot llegir una vegada
byte[] cos = ((ContentCachingRequestWrapper) peticio).getContentAsByteArray();
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(cos));
}
}Desat de la resposta, en un filtre que ho embolcalla tot:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class FiltreIdempotencia extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest peticio, HttpServletResponse resposta,
FilterChain cadena) throws ServletException, IOException {
var peticioCacheada = new ContentCachingRequestWrapper(peticio);
var respostaCacheada = new ContentCachingResponseWrapper(resposta);
try {
cadena.doFilter(peticioCacheada, respostaCacheada);
String clau = (String) peticio.getAttribute("idempotencia.clau");
if (clau != null) {
int estat = respostaCacheada.getStatus();
if (estat >= 200 && estat < 300) {
// Nomes es memoritza l'exit: un 500 ha de poder reintentar-se
repositori.completar(clau, estat,
new String(respostaCacheada.getContentAsByteArray(), UTF_8),
respostaCacheada.getHeader(HttpHeaders.LOCATION));
} else {
repositori.alliberar(clau); // allibera la clau per a un reintent legitim
}
}
} finally {
respostaCacheada.copyBodyToResponse(); // IMPRESCINDIBLE: sense aixo no arriba res al client
}
}
}La reserva atòmica, que és el punt delicat:
@Repository
public class RepositoriClausIdempotencia {
private final JdbcTemplate jdbc;
/**
* Retorna Optional.empty() si la clau s'ha reservat ara (som els primers),
* o la fila existent si ja hi era.
*
* ON CONFLICT DO NOTHING fa l'operacio ATOMICA a la base de dades:
* dues peticions simultanies amb la mateixa clau no poden reservar-la totes dues.
* Un "SELECT i despres INSERT" en Java tindria una finestra de cursa.
*/
public Optional<ClauIdempotencia> reservarSiNoExisteix(String clau, String empremta,
Instant ara, Instant caduca) {
int files = jdbc.update("""
insert into claus_idempotencia
(clau, empremta_peticio, estat, creada_el, caduca_el)
values (?, ?, 'EN_CURS', ?, ?)
on conflict (clau) do nothing
""", clau, empremta, Timestamp.from(ara), Timestamp.from(caduca));
if (files == 1) return Optional.empty(); // l'hem reservada nosaltres
return jdbc.query("select * from claus_idempotencia where clau = ?",
this::mapejar, clau).stream().findFirst();
}
}Neteja periòdica:
@Scheduled(cron = "0 0 3 * * *") // cada dia a les 3:00
@Transactional
public void purgarClausCaducades() {
int esborrades = repositori.esborrarCaducadesAbansDe(Instant.now(rellotge));
log.info("Claus d'idempotencia purgades: {}", esborrades);
}Implicacions de concurrència, que és el que avalua l'exercici:
| Escenari | Sense protecció | Amb aquesta implementació |
|---|---|---|
| Reintent després d'un timeout de xarxa | Dos préstecs creats | Es retorna la resposta original |
| Dues peticions simultànies, mateixa clau | Dos préstecs | Una processa, l'altra rep 409 |
| Mateixa clau, cos diferent | Comportament indefinit | 422 explícit |
| Fallada del servidor a mitges | Clau bloquejada per sempre | alliberar() al filtre + caducitat |
| Dues instàncies de l'aplicació | La memòria local no serveix | La base de dades és el punt d'acord |
La decisió clau és fer servir INSERT ... ON CONFLICT DO NOTHING en lloc de comprovar i després inserir. Entre el SELECT i l'INSERT de la versió ingènua hi ha una finestra per la qual una altra petició pot colar-se, i amb dues instàncies de l'aplicació (escalat horitzontal, 12-06) aquesta finestra s'obre constantment. L'atomicitat ha d'estar a la base de dades, que és l'únic recurs compartit.
Ús des del client:
CLAU=$(uuidgen)
curl -i -X POST http://localhost:8080/api/prestecs \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $CLAU" \
-d '{"isbn":"978-0000000001","idEmpleat":1,"dies":15}'
# → 201 Created, Location: /api/prestecs/42
# Reintent amb la mateixa clau
curl -i -X POST … -H "Idempotency-Key: $CLAU" -d '{…}'
# → 201 Created, Location: /api/prestecs/42, Idempotent-Replay: true
# (i NO s'ha creat un segon prestec)Conclusió
BiblioTech té API.
Entens com funciona una aplicació web en Java de debò, no com una caixa negra: la petició HTTP és text sobre un socket, el servidor incrustat de Tomcat l'analitza, el DispatcherServlet actua de front controller, els HandlerMapping encaminen, els ArgumentResolver converteixen els paràmetres, els HttpMessageConverter serialitzen amb Jackson i els HandlerExceptionResolver tradueixen excepcions. I sobretot has vist la taula que ho posa tot en perspectiva: cada peça de Spring MVC correspon a alguna cosa que tu vas escriure a mà al ServidorCataleg del mòdul 9. No estàs fent servir màgia; estàs delegant feina que ja saps fer.
Dissenyes REST amb criteri: recursos com a substantius, verbs amb la seva semàntica de seguretat i idempotència —inclosa la raó pràctica per la qual la idempotència importa, que és poder reintentar sense duplicar—, URI amb jerarquia de màxim dos nivells, el cas difícil de les accions que no són CRUD resolt amb subrecursos (POST /api/prestecs/42/devolucio), i la taula de codis d'estat amb els cinc errors clàssics que cal evitar. Amb el nivell 2 de Richardson com a objectiu honest i realista.
Escrius controladors que només tradueixen HTTP: reben, deleguen en el cas d'ús i retornen. Amb @PathVariable, @RequestParam i @RequestBody; amb convertidors propis que fan que el controlador treballi amb Isbn i no amb String; amb ResponseEntity únicament en els tres casos que la justifiquen —el Location d'un 201, el codi que depèn del resultat, i les capçaleres de memòria cau amb ETag—; i amb DTO record d'entrada i de sortida, l'asimetria dels quals és una defensa estructural: el client no pot enviar id, version ni estat perquè l'objecte no els té.
Valides al lloc correcte: jakarta.validation per a la forma —inclòs un validador propi d'ISBN-13 que comprova el dígit de control i dona missatges específics— i el domini per a les regles de negoci, sense confondre les dues coses. I gestiones els errors globalment amb @RestControllerAdvice i el format RFC 7807 que Spring 6 porta de sèrie, amb la taula completa de traducció de la jerarquia BiblioTechException del mòdul 6, la llista de camps no vàlids que estalvia tres viatges al client, el traceId del MDC d'11-07 que uneix la resposta amb el log, i el criteri de nivell de registre que evita que un 404 dispari una alerta.
Pagines amb Pageable i un DTO propi que no exposa l'estructura interna de Page, amb la mida màxima limitada i l'avís sobre la degradació de l'OFFSET. Filtres amb un objecte de criteris i Specification, que és el patró Especificació de 12-02 i genera consultes parametritzades. Tens l'API completa documentada endpoint per endpoint, amb exemples curl i les seves respostes reals, documentació automàtica amb springdoc-openapi i Swagger UI, i CORS configurat amb llista explícita d'orígens i l'aclariment que CORS protegeix el navegador, no la teva API.
Vas posar la transacció on va —al cas d'ús, mai al controlador— amb les tres raons concretes, i open-in-view a false. I proves la capa web en els dos nivells amb propòsits diferents: @WebMvcTest amb MockMvc i @MockitoBean per a mapatge, validació, codis i forma del JSON, ràpid i exhaustiu; i @SpringBootTest(RANDOM_PORT) amb TestRestTemplate per als fluxos complets amb persistència real. Moltes de les primeres, poques de les segones.
Vas triar la interfície d'usuari amb criteri: l'API REST com a peça central perquè ha de servir la CLI, l'app mòbil i la integració amb recursos humans, i Thymeleaf per a la interfície dels empleats perquè l'equip és de Java i la interactivitat requerida és baixa — sense tancar la porta a un front-end separat més endavant.
I vas tancar el cercle de 10-06 amb els fils virtuals: una línia de configuració, spring.threads.virtual.enabled=true, que converteix cada petició en un fil virtual de Java 21 i multiplica la concurrència sense tocar ni una sola línia del codi de BiblioTech. Amb les cinc advertències que cal conèixer abans d'activar-los, especialment que synchronized els ancora i que el pool de connexions continua sent el límit real.
BiblioTech té ara arquitectura, patrons, CLI i API. I una pregunta incòmoda: funciona de debò?
Hi ha quaranta-una proves heretades del mòdul 11 i unes quantes de noves d'aquesta lliçó. No hi ha mesura de cobertura, ni idea de si aquestes proves comproven alguna cosa o només executen codi. Les proves de repositori corren sobre H2, que no és la base de dades de producció. Ningú no ha executat una anàlisi estàtica. No hi ha integració contínua: si el Diego Alonso trenca el càlcul de multes, ningú no se n'assabenta fins que un empleat es queixa.
La lliçó següent converteix «tinc proves» en «tinc una estratègia de qualitat»: què provar a cada nivell i amb quins temps objectiu, Testcontainers amb PostgreSQL real perquè H2 menteix, cobertura amb JaCoCo i la seva interpretació honesta, proves de mutació amb PIT com l'única mesura que avalua les teves assercions, anàlisi estàtica, refactorització segura, TDD desenvolupat pas a pas amb una regla nova de BiblioTech, revisió de codi, i integració contínua amb GitHub Actions que trenca el PR quan alguna cosa falla.
Curs de Programació en Java
Mòdul 1: Introducció a Java
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
