Aquesta lliçó tanca el mòdul, i salda tres deutes que porten mòduls sencers esperant.

El primer es va contraure a 09-06. Allà, per llegir la resposta d'una API de metadades de llibres, vas escriure un analitzador de JSON amb indexOf i substring. Es va dir textualment que era un apedaçament didàctic i que es resoldria aquí. Aquell codi es trenca amb el primer caràcter d'escapada, amb el primer objecte imbricat i amb el primer camp que l'API decideixi afegir.

El segon ve de 03-09 i es va agreujar a 11-03: les entitats de BiblioTech tenen cinquanta línies de getters, equals, hashCode i toString escrits a mà. Codi que no aporta res, que cal mantenir i en el qual és fàcil equivocar-se.

El tercer és de 06-07: el logging de BiblioTech és java.util.logging, triat aleshores per no afegir dependències, amb la limitació que ja s'hi va assenyalar. I mvn dependency:tree t'ha ensenyat tres vegades que Spring Boot ja et va portar SLF4J i Logback sense que els demanessis.

Els tres tenen una cosa en comú, i és el que distingeix aquesta lliçó de les anteriors del mòdul: són llibreries, no frameworks. Tornant a 11-01, tu les crides a elles. No inverteixen el control, no imposen arquitectura, i treure-les és molt més barat que treure Spring. Són la caixa d'eines, no l'edifici.

En acabar dominaràs Jackson i hauràs reescrit el ClientMetadades; coneixeràs Lombok amb una valoració honesta dels seus problemes; hauràs migrat BiblioTech a SLF4J amb Logback configurat; i tindràs el panorama de les llibreries que convé conèixer i per a què serveix cadascuna.

Contingut

  1. Què és JSON i la seva correspondència amb Java
  2. Jackson: l'ObjectMapper
  3. Per què se'n crea un i es reutilitza
  4. Serialitzar: d'objecte a JSON
  5. Deserialitzar: de JSON a objecte
  6. Mapejar POJOs i record
  7. Les anotacions essencials de Jackson
  8. @JsonIgnoreProperties: la imprescindible
  9. Tipus genèrics amb TypeReference
  10. Mòduls: JavaTimeModule i les dates
  11. L'arbre JsonNode per a JSON dinàmic
  12. Serialitzadors i deserialitzadors propis
  13. BiblioTech: la reescriptura del ClientMetadades
  14. Seguretat: deserialització polimòrfica i gadgets
  15. Gson i JSON-B, esmentats
  16. Lombok: què és i com funciona
  17. Les anotacions de Lombok
  18. @Builder i @RequiredArgsConstructor
  19. Configuració de l'IDE i lombok.config
  20. Lombok: la valoració honesta
  21. @Data en entitats JPA: un bug real
  22. Els record enfront de Lombok
  23. SLF4J: façana enfront d'implementació
  24. Per què es programa contra SLF4J
  25. El patró LoggerFactory.getLogger
  26. Logging parametritzat amb {}
  27. Nivells i la seva correspondència amb java.util.logging
  28. Configurar Logback
  29. MDC: correlacionar peticions
  30. Logging estructurat en JSON
  31. BiblioTech: la migració a SLF4J
  32. Log4j2, esmentat
  33. Panorama final: altres llibreries que convé conèixer
  34. Errors comuns i consells
  35. Exercicis

  1. Què és JSON i la seva correspondència amb Java

JSON (JavaScript Object Notation) és el format d'intercanvi de dades dominant. És text, és llegible, i té només sis tipus.

{
  "isbn": "978-0000000001",
  "titol": "Java Eficac",
  "autor": "Joshua Bloch",
  "anyPublicacio": 2018,
  "disponible": true,
  "categories": ["Java", "Bones practiques"],
  "editorial": {
    "nom": "Addison-Wesley",
    "pais": "EUA"
  },
  "dataAlta": "2026-01-15",
  "valoracio": null
}

La correspondència amb Java:

JSON Java Notes
object {...} Classe, record, Map<String, Object> El cas normal
array [...] List, Set, array
string "..." String, enum, LocalDate, UUID Amb conversió
number int, long, double, BigDecimal BigDecimal per a diners
true / false boolean, Boolean
null null, Optional.empty()

El que JSON no té, i que causa la majoria dels problemes:

Falta a JSON Conseqüència
Tipus de data Es representen com a cadena. Cal acordar el format (ISO-8601, com a 10-05)
Enters enfront de decimals 1 es pot llegir com a int o com a double; 0.1 + 0.2 continua sense ser 0.3
Comentaris No es poden documentar els camps
Referències cícliques Un graf amb cicles provoca recursió infinita en serialitzar
Tipus declarats Res no diu si {"nom": "x"} és un Empleat o una Editorial

Aquesta última fila és l'arrel del problema de seguretat de l'apartat 14.

  1. Jackson: l'ObjectMapper

Jackson és la llibreria de JSON estàndard en Java. Ja la tens: spring-boot-starter-web la porta, i mvn dependency:tree te la va mostrar a 11-05. Per afegir-la explícitament:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <!-- sense version: la gestiona el BOM de Spring Boot (11-05) -->
</dependency>

<!-- Suport per a java.time (apartat 10) -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

Jackson té tres nivells d'API:

Nivell Classe principal Quan
Enllaç de dades ObjectMapper El 95 % dels casos: objecte ↔ JSON
Arbre JsonNode JSON dinàmic o d'estructura desconeguda
Streaming JsonParser, JsonGenerator Documents enormes, màxim rendiment

  1. Per què se'n crea un i es reutilitza

Abans del primer exemple, una regla que evita un problema de rendiment real:

Crea un ObjectMapper i reutilitza'l. Mai un per crida.

Un ObjectMapper és car de construir: en processar un tipus per primera vegada, l'introspecciona per reflexió (10-03) —camps, getters, anotacions— i desa el resultat en memòria cau. Aquesta memòria cau és el que fa que la segona serialització del mateix tipus sigui rapidíssima.

// MALAMENT: crea un mapper a cada crida. Llenca la memoria cau cada vegada.
public String aJson(Material material) {
    return new ObjectMapper().writeValueAsString(material);   // lent i amb brossa
}

// BE: un de sol, compartit
private static final ObjectMapper MAPPER = new ObjectMapper();

public String aJson(Material material) {
    return MAPPER.writeValueAsString(material);
}

I hi ha un detall que ho fa possible: ObjectMapper és segur per fer-lo servir des de diversos fils, sempre que no el reconfiguris després de començar a fer-lo servir. Un singleton està perfectament bé.

És exactament el mateix raonament que vas fer a 09-06 amb HttpClient i a 10-07 amb Pattern i DateTimeFormatter: objectes cars de crear, segurs davant de la concurrència, es construeixen una vegada.

Amb Spring, això ho resol el contenidor:

@Service
public class ExportadorCataleg {

    private final ObjectMapper mapper;   // Spring injecta el seu, ja configurat

    public ExportadorCataleg(ObjectMapper mapper) {
        this.mapper = mapper;
    }
}

Spring Boot autoconfigura un ObjectMapper (11-02) amb els mòduls registrats i una configuració assenyada. Fes-lo servir en comptes de crear el teu.

  1. Serialitzar: d'objecte a JSON

package com.nexussoftware.bibliotech.persistencia;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;

public class ExportadorJson {

    private final ObjectMapper mapper;

    public ExportadorJson(ObjectMapper mapper) { this.mapper = mapper; }

    public String exportar(Material material) {
        try {
            return mapper.writeValueAsString(material);
        } catch (JsonProcessingException e) {
            // Estrategia per capes del modul 6: embolcallar, conservar la causa
            throw new ExportacioException("No s'ha pogut serialitzar " + material.getIsbn(), e);
        }
    }

    public String exportarLlegible(Material material) {
        try {
            return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(material);
        } catch (JsonProcessingException e) {
            throw new ExportacioException("No s'ha pogut serialitzar", e);
        }
    }

    public void exportarAFitxer(List<Material> cataleg, Path desti) throws IOException {
        mapper.writeValue(desti.toFile(), cataleg);         // sense carregar-ho tot en memoria
    }
}

Resultat de writeValueAsString:

{"isbn":"978-0000000001","titol":"Java Eficac","autor":"Joshua Bloch","exemplarsDisponibles":3}

I de writerWithDefaultPrettyPrinter:

{
  "isbn" : "978-0000000001",
  "titol" : "Java Eficac",
  "autor" : "Joshua Bloch",
  "exemplarsDisponibles" : 3
}

Consell: fes servir el format llegible només per depurar o per a fitxers que llegiran persones. En una API produeix entre un 20 % i un 30 % més de bytes per cada petició.

Les destinacions possibles:

Mètode Destinació
writeValueAsString(o) String
writeValueAsBytes(o) byte[]
writeValue(File, o) Fitxer
writeValue(OutputStream, o) Flux (mòdul 7)
writeValue(Writer, o) Writer

Per a col·leccions o fitxers grans, escriure directament al flux evita construir una cadena gegant en memòria — el que 10-07 anomenava una assignació innecessària al monticle.

  1. Deserialitzar: de JSON a objecte

public Material importar(String json) {
    try {
        return mapper.readValue(json, Llibre.class);
    } catch (JsonProcessingException e) {
        throw new ImportacioException("JSON de material invalid", e);
    }
}

Fonts possibles:

Llibre desDeCadena  = mapper.readValue(json, Llibre.class);
Llibre desDeFitxer  = mapper.readValue(Path.of("llibre.json").toFile(), Llibre.class);
Llibre desDeFlux    = mapper.readValue(entrada, Llibre.class);
Llibre desDUrl      = mapper.readValue(new URL("https://..."), Llibre.class);

Què necessita Jackson per deserialitzar en una classe normal:

  1. Un constructor sense arguments (o un anotat amb @JsonCreator).
  2. Setters o camps accessibles.

Si no n'hi ha:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Cannot construct instance of `com.nexussoftware.bibliotech.domini.Llibre`
(no Creators, like default constructor, exist)

És el mateix requisit que imposa JPA (11-03), i per la mateixa raó: instanciació per reflexió.

  1. Mapejar POJOs i record

Amb una classe normal:

public class MetadadesLlibre {

    private String isbn;
    private String titol;
    private String autor;
    private int anyPublicacio;

    public MetadadesLlibre() { }   // exigit per Jackson

    // getters i setters...
}

Amb un record (04-07), i aquí hi ha una bona notícia:

public record MetadadesLlibre(String isbn, String titol, String autor, int anyPublicacio) { }

Jackson 2.12 i superiors admeten record de forma nativa. No cal cap anotació: fa servir el constructor canònic i els accessors. I com que el record és immutable, obtens un objecte que no pot quedar a mig construir.

Els record són ideals com a DTO (objectes de transferència de dades): representen la forma del JSON, són immutables, tenen equals i toString gratis, i no porten lògica. A 11-03 vam veure que no poden ser entitats JPA; aquí troben el seu lloc natural.

Un detall per deserialitzar record amb classes normals imbricades o quan falten noms de paràmetre: el compilador els ha de conservar. Per això a 11-05 es va configurar <parameters>true</parameters> al maven-compiler-plugin. Amb Spring Boot ja ve posat.

Imbricació:

public record MetadadesLlibre(
        String isbn,
        String titol,
        Autor autor,                    // objecte imbricat
        List<String> categories,        // array
        LocalDate dataPublicacio) {     // data ISO (apartat 10)

    public record Autor(String nom, String pais) { }
}
{
  "isbn": "978-0000000001",
  "titol": "Java Eficac",
  "autor": { "nom": "Joshua Bloch", "pais": "EUA" },
  "categories": ["Java", "Bones practiques"],
  "dataPublicacio": "2018-01-06"
}

Jackson resol la imbricació i les col·leccions recursivament, sense configuració.

  1. Les anotacions essencials de Jackson

Anotació Què fa Exemple
@JsonProperty("nom") Canvia el nom al JSON @JsonProperty("isbn_13")
@JsonIgnore Exclou el camp Contrasenyes, camps interns
@JsonInclude(NON_NULL) Omet els nuls Redueix la mida
@JsonFormat Format de dates i números pattern = "dd/MM/yyyy"
@JsonAlias({"a","b"}) Noms alternatius en llegir Compatibilitat amb versions
@JsonCreator Marca el constructor de deserialització Objectes immutables
@JsonIgnoreProperties(ignoreUnknown=true) Ignora camps desconeguts Imprescindible (apartat 8)
@JsonPropertyOrder Ordre dels camps Llegibilitat
@JsonAnySetter / @JsonAnyGetter Camps dinàmics en un Map Extensions
@JsonUnwrapped Aplana un objecte imbricat
@JsonSerialize / @JsonDeserialize Serialitzador propi Apartat 12

Exemple complet, amb un cas real de BiblioTech:

package com.nexussoftware.bibliotech.xarxa;

import com.fasterxml.jackson.annotation.*;
import java.time.LocalDate;
import java.util.List;

@JsonIgnoreProperties(ignoreUnknown = true)      // imprescindible: apartat 8
@JsonInclude(JsonInclude.Include.NON_NULL)       // no serialitzar nuls
public record RespostaMetadades(

        @JsonProperty("isbn_13")                 // l'API fa servir snake_case
        String isbn,

        @JsonAlias({"title", "book_title"})      // l'API va canviar el nom a la v2
        String titol,

        @JsonProperty("authors")
        List<String> autors,

        @JsonProperty("publish_date")
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
        LocalDate dataPublicacio,

        @JsonProperty("number_of_pages")
        Integer pagines) {
}

@JsonAlias mereix una nota: accepta diversos noms en llegir però escriu sempre amb el nom principal. És l'eina per sobreviure que una API reanomeni un camp sense trencar la compatibilitat amb les respostes antigues.

I @JsonIgnore té un ús de seguretat evident:

public class UsuariDto {
    private String correu;

    @JsonIgnore
    private String hashContrasenya;   // MAI no ha de sortir en una resposta
}

