Esta lección cierra el módulo, y salda tres deudas que llevan módulos enteros esperando.

La primera se contrajo en 09-06. Allí, para leer la respuesta de una API de metadatos de libros, escribiste un parseador de JSON con indexOf y substring. Se dijo textualmente que era un apaño didáctico y que se resolvería aquí. Ese código se rompe con el primer carácter de escape, con el primer objeto anidado y con el primer campo que la API decida añadir.

La segunda viene de 03-09 y se agravó en 11-03: las entidades de BiblioTech tienen cincuenta líneas de getters, equals, hashCode y toString escritos a mano. Código que no aporta nada, que hay que mantener y en el que es fácil equivocarse.

La tercera es de 06-07: el logging de BiblioTech es java.util.logging, elegido entonces por no añadir dependencias, con la limitación que ya se señaló allí. Y mvn dependency:tree te ha enseñado tres veces que Spring Boot ya te trajo SLF4J y Logback sin que los pidieras.

Las tres tienen algo en común, y es lo que distingue esta lección de las anteriores del módulo: son librerías, no frameworks. Volviendo a 11-01, tú las llamas a ellas. No invierten el control, no imponen arquitectura, y quitarlas es mucho más barato que quitar Spring. Son la caja de herramientas, no el edificio.

Al terminar dominarás Jackson y habrás reescrito el ClienteMetadatos; conocerás Lombok con una valoración honesta de sus problemas; habrás migrado BiblioTech a SLF4J con Logback configurado; y tendrás el panorama de las librerías que conviene conocer y para qué sirve cada una.

Contenido

  1. Qué es JSON y su correspondencia con Java
  2. Jackson: el ObjectMapper
  3. Por qué se crea uno y se reutiliza
  4. Serializar: de objeto a JSON
  5. Deserializar: de JSON a objeto
  6. Mapear POJOs y record
  7. Las anotaciones esenciales de Jackson
  8. @JsonIgnoreProperties: la imprescindible
  9. Tipos genéricos con TypeReference
  10. Módulos: JavaTimeModule y las fechas
  11. El árbol JsonNode para JSON dinámico
  12. Serializadores y deserializadores propios
  13. BiblioTech: la reescritura del ClienteMetadatos
  14. Seguridad: deserialización polimórfica y gadgets
  15. Gson y JSON-B, mencionados
  16. Lombok: qué es y cómo funciona
  17. Las anotaciones de Lombok
  18. @Builder y @RequiredArgsConstructor
  19. Configuración del IDE y lombok.config
  20. Lombok: la valoración honesta
  21. @Data en entidades JPA: un bug real
  22. Los record frente a Lombok
  23. SLF4J: fachada frente a implementación
  24. Por qué se programa contra SLF4J
  25. El patrón LoggerFactory.getLogger
  26. Logging parametrizado con {}
  27. Niveles y su correspondencia con java.util.logging
  28. Configurar Logback
  29. MDC: correlacionar peticiones
  30. Logging estructurado en JSON
  31. BiblioTech: la migración a SLF4J
  32. Log4j2, mencionado
  33. Panorama final: otras librerías que conviene conocer
  34. Errores Comunes y Consejos
  35. Ejercicios

  1. Qué es JSON y su correspondencia con Java

JSON (JavaScript Object Notation) es el formato de intercambio de datos dominante. Es texto, es legible, y tiene solo seis tipos.

{
  "isbn": "978-0000000001",
  "titulo": "Java Efectivo",
  "autor": "Joshua Bloch",
  "anioPublicacion": 2018,
  "disponible": true,
  "categorias": ["Java", "Buenas prácticas"],
  "editorial": {
    "nombre": "Addison-Wesley",
    "pais": "EE. UU."
  },
  "fechaAlta": "2026-01-15",
  "valoracion": null
}

La correspondencia con Java:

JSON Java Notas
object {...} Clase, record, Map<String, Object> El caso normal
array [...] List, Set, array
string "..." String, enum, LocalDate, UUID Con conversión
number int, long, double, BigDecimal BigDecimal para dinero
true / false boolean, Boolean
null null, Optional.empty()

Lo que JSON no tiene, y que causa la mayoría de los problemas:

Falta en JSON Consecuencia
Tipos de fecha Se representan como cadena. Hay que acordar el formato (ISO-8601, como en 10-05)
Enteros frente a decimales 1 puede leerse como int o como double; 0.1 + 0.2 sigue sin ser 0.3
Comentarios No se pueden documentar los campos
Referencias cíclicas Un grafo con ciclos provoca recursión infinita al serializar
Tipos declarados Nada dice si {"nombre": "x"} es un Empleado o una Editorial

Esa última fila es la raíz del problema de seguridad del apartado 14.

  1. Jackson: el ObjectMapper

Jackson es la librería de JSON estándar en Java. Ya la tienes: spring-boot-starter-web la trae, y mvn dependency:tree te la mostró en 11-05. Para añadirla explícitamente:

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

<!-- Soporte para java.time (apartado 10) -->
<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
</dependency>

Jackson tiene tres niveles de API:

Nivel Clase principal Cuándo
Enlace de datos ObjectMapper El 95 % de los casos: objeto ↔ JSON
Árbol JsonNode JSON dinámico o de estructura desconocida
Streaming JsonParser, JsonGenerator Documentos enormes, máximo rendimiento

  1. Por qué se crea uno y se reutiliza

Antes del primer ejemplo, una regla que evita un problema de rendimiento real:

Crea un ObjectMapper y reutilízalo. Nunca uno por llamada.

Un ObjectMapper es caro de construir: al procesar un tipo por primera vez, lo introspecciona por reflexión (10-03) —campos, getters, anotaciones— y cachea el resultado. Ese caché es lo que hace que la segunda serialización del mismo tipo sea rapidísima.

// MAL: crea un mapper en cada llamada. Tira el caché a la basura cada vez.
public String aJson(Material material) {
    return new ObjectMapper().writeValueAsString(material);   // lento y con basura
}

// BIEN: uno solo, compartido
private static final ObjectMapper MAPPER = new ObjectMapper();

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

Y hay un detalle que lo hace posible: ObjectMapper es seguro para usar desde varios hilos, siempre que no lo reconfigures después de empezar a usarlo. Un singleton está perfectamente bien.

Es exactamente el mismo razonamiento que hiciste en 09-06 con HttpClient y en 10-07 con Pattern y DateTimeFormatter: objetos caros de crear, seguros ante la concurrencia, se construyen una vez.

Con Spring, esto lo resuelve el contenedor:

@Service
public class ExportadorCatalogo {

    private final ObjectMapper mapper;   // Spring inyecta el suyo, ya configurado

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

Spring Boot autoconfigura un ObjectMapper (11-02) con los módulos registrados y una configuración sensata. Úsalo en vez de crear el tuyo.

  1. Serializar: de objeto 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 por capas del módulo 6: envolver, conservar la causa
            throw new ExportacionException("No se pudo serializar " + material.getIsbn(), e);
        }
    }

    public String exportarLegible(Material material) {
        try {
            return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(material);
        } catch (JsonProcessingException e) {
            throw new ExportacionException("No se pudo serializar", e);
        }
    }

    public void exportarAFichero(List<Material> catalogo, Path destino) throws IOException {
        mapper.writeValue(destino.toFile(), catalogo);      // sin cargarlo todo en memoria
    }
}

Resultado de writeValueAsString:

{"isbn":"978-0000000001","titulo":"Java Efectivo","autor":"Joshua Bloch","ejemplaresDisponibles":3}

Y de writerWithDefaultPrettyPrinter:

{
  "isbn" : "978-0000000001",
  "titulo" : "Java Efectivo",
  "autor" : "Joshua Bloch",
  "ejemplaresDisponibles" : 3
}

Consejo: usa el formato legible solo para depurar o para ficheros que van a leer personas. En una API produce entre un 20 % y un 30 % más de bytes por cada petición.

Los destinos posibles:

Método Destino
writeValueAsString(o) String
writeValueAsBytes(o) byte[]
writeValue(File, o) Fichero
writeValue(OutputStream, o) Flujo (módulo 7)
writeValue(Writer, o) Writer

Para colecciones o ficheros grandes, escribir directamente al flujo evita construir una cadena gigante en memoria — lo que 10-07 llamaba una asignación innecesaria en el heap.

  1. Deserializar: de JSON a objeto

public Material importar(String json) {
    try {
        return mapper.readValue(json, Libro.class);
    } catch (JsonProcessingException e) {
        throw new ImportacionException("JSON de material inválido", e);
    }
}

Fuentes posibles:

Libro desdeCadena  = mapper.readValue(json, Libro.class);
Libro desdeFichero = mapper.readValue(Path.of("libro.json").toFile(), Libro.class);
Libro desdeFlujo   = mapper.readValue(entrada, Libro.class);
Libro desdeUrl     = mapper.readValue(new URL("https://..."), Libro.class);

Qué necesita Jackson para deserializar en una clase normal:

  1. Un constructor sin argumentos (o uno anotado con @JsonCreator).
  2. Setters o campos accesibles.

Si no los hay:

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

Es el mismo requisito que impone JPA (11-03), y por la misma razón: instanciación por reflexión.

  1. Mapear POJOs y record

Con una clase normal:

public class MetadatosLibro {

    private String isbn;
    private String titulo;
    private String autor;
    private int anioPublicacion;

    public MetadatosLibro() { }   // exigido por Jackson

    // getters y setters...
}

Con un record (04-07), y aquí hay una buena noticia:

public record MetadatosLibro(String isbn, String titulo, String autor, int anioPublicacion) { }

Jackson 2.12 y superiores soportan record de forma nativa. No hace falta ninguna anotación: usa el constructor canónico y los accesores. Y como el record es inmutable, obtienes un objeto que no puede quedar a medio construir.

Los record son ideales como DTO (objetos de transferencia de datos): representan la forma del JSON, son inmutables, tienen equals y toString gratis, y no llevan lógica. En 11-03 vimos que no pueden ser entidades JPA; aquí encuentran su sitio natural.

Un detalle para deserializar record con clases normales anidadas o cuando faltan nombres de parámetro: el compilador debe conservarlos. Por eso en 11-05 se configuró <parameters>true</parameters> en el maven-compiler-plugin. Con Spring Boot ya viene puesto.

Anidamiento:

public record MetadatosLibro(
        String isbn,
        String titulo,
        Autor autor,                    // objeto anidado
        List<String> categorias,        // array
        LocalDate fechaPublicacion) {   // fecha ISO (apartado 10)

    public record Autor(String nombre, String pais) { }
}
{
  "isbn": "978-0000000001",
  "titulo": "Java Efectivo",
  "autor": { "nombre": "Joshua Bloch", "pais": "EE. UU." },
  "categorias": ["Java", "Buenas prácticas"],
  "fechaPublicacion": "2018-01-06"
}

Jackson resuelve el anidamiento y las colecciones recursivamente, sin configuración.

  1. Las anotaciones esenciales de Jackson

Anotación Qué hace Ejemplo
@JsonProperty("nombre") Cambia el nombre en el JSON @JsonProperty("isbn_13")
@JsonIgnore Excluye el campo Contraseñas, campos internos
@JsonInclude(NON_NULL) Omite los nulos Reduce el tamaño
@JsonFormat Formato de fechas y números pattern = "dd/MM/yyyy"
@JsonAlias({"a","b"}) Nombres alternativos al leer Compatibilidad con versiones
@JsonCreator Marca el constructor de deserialización Objetos inmutables
@JsonIgnoreProperties(ignoreUnknown=true) Ignora campos desconocidos Imprescindible (apartado 8)
@JsonPropertyOrder Orden de los campos Legibilidad
@JsonAnySetter / @JsonAnyGetter Campos dinámicos en un Map Extensiones
@JsonUnwrapped Aplana un objeto anidado
@JsonSerialize / @JsonDeserialize Serializador propio Apartado 12

Ejemplo completo, con un caso real de BiblioTech:

package com.nexussoftware.bibliotech.red;

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

