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
- Qué es JSON y su correspondencia con Java
- Jackson: el
ObjectMapper - Por qué se crea uno y se reutiliza
- Serializar: de objeto a JSON
- Deserializar: de JSON a objeto
- Mapear POJOs y
record - Las anotaciones esenciales de Jackson
@JsonIgnoreProperties: la imprescindible- Tipos genéricos con
TypeReference - Módulos:
JavaTimeModuley las fechas - El árbol
JsonNodepara JSON dinámico - Serializadores y deserializadores propios
- BiblioTech: la reescritura del
ClienteMetadatos - Seguridad: deserialización polimórfica y gadgets
- Gson y JSON-B, mencionados
- Lombok: qué es y cómo funciona
- Las anotaciones de Lombok
@Buildery@RequiredArgsConstructor- Configuración del IDE y
lombok.config - Lombok: la valoración honesta
@Dataen entidades JPA: un bug real- Los
recordfrente a Lombok - SLF4J: fachada frente a implementación
- Por qué se programa contra SLF4J
- El patrón
LoggerFactory.getLogger - Logging parametrizado con
{} - Niveles y su correspondencia con
java.util.logging - Configurar Logback
- MDC: correlacionar peticiones
- Logging estructurado en JSON
- BiblioTech: la migración a SLF4J
- Log4j2, mencionado
- Panorama final: otras librerías que conviene conocer
- Errores Comunes y Consejos
- Ejercicios
- 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.
- Jackson: el
ObjectMapper
ObjectMapperJackson 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 |
- Por qué se crea uno y se reutiliza
Antes del primer ejemplo, una regla que evita un problema de rendimiento real:
Crea un
ObjectMappery 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.
- 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:
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.
- 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:
- Un constructor sin argumentos (o uno anotado con
@JsonCreator). - 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.
- Mapear POJOs y
record
recordCon 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:
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.
- 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.
@JsonIgnoreProperties: la imprescindible
@JsonIgnoreProperties: la imprescindibleMerece 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:
com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException:
Unrecognized field "idioma" (class MetadatosLibro), not marked as ignorablePiensa 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:
O en Spring Boot, que ya lo hace por ti:
spring:
jackson:
deserialization:
fail-on-unknown-properties: false # valor por defecto en Spring BootRegla: 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?".
- Tipos genéricos con
TypeReference
TypeReferenceY 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 MetadatosLibroPor 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:
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);
- Módulos:
JavaTimeModule y las fechas
JavaTimeModule y las fechasJackson 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_nullMó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.
- El árbol
JsonNode para JSON dinámico
JsonNode para JSON dinámicoCuando 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 null → NullPointerException 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 | — | Sí |
| Estructura variable | Sí | — |
| Solo un campo de un JSON enorme | Sí | — |
| Seguridad de tipos | Ninguna | Total |
| Legibilidad | Baja | Alta |
| Refactorizable por el IDE | No | Sí |
Prefiere clases o record siempre que puedas. El árbol convierte errores de compilación en errores de ejecución.
- 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.
- BiblioTech: la reescritura del
ClienteMetadatos
ClienteMetadatosEl 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 | Sí (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.
- 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:
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:
- No actives el tipado por defecto para datos externos. Nunca.
- Si necesitas polimorfismo, usa
@JsonTypeInfocon 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.
- Mantén Jackson actualizado. Es exactamente el argumento de 11-01: las vulnerabilidades se descubren en código que ya existía.
- 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.
- 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 | 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.
- 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:
- No hay coste en ejecución. El
.classcontiene los métodos como si los hubieras escrito. Cero reflexión, cero proxies. - Puedes verlo con
javap:
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();
...
}- 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. - 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.
- 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.
@Builder y @RequiredArgsConstructor
@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.
- Configuración del IDE y
lombok.config
lombok.configLombok 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.QualifierLa 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.
- 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
@RequiredArgsConstructory@Slf4j, que se usan a diario y no tienen contrapartidas. - No es imprescindible, y los
recordde 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
@Datay@Valueconlombok.config, y prohíbe@SneakyThrows. - Nunca
@Dataen entidades JPA.
@Data en entidades JPA: un bug real
@Data en entidades JPA: un bug realEste 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 equivocadoEs 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 = 42Consecuencias: 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.
- Los
record frente a Lombok
record frente a LombokDesde 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 | Sí | @AllArgsConstructor |
| Validación en el constructor | Sí, compacto | Manual |
| Mutabilidad | No | Sí, con @Data |
| Builder | No (verboso a mano) | @Builder |
| Herencia | No (son final) |
Sí |
| Entidades JPA | No | Sí |
| Dependencia externa | Ninguna | Sí |
| 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.
- 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.
- Por qué se programa contra SLF4J
Cuatro razones concretas:
- 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.
- Se puede cambiar sin tocar el código. Pasar de Logback a Log4j2 son dos líneas en el
pom.xml(losexclusionsde 11-05). - 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.
- 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.
- El patrón
LoggerFactory.getLogger
LoggerFactory.getLoggerpackage 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=INFOEl 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:
- 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:
- Se llaman los tres getters.
- Se construye un
StringBuilder. - Se concatenan seis fragmentos.
- Se produce un
Stringnuevo en el heap. - Se llama a
log.debug(cadena). - El logger comprueba el nivel, ve que
DEBUGestá 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:
- Se llaman los tres getters (esto sí ocurre siempre).
- Se pasan la plantilla y los tres argumentos.
- 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();
- Niveles y su correspondencia con
java.util.logging
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.INFOdebe ser lo que un operador querría ver en producción. - Usar
ERRORpara cosas que no lo son. Si un ISBN no existe en la API externa, eso esDEBUGoWARN, noERROR. 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.
- 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.
- 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 creadoAhora se puede filtrar por a3f2-9b21 y ver solo la petición de Marta, entre miles de líneas.
Dos avisos importantes:
MDC.clear()en unfinally, siempre. El MDC vive en unThreadLocal, 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 deThreadLocalque 10-07 describió como uno de los cuatro patrones clásicos.- El MDC no se propaga a otros hilos automáticamente. Si lanzas trabajo a un
ExecutorService(08-05) o a unCompletableFuture(08-07), el hilo nuevo no tiene el MDC. Hay que copiarlo a mano conMDC.getCopyOfContextMap()yMDC.setContextMap(...).
MDC es la base de la correlación de trazas en sistemas distribuidos, y se desarrolla junto con la observabilidad en 12-07.
- 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:
Práctica habitual: texto legible en desarrollo, JSON en producción, seleccionado con <springProfile>.
- 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:
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:
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 | Sí |
| JSON estructurado | No | Sí |
| Unificación con las librerías | No | Sí, con puentes |
| Cambiar de implementación | Reescribir | Dos líneas de POM |
- 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:
- 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.
- 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).
- 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.
- 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?
- 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:
- Modela la respuesta con
recordde Jackson. Debe sobrevivir a que la API añada campos nuevos. - La API a veces devuelve
"title"en vez de"book_title"(versiones distintas del servicio). Resuélvelo. unit_pricedebe llegar comoBigDecimal, nunca comodouble.- Las fechas deben ser
LocalDateeInstantsegún corresponda. - Escribe un método
aDominio()que produzca unrecord DisponibilidadMaterial(String isbn, String titulo, int disponibles, BigDecimal precio, LocalDate actualizado). - Configura el
ObjectMappernecesario, indicando por qué cada opción. - 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:
- Explica cada uno de los dos incidentes relacionándolo con una anotación concreta.
- Identifica un tercer problema que aún no ha dado la cara.
- Identifica un cuarto problema, relacionado con
@Builder. - Reescribe la entidad corrigiendo todo.
- Escribe una prueba que habría detectado el primer incidente.
- Escribe el
lombok.configque 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:
- Identifica siete problemas de logging en ese código, más allá de la API usada.
- Reescríbelo con SLF4J aplicando todas las buenas prácticas.
- Añade correlación con MDC para que todas las líneas de una devolución compartan identificador, con la limpieza correcta.
- Escribe el fragmento de
logback-spring.xmlcon: consola legible endev, fichero rotado con JSON enprod, el identificador de correlación en ambos, yorg.hibernate.SQLenDEBUGsolo endev. - 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 -> FALSEEl 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.
toString()accede aprestamos→SELECT * FROM prestamo WHERE material_id = ?toString()accede acategorias→SELECT ... FROM material_categoria JOIN categoria ...- Cada
Prestamotiene su propiotoStringque llama agetMaterial().toString()→ recursión infinita siPrestamotambié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-03Un 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 -> NullPointerExceptionY 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 = trueCon 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
INFOpor operación, con todos los datos relevantes, en vez de tres genéricas. Es lo que un operador querría ver en producción. WARNpara la multa: es algo que conviene poder consultar sin activarDEBUG, pero no es un error.- Los mensajes no llevan el
prestamoIdporque ya está en el MDC y aparece en cada línea. - Sin
catch: sisavefalla, la excepción sube. Quien la maneje —un manejador global (12-04)— la registrará una vez, con su traza completa. MDC.clear()enfinally: sin él, la siguiente petición que reutilice el hilo heredaría elprestamoIdde 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.SQLaOFFen 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:WARNen 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 |
Sí | 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 sí 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
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