Encara que la pràctica més segura no és confiar en una anotació, sinó que el DTO de sortida no tingui ni tan sols aquell camp. Es tracta a 12-07.

  1. @JsonIgnoreProperties: la imprescindible

Mereix apartat propi perquè és l'anotació que més problemes de producció evita.

Per defecte, si el JSON porta un camp que la teva classe no té, Jackson falla:

public record MetadadesLlibre(String isbn, String titol) { }
{
  "isbn": "978-0000000001",
  "titol": "Java Eficac",
  "idioma": "ca"
}
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:
Unrecognized field "idioma" (class MetadadesLlibre), not marked as ignorable

Pensa en el que significa: el dia que l'API externa afegeixi un camp nou —una cosa perfectament compatible des del seu punt de vista— la teva aplicació deixa de funcionar. I aquesta API pot desplegar un dimarts al matí sense avisar-te.

La solució:

@JsonIgnoreProperties(ignoreUnknown = true)
public record MetadadesLlibre(String isbn, String titol) { }

O globalment:

mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

O a Spring Boot, que ja ho fa per tu:

spring:
  jackson:
    deserialization:
      fail-on-unknown-properties: false   # valor per defecte a Spring Boot

Regla: tot DTO que rebi dades d'un sistema extern ha de portar @JsonIgnoreProperties(ignoreUnknown = true).

El matís professional: per a JSON propi —la teva configuració interna, un fitxer que tu generes— pot tenir sentit fallar davant de camps desconeguts, perquè un camp inesperat indica un error d'escriptura. La distinció és "controlo jo l'emissor?".

  1. Tipus genèrics amb TypeReference

I aquí es cobra l'esborrat de tipus de 10-01.

// NO FUNCIONA com esperes
List<MetadadesLlibre> llista = mapper.readValue(json, List.class);
// -> retorna List<LinkedHashMap>, i esclata en fer-lo servir:
// ClassCastException: LinkedHashMap cannot be cast to MetadadesLlibre

Per què: per l'esborrat de tipus, List<MetadadesLlibre>.class no existeix. En execució només hi ha List.class, sense informació sobre el paràmetre. Jackson no pot saber què construir a dins i crea mapes genèrics.

La solució és TypeReference, i fa servir exactament el truc que vas aprendre a 10-01:

List<MetadadesLlibre> llista = mapper.readValue(json,
        new TypeReference<List<MetadadesLlibre>>() { });

Fixa't en les claus { } al final: creen una subclasse anònima (04-04) de TypeReference. I la informació genèrica d'una superclasse sí que es conserva al bytecode, així que Jackson la pot llegir per reflexió amb getGenericSuperclass(). És el mateix truc que vas estudiar a 10-01 en parlar de què sobreviu a l'esborrat.

Casos habituals:

// Llista
List<Material> materials = mapper.readValue(json, new TypeReference<>() { });

// Mapa
Map<String, List<Prestec>> perEmpleat = mapper.readValue(json, new TypeReference<>() { });

// Generic propi: el Resultat<T> de 10-01
Resultat<Material> resultat = mapper.readValue(json, new TypeReference<Resultat<Material>>() { });

Des de Java 10, l'operador diamant funciona a les subclasses anònimes, així que new TypeReference<>() { } n'hi ha prou quan el tipus s'infereix de la destinació.

Alternativa amb JavaType, útil quan el tipus es decideix en execució:

JavaType tipus = mapper.getTypeFactory().constructCollectionType(List.class, Material.class);
List<Material> materials = mapper.readValue(json, tipus);

  1. Mòduls: JavaTimeModule i les dates

Jackson és extensible mitjançant mòduls. L'imprescindible és el de java.time, perquè sense ell:

com.fasterxml.jackson.databind.exc.InvalidDefinitionException:
Java 8 date/time type `java.time.LocalDate` not supported by default:
add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);   // important!

O, millor, en una sola línia que registra tots els mòduls del classpath:

ObjectMapper mapper = JsonMapper.builder()
        .findAndAddModules()
        .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
        .build();

Aquesta segona línia, WRITE_DATES_AS_TIMESTAMPS, importa molt. Comparació amb la dataPublicacio d'un Instant:

// Activat (valor per defecte de Jackson): nombre de segons des de 1970
{"instantRegistre": 1774000800.000000000}

// Desactivat: ISO-8601, llegible i interoperable
{"instantRegistre": "2026-03-20T09:00:00Z"}

La segona forma és clarament millor: qualsevol sistema del món l'entén, és llegible en un log i és el que 10-05 va establir com a estàndard per a BiblioTech als CSV. Desactiva'l sempre.

Amb Spring Boot ja ve desactivat, i pots verificar-ho:

spring:
  jackson:
    serialization:
      write-dates-as-timestamps: false
    time-zone: Europe/Madrid
    default-property-inclusion: non_null

Mòduls habituals:

Mòdul Aporta
jackson-datatype-jsr310 java.time
jackson-module-parameter-names Noms de paràmetres per a constructors
jackson-datatype-jdk8 Optional
jackson-dataformat-yaml Llegir i escriure YAML amb la mateixa API
jackson-dataformat-csv CSV (apartat 33)
jackson-dataformat-xml XML

El d'Optional és interessant: sense ell, un Optional<String> es serialitza com {"present":true}, que no és el que vols. Amb ell, es serialitza com el valor o com a null. Tot i així, la recomanació general és no fer servir Optional als DTO: fes servir el tipus directament i deixa que sigui null al JSON. Optional està pensat per a valors de retorn, no per a camps.

I aquest jackson-dataformat-csv mereix que el vegis ara, perquè salda de passada el deute de 07-07:

CsvMapper mapper = new CsvMapper();
CsvSchema esquema = mapper.schemaFor(MaterialCsv.class).withHeader().withColumnSeparator(';');

// Escriure
mapper.writer(esquema).writeValue(fitxer, materials);

// Llegir
List<MaterialCsv> materials = mapper.readerFor(MaterialCsv.class)
        .with(esquema).<MaterialCsv>readValues(fitxer).readAll();

Aquell LectorCsv de setanta línies que vas escriure a mà a 07-07 —amb els seus casos límit de cometes i salts de línia incrustats que mai no va arribar a cobrir— cap ara en tres línies, amb la mateixa llibreria que ja tens.

  1. L'arbre JsonNode per a JSON dinàmic

Quan no coneixes l'estructura per endavant, o només necessites un camp d'una resposta enorme:

JsonNode arrel = mapper.readTree(json);

String titol = arrel.path("titol").asText();
int any      = arrel.path("anyPublicacio").asInt(0);            // 0 si falta
String pais  = arrel.path("editorial").path("pais").asText("Desconegut");

// Recorrer un array
for (JsonNode categoria : arrel.path("categories")) {
    System.out.println(categoria.asText());
}

// Comprovar existencia
if (arrel.has("valoracio") && !arrel.get("valoracio").isNull()) {
    double valoracio = arrel.get("valoracio").asDouble();
}

// Construir JSON a ma
ObjectNode nou = mapper.createObjectNode();
nou.put("isbn", "978-0000000001");
nou.put("titol", "Java Eficac");
nou.putArray("categories").add("Java").add("Bones practiques");
String json = mapper.writeValueAsString(nou);

Detall important: path() enfront de get().

Mètode Si el camp no existeix
get("x") Retorna nullNullPointerException en encadenar
path("x") Retorna un node "absent" → es pot encadenar amb seguretat

Fes servir path() llevat que necessitis distingir explícitament "no hi és" de "hi és i és null".

Quan fer servir l'arbre enfront dels objectes:

JsonNode (arbre) Classes o record
Estructura coneguda
Estructura variable
Només un camp d'un JSON enorme
Seguretat de tipus Cap Total
Llegibilitat Baixa Alta
Refactoritzable per l'IDE No

Prefereix classes o record sempre que puguis. L'arbre converteix errors de compilació en errors d'execució.

  1. Serialitzadors i deserialitzadors propis

Quan un tipus necessita un tractament especial. El cas de BiblioTech: el record Isbn d'11-03, que en JSON ha de ser una cadena plana, no un objecte.

// Sense serialitzador propi: {"isbn": {"valor": "978-0000000001"}}
// Amb serialitzador propi: {"isbn": "978-0000000001"}
package com.nexussoftware.bibliotech.persistencia;

import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import java.io.IOException;

public class SerialitzadorIsbn extends JsonSerializer<Isbn> {
    @Override
    public void serialize(Isbn isbn, JsonGenerator gen, SerializerProvider sp)
            throws IOException {
        gen.writeString(isbn.valor());
    }
}

public class DeserialitzadorIsbn extends JsonDeserializer<Isbn> {
    @Override
    public Isbn deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
        String text = p.getText();
        try {
            return new Isbn(text);           // la validacio del record actua aqui
        } catch (IsbnInvalidException e) {
            throw JsonMappingException.from(p, "ISBN invalid: " + text, e);
        }
    }
}

Registre, de dues formes:

// Opcio 1: al camp
public record MaterialDto(
        @JsonSerialize(using = SerialitzadorIsbn.class)
        @JsonDeserialize(using = DeserialitzadorIsbn.class)
        Isbn isbn,
        String titol) { }
// Opcio 2: en un modul, aplicat a TOTS els Isbn
SimpleModule modul = new SimpleModule("BiblioTech");
modul.addSerializer(Isbn.class, new SerialitzadorIsbn());
modul.addDeserializer(Isbn.class, new DeserialitzadorIsbn());
mapper.registerModule(modul);

Amb Spring, es registra com un bean i l'autoconfiguració el recull:

@Bean
public Module modulBiblioTech() {
    SimpleModule modul = new SimpleModule("BiblioTech");
    modul.addSerializer(Isbn.class, new SerialitzadorIsbn());
    modul.addDeserializer(Isbn.class, new DeserialitzadorIsbn());
    return modul;
}

Fixa't en el paral·lelisme amb 11-03: això és l'equivalent a Jackson de l'AttributeConverter de JPA. El mateix objecte de valor, dos adaptadors per a dues tecnologies. El domini es manté net i cada infraestructura té el seu traductor.

  1. BiblioTech: la reescriptura del ClientMetadades

El moment promès a 09-06.

Allò era l'apedaçament:

// 09-06: l'"apedacament didactic" reconegut com a tal
public class ClientMetadades {

    public Optional<String> extreureTitol(String json) {
        int inici = json.indexOf("\"title\":\"");
        if (inici < 0) return Optional.empty();
        inici += 9;
        int fi = json.indexOf("\"", inici);
        if (fi < 0) return Optional.empty();
        return Optional.of(json.substring(inici, fi));
    }

    public Optional<String> extreureAutor(String json) {
        // ... el mateix, un altre cop
    }
}

Tot el que està malament en aquest codi:

Problema Exemple que el trenca
No gestiona escapades "title":"Java: la \"guia\" definitiva" → títol truncat
No gestiona imbricació {"edition":{"title":"altra"}} → retorna el títol equivocat
Depèn de l'ordre i l'espaiat "title" : "x" (amb espais) → no troba res
No gestiona Unicode escapat é → surt literalment en comptes de é
No gestiona tipus Tot és String; no hi ha números, booleans ni dates
Un mètode per camp Vuit camps, vuit mètodes gairebé idèntics
No detecta JSON invàlid Retorna Optional.empty() com si el camp faltés
No hi ha tipus Cap verificació del compilador

I aquesta és la versió amb Jackson:

package com.nexussoftware.bibliotech.xarxa;

import com.fasterxml.jackson.annotation.*;
import java.time.LocalDate;
import java.util.List;

/** Resposta de l'API externa de metadades. Es un DTO: reflecteix el JSON, no el domini. */
@JsonIgnoreProperties(ignoreUnknown = true)   // l'API pot afegir camps quan vulgui
public record RespostaMetadades(

        @JsonProperty("isbn_13")     String isbn,
        @JsonAlias({"title"})        String titol,
        @JsonProperty("authors")     List<Autor> autors,
        @JsonProperty("publish_date") LocalDate dataPublicacio,
        @JsonProperty("number_of_pages") Integer pagines,
        @JsonProperty("publishers")  List<String> editorials) {

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Autor(String name, String key) { }

    /** Tradueix el DTO extern al model de domini de BiblioTech. */
    public MetadadesLlibre aDomini() {
        String autorsUnits = autors == null ? null
                : autors.stream().map(Autor::name).collect(Collectors.joining(", "));
        String editorial = editorials == null || editorials.isEmpty()
                ? null : editorials.get(0);
        return new MetadadesLlibre(autorsUnits, dataPublicacio, editorial, pagines);
    }
}
package com.nexussoftware.bibliotech.xarxa;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
import java.util.Optional;
import org.slf4j.*;
import org.springframework.stereotype.Component;

@Component
public class PassarelaMetadadesHttp implements PassarelaMetadades {

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

    private final HttpClient http;          // un de sol, injectat (11-02)
    private final ObjectMapper mapper;      // un de sol, injectat
    private final String urlBase;

    public PassarelaMetadadesHttp(HttpClient http, ObjectMapper mapper,
                                  @Value("${bibliotech.metadades.url}") String urlBase) {
        this.http = http;
        this.mapper = mapper;
        this.urlBase = urlBase;
    }

    @Override
    public Optional<MetadadesLlibre> cercarPerIsbn(String isbn) {

        HttpRequest peticio = HttpRequest.newBuilder()
                .uri(URI.create(urlBase + "/isbn/" + isbn + ".json"))
                .header("Accept", "application/json")
                .timeout(Duration.ofSeconds(5))
                .GET()
                .build();

        try {
            HttpResponse<String> resposta =
                    http.send(peticio, HttpResponse.BodyHandlers.ofString());

            if (resposta.statusCode() == 404) {
                log.debug("L'API no coneix l'ISBN {}", isbn);
                return Optional.empty();
            }
            if (resposta.statusCode() >= 500) {
                throw new PassarelaNoDisponibleException(
                        "L'API ha retornat " + resposta.statusCode());
            }

            // UNA LINIA. Sense indexOf, sense substring, sense casos limit sense cobrir.
            RespostaMetadades dto = mapper.readValue(resposta.body(), RespostaMetadades.class);

            log.info("Metadades obtingudes per a {}: {}", isbn, dto.titol());
            return Optional.of(dto.aDomini());

        } catch (JsonProcessingException e) {
            log.warn("Resposta JSON invalida per a {}", isbn, e);
            throw new PassarelaNoDisponibleException("Resposta illegible de l'API", e);
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) Thread.currentThread().interrupt();
            throw new PassarelaNoDisponibleException("Error de xarxa en consultar " + isbn, e);
        }
    }
}