@JsonIgnoreProperties(ignoreUnknown = true)      // imprescindible: apartado 8
@JsonInclude(JsonInclude.Include.NON_NULL)       // no serializar nulos
public record RespuestaMetadatos(

        @JsonProperty("isbn_13")                 // la API usa snake_case
        String isbn,

        @JsonAlias({"title", "book_title"})      // la API cambió el nombre en v2
        String titulo,

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

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

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

@JsonAlias merece una nota: acepta varios nombres al leer pero escribe siempre con el nombre principal. Es la herramienta para sobrevivir a que una API renombre un campo sin romper la compatibilidad con las respuestas antiguas.

Y @JsonIgnore tiene un uso de seguridad evidente:

public class UsuarioDto {
    private String correo;

    @JsonIgnore
    private String hashContrasena;   // NUNCA debe salir en una respuesta
}

Aunque la práctica más segura no es confiar en una anotación, sino que el DTO de salida no tenga siquiera ese campo. Se trata en 12-07.

  1. @JsonIgnoreProperties: la imprescindible

Merece apartado propio porque es la anotación que más problemas de producción evita.

Por defecto, si el JSON trae un campo que tu clase no tiene, Jackson falla:

public record MetadatosLibro(String isbn, String titulo) { }
{
  "isbn": "978-0000000001",
  "titulo": "Java Efectivo",
  "idioma": "es"
}
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:
Unrecognized field "idioma" (class MetadatosLibro), not marked as ignorable

Piensa en lo que significa: el día que la API externa añada un campo nuevo —algo perfectamente compatible desde su punto de vista— tu aplicación deja de funcionar. Y esa API puede desplegar un martes por la mañana sin avisarte.

La solución:

@JsonIgnoreProperties(ignoreUnknown = true)
public record MetadatosLibro(String isbn, String titulo) { }

O globalmente:

mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

O en Spring Boot, que ya lo hace por ti:

spring:
  jackson:
    deserialization:
      fail-on-unknown-properties: false   # valor por defecto en Spring Boot

Regla: todo DTO que reciba datos de un sistema externo debe llevar @JsonIgnoreProperties(ignoreUnknown = true).

El matiz profesional: para JSON propio —tu configuración interna, un fichero que tú generas— puede tener sentido fallar ante campos desconocidos, porque un campo inesperado indica un error de escritura. La distinción es "¿controlo yo el emisor?".

  1. Tipos genéricos con TypeReference

Y aquí se cobra el borrado de tipos de 10-01.

// NO FUNCIONA como esperas
List<MetadatosLibro> lista = mapper.readValue(json, List.class);
// -> devuelve List<LinkedHashMap>, y estalla al usarlo:
// ClassCastException: LinkedHashMap cannot be cast to MetadatosLibro

Por qué: por el borrado de tipos, List<MetadatosLibro>.class no existe. En ejecución solo hay List.class, sin información sobre el parámetro. Jackson no puede saber qué construir dentro y crea mapas genéricos.

La solución es TypeReference, y usa exactamente el truco que aprendiste en 10-01:

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

Fíjate en las llaves { } al final: crean una subclase anónima (04-04) de TypeReference. Y la información genérica de una superclase sí se conserva en el bytecode, así que Jackson puede leerla por reflexión con getGenericSuperclass(). Es el mismo truco que estudiaste en 10-01 al hablar de qué sobrevive al borrado.

Casos habituales:

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

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

// Genérico propio: el Resultado<T> de 10-01
Resultado<Material> resultado = mapper.readValue(json, new TypeReference<Resultado<Material>>() { });

Desde Java 10, el operador diamante funciona en las subclases anónimas, así que new TypeReference<>() { } basta cuando el tipo se infiere del destino.

Alternativa con JavaType, útil cuando el tipo se decide en ejecución:

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

  1. Módulos: JavaTimeModule y las fechas

Jackson es extensible mediante módulos. El imprescindible es el de java.time, porque sin él:

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);   // ¡importante!

O, mejor, en una sola línea que registra todos los módulos del classpath:

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

Esa segunda línea, WRITE_DATES_AS_TIMESTAMPS, importa mucho. Comparación con la fechaPublicacion de un Instant:

// Activado (valor por defecto de Jackson): número de segundos desde 1970
{"instanteRegistro": 1774000800.000000000}

// Desactivado: ISO-8601, legible e interoperable
{"instanteRegistro": "2026-03-20T09:00:00Z"}

La segunda forma es claramente mejor: cualquier sistema del mundo la entiende, es legible en un log y es lo que 10-05 estableció como estándar para BiblioTech en los CSV. Desactívalo siempre.

Con Spring Boot ya viene desactivado, y puedes verificarlo:

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

Módulos habituales:

Módulo Aporta
jackson-datatype-jsr310 java.time
jackson-module-parameter-names Nombres de parámetros para constructores
jackson-datatype-jdk8 Optional
jackson-dataformat-yaml Leer y escribir YAML con la misma API
jackson-dataformat-csv CSV (apartado 33)
jackson-dataformat-xml XML

El de Optional es interesante: sin él, un Optional<String> se serializa como {"present":true}, que no es lo que quieres. Con él, se serializa como el valor o como null. Aun así, la recomendación general es no usar Optional en los DTO: usa el tipo directamente y deja que sea null en el JSON. Optional está pensado para valores de retorno, no para campos.

Y ese jackson-dataformat-csv merece que lo veas ahora, porque salda de pasada la deuda de 07-07:

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

// Escribir
mapper.writer(esquema).writeValue(fichero, materiales);

// Leer
List<MaterialCsv> materiales = mapper.readerFor(MaterialCsv.class)
        .with(esquema).<MaterialCsv>readValues(fichero).readAll();

Ese LectorCsv de setenta líneas que escribiste a mano en 07-07 —con sus casos límite de comillas y saltos de línea embebidos que nunca llegó a cubrir— cabe ahora en tres líneas, con la misma librería que ya tienes.

  1. El árbol JsonNode para JSON dinámico

Cuando no conoces la estructura de antemano, o solo necesitas un campo de una respuesta enorme:

JsonNode raiz = mapper.readTree(json);

String titulo = raiz.path("titulo").asText();
int anio      = raiz.path("anioPublicacion").asInt(0);          // 0 si falta
String pais   = raiz.path("editorial").path("pais").asText("Desconocido");

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

// Comprobar existencia
if (raiz.has("valoracion") && !raiz.get("valoracion").isNull()) {
    double valoracion = raiz.get("valoracion").asDouble();
}

// Construir JSON a mano
ObjectNode nuevo = mapper.createObjectNode();
nuevo.put("isbn", "978-0000000001");
nuevo.put("titulo", "Java Efectivo");
nuevo.putArray("categorias").add("Java").add("Buenas prácticas");
String json = mapper.writeValueAsString(nuevo);

Detalle importante: path() frente a get().

Método Si el campo no existe
get("x") Devuelve nullNullPointerException al encadenar
path("x") Devuelve un nodo "faltante" → se puede encadenar con seguridad

Usa path() salvo que necesites distinguir explícitamente "no está" de "está y es null".

Cuándo usar el árbol frente a los objetos:

JsonNode (árbol) Clases o record
Estructura conocida
Estructura variable
Solo un campo de un JSON enorme
Seguridad de tipos Ninguna Total
Legibilidad Baja Alta
Refactorizable por el IDE No

Prefiere clases o record siempre que puedas. El árbol convierte errores de compilación en errores de ejecución.

  1. Serializadores y deserializadores propios

Cuando un tipo necesita un tratamiento especial. El caso de BiblioTech: el record Isbn de 11-03, que en JSON debe ser una cadena plana, no un objeto.

// Sin serializador propio: {"isbn": {"valor": "978-0000000001"}}
// Con serializador propio: {"isbn": "978-0000000001"}
package com.nexussoftware.bibliotech.persistencia;

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

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

public class DeserializadorIsbn extends JsonDeserializer<Isbn> {
    @Override
    public Isbn deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
        String texto = p.getText();
        try {
            return new Isbn(texto);          // la validación del record actúa aquí
        } catch (IsbnInvalidoException e) {
            throw JsonMappingException.from(p, "ISBN inválido: " + texto, e);
        }
    }
}

Registro, de dos formas:

// Opción 1: en el campo
public record MaterialDto(
        @JsonSerialize(using = SerializadorIsbn.class)
        @JsonDeserialize(using = DeserializadorIsbn.class)
        Isbn isbn,
        String titulo) { }
// Opción 2: en un módulo, aplicado a TODOS los Isbn
SimpleModule modulo = new SimpleModule("BiblioTech");
modulo.addSerializer(Isbn.class, new SerializadorIsbn());
modulo.addDeserializer(Isbn.class, new DeserializadorIsbn());
mapper.registerModule(modulo);

Con Spring, se registra como un bean y la autoconfiguración lo recoge:

@Bean
public Module moduloBiblioTech() {
    SimpleModule modulo = new SimpleModule("BiblioTech");
    modulo.addSerializer(Isbn.class, new SerializadorIsbn());
    modulo.addDeserializer(Isbn.class, new DeserializadorIsbn());
    return modulo;
}

Fíjate en el paralelismo con 11-03: esto es el equivalente en Jackson del AttributeConverter de JPA. El mismo objeto de valor, dos adaptadores para dos tecnologías. El dominio se mantiene limpio y cada infraestructura tiene su traductor.

  1. BiblioTech: la reescritura del ClienteMetadatos

El momento prometido en 09-06.

Aquello era el apaño:

// 09-06: el "apaño didáctico" reconocido como tal
public class ClienteMetadatos {

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

    public Optional<String> extraerAutor(String json) {
        // ... lo mismo, otra vez
    }
}

Todo lo que está mal en ese código:

Problema Ejemplo que lo rompe
No maneja escapes "title":"Java: la \"guía\" definitiva" → título truncado
No maneja anidamiento {"edition":{"title":"otra"}} → devuelve el título equivocado
Depende del orden y el espaciado "title" : "x" (con espacios) → no encuentra nada
No maneja Unicode escapado é → sale literalmente en vez de é
No maneja tipos Todo es String; no hay números, booleanos ni fechas
Un método por campo Ocho campos, ocho métodos casi idénticos
No detecta JSON inválido Devuelve Optional.empty() como si el campo faltara
No hay tipos Ninguna verificación del compilador

Y esta es la versión con Jackson:

package com.nexussoftware.bibliotech.red;

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

/** Respuesta de la API externa de metadatos. Es un DTO: refleja el JSON, no el dominio. */
@JsonIgnoreProperties(ignoreUnknown = true)   // la API puede añadir campos cuando quiera
public record RespuestaMetadatos(

        @JsonProperty("isbn_13")     String isbn,
        @JsonAlias({"title"})        String titulo,
        @JsonProperty("authors")     List<Autor> autores,
        @JsonProperty("publish_date") LocalDate fechaPublicacion,
        @JsonProperty("number_of_pages") Integer paginas,
        @JsonProperty("publishers")  List<String> editoriales) {

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

    /** Traduce el DTO externo al modelo de dominio de BiblioTech. */
    public MetadatosLibro aDominio() {
        String autoresUnidos = autores == null ? null
                : autores.stream().map(Autor::name).collect(Collectors.joining(", "));
        String editorial = editoriales == null || editoriales.isEmpty()
                ? null : editoriales.get(0);
        return new MetadatosLibro(autoresUnidos, fechaPublicacion, editorial, paginas);
    }
}
package com.nexussoftware.bibliotech.red;

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 PasarelaMetadatosHttp implements PasarelaMetadatos {

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

    private final HttpClient http;          // uno solo, inyectado (11-02)
    private final ObjectMapper mapper;      // uno solo, inyectado
    private final String urlBase;

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

    @Override
    public Optional<MetadatosLibro> buscarPorIsbn(String isbn) {

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

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

            if (respuesta.statusCode() == 404) {
                log.debug("La API no conoce el ISBN {}", isbn);
                return Optional.empty();
            }
            if (respuesta.statusCode() >= 500) {
                throw new PasarelaNoDisponibleException(
                        "La API devolvió " + respuesta.statusCode());
            }

            // UNA LÍNEA. Sin indexOf, sin substring, sin casos límite sin cubrir.
            RespuestaMetadatos dto = mapper.readValue(respuesta.body(), RespuestaMetadatos.class);

            log.info("Metadatos obtenidos para {}: {}", isbn, dto.titulo());
            return Optional.of(dto.aDominio());

        } catch (JsonProcessingException e) {
            log.warn("Respuesta JSON inválida para {}", isbn, e);
            throw new PasarelaNoDisponibleException("Respuesta ilegible de la API", e);
        } catch (IOException | InterruptedException e) {
            if (e instanceof InterruptedException) Thread.currentThread().interrupt();
            throw new PasarelaNoDisponibleException("Error de red al consultar " + isbn, e);
        }
    }
}

El antes y el después:

Aspecto indexOf (09-06) Jackson
Líneas de parseo ~40, una por campo 1
Escapes y Unicode Rompe Correcto
Anidamiento Rompe Correcto
Tipos Todo String LocalDate, Integer, List
Campo nuevo en la API Sin efecto (no lo lee) Sin efecto (ignoreUnknown)
Campo renombrado Devuelve vacío en silencio @JsonAlias lo cubre
JSON inválido Indistinguible de campo ausente Excepción explícita
Verificación del compilador Ninguna Total
Probable sin red Difícil (11-06)

Y una decisión de diseño que conviene subrayar: RespuestaMetadatos es un DTO, no una entidad de dominio. Refleja la forma del JSON externo —con sus isbn_13 y sus publishers— y tiene un método aDominio() que traduce. Así, si la API cambia su formato, solo cambia el DTO; el dominio de BiblioTech no se entera. Es la misma separación de fronteras de 11-06.

  1. Seguridad: deserialización polimórfica y gadgets

Un aviso serio que retoma directamente 07-05.

En 07-05, al hablar de serialización de Java, se advirtió de que deserializar datos de origen no confiable es peligroso. Con JSON el riesgo es menor, pero no es nulo, y el mecanismo es el mismo.

El problema aparece con la deserialización polimórfica: cuando le pides a Jackson que decida qué clase instanciar según el contenido del propio JSON.

// PELIGROSO con datos externos
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                             ObjectMapper.DefaultTyping.NON_FINAL);

Con eso activado, el JSON puede contener el nombre de la clase a instanciar:

{"@class": "com.ejemplo.ClasePeligrosa", "propiedad": "valor"}

Un atacante que controle el JSON puede indicar cualquier clase del classpath. Si alguna de esas clases —un gadget— hace algo peligroso en su constructor, en un setter o en un método de inicialización (abrir una conexión, cargar código, ejecutar un comando), se produce ejecución de código. Se conocen cadenas de gadgets en librerías muy comunes, y por eso Jackson mantiene una lista negra que hay que ir actualizando.

Las reglas prácticas:

  1. No actives el tipado por defecto para datos externos. Nunca.
  2. Si necesitas polimorfismo, usa @JsonTypeInfo con una lista blanca explícita:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "tipo")
@JsonSubTypes({
    @JsonSubTypes.Type(value = LibroDto.class,   name = "LIBRO"),
    @JsonSubTypes.Type(value = RevistaDto.class, name = "REVISTA"),
    @JsonSubTypes.Type(value = DvdDto.class,     name = "DVD")
})
public sealed interface MaterialDto permits LibroDto, RevistaDto, DvdDto { }

Así solo se pueden instanciar tres clases, las que tú has enumerado. Y fíjate en que sealed (10-06) refuerza la garantía en compilación: nadie puede añadir un subtipo sin tocar este fichero.

  1. Mantén Jackson actualizado. Es exactamente el argumento de 11-01: las vulnerabilidades se descubren en código que ya existía.
  2. Valida después de deserializar. Que el JSON se parsee no significa que los datos sean válidos. Jakarta Bean Validation (11-02) va justo después.

La seguridad de aplicaciones —validación de entrada, límites de tamaño, autorización— se trata a fondo en 12-07. Aquí basta con la regla: no actives tipado dinámico con datos que no controlas.

Un límite adicional que conviene conocer: un JSON con anidamiento muy profundo puede agotar la pila (el StackOverflowError de 10-07). Jackson 2.15 y superiores limitan la profundidad y el tamaño de los documentos por defecto, pero si expones una API pública conviene revisar esos límites explícitamente.

  1. Gson y JSON-B, mencionados

Librería Quién Notas
Jackson FasterXML El estándar. Integrado en Spring Boot. Más completo y más rápido
Gson Google Más simple, API mínima. No necesita constructor sin argumentos (usa Unsafe). Menos anotaciones y menos extensible
JSON-B Jakarta EE Estándar de la especificación. Implementaciones: Yasson, Johnzon. Poco usado fuera de Jakarta EE
JSON-P Jakarta EE API de bajo nivel (equivalente al árbol y al streaming de Jackson)

Recomendación: usa Jackson. Ya está en tu proyecto, es lo que espera Spring, tiene el ecosistema de módulos más rico y es lo que te encontrarás en cualquier código Java.

Gson tiene un nicho legítimo: proyectos pequeños o Android, donde su simplicidad y su tamaño reducido cuentan.

  1. Lombok: qué es y cómo funciona

Segunda deuda. Mira una entidad típica de BiblioTech:

public class Empleado {

    private Long id;
    private String correo;
    private String nombre;
    private String departamento;

    public Empleado() { }

    public Empleado(String correo, String nombre, String departamento) {
        this.correo = correo;
        this.nombre = nombre;
        this.departamento = departamento;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getCorreo() { return correo; }
    public void setCorreo(String correo) { this.correo = correo; }
    public String getNombre() { return nombre; }
    public void setNombre(String nombre) { this.nombre = nombre; }
    public String getDepartamento() { return departamento; }
    public void setDepartamento(String departamento) { this.departamento = departamento; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Empleado)) return false;
        Empleado otro = (Empleado) o;
        return Objects.equals(correo, otro.correo);
    }

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

    @Override
    public String toString() {
        return "Empleado{correo='" + correo + "', nombre='" + nombre + "'}";
    }
}

Cuarenta líneas, de las cuales cuatro son información: los cuatro campos. El resto es ruido mecánico.

Lombok genera todo eso en tiempo de compilación:

@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(of = "correo")
@ToString(of = {"correo", "nombre"})
public class Empleado {
    private Long id;
    private String correo;
    private String nombre;
    private String departamento;
}

Cómo funciona: un procesador de anotaciones

Y aquí vuelve 10-02. Lombok es un procesador de anotaciones (javax.annotation.processing): se engancha al compilador, y durante la compilación modifica el árbol sintáctico para añadir los métodos.

graph LR
    A["Empleado.java<br/>con @Getter"] --> B["javac<br/>fase de análisis"]
    B --> C["Procesador<br/>de Lombok"]
    C -->|"modifica el AST:<br/>añade getters,<br/>equals, hashCode"| D["javac<br/>generación"]
    D --> E["Empleado.class<br/>CON los métodos"]

Consecuencias importantes de que actúe en compilación:

  1. No hay coste en ejecución. El .class contiene los métodos como si los hubieras escrito. Cero reflexión, cero proxies.
  2. Puedes verlo con javap:
javap -p target/classes/com/nexussoftware/bibliotech/dominio/Empleado.class
public class com.nexussoftware.bibliotech.dominio.Empleado {
  private java.lang.Long id;
  private java.lang.String correo;
  public java.lang.Long getId();
  public void setId(java.lang.Long);
  public java.lang.String getCorreo();
  ...
}
  1. El IDE necesita un plugin. El código fuente no contiene getCorreo(), así que sin plugin el editor lo marca en rojo aunque compile bien.
  2. Es una API interna del compilador. Lombok usa mecanismos no estandarizados de javac, y por eso a veces se rompe con una versión nueva de Java hasta que publican una actualización. Es su riesgo real.

Dependencia:

<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>   <!-- solo en compilación (11-05) -->
    <optional>true</optional>
</dependency>

provided es exactamente lo correcto: Lombok no hace falta en ejecución, así que no se empaqueta.

  1. Las anotaciones de Lombok

Anotación Genera
@Getter / @Setter Getters y setters (en la clase o en un campo)
@ToString toString(), con of/exclude
@EqualsAndHashCode equals y hashCode, con of/exclude
@NoArgsConstructor Constructor vacío
@AllArgsConstructor Constructor con todos los campos
@RequiredArgsConstructor Constructor con los final y los @NonNull
@Data @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor
@Value Como @Data pero inmutable: todo final, sin setters
@Builder El patrón Builder
@Slf4j private static final Logger log = LoggerFactory.getLogger(X.class)
@NonNull Comprobación de nulidad al principio del método
@SneakyThrows Lanza excepciones comprobadas sin declararlas. Usar con mucha cautela
@Cleanup Cierre automático (hoy lo cubre try-with-resources, 06-06)

Dos merecen atención especial.

@Slf4j ahorra la línea que aparecerá en cada clase del apartado 25:

@Slf4j
@Service
public class GestorPrestamos {
    public void prestar(String isbn) {
        log.info("Prestando {}", isbn);      // 'log' existe, generado por Lombok
    }
}

@SneakyThrows merece una advertencia. Permite lanzar una excepción comprobada sin declararla en la firma:

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

Es un truco sobre el sistema de tipos: el llamador no puede capturar esa IOException con un catch (IOException e) sin que el compilador se queje de que nunca se lanza. Rompe el contrato del módulo 6. Úsalo solo en lambdas o en código donde la excepción sea genuinamente imposible; nunca para evitar pensar en el manejo de errores.

  1. @Builder y @RequiredArgsConstructor

@Builder

@Builder
@Getter
public class Prestamo {
    private final Material material;
    private final Empleado empleado;
    private final LocalDate fechaPrestamo;
    private final LocalDate fechaVencimiento;
    @Builder.Default
    private final EstadoPrestamo estado = EstadoPrestamo.ACTIVO;
}
Prestamo prestamo = Prestamo.builder()
        .material(libro)
        .empleado(marta)
        .fechaPrestamo(LocalDate.now(reloj))
        .fechaVencimiento(LocalDate.now(reloj).plusDays(15))
        .build();

Ventaja frente a un constructor de cinco parámetros: los argumentos van con nombre. Un new Prestamo(libro, marta, hoy, hoy.plusDays(15)) con dos fechas seguidas del mismo tipo es un accidente esperando a ocurrir; con builder, es imposible confundirlas.

Builder es un patrón de diseño, y como tal se estudia a fondo en 12-02, junto con Fábrica, Repositorio, Singleton y los demás. Aquí solo interesa que Lombok lo genera.

Ojo con @Builder.Default: sin él, los valores iniciales de los campos se ignoran y quedan a null. Es uno de los errores más frecuentes con Lombok.

@RequiredArgsConstructor

Esta es la que más se usa con Spring, y encaja perfectamente con la inyección por constructor de 11-02:

@Service
@RequiredArgsConstructor        // constructor con TODOS los campos final
public class GestorPrestamos {

    private final MaterialRepository materiales;
    private final EmpleadoRepository empleados;
    private final PrestamoRepository prestamos;
    private final CalculadoraMultas calculadora;
    private final ServicioAvisos avisos;
    private final Clock reloj;

    // El constructor de 6 parámetros lo genera Lombok
}

Se elimina un constructor de quince líneas que no aportaba nada, y se conserva la inyección por constructor con todas sus ventajas (11-02): campos final, objeto siempre válido, probable con new.

Pero hay una contrapartida real que 11-02 señaló: un constructor con nueve parámetros grita "esta clase hace demasiado". Con @RequiredArgsConstructor, esa señal se vuelve mucho menos visible: añadir un campo final es una línea. Es el mismo problema que tenía la inyección por campo, atenuado pero presente. Conviene ser consciente y vigilar el número de dependencias de todos modos.

  1. Configuración del IDE y lombok.config

Lombok necesita un plugin en el IDE porque el código fuente no contiene los métodos generados:

IDE Configuración
IntelliJ IDEA Plugin Lombok (incluido desde 2020.3) + activar el procesamiento de anotaciones
Eclipse Ejecutar java -jar lombok.jar y apuntar a la instalación
VS Code Extensión "Lombok Annotations Support"

Y un fichero lombok.config en la raíz del proyecto:

# Detiene la búsqueda de configuración en directorios superiores
config.stopBubbling = true

# Añade @lombok.Generated a lo generado: JaCoCo lo EXCLUYE de la cobertura (12-05)
lombok.addLombokGeneratedAnnotation = true

# Prohíbe @Data y @Value: obligan a ser explícito (ver apartado 21)
lombok.data.flagUsage = error
lombok.value.flagUsage = warning

# Prohíbe @SneakyThrows: rompe el contrato de excepciones del módulo 6
lombok.sneakyThrows.flagUsage = error

# Copia estas anotaciones al constructor generado (útil con Spring y Jackson)
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

La línea de addLombokGeneratedAnnotation es especialmente valiosa: sin ella, los getters generados cuentan como código no cubierto y hunden artificialmente el porcentaje de cobertura, empujando al equipo a escribir pruebas de getters — justo el antipatrón que 11-04 y 11-06 advirtieron.

  1. Lombok: la valoración honesta

Lombok es una de las librerías más discutidas del ecosistema Java. Merece una valoración equilibrada.

Qué aporta:

Ventaja Detalle
Menos código repetitivo Una entidad de 40 líneas queda en 8
Menos errores Un equals a mano puede olvidar un campo; el generado, no
Menos ruido al leer Los cuatro campos se ven de un vistazo
Cero coste en ejecución Es código generado, no reflexión
@RequiredArgsConstructor Encaja perfectamente con Spring

Qué problemas trae:

Problema Detalle
Depuración No se puede poner un punto de interrupción en un getter generado. Las trazas señalan líneas que no existen en el fuente
Dependencia del IDE Sin plugin, el editor marca errores donde no los hay. Un compañero nuevo pierde media mañana
Magia oculta @Data genera cinco cosas; hay que conocerlas para prever el comportamiento
API interna del compilador Lombok usa mecanismos no estandarizados de javac. Una versión nueva de Java puede romperlo hasta que publiquen actualización
@Data en entidades JPA Fuente real de bugs. Apartado 21
Facilita malos diseños Poner @Data a todo produce objetos anémicos: bolsas de datos sin comportamiento
Coste de salida "Deslombokizar" un proyecto grande es un proyecto en sí mismo

Ese último punto merece matiz: existe la herramienta delombok, que genera el código fuente equivalente. Así que la salida es factible, aunque produce un commit enorme.

La postura razonable:

  • Lombok es útil, sobre todo @RequiredArgsConstructor y @Slf4j, que se usan a diario y no tienen contrapartidas.
  • No es imprescindible, y los record de Java 16 cubren buena parte de sus casos.
  • Es una decisión de equipo: o lo usa todo el proyecto o no lo usa nadie. Mezclar produce inconsistencia.
  • Si lo usas, restringe @Data y @Value con lombok.config, y prohíbe @SneakyThrows.
  • Nunca @Data en entidades JPA.

  1. @Data en entidades JPA: un bug real

Este apartado existe porque es un problema concreto, frecuente y con consecuencias serias.

// LO QUE NO HAY QUE HACER
@Entity
@Data                       // <- incluye @EqualsAndHashCode con TODOS los campos
public class Material {

    @Id @GeneratedValue
    private Long id;

    private String isbn;
    private String titulo;

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

Tres bugs, todos reales:

Bug 1: equals y hashCode que cambian

@Data genera equals y hashCode con todos los campos, incluido el id generado.

Material libro = new Libro("978-0000000001", "Java Efectivo", 3, "J. Bloch");
Set<Material> conjunto = new HashSet<>();
conjunto.add(libro);                        // id = null -> hashCode X

repositorio.save(libro);                    // JPA asigna id = 42 -> hashCode Y

assertThat(conjunto.contains(libro));       // ¡FALSE! Está en el cubo equivocado

Es exactamente el problema que se explicó en 05-06 al hablar de HashSet: si el hashCode de un objeto cambia mientras está dentro de una colección basada en hash, el objeto se pierde. Y con JPA, el id cambia siempre: es null antes de persistir y un número después.

Bug 2: toString que dispara consultas

@Data genera un toString que incluye todos los campos, incluidas las relaciones perezosas.

log.debug("Material: {}", material);
// -> toString() llama a getPrestamos()
// -> se inicializa la colección perezosa
// -> SELECT * FROM prestamo WHERE material_id = 42

Consecuencias: una traza de log inofensiva provoca una consulta por cada objeto registrado —el problema N+1 de 11-03 disparado desde el logging—, o una LazyInitializationException si el contexto de persistencia ya se cerró. Y con relaciones bidireccionales, recursión infinita: Material.toString() llama a Prestamo.toString() que llama a Material.toString().

Bug 3: setters en todo

@Data genera setters para todos los campos, incluido el id. Cambiar el id de una entidad gestionada es una forma segura de corromper datos.

La solución

@Entity
@Getter                                       // solo getters
@Setter(AccessLevel.PROTECTED)                // setters restringidos
@NoArgsConstructor(access = AccessLevel.PROTECTED)   // el que exige JPA
@ToString(of = { "id", "isbn", "titulo" })    // SOLO campos escalares
@EqualsAndHashCode(of = "isbn")               // SOLO la clave de negocio, inmutable
public class Material {

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

    @Column(nullable = false, unique = true)
    private String isbn;                      // clave de negocio: nunca cambia

    private String titulo;

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

Reglas para entidades JPA con Lombok:

Regla Motivo
Nunca @Data Arrastra los tres bugs
@EqualsAndHashCode(of = "claveDeNegocio") Un valor inmutable, nunca el id generado
@ToString(of = {campos escalares}) Excluir todas las relaciones
@Setter restringido o ausente Preferir métodos de dominio con validación
Nunca setter para el id Lo gestiona JPA

Y la alternativa más limpia de todas: escribir equals y hashCode a mano en las entidades, siguiendo el patrón recomendado por Hibernate. Son diez líneas por entidad, se escriben una vez y eliminan toda esta clase de problemas.

  1. Los record frente a Lombok

Desde Java 16, los record (04-07) cubren buena parte de lo que Lombok ofrecía:

// Lombok
@Value
public class MetadatosLibro {
    String autor;
    LocalDate fechaPublicacion;
    String editorial;
}

// Java 16+, sin dependencias
public record MetadatosLibro(String autor, LocalDate fechaPublicacion, String editorial) { }

Comparación:

Característica record Lombok
Getters Sí (autor(), sin get) Sí (getAutor())
equals / hashCode / toString Sí, gratis Con anotaciones
Inmutabilidad Obligatoria Con @Value
Constructor canónico @AllArgsConstructor
Validación en el constructor Sí, compacto Manual
Mutabilidad No Sí, con @Data
Builder No (verboso a mano) @Builder
Herencia No (son final)
Entidades JPA No
Dependencia externa Ninguna
Soporte del IDE Nativo Plugin
Depuración Normal Complicada

La recomendación práctica:

Caso Elección
DTO, objetos de valor, respuestas de API record
Resultados de consultas y proyecciones record (11-03)
Entidades JPA Clase + Lombok restringido, o a mano
Clases mutables con muchos campos Lombok
Constructor de inyección en Spring @RequiredArgsConstructor
Logger @Slf4j

En BiblioTech, Ficha, ResumenSesion, MetadatosLibro, RespuestaMetadatos y PropiedadesBiblioTech son record; Material, Empleado y Prestamo son clases con Lombok restringido, porque son entidades JPA.

  1. SLF4J: fachada frente a implementación

Tercera deuda, la de 06-07.

Allí se eligió java.util.logging por no añadir dependencias, y se señaló la limitación: el ecosistema entero usa SLF4J. Aquí se resuelve.

El problema que SLF4J soluciona es histórico. Java ha tenido cuatro sistemas de logging populares —java.util.logging, Log4j 1, Commons Logging, Log4j 2— y cada librería elegía uno. Un proyecto con veinte dependencias podía tener cuatro sistemas de logging distintos escribiendo en cuatro sitios, con cuatro configuraciones.

SLF4J (Simple Logging Facade for Java) es una fachada: una API contra la que programas, sin comprometerte con ninguna implementación.

graph TD
    A["Tu código de BiblioTech<br/>log.info(...)"] --> B["slf4j-api<br/>LA FACHADA"]
    L1["Spring Framework"] --> B
    L2["Hibernate"] --> B
    L3["Jackson"] --> B