L'abans i el després:

Aspecte indexOf (09-06) Jackson
Línies d'anàlisi ~40, una per camp 1
Escapades i Unicode Trenca Correcte
Imbricació Trenca Correcte
Tipus Tot String LocalDate, Integer, List
Camp nou a l'API Sense efecte (no el llegeix) Sense efecte (ignoreUnknown)
Camp reanomenat Retorna buit en silenci @JsonAlias ho cobreix
JSON invàlid Indistingible de camp absent Excepció explícita
Verificació del compilador Cap Total
Provable sense xarxa Difícil (11-06)

I una decisió de disseny que convé subratllar: RespostaMetadades és un DTO, no una entitat de domini. Reflecteix la forma del JSON extern —amb els seus isbn_13 i els seus publishers— i té un mètode aDomini() que tradueix. Així, si l'API canvia el seu format, només canvia el DTO; el domini de BiblioTech no se n'assabenta. És la mateixa separació de fronteres d'11-06.

  1. Seguretat: deserialització polimòrfica i gadgets

Un avís seriós que reprèn directament 07-05.

A 07-05, en parlar de serialització de Java, es va advertir que deserialitzar dades d'origen no fiable és perillós. Amb JSON el risc és menor, però no és nul, i el mecanisme és el mateix.

El problema apareix amb la deserialització polimòrfica: quan li demanes a Jackson que decideixi quina classe instanciar segons el contingut del mateix JSON.

// PERILLOS amb dades externes
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                             ObjectMapper.DefaultTyping.NON_FINAL);

Amb això activat, el JSON pot contenir el nom de la classe a instanciar:

{"@class": "com.exemple.ClassePerillosa", "propietat": "valor"}

Un atacant que controli el JSON pot indicar qualsevol classe del classpath. Si alguna d'aquestes classes —un gadget— fa alguna cosa perillosa al seu constructor, en un setter o en un mètode d'inicialització (obrir una connexió, carregar codi, executar una ordre), es produeix execució de codi. Es coneixen cadenes de gadgets en llibreries molt comunes, i per això Jackson manté una llista negra que cal anar actualitzant.

Les regles pràctiques:

  1. No activis el tipatge per defecte per a dades externes. Mai.
  2. Si necessites polimorfisme, fes servir @JsonTypeInfo amb una llista blanca explícita:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "tipus")
@JsonSubTypes({
    @JsonSubTypes.Type(value = LlibreDto.class,  name = "LLIBRE"),
    @JsonSubTypes.Type(value = RevistaDto.class, name = "REVISTA"),
    @JsonSubTypes.Type(value = DvdDto.class,     name = "DVD")
})
public sealed interface MaterialDto permits LlibreDto, RevistaDto, DvdDto { }

Així només es poden instanciar tres classes, les que tu has enumerat. I fixa't que sealed (10-06) reforça la garantia en compilació: ningú pot afegir un subtipus sense tocar aquest fitxer.

  1. Mantén Jackson actualitzat. És exactament l'argument d'11-01: les vulnerabilitats es descobreixen en codi que ja existia.
  2. Valida després de deserialitzar. Que el JSON s'analitzi no significa que les dades siguin vàlides. Jakarta Bean Validation (11-02) va just després.

La seguretat d'aplicacions —validació d'entrada, límits de mida, autorització— es tracta a fons a 12-07. Aquí n'hi ha prou amb la regla: no activis tipatge dinàmic amb dades que no controles.

Un límit addicional que convé conèixer: un JSON amb imbricació molt profunda pot esgotar la pila (el StackOverflowError de 10-07). Jackson 2.15 i superiors limiten la profunditat i la mida dels documents per defecte, però si exposes una API pública convé revisar aquests límits explícitament.

  1. Gson i JSON-B, esmentats

Llibreria Qui Notes
Jackson FasterXML L'estàndard. Integrat a Spring Boot. Més complet i més ràpid
Gson Google Més simple, API mínima. No necessita constructor sense arguments (fa servir Unsafe). Menys anotacions i menys extensible
JSON-B Jakarta EE Estàndard de l'especificació. Implementacions: Yasson, Johnzon. Poc usat fora de Jakarta EE
JSON-P Jakarta EE API de baix nivell (equivalent a l'arbre i al streaming de Jackson)

Recomanació: fes servir Jackson. Ja és al teu projecte, és el que espera Spring, té l'ecosistema de mòduls més ric i és el que et trobaràs en qualsevol codi Java.

Gson té un nínxol legítim: projectes petits o Android, on la seva simplicitat i la seva mida reduïda compten.

  1. Lombok: què és i com funciona

Segon deute. Mira una entitat típica de BiblioTech:

public class Empleat {

    private Long id;
    private String correu;
    private String nom;
    private String departament;

    public Empleat() { }

    public Empleat(String correu, String nom, String departament) {
        this.correu = correu;
        this.nom = nom;
        this.departament = departament;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getCorreu() { return correu; }
    public void setCorreu(String correu) { this.correu = correu; }
    public String getNom() { return nom; }
    public void setNom(String nom) { this.nom = nom; }
    public String getDepartament() { return departament; }
    public void setDepartament(String departament) { this.departament = departament; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Empleat)) return false;
        Empleat altre = (Empleat) o;
        return Objects.equals(correu, altre.correu);
    }

    @Override
    public int hashCode() { return Objects.hash(correu); }

    @Override
    public String toString() {
        return "Empleat{correu='" + correu + "', nom='" + nom + "'}";
    }
}

Quaranta línies, de les quals quatre són informació: els quatre camps. La resta és soroll mecànic.

Lombok genera tot això en temps de compilació:

@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "correu")
@ToString(of = {"correu", "nom"})
public class Empleat {
    private Long id;
    private String correu;
    private String nom;
    private String departament;
}

Com funciona: un processador d'anotacions

I aquí torna 10-02. Lombok és un processador d'anotacions (javax.annotation.processing): s'enganxa al compilador, i durant la compilació modifica l'arbre sintàctic per afegir els mètodes.

graph LR
    A["Empleat.java<br/>amb @Getter"] --> B["javac<br/>fase d'analisi"]
    B --> C["Processador<br/>de Lombok"]
    C -->|"modifica l'AST:<br/>afegeix getters,<br/>equals, hashCode"| D["javac<br/>generacio"]
    D --> E["Empleat.class<br/>AMB els metodes"]

Conseqüències importants que actuï en compilació:

  1. No hi ha cost en execució. El .class conté els mètodes com si els haguessis escrit. Zero reflexió, zero proxies.
  2. Pots veure-ho amb javap:
javap -p target/classes/com/nexussoftware/bibliotech/domini/Empleat.class
public class com.nexussoftware.bibliotech.domini.Empleat {
  private java.lang.Long id;
  private java.lang.String correu;
  public java.lang.Long getId();
  public void setId(java.lang.Long);
  public java.lang.String getCorreu();
  ...
}
  1. L'IDE necessita un plugin. El codi font no conté getCorreu(), així que sense plugin l'editor el marca en vermell encara que compili bé.
  2. És una API interna del compilador. Lombok fa servir mecanismes no estandarditzats de javac, i per això de vegades es trenca amb una versió nova de Java fins que publiquen una actualització. És el seu risc real.

Dependència:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>   <!-- nomes en compilacio (11-05) -->
    <optional>true</optional>
</dependency>

provided és exactament el correcte: Lombok no cal en execució, així que no s'empaqueta.

  1. Les anotacions de Lombok

Anotació Genera
@Getter / @Setter Getters i setters (a la classe o en un camp)
@ToString toString(), amb of/exclude
@EqualsAndHashCode equals i hashCode, amb of/exclude
@NoArgsConstructor Constructor buit
@AllArgsConstructor Constructor amb tots els camps
@RequiredArgsConstructor Constructor amb els final i els @NonNull
@Data @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor
@Value Com @Data però immutable: tot final, sense setters
@Builder El patró Builder
@Slf4j private static final Logger log = LoggerFactory.getLogger(X.class)
@NonNull Comprovació de nul·litat al principi del mètode
@SneakyThrows Llança excepcions comprovades sense declarar-les. Fer servir amb molta cautela
@Cleanup Tancament automàtic (avui ho cobreix try-with-resources, 06-06)

Dues mereixen atenció especial.

@Slf4j estalvia la línia que apareixerà a cada classe de l'apartat 25:

@Slf4j
@Service
public class GestorPrestecs {
    public void prestar(String isbn) {
        log.info("Prestant {}", isbn);       // 'log' existeix, generat per Lombok
    }
}

@SneakyThrows mereix un advertiment. Permet llançar una excepció comprovada sense declarar-la a la signatura:

@SneakyThrows
public String llegir(Path fitxer) {
    return Files.readString(fitxer);    // IOException no declarada
}

És un truc sobre el sistema de tipus: el cridador no pot capturar aquesta IOException amb un catch (IOException e) sense que el compilador es queixi que mai no es llança. Trenca el contracte del mòdul 6. Fes-lo servir només en lambdes o en codi on l'excepció sigui genuïnament impossible; mai per evitar pensar en la gestió d'errors.

  1. @Builder i @RequiredArgsConstructor

@Builder

@Builder
@Getter
public class Prestec {
    private final Material material;
    private final Empleat empleat;
    private final LocalDate dataPrestec;
    private final LocalDate dataVenciment;
    @Builder.Default
    private final EstatPrestec estat = EstatPrestec.ACTIU;
}
Prestec prestec = Prestec.builder()
        .material(llibre)
        .empleat(marta)
        .dataPrestec(LocalDate.now(rellotge))
        .dataVenciment(LocalDate.now(rellotge).plusDays(15))
        .build();

Avantatge enfront d'un constructor de cinc paràmetres: els arguments van amb nom. Un new Prestec(llibre, marta, avui, avui.plusDays(15)) amb dues dates seguides del mateix tipus és un accident esperant a passar; amb builder, és impossible confondre-les.

Builder és un patró de disseny, i com a tal s'estudia a fons a 12-02, juntament amb Fàbrica, Repositori, Singleton i els altres. Aquí només interessa que Lombok el genera.

Compte amb @Builder.Default: sense ell, els valors inicials dels camps s'ignoren i queden a null. És un dels errors més freqüents amb Lombok.

@RequiredArgsConstructor

Aquesta és la que més es fa servir amb Spring, i encaixa perfectament amb la injecció per constructor d'11-02:

@Service
@RequiredArgsConstructor        // constructor amb TOTS els camps final
public class GestorPrestecs {

    private final MaterialRepository materials;
    private final EmpleatRepository empleats;
    private final PrestecRepository prestecs;
    private final CalculadoraMultes calculadora;
    private final ServeiAvisos avisos;
    private final Clock rellotge;

    // El constructor de 6 parametres el genera Lombok
}

S'elimina un constructor de quinze línies que no aportava res, i es conserva la injecció per constructor amb tots els seus avantatges (11-02): camps final, objecte sempre vàlid, provable amb new.

Però hi ha una contrapartida real que 11-02 va assenyalar: un constructor amb nou paràmetres crida "aquesta classe fa massa". Amb @RequiredArgsConstructor, aquest senyal es torna molt menys visible: afegir un camp final és una línia. És el mateix problema que tenia la injecció per camp, atenuat però present. Convé ser-ne conscient i vigilar el nombre de dependències de tota manera.

  1. Configuració de l'IDE i lombok.config

Lombok necessita un plugin a l'IDE perquè el codi font no conté els mètodes generats:

IDE Configuració
IntelliJ IDEA Plugin Lombok (inclòs des de 2020.3) + activar el processament d'anotacions
Eclipse Executar java -jar lombok.jar i apuntar a la instal·lació
VS Code Extensió "Lombok Annotations Support"

I un fitxer lombok.config a l'arrel del projecte:

# Atura la cerca de configuracio en directoris superiors
config.stopBubbling = true

# Afegeix @lombok.Generated al que es genera: JaCoCo ho EXCLOU de la cobertura (12-05)
lombok.addLombokGeneratedAnnotation = true

# Prohibeix @Data i @Value: obliguen a ser explicit (veure apartat 21)
lombok.data.flagUsage = error
lombok.value.flagUsage = warning

# Prohibeix @SneakyThrows: trenca el contracte d'excepcions del modul 6
lombok.sneakyThrows.flagUsage = error

# Copia aquestes anotacions al constructor generat (util amb Spring i Jackson)
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

La línia d'addLombokGeneratedAnnotation és especialment valuosa: sense ella, els getters generats compten com a codi no cobert i enfonsen artificialment el percentatge de cobertura, empenyent l'equip a escriure proves de getters — just l'antipatró que 11-04 i 11-06 van advertir.

  1. Lombok: la valoració honesta

Lombok és una de les llibreries més discutides de l'ecosistema Java. Mereix una valoració equilibrada.

Què aporta:

Avantatge Detall
Menys codi repetitiu Una entitat de 40 línies queda en 8
Menys errors Un equals a mà pot oblidar un camp; el generat, no
Menys soroll en llegir Els quatre camps es veuen d'un cop d'ull
Zero cost en execució És codi generat, no reflexió
@RequiredArgsConstructor Encaixa perfectament amb Spring

Quins problemes porta:

Problema Detall
Depuració No es pot posar un punt d'interrupció en un getter generat. Les traces assenyalen línies que no existeixen al font
Dependència de l'IDE Sense plugin, l'editor marca errors on no n'hi ha. Un company nou perd mig matí
Màgia oculta @Data genera cinc coses; cal conèixer-les per preveure el comportament
API interna del compilador Lombok fa servir mecanismes no estandarditzats de javac. Una versió nova de Java pot trencar-lo fins que publiquin actualització
@Data en entitats JPA Font real de bugs. Apartat 21
Facilita mals dissenys Posar @Data a tot produeix objectes anèmics: bosses de dades sense comportament
Cost de sortida "Deslombokitzar" un projecte gran és un projecte en si mateix

Aquest últim punt mereix matís: existeix l'eina delombok, que genera el codi font equivalent. Així que la sortida és factible, encara que produeix un commit enorme.

La postura raonable:

  • Lombok és útil, sobretot @RequiredArgsConstructor i @Slf4j, que es fan servir a diari i no tenen contrapartides.
  • No és imprescindible, i els record de Java 16 cobreixen bona part dels seus casos.
  • És una decisió d'equip: o el fa servir tot el projecte o no el fa servir ningú. Barrejar produeix inconsistència.
  • Si el fas servir, restringeix @Data i @Value amb lombok.config, i prohibeix @SneakyThrows.
  • Mai @Data en entitats JPA.

  1. @Data en entitats JPA: un bug real

Aquest apartat existeix perquè és un problema concret, freqüent i amb conseqüències serioses.

// EL QUE NO CAL FER
@Entity
@Data                       // <- inclou @EqualsAndHashCode amb TOTS els camps
public class Material {

    @Id @GeneratedValue
    private Long id;

    private String isbn;
    private String titol;

    @OneToMany(mappedBy = "material", fetch = FetchType.LAZY)
    private List<Prestec> prestecs;
}

Tres bugs, tots reals:

Bug 1: equals i hashCode que canvien

@Data genera equals i hashCode amb tots els camps, inclòs l'id generat.

Material llibre = new Llibre("978-0000000001", "Java Eficac", 3, "J. Bloch");
Set<Material> conjunt = new HashSet<>();
conjunt.add(llibre);                        // id = null -> hashCode X

repositori.save(llibre);                    // JPA assigna id = 42 -> hashCode Y

assertThat(conjunt.contains(llibre));       // FALSE! Es al cubell equivocat

És exactament el problema que es va explicar a 05-06 en parlar de HashSet: si el hashCode d'un objecte canvia mentre és dins d'una col·lecció basada en hash, l'objecte es perd. I amb JPA, l'id canvia sempre: és null abans de persistir i un número després.

Bug 2: toString que dispara consultes

@Data genera un toString que inclou tots els camps, incloses les relacions mandroses.

log.debug("Material: {}", material);
// -> toString() crida getPrestecs()
// -> s'inicialitza la col.leccio mandrosa
// -> SELECT * FROM prestecs WHERE material_id = 42

Conseqüències: una traça de log inofensiva provoca una consulta per cada objecte registrat —el problema N+1 d'11-03 disparat des del logging—, o una LazyInitializationException si el context de persistència ja s'ha tancat. I amb relacions bidireccionals, recursió infinita: Material.toString() crida Prestec.toString() que crida Material.toString().

Bug 3: setters en tot

@Data genera setters per a tots els camps, inclòs l'id. Canviar l'id d'una entitat gestionada és una forma segura de corrompre dades.

La solució

@Entity
@Getter                                       // nomes getters
@Setter(AccessLevel.PROTECTED)                // setters restringits
@NoArgsConstructor(access = AccessLevel.PROTECTED)   // el que exigeix JPA
@ToString(of = { "id", "isbn", "titol" })     // NOMES camps escalars
@EqualsAndHashCode(of = "isbn")               // NOMES la clau de negoci, immutable
public class Material {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String isbn;                      // clau de negoci: mai no canvia

    private String titol;

    @OneToMany(mappedBy = "material", fetch = FetchType.LAZY)
    private List<Prestec> prestecs = new ArrayList<>();
}

Regles per a entitats JPA amb Lombok:

Regla Motiu
Mai @Data Arrossega els tres bugs
@EqualsAndHashCode(of = "clauDeNegoci") Un valor immutable, mai l'id generat
@ToString(of = {camps escalars}) Excloure totes les relacions
@Setter restringit o absent Preferir mètodes de domini amb validació
Mai setter per a l'id El gestiona JPA

I l'alternativa més neta de totes: escriure equals i hashCode a mà a les entitats, seguint el patró recomanat per Hibernate. Són deu línies per entitat, s'escriuen una vegada i eliminen tota aquesta classe de problemes.

  1. Els record enfront de Lombok

Des de Java 16, els record (04-07) cobreixen bona part del que Lombok oferia:

// Lombok
@Value
public class MetadadesLlibre {
    String autor;
    LocalDate dataPublicacio;
    String editorial;
}

// Java 16+, sense dependencies
public record MetadadesLlibre(String autor, LocalDate dataPublicacio, String editorial) { }

Comparació:

Característica record Lombok
Getters Sí (autor(), sense get) Sí (getAutor())
equals / hashCode / toString Sí, gratis Amb anotacions
Immutabilitat Obligatòria Amb @Value
Constructor canònic @AllArgsConstructor
Validació al constructor Sí, compacte Manual
Mutabilitat No Sí, amb @Data
Builder No (verbós a mà) @Builder
Herència No (són final)
Entitats JPA No
Dependència externa Cap
Suport de l'IDE Natiu Plugin
Depuració Normal Complicada

La recomanació pràctica:

Cas Elecció
DTO, objectes de valor, respostes d'API record
Resultats de consultes i projeccions record (11-03)
Entitats JPA Classe + Lombok restringit, o a mà
Classes mutables amb molts camps Lombok
Constructor d'injecció a Spring @RequiredArgsConstructor
Logger @Slf4j

A BiblioTech, Fitxa, ResumSessio, MetadadesLlibre, RespostaMetadades i PropietatsBiblioTech són record; Material, Empleat i Prestec són classes amb Lombok restringit, perquè són entitats JPA.

  1. SLF4J: façana enfront d'implementació

Tercer deute, el de 06-07.

Allà es va triar java.util.logging per no afegir dependències, i es va assenyalar la limitació: l'ecosistema sencer fa servir SLF4J. Aquí es resol.

El problema que SLF4J soluciona és històric. Java ha tingut quatre sistemes de logging populars —java.util.logging, Log4j 1, Commons Logging, Log4j 2— i cada llibreria n'escollia un. Un projecte amb vint dependències podia tenir quatre sistemes de logging diferents escrivint a quatre llocs, amb quatre configuracions.

SLF4J (Simple Logging Facade for Java) és una façana: una API contra la qual programes, sense comprometre't amb cap implementació.

graph TD
    A["El teu codi de BiblioTech<br/>log.info(...)"] --> B["slf4j-api<br/>LA FACANA"]
    L1["Spring Framework"] --> B
    L2["Hibernate"] --> B
    L3["Jackson"] --> B

    B --> C{"Quin enllac hi ha<br/>al classpath?"}
    C --> D["logback-classic<br/>(per defecte a Spring Boot)"]
    C --> E["log4j-slf4j2-impl<br/>-> Log4j2"]
    C --> F["slf4j-jdk14<br/>-> java.util.logging"]
    C --> G["slf4j-simple<br/>-> stderr"]

    D --> H["Fitxers, consola,<br/>syslog, JSON..."]

Les tres peces:

Peça Què és Artefacte
API El que fas servir: Logger, LoggerFactory slf4j-api
Enllaç El pont API → implementació logback-classic, log4j-slf4j2-impl
Implementació Qui escriu de veritat logback-core, log4j-core

I una quarta, molt útil: els ponts (bridges), que redirigeixen a SLF4J el logging de llibreries que fan servir una altra API:

Pont Redirigeix
jul-to-slf4j java.util.logging → SLF4J
jcl-over-slf4j Commons Logging → SLF4J
log4j-over-slf4j Log4j 1 → SLF4J

Amb aquests ponts, tot el logging de la teva aplicació acaba en un sol lloc amb una sola configuració, encara que les teves dependències facin servir APIs diferents. És el valor real de la façana.

I ja ho tens: a 11-02 es va mostrar que spring-boot-starter arrossega spring-boot-starter-logging, que porta slf4j-api, logback-classic, logback-core i jul-to-slf4j.

  1. Per què es programa contra SLF4J