    B --> C{"¿Qué enlace hay<br/>en el classpath?"}
    C --> D["logback-classic<br/>(por defecto en 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["Ficheros, consola,<br/>syslog, JSON..."]

Las tres piezas:

Pieza Qué es Artefacto
API Lo que usas: Logger, LoggerFactory slf4j-api
Enlace El puente API → implementación logback-classic, log4j-slf4j2-impl
Implementación Quien escribe de verdad logback-core, log4j-core

Y una cuarta, muy útil: los puentes (bridges), que redirigen a SLF4J el logging de librerías que usan otra API:

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

Con esos puentes, todo el logging de tu aplicación acaba en un solo sitio con una sola configuración, aunque tus dependencias usen APIs distintas. Es el valor real de la fachada.

Y ya lo tienes: en 11-02 se mostró que spring-boot-starter arrastra spring-boot-starter-logging, que trae slf4j-api, logback-classic, logback-core y jul-to-slf4j.

  1. Por qué se programa contra SLF4J

Cuatro razones concretas:

  1. La aplicación elige la implementación, no la librería. Si BiblioTech fuera una librería usada por otros, imponerles Logback sería un abuso. Con SLF4J, cada aplicación decide.
  2. Se puede cambiar sin tocar el código. Pasar de Logback a Log4j2 son dos líneas en el pom.xml (los exclusions de 11-05).
  3. Todo se unifica. Con los puentes, el logging de Spring, Hibernate, Jackson y tu código sale por el mismo canal con la misma configuración.
  4. Es lo que espera el ecosistema. Toda librería Java moderna que registra trazas lo hace contra SLF4J.

Es exactamente el mismo principio que JPA frente a Hibernate en 11-03: programa contra la especificación, elige la implementación al desplegar.

  1. El patrón LoggerFactory.getLogger

package com.nexussoftware.bibliotech.servicio;

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

public class GestorPrestamos {

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

    public Prestamo prestar(String isbn, String correo) {
        log.debug("Solicitud de préstamo: isbn={}, empleado={}", isbn, correo);
        // ...
        log.info("Préstamo creado: id={}, vence={}", prestamo.getId(), prestamo.getFechaVencimiento());
        return prestamo;
    }
}

Cada elemento de esa declaración tiene su motivo:

Elemento Por qué
private Nadie de fuera debe usar el logger de esta clase
static Uno por clase, no uno por instancia
final No cambia
getLogger(X.class) El nombre del logger es el nombre completo de la clase

Ese último punto es el que permite configurar niveles por paquete:

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

El logger com.nexussoftware.bibliotech.servicio.GestorPrestamos hereda del logger com.nexussoftware.bibliotech, que hereda de com.nexussoftware, que hereda de root. Es una jerarquía, y por eso una sola línea configura todo un subárbol.

Con Lombok, la línea desaparece:

@Slf4j
public class GestorPrestamos { ... }

  1. Logging parametrizado con {}

Esta es la característica de SLF4J que más se subestima.

// MAL: concatenación
log.debug("Préstamo " + prestamo.getId() + " del material " + material.getTitulo()
          + " para " + empleado.getNombre());

// BIEN: parametrizado
log.debug("Préstamo {} del material {} para {}",
          prestamo.getId(), material.getTitulo(), empleado.getNombre());

Por qué importa: con concatenación, la cadena se construye SIEMPRE, incluso si el nivel DEBUG está desactivado.

Traza el primer caso con nivel INFO:

  1. Se llaman los tres getters.
  2. Se construye un StringBuilder.
  3. Se concatenan seis fragmentos.
  4. Se produce un String nuevo en el heap.
  5. Se llama a log.debug(cadena).
  6. El logger comprueba el nivel, ve que DEBUG está desactivado y descarta la cadena.

Todo ese trabajo, para nada. En un método que se ejecuta un millón de veces al día, es basura pura en el heap — exactamente lo que 10-07 llamaba trabajo innecesario.

Con la forma parametrizada:

  1. Se llaman los tres getters (esto sí ocurre siempre).
  2. Se pasan la plantilla y los tres argumentos.
  3. El logger comprueba el nivel. Si está desactivado, devuelve sin construir nada.

La cadena solo se compone si el mensaje se va a emitir. Se llama evaluación diferida.

Una medición ilustrativa, con la metodología de 10-07 (JMH, no un microbenchmark casero):

Forma Nivel DEBUG activado Nivel DEBUG desactivado
Concatenación ~180 ns/op, 320 B asignados ~150 ns/op, 320 B asignados
Parametrizada {} ~190 ns/op, 336 B asignados ~3 ns/op, 0 B asignados

La columna derecha es la que importa, porque en producción el nivel DEBUG está desactivado: cincuenta veces más rápido y cero asignaciones. Multiplicado por los millones de llamadas a log.debug de una aplicación real, la diferencia es medible en tiempo de GC.

Casos particulares:

// Excepciones: SIEMPRE como ÚLTIMO argumento, SIN {}
log.error("No se pudo prestar {}", isbn, excepcion);   // registra la traza completa

// MAL: pierde la traza de pila, que es lo único útil
log.error("No se pudo prestar " + isbn + ": " + excepcion.getMessage());

// Si el argumento es CARO de calcular, comprueba el nivel
if (log.isDebugEnabled()) {
    log.debug("Estado completo del catálogo: {}", catalogo.volcadoCompleto());
}

Ese último caso es la única situación donde isDebugEnabled() aporta algo: la evaluación diferida evita construir la cadena, pero no evita evaluar los argumentos. Si volcadoCompleto() recorre diez mil objetos, se ejecuta igualmente.

También se puede usar un Supplier con la API fluida de SLF4J 2:

log.atDebug().setMessage("Catálogo: {}")
   .addArgument(() -> catalogo.volcadoCompleto())   // lambda: solo se evalúa si hace falta
   .log();

  1. Niveles y su correspondencia con java.util.logging

SLF4J Cuándo usarlo java.util.logging (06-07)
ERROR Fallo que impide completar una operación y requiere atención SEVERE
WARN Algo anómalo, pero se continuó WARNING
INFO Eventos relevantes de negocio INFO
DEBUG Detalle para diagnosticar FINE
TRACE Detalle muy fino: cada iteración FINER / FINEST

java.util.logging tenía además CONFIG, que no tiene equivalente y se mapea a INFO.

Criterios prácticos, con ejemplos de BiblioTech:

// ERROR: hay que actuar. Alguien debería recibir una alerta.
log.error("No se pudo conectar con la base de datos tras 3 intentos", excepcion);

// WARN: anómalo pero recuperado.
log.warn("La API de metadatos no respondió para {}; se continúa sin enriquecer", isbn);

// INFO: eventos de negocio. Debe poder leerse en producción sin ruido.
log.info("Préstamo {} creado para {} con vencimiento {}", id, correo, vencimiento);

// DEBUG: para diagnosticar. Desactivado en producción.
log.debug("Evaluando {} reservas pendientes con antelación de {} días", total, dias);

// TRACE: muy fino. Casi nunca activado.
log.trace("Reserva {} : fechaSolicitud={}, caduca={}", id, solicitud, caducidad);

Dos errores frecuentes:

  • Todo a INFO. El log se vuelve ilegible y nadie lo mira. INFO debe ser lo que un operador querría ver en producción.
  • Usar ERROR para cosas que no lo son. Si un ISBN no existe en la API externa, eso es DEBUG o WARN, no ERROR. Si todo es un error, las alertas dejan de significar nada.

Y uno importante para el módulo 6: no registres y relances.

// MAL: la misma excepción aparecerá tres veces en el log
catch (IOException e) {
    log.error("Error al leer", e);
    throw new PersistenciaException("Error al leer", e);
}

// BIEN: o registras (porque aquí se maneja) o relanzas (y registra quien maneje)
catch (IOException e) {
    throw new PersistenciaException("Error al leer " + fichero, e);
}

Es la estrategia por capas de 06-07: registra donde manejas, no donde propagas.

  1. Configurar Logback

Logback se configura con src/main/resources/logback-spring.xml (la variante -spring permite usar perfiles de Spring):

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

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

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

    <!-- APPENDER 2: fichero con rotación por fecha Y tamaño -->
    <appender name="FICHERO" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/bibliotech.log</file>
        <encoder>
            <pattern>${PATRON_FICHERO}</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 al superar 50 MB -->
            <maxHistory>30</maxHistory>          <!-- conserva 30 días -->
            <totalSizeCap>2GB</totalSizeCap>     <!-- nunca más de 2 GB en total -->
        </rollingPolicy>
    </appender>

    <!-- APPENDER 3: asíncrono. No bloquea el hilo de negocio al escribir -->
    <appender name="FICHERO_ASINCRONO" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="FICHERO"/>
        <queueSize>512</queueSize>
        <discardingThreshold>0</discardingThreshold>   <!-- no descartar mensajes -->
    </appender>

    <!-- NIVELES POR PAQUETE -->
    <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"/>

    <!-- PERFILES 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="FICHERO_ASINCRONO"/>
        </root>
        <logger name="com.nexussoftware.bibliotech" level="INFO"/>
        <logger name="org.hibernate.SQL" level="OFF"/>
    </springProfile>

</configuration>

Conceptos de Logback:

Concepto Qué es
Appender El destino: consola, fichero, syslog, socket
Encoder / pattern El formato del mensaje
Rolling policy Cuándo y cómo rotar los ficheros
Logger Nivel para un paquete o clase concretos
Root logger Nivel por defecto, del que heredan todos
<springProfile> Configuración condicional por perfil de Spring

Patrones más usados:

Marca Significa
%d{...} Fecha y hora
%level / %-5level Nivel, alineado a 5 caracteres
%thread Nombre del hilo (módulo 8)
%logger{36} Nombre del logger, abreviado a 36 caracteres
%msg El mensaje
%n Salto de línea
%X{clave} Valor del MDC (apartado 29)
%ex La traza de la excepción

Y para lo sencillo, ni siquiera hace falta el XML: Spring Boot lo configura desde 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"

Empieza siempre por el application.yml. Pasa al logback-spring.xml solo cuando necesites appenders o políticas que las propiedades no cubran.

Sobre el appender asíncrono: escribir en disco bloquea el hilo que registra. En una aplicación con mucho tráfico, envolver el appender de fichero en un AsyncAppender mueve la escritura a un hilo aparte. La contrapartida es que, si el proceso muere de golpe, los mensajes en cola se pierden — por eso discardingThreshold a 0 y una cola razonable.

  1. MDC: correlacionar peticiones

Problema real: con cincuenta peticiones simultáneas (módulo 8), los mensajes de log se entremezclan. ¿Cuáles pertenecen a la petición de Marta?

El MDC (Mapped Diagnostic Context) es un mapa por hilo cuyos valores se pueden incluir en cada línea de log.

import org.slf4j.MDC;

public class FiltroCorrelacion implements Filter {

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

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

        MDC.put("idPeticion", idPeticion);
        MDC.put("usuario", usuarioActual());

        try {
            cadena.doFilter(peticion, respuesta);
        } finally {
            MDC.clear();   // IMPRESCINDIBLE: el hilo vuelve al pool (módulo 8)
        }
    }
}

Con %X{idPeticion} en el patrón, cada línea lleva el identificador:

2026-03-20 10:15:23.451 INFO  [http-nio-8080-exec-3] c.n.b.s.GestorPrestamos [a3f2-9b21] - Préstamo 4218 creado
2026-03-20 10:15:23.502 DEBUG [http-nio-8080-exec-3] c.n.b.p.PrestamoRepository [a3f2-9b21] - Guardando préstamo
2026-03-20 10:15:23.510 INFO  [http-nio-8080-exec-7] c.n.b.s.GestorPrestamos [7c81-4e55] - Préstamo 4219 creado

Ahora se puede filtrar por a3f2-9b21 y ver solo la petición de Marta, entre miles de líneas.

Dos avisos importantes:

  1. MDC.clear() en un finally, siempre. El MDC vive en un ThreadLocal, y en un pool de hilos (módulo 8) el hilo se reutiliza. Si no lo limpias, la petición siguiente hereda el identificador de la anterior — y eso no es solo confuso: si guardas datos del usuario, es una filtración entre peticiones. Es exactamente la fuga de ThreadLocal que 10-07 describió como uno de los cuatro patrones clásicos.
  2. El MDC no se propaga a otros hilos automáticamente. Si lanzas trabajo a un ExecutorService (08-05) o a un CompletableFuture (08-07), el hilo nuevo no tiene el MDC. Hay que copiarlo a mano con MDC.getCopyOfContextMap() y MDC.setContextMap(...).

MDC es la base de la correlación de trazas en sistemas distribuidos, y se desarrolla junto con la observabilidad en 12-07.

  1. Logging estructurado en JSON

En un entorno moderno, los logs no los lee una persona en un fichero: los ingiere un sistema (Elasticsearch, Loki, CloudWatch) que los indexa y permite consultarlos. Para eso, el texto plano es incómodo: hay que parsearlo con expresiones regulares frágiles.

El logging estructurado emite cada línea como un objeto JSON:

{"@timestamp":"2026-03-20T10:15:23.451+01:00","level":"INFO","logger":"com.nexussoftware.bibliotech.servicio.GestorPrestamos","thread":"http-nio-8080-exec-3","message":"Préstamo 4218 creado","idPeticion":"a3f2-9b21","usuario":"[email protected]"}

Ahora se puede consultar level:ERROR AND idPeticion:a3f2-9b21 sin parsear nada. Y los valores del MDC se convierten en campos consultables.

Con Logback y logstash-logback-encoder:

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

O, desde Spring Boot 3.4, sin dependencias adicionales:

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

Práctica habitual: texto legible en desarrollo, JSON en producción, seleccionado con <springProfile>.

  1. BiblioTech: la migración a SLF4J

Antes, tal como quedó en 06-07:

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

public class GestorPrestamos {

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

    public Prestamo prestar(String isbn, String correo) {
        if (LOG.isLoggable(Level.FINE)) {                     // comprobación manual
            LOG.fine("Prestando " + isbn + " a " + correo);   // concatenación
        }
        try {
            // ...
            LOG.info("Préstamo creado: " + prestamo.getId());
            return prestamo;
        } catch (Exception e) {
            LOG.log(Level.SEVERE, "Error al prestar " + isbn, e);   // API incómoda
            throw e;
        }
    }
}

Después:

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

public class GestorPrestamos {

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

    public Prestamo prestar(String isbn, String correo) {
        log.debug("Prestando {} a {}", isbn, correo);          // parametrizado, diferido

        Prestamo prestamo = /* ... */;
        log.info("Préstamo creado: id={}, vence={}",
                 prestamo.getId(), prestamo.getFechaVencimiento());
        return prestamo;
    }
}

Fíjate en que desapareció el try/catch: ya no se registra y relanza (apartado 27). Quien maneje la excepción registrará. Y desapareció el isLoggable, porque la evaluación diferida lo hace innecesario.

Los pasos de la migración:

1. Dependencias. Ya están (spring-boot-starter). Añadir el puente por si alguna librería antigua usa java.util.logging:

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

2. Sustituir los imports en todas las clases:

Antes Despué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 ConfiguracionLog. Aquella clase de 06-07 que configuraba java.util.logging por código desaparece: su función la cumple ahora application.yml y logback-spring.xml, configurables sin recompilar.

4. Configurar por entorno, aprovechando los perfiles de 11-02.

5. Verificar que no hay dos implementaciones en el classpath:

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

Si aparecen dos enlaces, SLF4J avisa al arrancar:

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]

Se arregla con un exclusion (11-05).

El balance:

Aspecto java.util.logging (06-07) SLF4J + Logback
Parametrización No, concatenación {} con evaluación diferida
Rendimiento con nivel desactivado Construye la cadena ~0 ns, 0 bytes
API de excepciones log(Level, msg, e) log.error(msg, e)
Configuración Por código o .properties limitado XML completo o application.yml
Rotación de ficheros Limitada Por fecha, tamaño, con compresión y límite total
Configuración por entorno No <springProfile>
MDC No
JSON estructurado No
Unificación con las librerías No Sí, con puentes
Cambiar de implementación Reescribir Dos líneas de POM

  1. Log4j2, mencionado

Log4j 2 es la alternativa principal a Logback. Es rápida —su modo asíncrono con disruptor tiene un rendimiento excelente—, su API de Logger es rica y soporta lambdas nativamente.

Sustituirla en 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>

Dos líneas, y ni una del código cambia. Esa es la demostración práctica del valor de la fachada.

Y aquí conviene recordar Log4Shell (11-01): la vulnerabilidad crítica de diciembre de 2021 afectó a Log4j 2, no a Logback. Dos observaciones profesionales:

  1. No convierte a Log4j2 en mala opción. La vulnerabilidad se corrigió, el proyecto reaccionó, y hoy es una implementación sólida y muy usada.
  2. Sí ilustra el argumento de 11-01: cualquier dependencia puede tener una vulnerabilidad crítica, la pregunta difícil es saber qué tienes, y la capacidad de actualizar rápido depende de tener Maven (11-05) y pruebas (11-04, 11-06).