Quatre raons concretes:

  1. L'aplicació tria la implementació, no la llibreria. Si BiblioTech fos una llibreria usada per altres, imposar-los Logback seria un abús. Amb SLF4J, cada aplicació decideix.
  2. Es pot canviar sense tocar el codi. Passar de Logback a Log4j2 són dues línies al pom.xml (els exclusions d'11-05).
  3. Tot s'unifica. Amb els ponts, el logging de Spring, Hibernate, Jackson i el teu codi surt pel mateix canal amb la mateixa configuració.
  4. És el que espera l'ecosistema. Tota llibreria Java moderna que registra traces ho fa contra SLF4J.

És exactament el mateix principi que JPA enfront d'Hibernate a 11-03: programa contra l'especificació, tria la implementació en desplegar.

  1. El patró LoggerFactory.getLogger

package com.nexussoftware.bibliotech.servei;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class GestorPrestecs {

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

    public Prestec prestar(String isbn, String correu) {
        log.debug("Solicitud de prestec: isbn={}, empleat={}", isbn, correu);
        // ...
        log.info("Prestec creat: id={}, venc={}", prestec.getId(), prestec.getDataVenciment());
        return prestec;
    }
}

Cada element d'aquesta declaració té el seu motiu:

Element Per què
private Ningú de fora no ha de fer servir el logger d'aquesta classe
static Un per classe, no un per instància
final No canvia
getLogger(X.class) El nom del logger és el nom complet de la classe

Aquest últim punt és el que permet configurar nivells per paquet:

logging.level.com.nexussoftware.bibliotech=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework=INFO

El logger com.nexussoftware.bibliotech.servei.GestorPrestecs hereta del logger com.nexussoftware.bibliotech, que hereta de com.nexussoftware, que hereta de root. És una jerarquia, i per això una sola línia configura tot un subarbre.

Amb Lombok, la línia desapareix:

@Slf4j
public class GestorPrestecs { ... }

  1. Logging parametritzat amb {}

Aquesta és la característica d'SLF4J que més se subestima.

// MALAMENT: concatenacio
log.debug("Prestec " + prestec.getId() + " del material " + material.getTitol()
          + " per a " + empleat.getNom());

// BE: parametritzat
log.debug("Prestec {} del material {} per a {}",
          prestec.getId(), material.getTitol(), empleat.getNom());

Per què importa: amb concatenació, la cadena es construeix SEMPRE, fins i tot si el nivell DEBUG està desactivat.

Traça el primer cas amb nivell INFO:

  1. Es criden els tres getters.
  2. Es construeix un StringBuilder.
  3. Es concatenen sis fragments.
  4. Es produeix un String nou al monticle.
  5. Es crida log.debug(cadena).
  6. El logger comprova el nivell, veu que DEBUG està desactivat i descarta la cadena.

Tota aquesta feina, per a res. En un mètode que s'executa un milió de vegades al dia, és brossa pura al monticle — exactament el que 10-07 anomenava feina innecessària.

Amb la forma parametritzada:

  1. Es criden els tres getters (això sí que passa sempre).
  2. Es passen la plantilla i els tres arguments.
  3. El logger comprova el nivell. Si està desactivat, retorna sense construir res.

La cadena només es compon si el missatge s'ha d'emetre. Se'n diu avaluació diferida.

Un mesurament il·lustratiu, amb la metodologia de 10-07 (JMH, no un microbenchmark casolà):

Forma Nivell DEBUG activat Nivell DEBUG desactivat
Concatenació ~180 ns/op, 320 B assignats ~150 ns/op, 320 B assignats
Parametritzada {} ~190 ns/op, 336 B assignats ~3 ns/op, 0 B assignats

La columna dreta és la que importa, perquè en producció el nivell DEBUG està desactivat: cinquanta vegades més ràpid i zero assignacions. Multiplicat pels milions de crides a log.debug d'una aplicació real, la diferència és mesurable en temps de GC.

Casos particulars:

// Excepcions: SEMPRE com a ULTIM argument, SENSE {}
log.error("No s'ha pogut prestar {}", isbn, excepcio);   // registra la traca completa

// MALAMENT: perd la traca de pila, que es l'unic util
log.error("No s'ha pogut prestar " + isbn + ": " + excepcio.getMessage());

// Si l'argument es CAR de calcular, comprova el nivell
if (log.isDebugEnabled()) {
    log.debug("Estat complet del cataleg: {}", cataleg.bolcatComplet());
}

Aquest últim cas és l'única situació on isDebugEnabled() aporta alguna cosa: l'avaluació diferida evita construir la cadena, però no evita avaluar els arguments. Si bolcatComplet() recorre deu mil objectes, s'executa igualment.

També es pot fer servir un Supplier amb l'API fluida d'SLF4J 2:

log.atDebug().setMessage("Cataleg: {}")
   .addArgument(() -> cataleg.bolcatComplet())   // lambda: nomes s'avalua si cal
   .log();

  1. Nivells i la seva correspondència amb java.util.logging

SLF4J Quan fer-lo servir java.util.logging (06-07)
ERROR Fallada que impedeix completar una operació i requereix atenció SEVERE
WARN Alguna cosa anòmala, però es va continuar WARNING
INFO Esdeveniments rellevants de negoci INFO
DEBUG Detall per diagnosticar FINE
TRACE Detall molt fi: cada iteració FINER / FINEST

java.util.logging tenia a més CONFIG, que no té equivalent i es mapeja a INFO.

Criteris pràctics, amb exemples de BiblioTech:

// ERROR: cal actuar. Algu hauria de rebre una alerta.
log.error("No s'ha pogut connectar amb la base de dades despres de 3 intents", excepcio);

// WARN: anomal pero recuperat.
log.warn("L'API de metadades no ha respost per a {}; es continua sense enriquir", isbn);

// INFO: esdeveniments de negoci. Ha de poder llegir-se en produccio sense soroll.
log.info("Prestec {} creat per a {} amb venciment {}", id, correu, venciment);

// DEBUG: per diagnosticar. Desactivat en produccio.
log.debug("Avaluant {} reserves pendents amb antelacio de {} dies", total, dies);

// TRACE: molt fi. Gairebe mai activat.
log.trace("Reserva {} : dataSolicitud={}, caduca={}", id, solicitud, caducitat);

Dos errors freqüents:

  • Tot a INFO. El log es torna il·legible i ningú el mira. INFO ha de ser el que un operador voldria veure en producció.
  • Fer servir ERROR per a coses que no ho són. Si un ISBN no existeix a l'API externa, això és DEBUG o WARN, no ERROR. Si tot és un error, les alertes deixen de significar res.

I un d'important per al mòdul 6: no registris i rellancis.

// MALAMENT: la mateixa excepcio apareixera tres vegades al log
catch (IOException e) {
    log.error("Error en llegir", e);
    throw new PersistenciaException("Error en llegir", e);
}

// BE: o registres (perque aqui es gestiona) o rellances (i registra qui gestioni)
catch (IOException e) {
    throw new PersistenciaException("Error en llegir " + fitxer, e);
}

És l'estratègia per capes de 06-07: registra on gestiones, no on propagues.

  1. Configurar Logback

Logback es configura amb src/main/resources/logback-spring.xml (la variant -spring permet fer servir perfils de Spring):

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <property name="PATRO_CONSOLA"
              value="%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n"/>
    <property name="PATRO_FITXER"
              value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{40} [%X{idPeticio}] - %msg%n"/>

    <!-- APPENDER 1: consola -->
    <appender name="CONSOLA" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${PATRO_CONSOLA}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- APPENDER 2: fitxer amb rotacio per data I mida -->
    <appender name="FITXER" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/bibliotech.log</file>
        <encoder>
            <pattern>${PATRO_FITXER}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>logs/bibliotech-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>50MB</maxFileSize>      <!-- rota en superar 50 MB -->
            <maxHistory>30</maxHistory>          <!-- conserva 30 dies -->
            <totalSizeCap>2GB</totalSizeCap>     <!-- mai mes de 2 GB en total -->
        </rollingPolicy>
    </appender>

    <!-- APPENDER 3: asincron. No bloqueja el fil de negoci en escriure -->
    <appender name="FITXER_ASINCRON" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="FITXER"/>
        <queueSize>512</queueSize>
        <discardingThreshold>0</discardingThreshold>   <!-- no descartar missatges -->
    </appender>

    <!-- NIVELLS PER PAQUET -->
    <logger name="com.nexussoftware.bibliotech" level="DEBUG"/>
    <logger name="org.hibernate.SQL" level="DEBUG"/>
    <logger name="org.hibernate.orm.jdbc.bind" level="TRACE"/>
    <logger name="org.springframework" level="INFO"/>

    <!-- PERFILS DE SPRING (11-02) -->
    <springProfile name="dev">
        <root level="DEBUG">
            <appender-ref ref="CONSOLA"/>
        </root>
    </springProfile>

    <springProfile name="prod">
        <root level="INFO">
            <appender-ref ref="FITXER_ASINCRON"/>
        </root>
        <logger name="com.nexussoftware.bibliotech" level="INFO"/>
        <logger name="org.hibernate.SQL" level="OFF"/>
    </springProfile>

</configuration>

Conceptes de Logback:

Concepte Què és
Appender La destinació: consola, fitxer, syslog, socket
Encoder / pattern El format del missatge
Rolling policy Quan i com rotar els fitxers
Logger Nivell per a un paquet o classe concrets
Root logger Nivell per defecte, del qual hereten tots
<springProfile> Configuració condicional per perfil de Spring

Patrons més usats:

Marca Significa
%d{...} Data i hora
%level / %-5level Nivell, alineat a 5 caràcters
%thread Nom del fil (mòdul 8)
%logger{36} Nom del logger, abreujat a 36 caràcters
%msg El missatge
%n Salt de línia
%X{clau} Valor del MDC (apartat 29)
%ex La traça de l'excepció

I per al que és senzill, ni tan sols cal l'XML: Spring Boot ho configura des d'application.yml:

logging:
  level:
    root: INFO
    com.nexussoftware.bibliotech: DEBUG
    org.hibernate.SQL: DEBUG
  file:
    name: logs/bibliotech.log
  logback:
    rollingpolicy:
      max-file-size: 50MB
      max-history: 30
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n"

Comença sempre per l'application.yml. Passa al logback-spring.xml només quan necessitis appenders o polítiques que les propietats no cobreixin.

Sobre l'appender asíncron: escriure en disc bloqueja el fil que registra. En una aplicació amb molt trànsit, embolcallar l'appender de fitxer en un AsyncAppender mou l'escriptura a un fil a part. La contrapartida és que, si el procés mor de cop, els missatges en cua es perden — per això discardingThreshold a 0 i una cua raonable.

  1. MDC: correlacionar peticions

Problema real: amb cinquanta peticions simultànies (mòdul 8), els missatges de log es barregen. Quins pertanyen a la petició de Marta?

El MDC (Mapped Diagnostic Context) és un mapa per fil els valors del qual es poden incloure a cada línia de log.

import org.slf4j.MDC;

public class FiltreCorrelacio implements Filter {

    @Override
    public void doFilter(ServletRequest peticio, ServletResponse resposta, FilterChain cadena)
            throws IOException, ServletException {

        String idPeticio = Optional
                .ofNullable(((HttpServletRequest) peticio).getHeader("X-Request-Id"))
                .orElse(UUID.randomUUID().toString());

        MDC.put("idPeticio", idPeticio);
        MDC.put("usuari", usuariActual());

        try {
            cadena.doFilter(peticio, resposta);
        } finally {
            MDC.clear();   // IMPRESCINDIBLE: el fil torna al pool (modul 8)
        }
    }
}

Amb %X{idPeticio} al patró, cada línia porta l'identificador:

2026-03-20 10:15:23.451 INFO  [http-nio-8080-exec-3] c.n.b.s.GestorPrestecs [a3f2-9b21] - Prestec 4218 creat
2026-03-20 10:15:23.502 DEBUG [http-nio-8080-exec-3] c.n.b.p.PrestecRepository [a3f2-9b21] - Desant prestec
2026-03-20 10:15:23.510 INFO  [http-nio-8080-exec-7] c.n.b.s.GestorPrestecs [7c81-4e55] - Prestec 4219 creat

Ara es pot filtrar per a3f2-9b21 i veure només la petició de Marta, entre milers de línies.

Dos avisos importants:

  1. MDC.clear() en un finally, sempre. El MDC viu en un ThreadLocal, i en un pool de fils (mòdul 8) el fil es reutilitza. Si no el netejes, la petició següent hereta l'identificador de l'anterior — i això no és només confús: si desis dades de l'usuari, és una fuita entre peticions. És exactament la fuita de ThreadLocal que 10-07 va descriure com un dels quatre patrons clàssics.
  2. El MDC no es propaga a altres fils automàticament. Si llances feina a un ExecutorService (08-05) o a un CompletableFuture (08-07), el fil nou no té el MDC. Cal copiar-lo a mà amb MDC.getCopyOfContextMap() i MDC.setContextMap(...).

MDC és la base de la correlació de traces en sistemes distribuïts, i es desenvolupa juntament amb l'observabilitat a 12-07.

  1. Logging estructurat en JSON

En un entorn modern, els logs no els llegeix una persona en un fitxer: els ingereix un sistema (Elasticsearch, Loki, CloudWatch) que els indexa i permet consultar-los. Per a això, el text pla és incòmode: cal analitzar-lo amb expressions regulars fràgils.

El logging estructurat emet cada línia com un objecte JSON:

{"@timestamp":"2026-03-20T10:15:23.451+01:00","level":"INFO","logger":"com.nexussoftware.bibliotech.servei.GestorPrestecs","thread":"http-nio-8080-exec-3","message":"Prestec 4218 creat","idPeticio":"a3f2-9b21","usuari":"[email protected]"}

Ara es pot consultar level:ERROR AND idPeticio:a3f2-9b21 sense analitzar res. I els valors del MDC es converteixen en camps consultables.

Amb Logback i logstash-logback-encoder:

<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        <includeMdcKeyName>idPeticio</includeMdcKeyName>
        <includeMdcKeyName>usuari</includeMdcKeyName>
    </encoder>
</appender>

O, des de Spring Boot 3.4, sense dependències addicionals:

logging:
  structured:
    format:
      console: ecs      # Elastic Common Schema

Pràctica habitual: text llegible en desenvolupament, JSON en producció, seleccionat amb <springProfile>.

  1. BiblioTech: la migració a SLF4J

Abans, tal com va quedar a 06-07:

import java.util.logging.Level;
import java.util.logging.Logger;

public class GestorPrestecs {

    private static final Logger LOG = Logger.getLogger(GestorPrestecs.class.getName());

    public Prestec prestar(String isbn, String correu) {
        if (LOG.isLoggable(Level.FINE)) {                     // comprovacio manual
            LOG.fine("Prestant " + isbn + " a " + correu);    // concatenacio
        }
        try {
            // ...
            LOG.info("Prestec creat: " + prestec.getId());
            return prestec;
        } catch (Exception e) {
            LOG.log(Level.SEVERE, "Error en prestar " + isbn, e);   // API incomoda
            throw e;
        }
    }
}

Després:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class GestorPrestecs {

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

    public Prestec prestar(String isbn, String correu) {
        log.debug("Prestant {} a {}", isbn, correu);           // parametritzat, diferit

        Prestec prestec = /* ... */;
        log.info("Prestec creat: id={}, venc={}",
                 prestec.getId(), prestec.getDataVenciment());
        return prestec;
    }
}

Fixa't que va desaparèixer el try/catch: ja no es registra i rellança (apartat 27). Qui gestioni l'excepció registrarà. I va desaparèixer l'isLoggable, perquè l'avaluació diferida el fa innecessari.

Els passos de la migració:

1. Dependències. Ja hi són (spring-boot-starter). Afegir el pont per si alguna llibreria antiga fa servir java.util.logging:

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>jul-to-slf4j</artifactId>
</dependency>

2. Substituir els imports a totes les classes:

Abans Després
java.util.logging.Logger org.slf4j.Logger
Logger.getLogger(X.class.getName()) LoggerFactory.getLogger(X.class)
LOG.fine("a" + b) log.debug("a{}", b)
LOG.info(...) log.info(...)
LOG.warning(...) log.warn(...)
LOG.log(Level.SEVERE, msg, e) log.error(msg, e)
if (LOG.isLoggable(Level.FINE)) (eliminar)

3. Eliminar ConfiguracioLog. Aquella classe de 06-07 que configurava java.util.logging per codi desapareix: la seva funció la compleixen ara application.yml i logback-spring.xml, configurables sense recompilar.

4. Configurar per entorn, aprofitant els perfils d'11-02.

5. Verificar que no hi ha dues implementacions al classpath:

./mvnw dependency:tree | grep -E "slf4j|logback|log4j"

Si apareixen dos enllaços, SLF4J avisa en arrencar:

SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [.../logback-classic-1.5.6.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [.../slf4j-simple-2.0.13.jar!/org/slf4j/impl/StaticLoggerBinder.class]

S'arregla amb un exclusion (11-05).

El balanç:

Aspecte java.util.logging (06-07) SLF4J + Logback
Parametrització No, concatenació {} amb avaluació diferida
Rendiment amb nivell desactivat Construeix la cadena ~0 ns, 0 bytes
API d'excepcions log(Level, msg, e) log.error(msg, e)
Configuració Per codi o .properties limitat XML complet o application.yml
Rotació de fitxers Limitada Per data, mida, amb compressió i límit total
Configuració per entorn No <springProfile>
MDC No
JSON estructurat No
Unificació amb les llibreries No Sí, amb ponts
Canviar d'implementació Reescriure Dues línies de POM

  1. Log4j2, esmentat

Log4j 2 és l'alternativa principal a Logback. És ràpida —el seu mode asíncron amb disruptor té un rendiment excel·lent—, la seva API de Logger és rica i admet lambdes nativament.

Substituir-la a Spring Boot:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-logging</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>

Dues línies, i ni una del codi canvia. Aquesta és la demostració pràctica del valor de la façana.

I aquí convé recordar Log4Shell (11-01): la vulnerabilitat crítica del desembre del 2021 va afectar Log4j 2, no Logback. Dues observacions professionals:

  1. No converteix Log4j2 en mala opció. La vulnerabilitat es va corregir, el projecte va reaccionar, i avui és una implementació sòlida i molt usada.
  2. Sí que il·lustra l'argument d'11-01: qualsevol dependència pot tenir una vulnerabilitat crítica, la pregunta difícil és saber què tens, i la capacitat d'actualitzar de pressa depèn de tenir Maven (11-05) i proves (11-04, 11-06).

  1. Panorama final: altres llibreries que convé conèixer

Tancament de l'ecosistema. No cal aprendre-les ara: cal saber que existeixen per no reinventar-les.

Llibreria Per a què Quan la necessitaràs
Apache Commons Lang 3 Utilitats de String, Objects, Random, reflexió, comparació Menys que abans: String.isBlank(), Objects.requireNonNullElse i els record cobreixen molt
Apache Commons IO Còpia de fluxos, utilitats de fitxers Menys que abans: Files de NIO.2 (07-06) cobreix gairebé tot
Guava Col·leccions immutables, Multimap, BiMap, memòria cau, Preconditions Quan necessitis estructures que el JDK no té
MapStruct Mapejadors entitat ↔ DTO generats en compilació Tan bon punt tinguis quinze entitats i els seus DTO. Alternativa amb tipus a escriure mapatges a mà
Caffeine Memòria cau d'alt rendiment, amb expiració i mida màxima El substitut modern de la memòria cau de Guava; integrat amb @Cacheable de Spring
OpenCSV / Commons CSV Llegir i escriure CSV bé, amb cometes, escapades i salts incrustats Salda el deute de 07-07: el teu LectorCsv a mà no cobria els casos límit
Flyway / Liquibase Migracions d'esquema versionades Tan bon punt hi hagi una base de dades en producció (11-03, 12-06)
springdoc-openapi Documentació OpenAPI/Swagger generada dels teus controladors Tan bon punt exposis una API REST (12-04)
Resilience4j Reintents, curtcircuits, limitadors de taxa, mampares Quan cridis serveis externs. És el teu ProxyReintents de 10-03, fet producció
Micrometer Mètriques: comptadors, temporitzadors, histogrames Observabilitat (12-07). Integrat amb Actuator
Testcontainers Serveis reals a Docker per a proves Proves d'integració serioses (11-06, 12-05)
WireMock Servidor HTTP fals per a proves Provar clients HTTP sense l'API real
ArchUnit Verificar regles d'arquitectura com a proves "El domini no pot importar Spring", verificat a la compilació
JMH Microbenchmarks fiables Mesurar de veritat (10-07)

Tres mereixen un comentari més llarg.

OpenCSV, perquè tanca l'últim deute pendent del curs:

// El teu LectorCsv de 07-07: 70 linies, sense cobrir cometes ni salts incrustats
// Amb OpenCSV:
try (var lector = new CsvToBeanBuilder<MaterialCsv>(new FileReader(fitxer, UTF_8))
        .withType(MaterialCsv.class)
        .withSeparator(';')
        .build()) {
    List<MaterialCsv> materials = lector.parse();
}

I ja vas veure a l'apartat 10 que jackson-dataformat-csv fa el mateix amb la llibreria que ja tens.

MapStruct, perquè resol un problema que apareixerà a 12-04:

@Mapper(componentModel = "spring")
public interface MaterialMapper {
    MaterialDto aDto(Material material);
    List<MaterialDto> aDtos(List<Material> materials);
}

MapStruct genera la implementació en compilació —un altre processador d'anotacions, com Lombok—, amb tipus verificats: si afegeixes un camp al DTO i no existeix a l'entitat, no compila. És molt superior a mapejar a mà o per reflexió.

ArchUnit, perquè converteix en prova automàtica el que 11-05 aconseguia amb mòduls Maven:

@Test
void elDominiNoDepenDeSpringNiDeJpa() {
    JavaClasses classes = new ClassFileImporter()
            .importPackages("com.nexussoftware.bibliotech");

    noClasses().that().resideInAPackage("..domini..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("org.springframework..", "jakarta.persistence..")
            .check(classes);
}

Aquesta prova, executada per JUnit a cada compilació, impedeix que algú introdueixi una anotació de Spring al domini. És la disciplina arquitectònica convertida en alguna cosa verificable.

  1. Errors comuns i consells

Error: crear un ObjectMapper a cada crida. És car i llença la memòria cau d'introspecció. Un de sol, o el que injecta Spring.

Error: oblidar @JsonIgnoreProperties(ignoreUnknown = true). El dia que l'API externa afegeixi un camp, la teva aplicació deixa de funcionar.

Error: readValue(json, List.class). Per l'esborrat de tipus retorna List<LinkedHashMap> i esclata en fer-lo servir. TypeReference.

Error: no registrar JavaTimeModule o deixar WRITE_DATES_AS_TIMESTAMPS actiu. O falla, o produeix números en lloc d'ISO-8601.

Error: activar el tipatge per defecte de Jackson amb dades externes. És una vulnerabilitat d'execució de codi. Llista blanca amb @JsonTypeInfo.

Error: fer servir get() en comptes de path() amb JsonNode. NullPointerException en encadenar.

Error: @Data en una entitat JPA. Tres bugs: hashCode que canvia, toString que dispara consultes mandroses i setters per a l'id.

Error: @Builder sense @Builder.Default. Els valors inicials dels camps s'ignoren silenciosament.

Error: @SneakyThrows per comoditat. Trenca el contracte d'excepcions i el cridador no pot capturar el que no està declarat.

Error: concatenar a les crides al logger. La cadena es construeix encara que el nivell estigui desactivat.

Error: log.error("...", e.getMessage()). Perd la traça de pila, que és l'única cosa realment útil. L'excepció va com a últim argument, sense {}.

Error: registrar i rellançar. La mateixa excepció apareix tres vegades al log. Registra on gestiones.

Error: no netejar el MDC. En un pool de fils, la petició següent hereta les dades de l'anterior: confusió i fuita de dades.

Error: dues implementacions de logging al classpath. SLF4J avisa en arrencar i el comportament és impredictible. exclusions.

Error: tot a nivell INFO. El log es torna il·legible i ningú el mira.

Consell: fes servir record per als DTO. Immutables, amb equals i toString gratis, admesos per Jackson de forma nativa, i sense dependències.

Consell: separa el DTO extern del domini. RespostaMetadades reflecteix el JSON de l'API; MetadadesLlibre és el teu domini. Si l'API canvia, només canvia el DTO.

Consell: de Lombok, @RequiredArgsConstructor i @Slf4j. Són els que aporten sense contrapartides. Restringeix @Data amb lombok.config.

Consell: activa lombok.addLombokGeneratedAnnotation. Evita que els getters generats enfonsin el percentatge de cobertura.

Consell: comença a configurar el logging per application.yml. Passa a l'XML només quan necessitis appenders o rotacions que les propietats no cobreixin.

Consell: text llegible en desenvolupament, JSON en producció. Amb <springProfile>, sense tocar el codi.

Consell: pensa en qui llegirà el log. Un missatge útil diu què va passar, amb quines dades i què es va fer. "Error" no és un missatge.

Consell: abans d'afegir una llibreria del panorama, repassa els criteris d'11-01. Ho fa ja el JDK? Està mantinguda? Què arrossega?

  1. Exercicis

Exercici 1: el client de l'API de disponibilitat

Nexus Software vol consultar en temps real la disponibilitat dels llibres al proveïdor extern. L'API retorna:

{
  "request_id": "req-8821",
  "generated_at": "2026-03-20T10:15:23Z",
  "items": [
    {
      "isbn_13": "978-0000000001",
      "book_title": "Java Eficac",
      "stock": { "available": 3, "reserved": 1, "warehouse": "MAD-01" },
      "unit_price": 54.95,
      "currency": "EUR",
      "last_updated": "2026-03-19",
      "tags": ["java", "best-practices"],
      "discontinued": false
    }
  ],
  "warnings": []
}

Requisits:

  1. Modela la resposta amb record de Jackson. Ha de sobreviure que l'API afegeixi camps nous.
  2. L'API de vegades retorna "title" en comptes de "book_title" (versions diferents del servei). Resol-ho.
  3. unit_price ha d'arribar com a BigDecimal, mai com a double.
  4. Les dates han de ser LocalDate i Instant segons correspongui.
  5. Escriu un mètode aDomini() que produeixi un record DisponibilitatMaterial(String isbn, String titol, int disponibles, BigDecimal preu, LocalDate actualitzat).
  6. Configura l'ObjectMapper necessari, indicant per què cada opció.
  7. Escriu una prova (11-04, 11-06) que deserialitzi el JSON de l'enunciat, més un camp desconegut, i verifiqui la conversió a domini.

Exercici 2: arreglar una entitat amb Lombok

Aquesta entitat és al projecte i provoca dos incidents reportats: "de vegades un material desapareix de la selecció" i "el log de depuració triga 30 segons i de vegades peta".

@Entity
@Table(name = "materials")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Material {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String isbn;

    private String titol;

    private int exemplarsDisponibles = 1;

    @OneToMany(mappedBy = "material", fetch = FetchType.LAZY)
    private List<Prestec> prestecs = new ArrayList<>();

    @ManyToMany(fetch = FetchType.LAZY)
    private Set<Categoria> categories = new HashSet<>();

    @Version
    private Long version;
}

Es demana:

  1. Explica cadascun dels dos incidents relacionant-lo amb una anotació concreta.
  2. Identifica un tercer problema que encara no ha donat la cara.
  3. Identifica un quart problema, relacionat amb @Builder.
  4. Reescriu l'entitat corregint-ho tot.
  5. Escriu una prova que hauria detectat el primer incident.
  6. Escriu el lombok.config que impediria que això torni a passar.

Exercici 3: migrar el logging i correlacionar

GestorDevolucions té aquest logging, heretat de 06-07:

public class GestorDevolucions {

    private static final Logger LOG = Logger.getLogger(GestorDevolucions.class.getName());

    public ResultatDevolucio retornar(Long prestecId) {

        LOG.info("Iniciant devolucio del prestec " + prestecId);

        Prestec prestec = prestecs.findById(prestecId).orElse(null);
        if (prestec == null) {
            LOG.severe("Prestec no trobat: " + prestecId);
            throw new PrestecNoTrobatException(prestecId);
        }

        if (LOG.isLoggable(Level.FINE)) {
            LOG.fine("Estat del prestec: " + prestec.toString()
                     + " amb material " + prestec.getMaterial().toString());
        }

        BigDecimal multa = calculadora.calcular(prestec);
        LOG.info("Multa calculada: " + multa);

        try {
            prestec.retornar(LocalDate.now(rellotge));
            prestecs.save(prestec);
        } catch (Exception e) {
            LOG.log(Level.SEVERE, "Error en desar la devolucio de " + prestecId, e);
            throw new PersistenciaException("Error en retornar " + prestecId, e);
        }

        LOG.info("Devolucio completada");
        return new ResultatDevolucio(prestec, multa, false);
    }
}

Es demana:

  1. Identifica set problemes de logging en aquest codi, més enllà de l'API feta servir.
  2. Reescriu-lo amb SLF4J aplicant totes les bones pràctiques.
  3. Afegeix correlació amb MDC perquè totes les línies d'una devolució comparteixin identificador, amb la neteja correcta.
  4. Escriu el fragment de logback-spring.xml amb: consola llegible a dev, fitxer rotat amb JSON a prod, l'identificador de correlació en tots dos, i org.hibernate.SQL a DEBUG només a dev.
  5. Explica què hauria costat aquesta migració si el codi hagués estat programat contra SLF4J des de 06-07.

Solucions

Solució 1

package com.nexussoftware.bibliotech.xarxa;

import com.fasterxml.jackson.annotation.*;
import java.math.BigDecimal;
import java.time.*;
import java.util.List;

/** (1) DTO de l'API externa de disponibilitat. Reflecteix el JSON, no el domini. */
@JsonIgnoreProperties(ignoreUnknown = true)          // (1) sobreviu a camps nous
public record RespostaDisponibilitat(

        @JsonProperty("request_id")   String idPeticio,
        @JsonProperty("generated_at") Instant generatEl,      // (4) instant absolut
        @JsonProperty("items")        List<Item> items,
        @JsonProperty("warnings")     List<String> avisos) {

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Item(

            @JsonProperty("isbn_13") String isbn,

            // (2) l'API v1 fa servir "book_title", la v2 fa servir "title"
            @JsonProperty("book_title")
            @JsonAlias({"title"})
            String titol,

            @JsonProperty("stock") Stock stock,

            // (3) BigDecimal, MAI double per a diners (01-04, 11-03)
            @JsonProperty("unit_price") BigDecimal preuUnitari,

            @JsonProperty("currency") String moneda,

            // (4) data de negoci sense hora: LocalDate (10-05)
            @JsonProperty("last_updated") LocalDate ultimaActualitzacio,

            @JsonProperty("tags") List<String> etiquetes,

            @JsonProperty("discontinued") boolean descatalogat) {

        @JsonIgnoreProperties(ignoreUnknown = true)
        public record Stock(
                @JsonProperty("available") int disponibles,
                @JsonProperty("reserved")  int reservats,
                @JsonProperty("warehouse") String magatzem) { }

        // (5) traduccio al domini
        public DisponibilitatMaterial aDomini() {
            return new DisponibilitatMaterial(
                    isbn,
                    titol,
                    stock == null ? 0 : stock.disponibles(),   // defensiu: el JSON pot ometre'l
                    preuUnitari,
                    ultimaActualitzacio);
        }
    }

    /** (5) Tots els items, ja traduits al domini. */
    public List<DisponibilitatMaterial> aDomini() {
        return items == null ? List.of() : items.stream().map(Item::aDomini).toList();
    }
}
// (5) El record de DOMINI: noms en catala, sense rastre de l'API externa
public record DisponibilitatMaterial(
        String isbn,
        String titol,
        int disponibles,
        BigDecimal preu,
        LocalDate actualitzat) {

    public boolean hiHaExistencies() { return disponibles > 0; }
}

(6) Configuració de l'ObjectMapper:

@Configuration
public class ConfiguracioJackson {

    @Bean
    public ObjectMapper objectMapper() {
        return JsonMapper.builder()

                // Suport de java.time: sense aixo, LocalDate i Instant fallen
                .addModule(new JavaTimeModule())

                // Dates en ISO-8601, no com a nombre de segons.
                // Llegible, interoperable i coherent amb el decidit a 10-05.
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)

                // Redundant amb @JsonIgnoreProperties, pero protegeix
                // qualsevol DTO on algu oblidi l'anotacio.
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)

                // Els decimals del JSON es llegeixen com a BigDecimal, no com a double.
                // Imprescindible per a imports: evita 54.949999999999996.
                .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)

                // No serialitzar camps null: respostes mes petites.
                .serializationInclusion(JsonInclude.Include.NON_NULL)

                .build();
    }
}