  1. Panorama final: otras librerías que conviene conocer

Cierre del ecosistema. No hay que aprenderlas ahora: hay que saber que existen para no reinventarlas.

Librería Para qué Cuándo la necesitarás
Apache Commons Lang 3 Utilidades de String, Objects, Random, reflexión, comparación Menos que antes: String.isBlank(), Objects.requireNonNullElse y los record cubren mucho
Apache Commons IO Copia de flujos, utilidades de ficheros Menos que antes: Files de NIO.2 (07-06) cubre casi todo
Guava Colecciones inmutables, Multimap, BiMap, caché, Preconditions Cuando necesites estructuras que el JDK no tiene
MapStruct Mapeadores entidad ↔ DTO generados en compilación En cuanto tengas quince entidades y sus DTO. Alternativa con tipos a escribir mapeos a mano
Caffeine Caché en memoria de alto rendimiento, con expiración y tamaño máximo El sustituto moderno del caché de Guava; integrado con @Cacheable de Spring
OpenCSV / Commons CSV Leer y escribir CSV bien, con comillas, escapes y saltos embebidos Salda la deuda de 07-07: tu LectorCsv a mano no cubría los casos límite
Flyway / Liquibase Migraciones de esquema versionadas En cuanto haya una base de datos en producción (11-03, 12-06)
springdoc-openapi Documentación OpenAPI/Swagger generada de tus controladores En cuanto expongas una API REST (12-04)
Resilience4j Reintentos, cortocircuitos, limitadores de tasa, mamparos Cuando llames a servicios externos. Es tu ProxyReintentos de 10-03, hecho producción
Micrometer Métricas: contadores, temporizadores, histogramas Observabilidad (12-07). Integrado con Actuator
Testcontainers Servicios reales en Docker para pruebas Pruebas de integración serias (11-06, 12-05)
WireMock Servidor HTTP falso para pruebas Probar clientes HTTP sin la API real
ArchUnit Verificar reglas de arquitectura como pruebas "El dominio no puede importar Spring", verificado en la compilación
JMH Microbenchmarks fiables Medir de verdad (10-07)

Tres merecen un comentario más largo.

OpenCSV, porque cierra la última deuda pendiente del curso:

// Tu LectorCsv de 07-07: 70 líneas, sin cubrir comillas ni saltos embebidos
// Con OpenCSV:
try (var lector = new CsvToBeanBuilder<MaterialCsv>(new FileReader(fichero, UTF_8))
        .withType(MaterialCsv.class)
        .withSeparator(';')
        .build()) {
    List<MaterialCsv> materiales = lector.parse();
}

Y ya viste en el apartado 10 que jackson-dataformat-csv hace lo mismo con la librería que ya tienes.

MapStruct, porque resuelve un problema que aparecerá en 12-04:

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

MapStruct genera la implementación en compilación —otro procesador de anotaciones, como Lombok—, con tipos verificados: si añades un campo al DTO y no existe en la entidad, no compila. Es muy superior a mapear a mano o por reflexión.

ArchUnit, porque convierte en prueba automática lo que 11-05 conseguía con módulos Maven:

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

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

Esa prueba, ejecutada por JUnit en cada compilación, impide que alguien introduzca una anotación de Spring en el dominio. Es la disciplina arquitectónica convertida en algo verificable.

  1. Errores Comunes y Consejos

Error: crear un ObjectMapper en cada llamada. Es caro y tira el caché de introspección. Uno solo, o el que inyecta Spring.

Error: olvidar @JsonIgnoreProperties(ignoreUnknown = true). El día que la API externa añada un campo, tu aplicación deja de funcionar.

Error: readValue(json, List.class). Por el borrado de tipos devuelve List<LinkedHashMap> y estalla al usarlo. TypeReference.

Error: no registrar JavaTimeModule o dejar WRITE_DATES_AS_TIMESTAMPS activo. O falla, o produce números en lugar de ISO-8601.

Error: activar el tipado por defecto de Jackson con datos externos. Es una vulnerabilidad de ejecución de código. Lista blanca con @JsonTypeInfo.

Error: usar get() en vez de path() con JsonNode. NullPointerException al encadenar.

Error: @Data en una entidad JPA. Tres bugs: hashCode que cambia, toString que dispara consultas perezosas y setters para el id.

Error: @Builder sin @Builder.Default. Los valores iniciales de los campos se ignoran silenciosamente.

Error: @SneakyThrows por comodidad. Rompe el contrato de excepciones y el llamador no puede capturar lo que no está declarado.

Error: concatenar en las llamadas al logger. La cadena se construye aunque el nivel esté desactivado.

Error: log.error("...", e.getMessage()). Pierde la traza de pila, que es lo único realmente útil. La excepción va como último argumento, sin {}.

Error: registrar y relanzar. La misma excepción aparece tres veces en el log. Registra donde manejas.

Error: no limpiar el MDC. En un pool de hilos, la petición siguiente hereda los datos de la anterior: confusión y filtración de datos.

Error: dos implementaciones de logging en el classpath. SLF4J avisa al arrancar y el comportamiento es impredecible. exclusions.

Error: todo a nivel INFO. El log se vuelve ilegible y nadie lo mira.

Consejo: usa record para los DTO. Inmutables, con equals y toString gratis, soportados por Jackson de forma nativa, y sin dependencias.

Consejo: separa el DTO externo del dominio. RespuestaMetadatos refleja el JSON de la API; MetadatosLibro es tu dominio. Si la API cambia, solo cambia el DTO.

Consejo: de Lombok, @RequiredArgsConstructor y @Slf4j. Son los que aportan sin contrapartidas. Restringe @Data con lombok.config.

Consejo: activa lombok.addLombokGeneratedAnnotation. Evita que los getters generados hundan el porcentaje de cobertura.

Consejo: empieza a configurar el logging por application.yml. Pasa al XML solo cuando necesites appenders o rotaciones que las propiedades no cubran.

Consejo: texto legible en desarrollo, JSON en producción. Con <springProfile>, sin tocar el código.

Consejo: piensa en quién va a leer el log. Un mensaje útil dice qué pasó, con qué datos y qué se hizo. "Error" no es un mensaje.

Consejo: antes de añadir una librería del panorama, repasa los criterios de 11-01. ¿Lo hace ya el JDK? ¿Está mantenida? ¿Qué arrastra?

  1. Ejercicios

Ejercicio 1: el cliente de la API de disponibilidad

Nexus Software quiere consultar en tiempo real la disponibilidad de los libros en el proveedor externo. La API devuelve:

{
  "request_id": "req-8821",
  "generated_at": "2026-03-20T10:15:23Z",
  "items": [
    {
      "isbn_13": "978-0000000001",
      "book_title": "Java Efectivo",
      "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": []
}

Requisitos:

  1. Modela la respuesta con record de Jackson. Debe sobrevivir a que la API añada campos nuevos.
  2. La API a veces devuelve "title" en vez de "book_title" (versiones distintas del servicio). Resuélvelo.
  3. unit_price debe llegar como BigDecimal, nunca como double.
  4. Las fechas deben ser LocalDate e Instant según corresponda.
  5. Escribe un método aDominio() que produzca un record DisponibilidadMaterial(String isbn, String titulo, int disponibles, BigDecimal precio, LocalDate actualizado).
  6. Configura el ObjectMapper necesario, indicando por qué cada opción.
  7. Escribe una prueba (11-04, 11-06) que deserialice el JSON del enunciado, más un campo desconocido, y verifique la conversión a dominio.

Ejercicio 2: arreglar una entidad con Lombok

Esta entidad está en el proyecto y provoca dos incidentes reportados: "a veces un material desaparece de la selección" y "el log de depuración tarda 30 segundos y a veces revienta".

@Entity
@Table(name = "material")
@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 titulo;

    private int ejemplaresDisponibles = 1;

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

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

    @Version
    private Long version;
}

Se pide:

  1. Explica cada uno de los dos incidentes relacionándolo con una anotación concreta.
  2. Identifica un tercer problema que aún no ha dado la cara.
  3. Identifica un cuarto problema, relacionado con @Builder.
  4. Reescribe la entidad corrigiendo todo.
  5. Escribe una prueba que habría detectado el primer incidente.
  6. Escribe el lombok.config que impediría que esto vuelva a ocurrir.

Ejercicio 3: migrar el logging y correlacionar

GestorDevoluciones tiene este logging, heredado de 06-07:

public class GestorDevoluciones {

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

    public ResultadoDevolucion devolver(Long prestamoId) {

        LOG.info("Iniciando devolución del préstamo " + prestamoId);

        Prestamo prestamo = prestamos.findById(prestamoId).orElse(null);
        if (prestamo == null) {
            LOG.severe("Préstamo no encontrado: " + prestamoId);
            throw new PrestamoNoEncontradoException(prestamoId);
        }

        if (LOG.isLoggable(Level.FINE)) {
            LOG.fine("Estado del préstamo: " + prestamo.toString()
                     + " con material " + prestamo.getMaterial().toString());
        }

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

        try {
            prestamo.devolver(LocalDate.now(reloj));
            prestamos.save(prestamo);
        } catch (Exception e) {
            LOG.log(Level.SEVERE, "Error al guardar la devolución de " + prestamoId, e);
            throw new PersistenciaException("Error al devolver " + prestamoId, e);
        }

        LOG.info("Devolución completada");
        return new ResultadoDevolucion(prestamo, multa, false);
    }
}

Se pide:

  1. Identifica siete problemas de logging en ese código, más allá de la API usada.
  2. Reescríbelo con SLF4J aplicando todas las buenas prácticas.
  3. Añade correlación con MDC para que todas las líneas de una devolución compartan identificador, con la limpieza correcta.
  4. Escribe el fragmento de logback-spring.xml con: consola legible en dev, fichero rotado con JSON en prod, el identificador de correlación en ambos, y org.hibernate.SQL en DEBUG solo en dev.
  5. Explica qué habría costado esta migración si el código hubiera estado programado contra SLF4J desde 06-07.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.red;

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

/** (1) DTO de la API externa de disponibilidad. Refleja el JSON, no el dominio. */
@JsonIgnoreProperties(ignoreUnknown = true)          // (1) sobrevive a campos nuevos
public record RespuestaDisponibilidad(

        @JsonProperty("request_id")   String idPeticion,
        @JsonProperty("generated_at") Instant generadoEn,     // (4) instante absoluto
        @JsonProperty("items")        List<Item> items,
        @JsonProperty("warnings")     List<String> avisos) {

    @JsonIgnoreProperties(ignoreUnknown = true)
    public record Item(

            @JsonProperty("isbn_13") String isbn,

            // (2) la API v1 usa "book_title", la v2 usa "title"
            @JsonProperty("book_title")
            @JsonAlias({"title"})
            String titulo,

            @JsonProperty("stock") Stock stock,

            // (3) BigDecimal, NUNCA double para dinero (01-04, 11-03)
            @JsonProperty("unit_price") BigDecimal precioUnitario,

            @JsonProperty("currency") String moneda,

            // (4) fecha de negocio sin hora: LocalDate (10-05)
            @JsonProperty("last_updated") LocalDate ultimaActualizacion,

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

            @JsonProperty("discontinued") boolean descatalogado) {

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

        // (5) traducción al dominio
        public DisponibilidadMaterial aDominio() {
            return new DisponibilidadMaterial(
                    isbn,
                    titulo,
                    stock == null ? 0 : stock.disponibles(),   // defensivo: el JSON puede omitirlo
                    precioUnitario,
                    ultimaActualizacion);
        }
    }

    /** (5) Todos los ítems, ya traducidos al dominio. */
    public List<DisponibilidadMaterial> aDominio() {
        return items == null ? List.of() : items.stream().map(Item::aDominio).toList();
    }
}
// (5) El record de DOMINIO: nombres en español, sin rastro de la API externa
public record DisponibilidadMaterial(
        String isbn,
        String titulo,
        int disponibles,
        BigDecimal precio,
        LocalDate actualizado) {

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

(6) Configuración del ObjectMapper:

@Configuration
public class ConfiguracionJackson {

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

                // Soporte de java.time: sin esto, LocalDate e Instant fallan
                .addModule(new JavaTimeModule())

                // Fechas en ISO-8601, no como número de segundos.
                // Legible, interoperable y coherente con lo decidido en 10-05.
                .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)

                // Redundante con @JsonIgnoreProperties, pero protege
                // cualquier DTO donde alguien olvide la anotación.
                .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)

                // Los decimales del JSON se leen como BigDecimal, no como double.
                // Imprescindible para importes: evita 54.949999999999996.
                .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)

                // No serializar campos null: respuestas más pequeñas.
                .serializationInclusion(JsonInclude.Include.NON_NULL)

                .build();
    }
}

Esa opción USE_BIG_DECIMAL_FOR_FLOATS es la que de verdad garantiza el punto 3: sin ella, Jackson lee 54.95 como double internamente y puede introducir error de representación antes de convertir a BigDecimal.

(7) La prueba:

class RespuestaDisponibilidadTest {

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