Aquesta opció USE_BIG_DECIMAL_FOR_FLOATS és la que de veritat garanteix el punt 3: sense ella, Jackson llegeix 54.95 com a double internament i pot introduir error de representació abans de convertir a BigDecimal.

(7) La prova:

class RespostaDisponibilitatTest {

    private final ObjectMapper mapper = new ConfiguracioJackson().objectMapper();

    @Test
    @DisplayName("deserialitza la resposta i la tradueix al domini, ignorant camps desconeguts")
    void deserialitzaITradueix() throws Exception {
        String json = """
                {
                  "request_id": "req-8821",
                  "generated_at": "2026-03-20T10:15:23Z",
                  "server_region": "eu-west-1",
                  "items": [
                    {
                      "isbn_13": "978-0000000001",
                      "book_title": "Java Eficac",
                      "stock": { "available": 3, "reserved": 1, "warehouse": "MAD-01" },
                      "unit_price": 54.95,
                      "currency": "EUR",
                      "last_updated": "2026-03-19",
                      "tags": ["java", "best-practices"],
                      "discontinued": false,
                      "promo_code": "SPRING26"
                    }
                  ],
                  "warnings": []
                }
                """;   // bloc de text de 10-06

        RespostaDisponibilitat resposta =
                mapper.readValue(json, RespostaDisponibilitat.class);

        // Els camps desconeguts (server_region, promo_code) no trenquen res
        assertThat(resposta.idPeticio()).isEqualTo("req-8821");
        assertThat(resposta.generatEl()).isEqualTo(Instant.parse("2026-03-20T10:15:23Z"));
        assertThat(resposta.avisos()).isEmpty();

        assertThat(resposta.aDomini()).singleElement().satisfies(d -> {
            assertThat(d.isbn()).isEqualTo("978-0000000001");
            assertThat(d.titol()).isEqualTo("Java Eficac");
            assertThat(d.disponibles()).isEqualTo(3);
            assertThat(d.preu()).isEqualByComparingTo("54.95");   // exacte, sense error
            assertThat(d.actualitzat()).isEqualTo(LocalDate.of(2026, 3, 19));
            assertThat(d.hiHaExistencies()).isTrue();
        });
    }

    @Test
    @DisplayName("accepta també el nom antic del camp títol")
    void acceptaLAliasDeVersioAntiga() throws Exception {
        String jsonV2 = """
                {"items":[{"isbn_13":"978-0000000002","title":"Patrons de Disseny",
                           "stock":{"available":2},"unit_price":49.00,
                           "last_updated":"2026-03-18"}]}
                """;

        var resposta = mapper.readValue(jsonV2, RespostaDisponibilitat.class);

        assertThat(resposta.aDomini()).singleElement()
                .extracting(DisponibilitatMaterial::titol)
                .isEqualTo("Patrons de Disseny");
    }

    @Test
    @DisplayName("el preu es llegeix com a BigDecimal exacte, no com a double")
    void preuExacte() throws Exception {
        String json = """
                {"items":[{"isbn_13":"978-0000000003","book_title":"Refactoritzacio",
                           "stock":{"available":1},"unit_price":0.1,
                           "last_updated":"2026-03-20"}]}
                """;

        var d = mapper.readValue(json, RespostaDisponibilitat.class).aDomini().get(0);

        // Amb double seria 0.1000000000000000055511151231257827
        assertThat(d.preu()).isEqualByComparingTo(new BigDecimal("0.1"));
        assertThat(d.preu().toPlainString()).isEqualTo("0.1");
    }
}

La primera prova inclou deliberadament dos camps que el DTO no coneix (server_region i promo_code), que és exactament l'escenari de l'apartat 8: l'API afegeix camps i l'aplicació continua funcionant.

Solució 2

(1) Els dos incidents.

Incident A: "de vegades un material desapareix de la selecció". El causa @Data, que genera @EqualsAndHashCode amb tots els camps, inclòs l'id autogenerat.

Material llibre = Material.builder().isbn("978-0000000001").titol("Java Eficac").build();
Set<Material> seleccio = new HashSet<>();
seleccio.add(llibre);                    // id = null -> hashCode H1 -> cubell A

repositori.save(llibre);                 // JPA assigna id = 42 -> hashCode H2

seleccio.contains(llibre);               // busca al cubell B -> FALSE

L'objecte continua dins del HashSet, però al cubell equivocat. És exactament el problema descrit a 05-06: si el hashCode canvia mentre l'objecte és en una col·lecció hash, es perd. I amb JPA canvia sempre, perquè l'id és null abans de persistir.

Incident B: "el log de depuració triga 30 segons i de vegades peta". El causa el toString de @Data, que inclou tots els camps, incloses les dues relacions mandroses.

log.debug("Material: {}", material);
  1. toString() accedeix a prestecsSELECT * FROM prestecs WHERE material_id = ?
  2. toString() accedeix a categoriesSELECT ... FROM materials_categories JOIN categories ...
  3. Cada Prestec té el seu propi toString que crida getMaterial().toString()recursió infinita si Prestec també porta @Data.

D'aquí els 30 segons —el problema N+1 d'11-03 disparat des d'una traça de log— i el "de vegades peta": o StackOverflowError per la recursió, o LazyInitializationException (11-03) si el context ja s'ha tancat.

(2) Tercer problema, encara sense manifestar-se: @Data genera setters per a TOTS els camps, inclosos id i version.

material.setId(99L);        // corromp la identitat de l'entitat
material.setVersion(0L);    // trenca el bloqueig optimista d'11-03

Un setVersion(0L) accidental faria que Hibernate cregués que la fila és a la seva versió inicial, i una actualització perduda passaria desapercebuda. També setExemplarsDisponibles(-5) és possible: no hi ha cap validació.

(3) Quart problema: @Builder sense @Builder.Default.

private int exemplarsDisponibles = 1;
private List<Prestec> prestecs = new ArrayList<>();
private Set<Categoria> categories = new HashSet<>();

En construir amb el builder, aquestes inicialitzacions s'ignoren:

Material m = Material.builder().isbn("978-0000000001").build();
m.getExemplarsDisponibles();    // 0, no 1
m.getPrestecs();                // null, no llista buida -> NullPointerException

I aquest getPrestecs() a null provocarà un NullPointerException la primera vegada que algú afegeixi un préstec, en un lloc aparentment sense relació.

(4) L'entitat corregida:

package com.nexussoftware.bibliotech.domini;

import jakarta.persistence.*;
import java.util.*;
import lombok.*;

@Entity
@Table(name = "materials")
@Getter                                                   // nomes lectura publica
@Setter(AccessLevel.PROTECTED)                            // setters restringits a JPA/subclasses
@NoArgsConstructor(access = AccessLevel.PROTECTED)        // el que exigeix JPA (11-03)
@ToString(of = { "id", "isbn", "titol", "exemplarsDisponibles" })   // NOMES escalars
@EqualsAndHashCode(of = "isbn")                           // clau de negoci IMMUTABLE
public class Material {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 20, updatable = false)
    private String isbn;                                  // updatable=false: mai no canvia

    @Column(nullable = false, length = 200)
    private String titol;

    @Column(name = "exemplars_disponibles", nullable = false)
    private int exemplarsDisponibles;

    @OneToMany(mappedBy = "material", fetch = FetchType.LAZY)
    private List<Prestec> prestecs = new ArrayList<>();

    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(name = "materials_categories",
               joinColumns = @JoinColumn(name = "material_id"),
               inverseJoinColumns = @JoinColumn(name = "categoria_id"))
    private Set<Categoria> categories = new HashSet<>();

    @Version
    private Long version;

    /** Constructor public amb validacio: substitueix @Builder i @AllArgsConstructor. */
    public Material(String isbn, String titol, int exemplarsDisponibles) {
        this.isbn = Objects.requireNonNull(isbn, "L'ISBN es obligatori");
        this.titol = Objects.requireNonNull(titol, "El titol es obligatori");
        if (exemplarsDisponibles < 0) {
            throw new IllegalArgumentException("Els exemplars no poden ser negatius");
        }
        this.exemplarsDisponibles = exemplarsDisponibles;
    }

    // Metodes de DOMINI amb validacio, en lloc de setters oberts
    public void prestarUnExemplar() {
        if (exemplarsDisponibles <= 0) throw new SenseExemplarsException(isbn);
        exemplarsDisponibles--;
    }

    public void retornarUnExemplar() { exemplarsDisponibles++; }

    /** Copia defensiva: ningu modifica la col.leccio des de fora (11-03 §13). */
    public List<Prestec> getPrestecs() { return List.copyOf(prestecs); }
}

Canvis i el seu motiu:

Abans Ara Corregeix
@Data @Getter + @Setter(PROTECTED) Incidents A, B i problema 3
equals/hashCode amb tot @EqualsAndHashCode(of = "isbn") Incident A
toString amb tot @ToString(of = {escalars}) Incident B
Setters públics per a id i version Setters protected Problema 3
@Builder sense defaults Constructor amb validació Problema 4
Sense validació requireNonNull i comprovacions Dades invàlides
Col·leccions exposades Còpia defensiva Modificació externa

Nota: s'ha eliminat @Builder en lloc d'arreglar-lo amb @Builder.Default. Per a una entitat amb tres camps obligatoris, un constructor validat és més clar i més segur. El Builder aporta quan hi ha molts paràmetres opcionals, i aquest patró s'estudia a 12-02.

(5) La prova que hauria detectat l'incident A:

@Test
@DisplayName("un Material continua sent localitzable en un HashSet després de rebre el seu id")
void identitatEstableDespresDePersistir() {

    Material llibre = new Material("978-0000000001", "Java Eficac", 3);
    Set<Material> seleccio = new HashSet<>();
    seleccio.add(llibre);

    // Simula el que fa JPA en persistir: assignar l'id generat
    ReflectionTestUtils.setField(llibre, "id", 42L);

    // Amb @Data aixo FALLA: el hashCode va canviar i l'objecte es en un altre cubell
    assertThat(seleccio).contains(llibre);
    assertThat(seleccio.contains(llibre)).isTrue();
}

@Test
@DisplayName("toString no accedeix a les relacions mandroses")
void toStringSenseRelacions() {
    Material llibre = new Material("978-0000000001", "Java Eficac", 3);

    assertThat(llibre.toString())
            .contains("978-0000000001")
            .contains("Java Eficac")
            .doesNotContain("prestecs")       // hauria disparat la consulta
            .doesNotContain("categories");
}

I una versió d'integració que detectaria l'incident B contra la base de dades:

@DataJpaTest
class MaterialToStringIT {

    @Autowired private TestEntityManager em;

    @Test
    @DisplayName("toString no provoca LazyInitializationException fora de la sessió")
    void toStringSegurForaDeSessio() {
        Material llibre = em.persistAndFlush(new Material("978-0000000001", "Java Eficac", 3));
        em.clear();                                   // simula el tancament del context

        Material separat = em.getEntityManager()
                .getReference(Material.class, llibre.getId());

        assertThatCode(separat::toString).doesNotThrowAnyException();
    }
}

(6) El lombok.config preventiu:

config.stopBubbling = true

# @Data es l'arrel dels dos incidents: prohibit a tot el projecte
lombok.data.flagUsage = error

# @Value es immutable pero incompatible amb JPA: avis
lombok.value.flagUsage = warning

# @AllArgsConstructor permet construir entitats sense validacio
lombok.allArgsConstructor.flagUsage = warning

# @SneakyThrows trenca el contracte d'excepcions del modul 6
lombok.sneakyThrows.flagUsage = error

# Els metodes generats no compten per a la cobertura (12-05)
lombok.addLombokGeneratedAnnotation = true

Amb lombok.data.flagUsage = error, qualsevol @Data que algú introdueixi en el futur no compila. La regla deixa de dependre que tothom recordi la revisió de codi.

Solució 3

(1) Els set problemes:

# Problema Conseqüència
1 Concatenació a totes les crides La cadena es construeix encara que el nivell estigui desactivat: feina i brossa inútils
2 toString() explícit d'entitats al FINE Amb relacions mandroses, dispara consultes o llança LazyInitializationException (exercici 2)
3 Registrar i rellançar al catch La mateixa excepció apareixerà dues o tres vegades al log
4 severe per a "no trobat" No és un error del sistema: és un cas de negoci. Contamina les alertes
5 INFO per a tot "Iniciant", "Multa calculada", "Devolució completada": tres línies per operació en producció
6 Missatges sense context "Devolucio completada" no diu de quin préstec. Inútil amb trànsit concurrent
7 isLoggable manual Innecessari amb parametrització, i afegeix soroll

Un vuitè, addicional: catch (Exception e) captura massa, incloses excepcions de negoci que no s'haurien de tractar com a fallades de persistència. És un problema del mòdul 6, no de logging, però es corregeix de passada.

(2) i (3) Versió reescrita amb SLF4J i MDC:

package com.nexussoftware.bibliotech.servei;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.UUID;
import org.slf4j.*;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor          // Lombok: injeccio per constructor (11-02)
public class GestorDevolucions {

    private static final Logger log = LoggerFactory.getLogger(GestorDevolucions.class);
    // (o simplement @Slf4j sobre la classe)

    private final PrestecRepository prestecs;
    private final CalculadoraMultes calculadora;
    private final Clock rellotge;

    @Transactional
    public ResultatDevolucio retornar(Long prestecId) {

        // (3) MDC: correlacio de totes les linies d'aquesta devolucio
        MDC.put("operacio", "devolucio");
        MDC.put("prestecId", String.valueOf(prestecId));
        MDC.put("idOperacio", UUID.randomUUID().toString());

        try {
            // (5) DEBUG, no INFO: l'inici d'una operacio no interessa en produccio
            log.debug("Iniciant devolucio");

            Prestec prestec = prestecs.findById(prestecId)
                    .orElseThrow(() -> {
                        // (4) DEBUG, no ERROR: es un cas de negoci, no una fallada del sistema
                        log.debug("Prestec no trobat");
                        return new PrestecNoTrobatException(prestecId);
                    });

            // (1)(2)(7) parametritzat, sense toString() d'entitats, sense isLoggable
            log.debug("Prestec localitzat: isbn={}, venciment={}, estat={}",
                    prestec.getMaterial().getIsbn(),      // nomes camps ESCALARS
                    prestec.getDataVenciment(),
                    prestec.getEstat());

            BigDecimal multa = calculadora.calcular(prestec);

            prestec.retornar(LocalDate.now(rellotge));
            prestecs.save(prestec);
            // (3) sense catch: l'excepcio de persistencia puja i la registra qui la gestioni

            // (5)(6) UNA linia INFO per operacio, amb TOT el context rellevant
            log.info("Devolucio completada: isbn={}, empleat={}, multa={} EUR, diesRetard={}",
                    prestec.getMaterial().getIsbn(),
                    prestec.getEmpleat().getCorreu(),
                    multa,
                    prestec.diesDeRetard(LocalDate.now(rellotge)));

            if (multa.compareTo(BigDecimal.ZERO) > 0) {
                log.warn("Devolucio amb multa: isbn={}, quantitat={} EUR",
                        prestec.getMaterial().getIsbn(), multa);
            }

            return new ResultatDevolucio(prestec, multa, false);

        } finally {
            MDC.clear();   // (3) IMPRESCINDIBLE: el fil torna al pool (10-07)
        }
    }
}