    @Test
    @DisplayName("deserializa la respuesta y la traduce al dominio, ignorando campos desconocidos")
    void deserializaYTraduce() 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 Efectivo",
                      "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": []
                }
                """;   // bloque de texto de 10-06

        RespuestaDisponibilidad respuesta =
                mapper.readValue(json, RespuestaDisponibilidad.class);

        // Los campos desconocidos (server_region, promo_code) no rompen nada
        assertThat(respuesta.idPeticion()).isEqualTo("req-8821");
        assertThat(respuesta.generadoEn()).isEqualTo(Instant.parse("2026-03-20T10:15:23Z"));
        assertThat(respuesta.avisos()).isEmpty();

        assertThat(respuesta.aDominio()).singleElement().satisfies(d -> {
            assertThat(d.isbn()).isEqualTo("978-0000000001");
            assertThat(d.titulo()).isEqualTo("Java Efectivo");
            assertThat(d.disponibles()).isEqualTo(3);
            assertThat(d.precio()).isEqualByComparingTo("54.95");   // exacto, sin error
            assertThat(d.actualizado()).isEqualTo(LocalDate.of(2026, 3, 19));
            assertThat(d.hayExistencias()).isTrue();
        });
    }

    @Test
    @DisplayName("acepta también el nombre antiguo del campo título")
    void aceptaElAliasDeVersionAntigua() throws Exception {
        String jsonV2 = """
                {"items":[{"isbn_13":"978-0000000002","title":"Patrones de Diseño",
                           "stock":{"available":2},"unit_price":49.00,
                           "last_updated":"2026-03-18"}]}
                """;

        var respuesta = mapper.readValue(jsonV2, RespuestaDisponibilidad.class);

        assertThat(respuesta.aDominio()).singleElement()
                .extracting(DisponibilidadMaterial::titulo)
                .isEqualTo("Patrones de Diseño");
    }

    @Test
    @DisplayName("el precio se lee como BigDecimal exacto, no como double")
    void precioExacto() throws Exception {
        String json = """
                {"items":[{"isbn_13":"978-0000000003","book_title":"Refactorización",
                           "stock":{"available":1},"unit_price":0.1,
                           "last_updated":"2026-03-20"}]}
                """;

        var d = mapper.readValue(json, RespuestaDisponibilidad.class).aDominio().get(0);

        // Con double sería 0.1000000000000000055511151231257827
        assertThat(d.precio()).isEqualByComparingTo(new BigDecimal("0.1"));
        assertThat(d.precio().toPlainString()).isEqualTo("0.1");
    }
}

La primera prueba incluye deliberadamente dos campos que el DTO no conoce (server_region y promo_code), que es exactamente el escenario del apartado 8: la API añade campos y la aplicación sigue funcionando.

Solución 2

(1) Los dos incidentes.

Incidente A: "a veces un material desaparece de la selección". Lo causa @Data, que genera @EqualsAndHashCode con todos los campos, incluido el id autogenerado.

Material libro = Material.builder().isbn("978-0000000001").titulo("Java Efectivo").build();
Set<Material> seleccion = new HashSet<>();
seleccion.add(libro);                    // id = null -> hashCode H1 -> cubo A

repositorio.save(libro);                 // JPA asigna id = 42 -> hashCode H2

seleccion.contains(libro);               // busca en el cubo B -> FALSE

El objeto sigue dentro del HashSet, pero en el cubo equivocado. Es exactamente el problema descrito en 05-06: si el hashCode cambia mientras el objeto está en una colección hash, se pierde. Y con JPA cambia siempre, porque el id es null antes de persistir.

Incidente B: "el log de depuración tarda 30 segundos y a veces revienta". Lo causa el toString de @Data, que incluye todos los campos, incluidas las dos relaciones perezosas.

log.debug("Material: {}", material);
  1. toString() accede a prestamosSELECT * FROM prestamo WHERE material_id = ?
  2. toString() accede a categoriasSELECT ... FROM material_categoria JOIN categoria ...
  3. Cada Prestamo tiene su propio toString que llama a getMaterial().toString()recursión infinita si Prestamo también lleva @Data.

De ahí los 30 segundos —el problema N+1 de 11-03 disparado desde una traza de log— y el "a veces revienta": o StackOverflowError por la recursión, o LazyInitializationException (11-03) si el contexto ya se cerró.

(2) Tercer problema, aún sin manifestarse: @Data genera setters para TODOS los campos, incluidos id y version.

material.setId(99L);        // corrompe la identidad de la entidad
material.setVersion(0L);    // rompe el bloqueo optimista de 11-03

Un setVersion(0L) accidental haría que Hibernate creyera que la fila está en su versión inicial, y una actualización perdida pasaría desapercibida. También setEjemplaresDisponibles(-5) es posible: no hay ninguna validación.

(3) Cuarto problema: @Builder sin @Builder.Default.

private int ejemplaresDisponibles = 1;
private List<Prestamo> prestamos = new ArrayList<>();
private Set<Categoria> categorias = new HashSet<>();

Al construir con el builder, esas inicializaciones se ignoran:

Material m = Material.builder().isbn("978-0000000001").build();
m.getEjemplaresDisponibles();   // 0, no 1
m.getPrestamos();               // null, no lista vacía -> NullPointerException

Y ese getPrestamos() a null provocará un NullPointerException la primera vez que alguien añada un préstamo, en un sitio aparentemente sin relación.

(4) La entidad corregida:

package com.nexussoftware.bibliotech.dominio;

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

@Entity
@Table(name = "material")
@Getter                                                   // solo lectura pública
@Setter(AccessLevel.PROTECTED)                            // setters restringidos a JPA/subclases
@NoArgsConstructor(access = AccessLevel.PROTECTED)        // el que exige JPA (11-03)
@ToString(of = { "id", "isbn", "titulo", "ejemplaresDisponibles" })   // SOLO escalares
@EqualsAndHashCode(of = "isbn")                           // clave de negocio INMUTABLE
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: nunca cambia

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

    @Column(name = "ejemplares_disponibles", nullable = false)
    private int ejemplaresDisponibles;

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

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

    @Version
    private Long version;

    /** Constructor público con validación: sustituye a @Builder y @AllArgsConstructor. */
    public Material(String isbn, String titulo, int ejemplaresDisponibles) {
        this.isbn = Objects.requireNonNull(isbn, "El ISBN es obligatorio");
        this.titulo = Objects.requireNonNull(titulo, "El título es obligatorio");
        if (ejemplaresDisponibles < 0) {
            throw new IllegalArgumentException("Los ejemplares no pueden ser negativos");
        }
        this.ejemplaresDisponibles = ejemplaresDisponibles;
    }

    // Métodos de DOMINIO con validación, en lugar de setters abiertos
    public void prestarUnEjemplar() {
        if (ejemplaresDisponibles <= 0) throw new SinEjemplaresException(isbn);
        ejemplaresDisponibles--;
    }

    public void devolverUnEjemplar() { ejemplaresDisponibles++; }

    /** Copia defensiva: nadie modifica la colección desde fuera (11-03 §13). */
    public List<Prestamo> getPrestamos() { return List.copyOf(prestamos); }
}

Cambios y su motivo:

Antes Ahora Corrige
@Data @Getter + @Setter(PROTECTED) Incidentes A, B y problema 3
equals/hashCode con todo @EqualsAndHashCode(of = "isbn") Incidente A
toString con todo @ToString(of = {escalares}) Incidente B
Setters públicos para id y version Setters protected Problema 3
@Builder sin defaults Constructor con validación Problema 4
Sin validación requireNonNull y comprobaciones Datos inválidos
Colecciones expuestas Copia defensiva Modificación externa

Nota: se ha eliminado @Builder en lugar de arreglarlo con @Builder.Default. Para una entidad con tres campos obligatorios, un constructor validado es más claro y más seguro. El Builder aporta cuando hay muchos parámetros opcionales, y ese patrón se estudia en 12-02.

(5) La prueba que habría detectado el incidente A:

@Test
@DisplayName("un Material sigue siendo localizable en un HashSet tras recibir su id")
void identidadEstableTrasPersistir() {

    Material libro = new Material("978-0000000001", "Java Efectivo", 3);
    Set<Material> seleccion = new HashSet<>();
    seleccion.add(libro);

    // Simula lo que hace JPA al persistir: asignar el id generado
    ReflectionTestUtils.setField(libro, "id", 42L);

    // Con @Data esto FALLA: el hashCode cambió y el objeto está en otro cubo
    assertThat(seleccion).contains(libro);
    assertThat(seleccion.contains(libro)).isTrue();
}

@Test
@DisplayName("toString no accede a las relaciones perezosas")
void toStringSinRelaciones() {
    Material libro = new Material("978-0000000001", "Java Efectivo", 3);

    assertThat(libro.toString())
            .contains("978-0000000001")
            .contains("Java Efectivo")
            .doesNotContain("prestamos")      // habría disparado la consulta
            .doesNotContain("categorias");
}

Y una versión de integración que detectaría el incidente B contra la base de datos:

@DataJpaTest
class MaterialToStringIT {

    @Autowired private TestEntityManager em;

    @Test
    @DisplayName("toString no provoca LazyInitializationException fuera de la sesión")
    void toStringSeguroFueraDeSesion() {
        Material libro = em.persistAndFlush(new Material("978-0000000001", "Java Efectivo", 3));
        em.clear();                                   // simula el cierre del contexto

        Material separado = em.getEntityManager()
                .getReference(Material.class, libro.getId());

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

(6) El lombok.config preventivo:

config.stopBubbling = true

# @Data es la raíz de los dos incidentes: prohibido en todo el proyecto
lombok.data.flagUsage = error

# @Value es inmutable pero incompatible con JPA: aviso
lombok.value.flagUsage = warning

# @AllArgsConstructor permite construir entidades sin validación
lombok.allArgsConstructor.flagUsage = warning

# @SneakyThrows rompe el contrato de excepciones del módulo 6
lombok.sneakyThrows.flagUsage = error

# Los métodos generados no cuentan para la cobertura (12-05)
lombok.addLombokGeneratedAnnotation = true

Con lombok.data.flagUsage = error, cualquier @Data que alguien introduzca en el futuro no compila. La regla deja de depender de que todo el mundo recuerde la revisión de código.

Solución 3

(1) Los siete problemas:

# Problema Consecuencia
1 Concatenación en todas las llamadas La cadena se construye aunque el nivel esté desactivado: trabajo y basura inútiles
2 toString() explícito de entidades en el FINE Con relaciones perezosas, dispara consultas o lanza LazyInitializationException (ejercicio 2)
3 Registrar y relanzar en el catch La misma excepción aparecerá dos o tres veces en el log
4 severe para "no encontrado" No es un error del sistema: es un caso de negocio. Contamina las alertas
5 INFO para todo "Iniciando", "Multa calculada", "Devolución completada": tres líneas por operación en producción
6 Mensajes sin contexto "Devolución completada" no dice de qué préstamo. Inútil con tráfico concurrente
7 isLoggable manual Innecesario con parametrización, y añade ruido

Un octavo, adicional: catch (Exception e) captura demasiado, incluidas excepciones de negocio que no deberían tratarse como fallos de persistencia. Es un problema del módulo 6, no de logging, pero se corrige de paso.

(2) y (3) Versión reescrita con SLF4J y MDC:

package com.nexussoftware.bibliotech.servicio;

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: inyección por constructor (11-02)
public class GestorDevoluciones {

    private static final Logger log = LoggerFactory.getLogger(GestorDevoluciones.class);
    // (o simplemente @Slf4j sobre la clase)

    private final PrestamoRepository prestamos;
    private final CalculadoraMultas calculadora;
    private final Clock reloj;

    @Transactional
    public ResultadoDevolucion devolver(Long prestamoId) {

        // (3) MDC: correlación de todas las líneas de esta devolución
        MDC.put("operacion", "devolucion");
        MDC.put("prestamoId", String.valueOf(prestamoId));
        MDC.put("idOperacion", UUID.randomUUID().toString());

        try {
            // (5) DEBUG, no INFO: el inicio de una operación no interesa en producción
            log.debug("Iniciando devolución");

            Prestamo prestamo = prestamos.findById(prestamoId)
                    .orElseThrow(() -> {
                        // (4) DEBUG, no ERROR: es un caso de negocio, no un fallo del sistema
                        log.debug("Préstamo no encontrado");
                        return new PrestamoNoEncontradoException(prestamoId);
                    });

            // (1)(2)(7) parametrizado, sin toString() de entidades, sin isLoggable
            log.debug("Préstamo localizado: isbn={}, vencimiento={}, estado={}",
                    prestamo.getMaterial().getIsbn(),     // solo campos ESCALARES
                    prestamo.getFechaVencimiento(),
                    prestamo.getEstado());

            BigDecimal multa = calculadora.calcular(prestamo);

            prestamo.devolver(LocalDate.now(reloj));
            prestamos.save(prestamo);
            // (3) sin catch: la excepción de persistencia sube y la registra quien la maneje

            // (5)(6) UNA línea INFO por operación, con TODO el contexto relevante
            log.info("Devolución completada: isbn={}, empleado={}, multa={} €, diasRetraso={}",
                    prestamo.getMaterial().getIsbn(),
                    prestamo.getEmpleado().getCorreo(),
                    multa,
                    prestamo.diasDeRetraso(LocalDate.now(reloj)));

            if (multa.compareTo(BigDecimal.ZERO) > 0) {
                log.warn("Devolución con multa: isbn={}, importe={} €",
                        prestamo.getMaterial().getIsbn(), multa);
            }

            return new ResultadoDevolucion(prestamo, multa, false);

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

Decisiones destacables:

  • Una sola línea INFO por operación, con todos los datos relevantes, en vez de tres genéricas. Es lo que un operador querría ver en producción.
  • WARN para la multa: es algo que conviene poder consultar sin activar DEBUG, pero no es un error.
  • Los mensajes no llevan el prestamoId porque ya está en el MDC y aparece en cada línea.
  • Sin catch: si save falla, la excepción sube. Quien la maneje —un manejador global (12-04)— la registrará una vez, con su traza completa.
  • MDC.clear() en finally: sin él, la siguiente petición que reutilice el hilo heredaría el prestamoId de esta.

(4) logback-spring.xml:

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

    <!-- ============ DESARROLLO: consola legible ============ -->
    <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{idOperacion:-sin-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 solo en dev -->
        <logger name="org.hibernate.orm.jdbc.bind" level="TRACE"/>  <!-- parámetros -->
        <logger name="org.springframework" level="INFO"/>

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

    <!-- ============ PRODUCCIÓN: fichero rotado en JSON ============ -->
    <springProfile name="prod">

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

            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <includeMdcKeyName>idOperacion</includeMdcKeyName>
                <includeMdcKeyName>prestamoId</includeMdcKeyName>
                <includeMdcKeyName>operacion</includeMdcKeyName>
                <customFields>{"aplicacion":"bibliotech","entorno":"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>

        <!-- Asíncrono: escribir en disco no bloquea el hilo de negocio -->
        <appender name="ASINCRONO" class="ch.qos.logback.classic.AsyncAppender">
            <appender-ref ref="FICHERO_JSON"/>
            <queueSize>1024</queueSize>
            <discardingThreshold>0</discardingThreshold>
        </appender>

        <logger name="com.nexussoftware.bibliotech" level="INFO"/>
        <logger name="org.hibernate.SQL" level="OFF"/>       <!-- NUNCA SQL en producción -->
        <logger name="org.springframework" level="WARN"/>

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

</configuration>

Detalles que conviene subrayar:

  • %X{idOperacion:-sin-id}: el :- da un valor por defecto si la clave no está en el MDC (por ejemplo, en el arranque). Sin él, aparecería vacío.
  • org.hibernate.SQL a OFF en producción: registrar cada sentencia SQL en producción degrada el rendimiento y puede volcar datos sensibles al log.
  • totalSizeCap: garantiza que los logs nunca llenen el disco, que es una causa clásica de caída de un servicio.
  • JSON en producción con los campos del MDC como campos indexables, que es lo que permite consultar operacion:devolucion AND level:WARN en el sistema de logs.

(5) Qué habría costado con SLF4J desde 06-07:

Prácticamente nada. Concretamente:

Tarea Con java.util.logging (real) Con SLF4J desde el principio
Cambiar imports 40+ ficheros Ninguno
Reescribir concatenaciones Todas las llamadas Ninguna
Adaptar log(Level.X, ...) Todas las de error Ninguna
Eliminar isLoggable Todas Ninguna
Eliminar ConfiguracionLog No existiría
Configurar Logback Igual Igual
Añadir MDC Igual Igual

La migración habría sido añadir dos dependencias y un fichero XML, sin tocar una sola línea de código Java.

Y esa es exactamente la lección de la fachada, y la razón por la que se recomienda desde el principio: programar contra una API estable y neutral hace que cambiar la implementación cueste dos líneas de pom.xml. Es el mismo principio que JPA frente a Hibernate (11-03) y que la interfaz PasarelaMetadatos frente a HttpClient (11-06): aísla lo que puede cambiar detrás de algo que no cambia.

La decisión de 06-07 —usar java.util.logging para no añadir dependencias— era razonable en su contexto pedagógico, porque entonces no había ni Maven ni forma cómoda de gestionar dependencias. En un proyecto real, SLF4J desde la primera línea.

Conclusión

El módulo 11 termina, y las tres deudas están saldadas.

Jackson ha sustituido al apaño del módulo 9. Conoces JSON y su correspondencia con los tipos de Java —incluido lo que JSON no tiene y que causa la mayoría de los problemas—, dominas el ObjectMapper con la regla de crearlo una vez y reutilizarlo, por la misma razón que el HttpClient de 09-06 y el Pattern de 10-07: caro de construir, seguro ante la concurrencia. Serializas y deserializas POJOs y record —soportados de forma nativa y perfectos como DTO—, y conoces las anotaciones esenciales, con @JsonIgnoreProperties(ignoreUnknown = true) como la que más incidentes evita: sin ella, el día que la API externa añada un campo, tu aplicación deja de funcionar.

Sabes resolver los genéricos con TypeReference, entendiendo por qué hace falta —el borrado de tipos de 10-01— y cómo funciona el truco de la subclase anónima cuya información genérica sobrevive en el bytecode. Registras el JavaTimeModule y desactivas WRITE_DATES_AS_TIMESTAMPS para obtener el ISO-8601 que 10-05 estableció como estándar. Manejas el árbol JsonNode para lo dinámico —con path() en vez de get()— y escribes serializadores propios para objetos de valor como Isbn, que son el equivalente en Jackson del AttributeConverter de JPA: el mismo objeto de dominio, dos adaptadores para dos infraestructuras.

Y has reescrito el ClienteMetadatos: cuarenta líneas de indexOf que se rompían con el primer escape, el primer anidamiento o el primer espacio inesperado, sustituidas por una línea y un DTO tipado que sobrevive a que la API cambie. Con la separación correcta entre el DTO externo y el dominio, y con la nota de seguridad que retoma 07-05: no actives el tipado dinámico con datos que no controlas, porque los gadgets de deserialización son ejecución de código, y si necesitas polimorfismo, usa @JsonTypeInfo con lista blanca reforzada por sealed.

Lombok ha eliminado el código repetitivo, y lo entiendes de verdad: es un procesador de anotaciones de 10-02, actúa en compilación, no cuesta nada en ejecución y puedes verlo con javap. Conoces sus anotaciones, con @RequiredArgsConstructor encajando perfectamente en la inyección por constructor de 11-02 y @Slf4j ahorrando una línea en cada clase. Y tienes la valoración honesta: lo que aporta, lo que cuesta —depuración, dependencia del IDE, API interna del compilador, coste de salida— y sobre todo el problema concreto que provoca bugs reales: @Data en entidades JPA, con sus tres consecuencias diseccionadas: un hashCode que cambia al persistir y hace desaparecer objetos de un HashSet; un toString que dispara relaciones perezosas y provoca N+1, LazyInitializationException o recursión infinita; y setters para el id y la version. Con las reglas para evitarlo y un lombok.config que convierte la disciplina en algo que el compilador verifica. Y sabes cuándo un record es mejor opción, que es casi siempre para DTO y objetos de valor.

SLF4J y Logback han sustituido a java.util.logging. Entiendes la fachada frente a la implementación y sus cuatro razones, con los puentes que unifican en un solo canal el logging de Spring, Hibernate, Jackson y tu código. Conoces el patrón LoggerFactory.getLogger(X.class) y por qué cada modificador está ahí, y sobre todo el logging parametrizado con {}, cuya ventaja no es estética sino medible: la cadena no se construye si el nivel está desactivado, cincuenta veces más rápido y cero asignaciones en el caso que ocurre en producción. Con los casos particulares: la excepción como último argumento sin {}, y isDebugEnabled solo cuando el argumento es caro de calcular.

Manejas los niveles y su correspondencia con java.util.logging, con el criterio de qué va en cada uno y las dos reglas que más mejoran un log: INFO es lo que un operador querría ver, y no registres y relances. Configuras Logback con appenders, rotación por fecha y tamaño con límite total, escritura asíncrona y <springProfile> para tener texto legible en desarrollo y JSON estructurado en producción sin tocar código. Y conoces el MDC para correlacionar peticiones, con sus dos avisos: MDC.clear() en un finally siempre, porque en un pool de hilos no limpiarlo es a la vez confusión y filtración de datos entre peticiones —la fuga de ThreadLocal de 10-07—, y que no se propaga automáticamente a otros hilos.

Y tienes el panorama final del ecosistema: Commons, Guava, MapStruct, Caffeine, OpenCSV —que salda de paso la última deuda, el CSV a mano de 07-07—, Flyway, springdoc-openapi, Resilience4j —que es tu ProxyReintentos de 10-03 hecho producción—, Micrometer, Testcontainers, WireMock, ArchUnit y JMH. No para aprenderlas ahora, sino para no reinventarlas.


BiblioTech, al cerrar el módulo 11, es otro proyecto.

Es un proyecto Maven reproducible, con su wrapper, su POM comentado, sus dependencias declaradas y auditables, sus fases y sus plugins. Se compila con ./mvnw clean verify en cualquier máquina del mundo que tenga Java 17, y cambiar la versión de una dependencia vulnerable es una línea y un comando.

Su contenedor es Spring: el ContenedorSimple de ciento cincuenta líneas ha desaparecido, sustituido por un ApplicationContext con inyección por constructor, estereotipos, ciclo de vida, ámbitos, perfiles dev y prod, propiedades tipadas y validadas que impiden arrancar con configuración inválida, y aspectos declarativos donde antes había tres proxies aplicados a mano.

Su persistencia es JPA sobre Hibernate y H2: entidades mapeadas, relaciones con integridad referencial, transacciones ACID, carga perezosa controlada, consultas JPQL parametrizadas, bloqueo optimista con @Version para el ejemplar disputado, y repositorios de Spring Data que son interfaces sin implementación. Los ficheros CSV se han ido.

Tiene pruebas: cuarenta y una unitarias que corren en menos de un segundo, más las de integración; el Clock de 10-05 por fin cobrado con Clock.fixed; pruebas parametrizadas que cubren doce casos de multas en seis líneas; y con Mockito, los caminos de error que en producción ocurren el peor día —la base de datos que falla, la API caída, el conflicto de concurrencia con su reintento—, más el ClienteMetadatos probado sin tocar la red.

Su JSON es Jackson, su código repetitivo lo genera Lombok de forma restringida y consciente, y su logging es SLF4J con Logback, parametrizado, correlacionado con MDC y configurado por entorno.

Y no es todavía una aplicación.

Es un conjunto de piezas excelentes que aún no forman un producto. No tiene una estructura de proyecto pensada para crecer: todo vive en un módulo. No aplica conscientemente los patrones que hacen mantenible un sistema — han ido apareciendo (Inyección de Dependencias, Proxy, Repositorio, Fábrica, Builder, Singleton) y en cada aparición se han nombrado y aplazado. No tiene una interfaz por la que un empleado de Nexus Software pueda usarla: ni consola decente ni API web. No tiene medida de su propia calidad. No está desplegada en ninguna parte. Y no tiene ni autenticación, ni autorización, ni observabilidad, ni plan de evolución del esquema.

El módulo 12 convierte las piezas en un producto. Configurarás el proyecto de verdad, con módulos que impongan las fronteras arquitectónicas. Estudiarás los patrones de diseño que llevas todo el módulo encontrándote sin nombrarlos del todo. Construirás la aplicación de consola y la aplicación web con Spring Boot y REST. Medirás la calidad con cobertura y pruebas de integración serias. Desplegarás con contenedores y migraciones versionadas. Y añadirás seguridad, observabilidad y una estrategia de evolución.

Has aprendido a usar las herramientas. Ahora vas a construir algo con ellas.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

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

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

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

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

© Copyright 2026. Todos los derechos reservados