Decisions destacables:

  • Una sola línia INFO per operació, amb totes les dades rellevants, en comptes de tres genèriques. És el que un operador voldria veure en producció.
  • WARN per a la multa: és una cosa que convé poder consultar sense activar DEBUG, però no és un error.
  • Els missatges no porten el prestecId perquè ja és al MDC i apareix a cada línia.
  • Sense catch: si save falla, l'excepció puja. Qui la gestioni —un gestor global (12-04)— la registrarà una vegada, amb la seva traça completa.
  • MDC.clear() a finally: sense ell, la petició següent que reutilitzi el fil heretaria el prestecId d'aquesta.

(4) logback-spring.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- ============ DESENVOLUPAMENT: consola llegible ============ -->
    <springProfile name="dev">

        <appender name="CONSOLA" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <pattern>%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{30}) [%X{idOperacio:-sense-id}] - %msg%n</pattern>
                <charset>UTF-8</charset>
            </encoder>
        </appender>

        <logger name="com.nexussoftware.bibliotech" level="DEBUG"/>
        <logger name="org.hibernate.SQL" level="DEBUG"/>            <!-- SQL nomes a dev -->
        <logger name="org.hibernate.orm.jdbc.bind" level="TRACE"/>  <!-- parametres -->
        <logger name="org.springframework" level="INFO"/>

        <root level="INFO">
            <appender-ref ref="CONSOLA"/>
        </root>
    </springProfile>

    <!-- ============ PRODUCCIO: fitxer rotat en JSON ============ -->
    <springProfile name="prod">

        <appender name="FITXER_JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>logs/bibliotech.json</file>

            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>idOperacio</includeMdcKeyName>
                <includeMdcKeyName>prestecId</includeMdcKeyName>
                <includeMdcKeyName>operacio</includeMdcKeyName>
                <customFields>{"aplicacio":"bibliotech","entorn":"prod"}</customFields>
            </encoder>

            <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
                <fileNamePattern>logs/bibliotech-%d{yyyy-MM-dd}.%i.json.gz</fileNamePattern>
                <maxFileSize>100MB</maxFileSize>
                <maxHistory>30</maxHistory>
                <totalSizeCap>5GB</totalSizeCap>
            </rollingPolicy>
        </appender>

        <!-- Asincron: escriure en disc no bloqueja el fil de negoci -->
        <appender name="ASINCRON" class="ch.qos.logback.classic.AsyncAppender">
            <appender-ref ref="FITXER_JSON"/>
            <queueSize>1024</queueSize>
            <discardingThreshold>0</discardingThreshold>
        </appender>

        <logger name="com.nexussoftware.bibliotech" level="INFO"/>
        <logger name="org.hibernate.SQL" level="OFF"/>      <!-- MAI SQL en produccio -->
        <logger name="org.springframework" level="WARN"/>

        <root level="INFO">
            <appender-ref ref="ASINCRON"/>
        </root>
    </springProfile>

</configuration>

Detalls que convé subratllar:

  • %X{idOperacio:-sense-id}: el :- dóna un valor per defecte si la clau no és al MDC (per exemple, a l'arrencada). Sense ell, apareixeria buit.
  • org.hibernate.SQL a OFF en producció: registrar cada sentència SQL en producció degrada el rendiment i pot bolcar dades sensibles al log.
  • totalSizeCap: garanteix que els logs mai no omplin el disc, que és una causa clàssica de caiguda d'un servei.
  • JSON en producció amb els camps del MDC com a camps indexables, que és el que permet consultar operacio:devolucio AND level:WARN al sistema de logs.

(5) Què hauria costat amb SLF4J des de 06-07:

Pràcticament res. Concretament:

Tasca Amb java.util.logging (real) Amb SLF4J des del principi
Canviar imports 40+ fitxers Cap
Reescriure concatenacions Totes les crides Cap
Adaptar log(Level.X, ...) Totes les d'error Cap
Eliminar isLoggable Totes Cap
Eliminar ConfiguracioLog No existiria
Configurar Logback Igual Igual
Afegir MDC Igual Igual

La migració hauria estat afegir dues dependències i un fitxer XML, sense tocar una sola línia de codi Java.

I aquesta és exactament la lliçó de la façana, i la raó per la qual es recomana des del principi: programar contra una API estable i neutral fa que canviar la implementació costi dues línies de pom.xml. És el mateix principi que JPA enfront d'Hibernate (11-03) i que la interfície PassarelaMetadades enfront d'HttpClient (11-06): aïlla el que pot canviar darrere d'alguna cosa que no canvia.

La decisió de 06-07 —fer servir java.util.logging per no afegir dependències— era raonable en el seu context pedagògic, perquè aleshores no hi havia ni Maven ni forma còmoda de gestionar dependències. En un projecte real, SLF4J des de la primera línia.

Conclusió

El mòdul 11 acaba, i els tres deutes estan saldats.

Jackson ha substituït l'apedaçament del mòdul 9. Coneixes JSON i la seva correspondència amb els tipus de Java —inclòs el que JSON no té i que causa la majoria dels problemes—, domines l'ObjectMapper amb la regla de crear-lo una vegada i reutilitzar-lo, per la mateixa raó que l'HttpClient de 09-06 i el Pattern de 10-07: car de construir, segur davant de la concurrència. Serialitzes i deserialitzes POJOs i record —admesos de forma nativa i perfectes com a DTO—, i coneixes les anotacions essencials, amb @JsonIgnoreProperties(ignoreUnknown = true) com la que més incidents evita: sense ella, el dia que l'API externa afegeixi un camp, la teva aplicació deixa de funcionar.

Saps resoldre els genèrics amb TypeReference, entenent per què cal —l'esborrat de tipus de 10-01— i com funciona el truc de la subclasse anònima la informació genèrica de la qual sí que sobreviu al bytecode. Registres el JavaTimeModule i desactives WRITE_DATES_AS_TIMESTAMPS per obtenir l'ISO-8601 que 10-05 va establir com a estàndard. Manegues l'arbre JsonNode per al que és dinàmic —amb path() en comptes de get()— i escrius serialitzadors propis per a objectes de valor com Isbn, que són l'equivalent a Jackson de l'AttributeConverter de JPA: el mateix objecte de domini, dos adaptadors per a dues infraestructures.

I has reescrit el ClientMetadades: quaranta línies d'indexOf que es trencaven amb la primera escapada, la primera imbricació o el primer espai inesperat, substituïdes per una línia i un DTO tipat que sobreviu que l'API canviï. Amb la separació correcta entre el DTO extern i el domini, i amb la nota de seguretat que reprèn 07-05: no activis el tipatge dinàmic amb dades que no controles, perquè els gadgets de deserialització són execució de codi, i si necessites polimorfisme, fes servir @JsonTypeInfo amb llista blanca reforçada per sealed.

Lombok ha eliminat el codi repetitiu, i l'entens de veritat: és un processador d'anotacions de 10-02, actua en compilació, no costa res en execució i pots veure-ho amb javap. Coneixes les seves anotacions, amb @RequiredArgsConstructor encaixant perfectament en la injecció per constructor d'11-02 i @Slf4j estalviant una línia a cada classe. I tens la valoració honesta: el que aporta, el que costa —depuració, dependència de l'IDE, API interna del compilador, cost de sortida— i sobretot el problema concret que provoca bugs reals: @Data en entitats JPA, amb les seves tres conseqüències dissecades: un hashCode que canvia en persistir i fa desaparèixer objectes d'un HashSet; un toString que dispara relacions mandroses i provoca N+1, LazyInitializationException o recursió infinita; i setters per a l'id i la version. Amb les regles per evitar-ho i un lombok.config que converteix la disciplina en una cosa que el compilador verifica. I saps quan un record és millor opció, que és gairebé sempre per a DTO i objectes de valor.

SLF4J i Logback han substituït java.util.logging. Entens la façana enfront de la implementació i les seves quatre raons, amb els ponts que unifiquen en un sol canal el logging de Spring, Hibernate, Jackson i el teu codi. Coneixes el patró LoggerFactory.getLogger(X.class) i per què cada modificador hi és, i sobretot el logging parametritzat amb {}, l'avantatge del qual no és estètic sinó mesurable: la cadena no es construeix si el nivell està desactivat, cinquanta vegades més ràpid i zero assignacions en el cas que passa en producció. Amb els casos particulars: l'excepció com a últim argument sense {}, i isDebugEnabled només quan l'argument és car de calcular.

Manegues els nivells i la seva correspondència amb java.util.logging, amb el criteri de què va a cadascun i les dues regles que més milloren un log: INFO és el que un operador voldria veure, i no registris i rellancis. Configures Logback amb appenders, rotació per data i mida amb límit total, escriptura asíncrona i <springProfile> per tenir text llegible en desenvolupament i JSON estructurat en producció sense tocar codi. I coneixes el MDC per correlacionar peticions, amb els seus dos avisos: MDC.clear() en un finally sempre, perquè en un pool de fils no netejar-lo és alhora confusió i fuita de dades entre peticions —la fuita de ThreadLocal de 10-07—, i que no es propaga automàticament a altres fils.

I tens el panorama final de l'ecosistema: Commons, Guava, MapStruct, Caffeine, OpenCSV —que salda de passada l'últim deute, el CSV a mà de 07-07—, Flyway, springdoc-openapi, Resilience4j —que és el teu ProxyReintents de 10-03 fet producció—, Micrometer, Testcontainers, WireMock, ArchUnit i JMH. No per aprendre-les ara, sinó per no reinventar-les.


BiblioTech, en tancar el mòdul 11, és un altre projecte.

És un projecte Maven reproduïble, amb el seu wrapper, el seu POM comentat, les seves dependències declarades i auditables, les seves fases i els seus plugins. Es compila amb ./mvnw clean verify a qualsevol màquina del món que tingui Java 17, i canviar la versió d'una dependència vulnerable és una línia i una ordre.

El seu contenidor és Spring: el ContenidorSimple de cent cinquanta línies ha desaparegut, substituït per un ApplicationContext amb injecció per constructor, estereotips, cicle de vida, àmbits, perfils dev i prod, propietats tipades i validades que impedeixen arrencar amb configuració invàlida, i aspectes declaratius on abans hi havia tres proxies aplicats a mà.

La seva persistència és JPA sobre Hibernate i H2: entitats mapejades, relacions amb integritat referencial, transaccions ACID, càrrega mandrosa controlada, consultes JPQL parametritzades, bloqueig optimista amb @Version per a l'exemplar disputat, i repositoris de Spring Data que són interfícies sense implementació. Els fitxers CSV se n'han anat.

proves: quaranta-una unitàries que corren en menys d'un segon, més les d'integració; el Clock de 10-05 per fi cobrat amb Clock.fixed; proves parametritzades que cobreixen dotze casos de multes en sis línies; i amb Mockito, els camins d'error que en producció passen el pitjor dia —la base de dades que falla, l'API caiguda, el conflicte de concurrència amb el seu reintent—, més el ClientMetadades provat sense tocar la xarxa.

El seu JSON és Jackson, el seu codi repetitiu el genera Lombok de forma restringida i conscient, i el seu logging és SLF4J amb Logback, parametritzat, correlacionat amb MDC i configurat per entorn.

I encara no és una aplicació.

És un conjunt de peces excel·lents que encara no formen un producte. No té una estructura de projecte pensada per créixer: tot viu en un mòdul. No aplica conscientment els patrons que fan mantenible un sistema — han anat apareixent (Injecció de Dependències, Proxy, Repositori, Fàbrica, Builder, Singleton) i a cada aparició s'han anomenat i ajornat. No té una interfície per la qual un empleat de Nexus Software pugui fer-la servir: ni consola decent ni API web. No té mesura de la seva pròpia qualitat. No està desplegada enlloc. I no té ni autenticació, ni autorització, ni observabilitat, ni pla d'evolució de l'esquema.

El mòdul 12 converteix les peces en un producte. Configuraràs el projecte de veritat, amb mòduls que imposin les fronteres arquitectòniques. Estudiaràs els patrons de disseny que portes tot el mòdul trobant-te sense anomenar-los del tot. Construiràs l'aplicació de consola i l'aplicació web amb Spring Boot i REST. Mesuraràs la qualitat amb cobertura i proves d'integració serioses. Desplegaràs amb contenidors i migracions versionades. I afegiràs seguretat, observabilitat i una estratègia d'evolució.

Has après a fer servir les eines. Ara construiràs alguna cosa amb elles.

Curs de Programació en Java

Mòdul 1: Introducció a Java

Mòdul 2: Flux de control

Mòdul 3: Programació orientada a objectes

Mòdul 4: Programació orientada a objectes avançada

Mòdul 5: Estructures de dades i col·leccions

Mòdul 6: Gestió d'excepcions

Mòdul 7: Entrada/sortida de fitxers

Mòdul 8: Multifil i concurrència

Mòdul 9: Xarxes

Mòdul 10: Temes avançats

Mòdul 11: Frameworks i llibreries de Java

Mòdul 12: Construcció d'aplicacions del món real

© Copyright 2026. Tots els drets reservats