La lección anterior terminó con una ironía: BiblioTech tiene un @Transactional sobre prestar que no hace absolutamente nada, porque debajo no hay ninguna base de datos que pueda confirmar o revertir. Toda la persistencia sigue siendo lo que era en el módulo 7: ficheros CSV escritos a mano.
Esta lección los sustituye por una base de datos relacional de verdad.
Hibernate es el ORM más usado del mundo Java, y la implementación de referencia de Jakarta Persistence (antes JPA). Su trabajo es traducir entre dos mundos que no encajan: el de los objetos, con herencia, referencias y navegación; y el de las tablas, con filas, columnas y claves foráneas. Ese trabajo es tedioso, repetitivo y fácil de hacer mal, y por eso existe una herramienta para automatizarlo.
Pero un ORM no es una capa transparente que se pueda usar sin entenderla. Es probablemente la pieza de la que más gente abusa sin saber lo que está pasando: consultas que se multiplican por cien, excepciones que aparecen fuera de la transacción, entidades que se guardan solas sin que nadie llame a save, y esquemas de producción destruidos por una propiedad mal puesta. Todo eso tiene explicación, y esta lección la da.
La ventaja con la que llegas es la de siempre: Hibernate lee @Entity y @Column por reflexión, exactamente igual que tu ExportadorAnotado leía @CampoCsv. Y Spring Data JPA te dará repositorios funcionando sin escribir la implementación, con el mismo Proxy.newProxyInstance de 10-03.
Al terminar sabrás qué automatiza un ORM y qué cuesta; mapearás entidades y relaciones completas; entenderás el contexto de persistencia y los estados de una entidad; sabrás por qué aparece la LazyInitializationException y qué es el problema N+1, con sus soluciones; escribirás consultas JPQL con parámetros —y sabrás por qué concatenar cadenas es una vulnerabilidad—; manejarás transacciones, propagación y bloqueo optimista; y BiblioTech guardará sus datos en H2 con Material, Empleado y Prestamo como entidades reales.
Contenido
- El desajuste objeto-relacional
- Qué se gana y qué se pierde frente a JDBC
- JDBC en veinte líneas: la línea base
- JPA frente a Hibernate: especificación e implementación
- Poner en marcha JPA con H2 en BiblioTech
- La primera entidad:
@Entity,@Table,@Id @GeneratedValuey sus estrategias@Columny el mapeo de tipos@Enumerated: por quéSTRINGy nuncaORDINALLocalDate,Instanty los conversores propios@Embeddabley@Embedded- Relaciones: los cuatro tipos
- Lado propietario y
mappedBy FetchType: carga perezosa frente a ansiosa- La
LazyInitializationException - El problema N+1
- Resolverlo:
JOIN FETCHy@EntityGraph CascadeTypeyorphanRemoval- El contexto de persistencia y el
EntityManager - Los cuatro estados de una entidad
- La comprobación de cambios automática
flushy el orden de las operaciones- Las operaciones:
persist,merge,find,getReference,remove - Consultas: JPQL
- Consultas con nombre, API de criterios y SQL nativo
- Inyección SQL: por qué nunca se concatena
- Transacciones:
@Transactionalcon base de datos real - Propagación y aislamiento
- Bloqueo optimista con
@Version - Cachés de primer y segundo nivel
ddl-autoy por qué nuncaupdateen producción- Spring Data JPA
- BiblioTech: de CSV a H2
- Errores Comunes y Consejos
- Ejercicios
- El desajuste objeto-relacional
El problema tiene nombre propio: desajuste de impedancia objeto-relacional (object-relational impedance mismatch). El modelo de objetos y el modelo relacional se inventaron para cosas distintas y no encajan de forma natural.
Los puntos de fricción concretos:
| Concepto | En objetos | En tablas |
|---|---|---|
| Identidad | == (referencia) y equals (valor): dos conceptos |
Clave primaria: uno solo |
| Herencia | Material sellada con Libro, Revista, Dvd |
No existe |
| Asociación | Referencia dirigida (prestamo.material()) |
Clave foránea bidireccional por naturaleza |
| Navegación | Encadenada: prestamo.material().editorial().pais() |
Con JOIN, todo de golpe |
| Granularidad | Objetos pequeños: Direccion, Dinero |
Columnas en la misma fila |
| Colecciones | List, Set, Map |
Filas relacionadas |
| Tipos | LocalDate, Optional, enums, record |
DATE, VARCHAR, NUMERIC |
null |
Ausencia de referencia | NULL con semántica de tres valores |
Fíjate en la fila de la herencia, porque es la más brutal: el modelo relacional no tiene herencia. La jerarquía sellada Material de BiblioTech, que en 10-06 permitía switch exhaustivos verificados por el compilador, sencillamente no se puede expresar en SQL. Alguien tiene que decidir cómo se representa, y hay tres estrategias distintas, cada una con sus compromisos.
Y la de la navegación es la que más problemas de rendimiento causa: en Java, prestamo.material().editorial() es gratis; en SQL, cada salto puede ser una consulta. De ahí sale el problema N+1 del apartado 16.
Un ORM (Object-Relational Mapper) es una herramienta que traduce automáticamente entre los dos mundos. No elimina el desajuste: lo gestiona. Y saber que existe es la diferencia entre usar Hibernate bien y sufrirlo.
- Qué se gana y qué se pierde frente a JDBC
Con honestidad, porque hay opiniones fuertes en ambos sentidos y las dos tienen razón en parte.
| Aspecto | JDBC directo | ORM (Hibernate/JPA) |
|---|---|---|
| Código repetitivo | Muchísimo: ResultSet a objeto, a mano, por columna |
Casi ninguno |
| Control del SQL | Total | Parcial; se puede recuperar con SQL nativo |
| Curva de aprendizaje | Baja (si sabes SQL) | Alta |
| Rendimiento sencillo | Predecible | Bueno, con cachés que ayudan |
| Rendimiento mal usado | Malo por tu culpa | Catastrófico (N+1, carga ansiosa masiva) |
| Portabilidad entre motores | Baja: el SQL varía | Alta: dialectos |
| Consultas complejas / informes | Natural | Incómodo; se acaba usando SQL nativo |
| Escritura de un grafo de objetos | Muy laboriosa | Trivial: cascadas |
| Transparencia | Ves cada sentencia | Genera SQL que no ves si no lo pides |
| Caché | La haces tú | Primer y segundo nivel incluidos |
La regla profesional razonable:
- ORM para el CRUD del dominio, que es el 80 % del código de una aplicación de gestión: guardar un préstamo, cargar un empleado, actualizar un material.
- SQL directo (JDBC,
JdbcTemplateo jOOQ) para informes, agregaciones complejas y procesos masivos.
Ambas cosas conviven perfectamente en el mismo proyecto, y eso es lo que hace BiblioTech.
- JDBC en veinte líneas: la línea base
Para entender qué automatiza Hibernate, primero hay que ver qué había que escribir. Esto es JDBC puro leyendo préstamos:
package com.nexussoftware.bibliotech.persistencia;
import java.sql.*;
import java.time.LocalDate;
import java.util.*;
public class RepositorioPrestamosJdbc {
private final DataSource dataSource;
public RepositorioPrestamosJdbc(DataSource dataSource) {
this.dataSource = dataSource;
}
public List<Prestamo> buscarPorEmpleado(String correoEmpleado) {
String sql = """
SELECT id, isbn, empleado_correo, fecha_prestamo,
fecha_vencimiento, fecha_devolucion
FROM prestamo
WHERE empleado_correo = ?
""";
List<Prestamo> resultado = new ArrayList<>();
// try-with-resources del módulo 6: cierra los tres recursos en orden inverso
try (Connection conexion = dataSource.getConnection();
PreparedStatement sentencia = conexion.prepareStatement(sql)) {
sentencia.setString(1, correoEmpleado); // parámetro, NUNCA concatenación
try (ResultSet filas = sentencia.executeQuery()) {
while (filas.next()) {
// Mapeo MANUAL columna a columna: esto es lo que automatiza el ORM
Date devolucion = filas.getDate("fecha_devolucion");
resultado.add(new Prestamo(
filas.getLong("id"),
filas.getString("isbn"),
filas.getString("empleado_correo"),
filas.getDate("fecha_prestamo").toLocalDate(),
filas.getDate("fecha_vencimiento").toLocalDate(),
devolucion == null
? Optional.empty()
: Optional.of(devolucion.toLocalDate())));
}
}
} catch (SQLException e) {
throw new PersistenciaException("Error al buscar préstamos de " + correoEmpleado, e);
}
return resultado;
}
}Lo que hay ahí, y lo que Hibernate va a eliminar:
- SQL escrito a mano para cada consulta.
- Mapeo columna a columna, con
getLong,getString,getDate. - Conversión de tipos:
java.sql.DateaLocalDate,nullaOptional.empty(). - Gestión de recursos: tres
try-with-resourcesanidados. - Traducción de excepciones:
SQLExceptiona la jerarquía del módulo 6. - Nombres de columna como cadenas:
"fecha_vencimiento"mal escrito falla en ejecución.
Multiplica eso por las cuarenta consultas de una aplicación real y por las seis entidades, y tendrás varios miles de líneas que no aportan valor y que hay que mantener cada vez que cambia una columna.
JDBC no es malo. Es de bajo nivel. Con Hibernate, esa consulta entera se convierte en:
Y ni siquiera hay que escribir esa línea, como verás en el apartado 32.
- JPA frente a Hibernate: especificación e implementación
Otra confusión clásica que conviene despejar.
Jakarta Persistence (JPA) es una especificación: un conjunto de interfaces y anotaciones estandarizadas, en el paquete jakarta.persistence. Define EntityManager, @Entity, @Id, @OneToMany, JPQL... pero no contiene código que funcione.
Hibernate es una implementación de esa especificación. Es quien de verdad genera el SQL, gestiona el contexto de persistencia y habla con la base de datos.
graph TD
A["Tu código de BiblioTech"] --> B["API JPA<br/>jakarta.persistence<br/>@Entity, EntityManager, JPQL"]
B --> C["Hibernate 6.x<br/>(la implementación)"]
B -.->|alternativas| D["EclipseLink"]
B -.-> E["OpenJPA"]
C --> F["JDBC"]
F --> G[("H2 / PostgreSQL /<br/>MySQL / Oracle")]
| Aspecto | JPA (jakarta.persistence) |
Hibernate (org.hibernate) |
|---|---|---|
| Qué es | Especificación, interfaces | Implementación con código |
| Paquete | jakarta.persistence.* |
org.hibernate.* |
| Ejemplos | @Entity, EntityManager, JPQL |
Session, @BatchSize, HQL, filtros |
| Portable a otra implementación | Sí | No |
La recomendación práctica: programa contra JPA siempre que puedas y usa extensiones de Hibernate solo cuando lo necesites de verdad. Es el mismo principio de la fachada que verás con SLF4J en 11-07.
Aviso de versiones, importante. Jakarta EE renombró todos los paquetes
javax.*ajakarta.*. Spring Boot 3 y Hibernate 6 usanjakarta.persistence. Si copias un ejemplo conimport javax.persistence.Entity, es de Spring Boot 2 o anterior y no compilará. Es el error de copia y pega más frecuente hoy.
- Poner en marcha JPA con H2 en BiblioTech
Dos dependencias añadidas al pom.xml de 11-02:
<!-- Hibernate + JPA + Spring Data + gestión de transacciones -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2: base de datos en memoria. No hay que instalar NADA -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>Y la configuración en application.yml:
spring:
datasource:
url: jdbc:h2:mem:bibliotech;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
h2:
console:
enabled: true # http://localhost:8080/h2-console (solo en dev)
path: /h2-console
jpa:
hibernate:
ddl-auto: create-drop # SOLO en desarrollo. Ver apartado 31
show-sql: true # muestra el SQL generado
properties:
hibernate:
format_sql: true # lo formatea legible
jdbc:
batch_size: 25
open-in-view: false # IMPORTANTE. Ver apartado 15Con eso ya está. Ejecuta:
Y en el arranque verás las condiciones del apartado 19 de 11-02 cumpliéndose: DataSourceAutoConfiguration detecta H2 en el classpath, ve que tú no has definido un DataSource, y lo crea. HibernateJpaAutoConfiguration detecta Hibernate y monta el EntityManagerFactory y el gestor de transacciones. Cero código de configuración.
Dos notas sobre show-sql: es imprescindible mientras aprendes —vas a ver exactamente qué SQL genera Hibernate y eso es la mitad del aprendizaje— y hay que desactivarlo en producción, donde el logging correcto es por SLF4J (11-07):
logging:
level:
org.hibernate.SQL: DEBUG # las sentencias
org.hibernate.orm.jdbc.bind: TRACE # los parámetros
- La primera entidad:
@Entity, @Table, @Id
@Entity, @Table, @IdConvertimos Libro de BiblioTech en una entidad:
package com.nexussoftware.bibliotech.dominio;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity // "esta clase se mapea a una tabla"
@Table(name = "material", // nombre de tabla explícito
indexes = @Index(name = "idx_material_isbn", columnList = "isbn", unique = true))
public class Libro {
@Id // clave primaria
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "isbn", nullable = false, unique = true, length = 20)
private String isbn;
@Column(nullable = false, length = 200)
private String titulo;
@Column(name = "anio_publicacion")
private Integer anioPublicacion;
@Column(name = "ejemplares_disponibles", nullable = false)
private int ejemplaresDisponibles;
// JPA EXIGE un constructor sin argumentos (puede ser protected)
protected Libro() { }
public Libro(String isbn, String titulo, int ejemplaresDisponibles) {
this.isbn = isbn;
this.titulo = titulo;
this.ejemplaresDisponibles = ejemplaresDisponibles;
}
// getters y (solo los necesarios) setters
public Long getId() { return id; }
public String getIsbn() { return isbn; }
public String getTitulo() { return titulo; }
public int getEjemplaresDisponibles() { return ejemplaresDisponibles; }
// Comportamiento de dominio: la entidad no es una bolsa de datos
public void prestarUnEjemplar() {
if (ejemplaresDisponibles <= 0) {
throw new SinEjemplaresException(isbn);
}
ejemplaresDisponibles--;
}
public void devolverUnEjemplar() { ejemplaresDisponibles++; }
}Los requisitos que impone JPA a una entidad, y que conviene conocer porque los errores son crípticos:
| Requisito | Motivo |
|---|---|
Anotada con @Entity |
Es lo que la hace mapeable |
Un @Id |
Sin identidad no hay fila |
| Constructor sin argumentos | Hibernate la instancia por reflexión (10-03) |
Clase no final |
Necesita generar subclases proxy para la carga perezosa |
Campos no final |
Los rellena por reflexión |
Los métodos no pueden ser final |
Interceptación de la carga perezosa |
Y ahí tienes por qué un record no puede ser una entidad JPA: es final, sus campos son final y no tiene constructor sin argumentos. Los record de BiblioTech (Ficha, ResumenSesion) seguirán siendo perfectos como DTO y como proyecciones de consulta, pero las entidades tienen que ser clases mutables. Es un compromiso real del ORM, no un capricho.
Sobre @Table: si no la pones, la tabla se llama como la clase. Ponerla explícitamente es buena práctica, porque Prestamo mapearía a una tabla PRESTAMO, y palabras como ORDER, USER o GROUP son reservadas en SQL y dan errores desconcertantes.
@GeneratedValue y sus estrategias
@GeneratedValue y sus estrategiasQuién genera la clave primaria. Es una decisión con impacto real en el rendimiento.
| Estrategia | Cómo funciona | Ventajas | Inconvenientes |
|---|---|---|---|
IDENTITY |
Columna autoincremental del motor | Simple; funciona en todos | Impide el agrupamiento de inserciones: obliga a un INSERT inmediato para conocer el id |
SEQUENCE |
Secuencia de la base de datos | La mejor: permite reservar ids por lotes y agrupar inserciones | No todos los motores tienen secuencias (MySQL antiguo) |
TABLE |
Una tabla auxiliar de contadores | Portable a todo | Lenta; contención. Evítala |
AUTO |
Hibernate elige | Cómoda | Impredecible entre motores |
| (ninguna) | La asignas tú | Control total; claves naturales | Tienes que garantizar unicidad |
La recomendación con Hibernate 6 y una base de datos que soporte secuencias:
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "material_seq")
@SequenceGenerator(name = "material_seq", sequenceName = "material_seq",
allocationSize = 50) // reserva 50 ids de golpe: 1 consulta cada 50 inserciones
private Long id;El motivo del allocationSize importa: con IDENTITY, Hibernate no puede agrupar inserciones, porque necesita ejecutar cada INSERT para que el motor le devuelva el id. Con SEQUENCE y allocationSize=50, puede acumular 50 inserciones y enviarlas en un lote. En una importación de 10.000 materiales, la diferencia es de minutos.
Para los ejemplos con H2 de esta lección usaremos IDENTITY por simplicidad, pero conoce la razón por la que en producción se prefiere SEQUENCE.
@Column y el mapeo de tipos
@Column y el mapeo de tipos@Column describe la columna. Sus atributos útiles:
| Atributo | Qué hace | Ejemplo |
|---|---|---|
name |
Nombre de la columna | @Column(name = "fecha_prestamo") |
nullable |
NOT NULL en el esquema generado |
nullable = false |
unique |
Restricción de unicidad | unique = true |
length |
Longitud de VARCHAR (por defecto 255) |
length = 20 |
precision / scale |
Para BigDecimal |
precision = 10, scale = 2 |
insertable / updatable |
Excluir de INSERT o UPDATE |
updatable = false |
columnDefinition |
SQL en crudo. Rompe la portabilidad | Último recurso |
Los tipos que JPA mapea automáticamente:
| Tipo Java | Columna SQL típica |
|---|---|
String |
VARCHAR |
int, Integer, long, Long |
INTEGER, BIGINT |
boolean, Boolean |
BOOLEAN |
BigDecimal |
NUMERIC(p,s) |
LocalDate |
DATE |
LocalDateTime |
TIMESTAMP |
Instant |
TIMESTAMP (UTC) |
byte[] |
BLOB / VARBINARY |
enum |
Depende de @Enumerated (apartado 9) |
| Otro | Necesita un AttributeConverter (apartado 10) |
Dos avisos concretos sobre BiblioTech:
Uno: nunca double para dinero. Ya se dijo en 01-04 y aquí importa más que nunca. Las multas son BigDecimal con precision y scale explícitos:
Dos: Optional no se mapea. Optional<LocalDate> fechaDevolucion era una decisión excelente en 10-04 para la API pública, pero JPA no sabe persistir Optional. La solución es un campo anulable y un getter que devuelva Optional:
@Column(name = "fecha_devolucion")
private LocalDate fechaDevolucion; // campo: puede ser null
public Optional<LocalDate> getFechaDevolucion() { // API: Optional
return Optional.ofNullable(fechaDevolucion);
}Así conservas la garantía de 10-04 —quien llame nunca recibe null— y JPA puede persistir el campo. Es el patrón estándar.
@Enumerated: por qué STRING y nunca ORDINAL
@Enumerated: por qué STRING y nunca ORDINALBiblioTech tiene tres enums desde el módulo 4: Gravedad, TipoMaterial y EstadoPrestamo. Su mapeo tiene una trampa con consecuencias graves.
// MAL: por defecto JPA usa ORDINAL (el índice: 0, 1, 2)
@Enumerated(EnumType.ORDINAL)
private EstadoPrestamo estado;
// BIEN: siempre STRING
@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
private EstadoPrestamo estado;Por qué ORDINAL es peligroso. Con él, la base de datos guarda 0, 1, 2. Ahora imagina que dentro de seis meses alguien añade un estado en su sitio lógico:
Todos los préstamos que estaban guardados con 1 (VENCIDO) pasan a leerse como RENOVADO, y los 2 (DEVUELTO) como VENCIDO. Todos los datos históricos quedan corrompidos silenciosamente. Ninguna excepción, ningún aviso. Se descubre semanas después, cuando alguien reclama una multa de un libro que devolvió.
Además, ORDINAL hace la base de datos ilegible: SELECT * FROM prestamo devuelve números que no significan nada.
| Aspecto | ORDINAL |
STRING |
|---|---|---|
| Qué guarda | El índice (0, 1, 2) | El nombre ("ACTIVO") |
| Espacio | Menos | Un poco más |
| Reordenar o insertar constantes | Corrompe los datos | Sin problema |
| Renombrar una constante | Sin problema | Hay que migrar (y el fallo es visible) |
| Legible en SQL | No | Sí |
La regla es absoluta: @Enumerated(EnumType.STRING), siempre. El ahorro de espacio de ORDINAL no compensa ni de lejos el riesgo. Y lo más peligroso es que ORDINAL es el valor por defecto: si olvidas la anotación, tienes el comportamiento malo.
LocalDate, Instant y los conversores propios
LocalDate, Instant y los conversores propiosBuenas noticias: desde JPA 2.2, java.time se mapea de forma nativa. Todo el trabajo de 10-05 se traslada directamente:
@Column(name = "fecha_prestamo", nullable = false)
private LocalDate fechaPrestamo; // -> DATE
@Column(name = "fecha_vencimiento", nullable = false)
private LocalDate fechaVencimiento; // -> DATE
@Column(name = "instante_registro", nullable = false)
private Instant instanteRegistro; // -> TIMESTAMP en UTCLa distinción de 10-05 sigue siendo la correcta: LocalDate para fechas de negocio (un préstamo vence "el día 20", sin hora ni zona) e Instant para marcas de tiempo de auditoría (un instante absoluto en la línea temporal).
Y java.util.Date y Calendar no se usan nunca más. Si ves @Temporal(TemporalType.DATE) en un ejemplo, es código anterior a Java 8.
Conversores para tipos propios
¿Y si quieres persistir un tipo que JPA no conoce? Ahí entra AttributeConverter. BiblioTech tiene un record Isbn como objeto de valor:
package com.nexussoftware.bibliotech.dominio;
public record Isbn(String valor) {
public Isbn {
if (valor == null || !valor.matches("\\d{3}-\\d{10}")) {
throw new IsbnInvalidoException(valor);
}
}
}El conversor:
package com.nexussoftware.bibliotech.persistencia;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import com.nexussoftware.bibliotech.dominio.Isbn;
@Converter(autoApply = true) // se aplica a TODOS los campos de tipo Isbn
public class ConversorIsbn implements AttributeConverter<Isbn, String> {
@Override
public String convertToDatabaseColumn(Isbn isbn) {
return isbn == null ? null : isbn.valor();
}
@Override
public Isbn convertToEntityAttribute(String columna) {
return columna == null ? null : new Isbn(columna);
}
}Con autoApply = true, cualquier campo Isbn se convierte solo. Sin él, hay que marcarlo:
@Convert(converter = ConversorIsbn.class)
@Column(name = "isbn", nullable = false, unique = true)
private Isbn isbn;Esto permite tener objetos de valor con validación en el dominio y columnas simples en la base de datos: lo mejor de los dos mundos. Otros usos habituales: cifrar un campo sensible, guardar una lista corta como cadena separada por comas, o mapear un booleano a 'S'/'N' en una base de datos heredada.
@Embeddable y @Embedded
@Embeddable y @EmbeddedUn embebible es un objeto que no tiene identidad propia y cuyos campos se guardan en la misma fila que la entidad que lo contiene. Sirve para dar estructura al modelo de objetos sin crear tablas.
package com.nexussoftware.bibliotech.dominio;
import jakarta.persistence.Embeddable;
@Embeddable
public class DatosPublicacion {
private String editorial;
private Integer anio;
private String idioma;
protected DatosPublicacion() { }
public DatosPublicacion(String editorial, Integer anio, String idioma) {
this.editorial = editorial;
this.anio = anio;
this.idioma = idioma;
}
public boolean esReciente(int anioActual) { // comportamiento propio
return anio != null && anioActual - anio <= 3;
}
}Uso:
@Entity
public class Libro {
@Embedded
private DatosPublicacion publicacion;
// Si el mismo embebible aparece dos veces, hay que renombrar columnas:
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "editorial", column = @Column(name = "editorial_original")),
@AttributeOverride(name = "anio", column = @Column(name = "anio_original"))
})
private DatosPublicacion publicacionOriginal;
}La tabla resultante tiene columnas editorial, anio, idioma, editorial_original, anio_original... en la misma fila de material. No hay tabla datos_publicacion ni JOIN.
| Concepto | @Entity |
@Embeddable |
|---|---|---|
| Tabla propia | Sí | No: columnas en la del contenedor |
Identidad (@Id) |
Sí | No |
| Ciclo de vida | Propio | El de la entidad que lo contiene |
| Se puede consultar solo | Sí | No |
| Ejemplo | Libro, Prestamo |
DatosPublicacion, Direccion, Dinero |
Es la respuesta al problema de granularidad del apartado 1: puedes tener objetos pequeños y cohesivos en Java sin fragmentar el esquema.
- Relaciones: los cuatro tipos
El modelo de BiblioTech:
- Un
Prestamopertenece a unMaterialy a unEmpleado. - Un
Materialtiene muchosPrestamo. - Un
Empleadotiene muchosPrestamoy muchasReserva. - Un
Materialtiene muchasCategoriay unaCategoriatiene muchosMaterial.
@ManyToOne: el lado que tiene la clave foránea
@Entity
@Table(name = "prestamo")
public class Prestamo {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // SIEMPRE LAZY. Apartado 14
@JoinColumn(name = "material_id", nullable = false) // la columna FK
private Material material;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "empleado_id", nullable = false)
private Empleado empleado;
@Column(name = "fecha_prestamo", nullable = false)
private LocalDate fechaPrestamo;
@Column(name = "fecha_vencimiento", nullable = false)
private LocalDate fechaVencimiento;
@Column(name = "fecha_devolucion")
private LocalDate fechaDevolucion;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private EstadoPrestamo estado;
@Version // bloqueo optimista. Apartado 29
private Long version;
}La tabla prestamo tendrá columnas material_id y empleado_id con claves foráneas.
@OneToMany: el lado inverso
@Entity
@Table(name = "empleado")
public class Empleado {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String correo;
@Column(nullable = false)
private String nombre;
// mappedBy = "empleado" -> el campo de Prestamo que es dueño de la relación
@OneToMany(mappedBy = "empleado",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY) // LAZY es el valor por defecto aquí
private List<Prestamo> prestamos = new ArrayList<>();
// Método de conveniencia: mantiene AMBOS lados sincronizados
public void anadirPrestamo(Prestamo prestamo) {
prestamos.add(prestamo);
prestamo.asignarEmpleado(this);
}
public void quitarPrestamo(Prestamo prestamo) {
prestamos.remove(prestamo);
prestamo.asignarEmpleado(null);
}
}Esos métodos de conveniencia no son opcionales en la práctica: si añades a la lista sin poner el campo del otro lado, la relación no se persiste (apartado 13) y además el estado en memoria queda incoherente.
@OneToOne
@Entity
public class Empleado {
@OneToOne(mappedBy = "empleado", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private FichaBiblioteca ficha;
}
@Entity
public class FichaBiblioteca {
@Id @GeneratedValue private Long id;
@OneToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "empleado_id", unique = true)
private Empleado empleado; // lado propietario
}Trampa conocida: un @OneToOne opcional en el lado inverso no puede ser perezoso con proxies, porque Hibernate necesita consultar para saber si hay fila o no. Si el rendimiento importa, ponlo optional = false o usa una clave primaria compartida.
@ManyToMany
@Entity
public class Material {
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(name = "material_categoria",
joinColumns = @JoinColumn(name = "material_id"),
inverseJoinColumns = @JoinColumn(name = "categoria_id"))
private Set<Categoria> categorias = new HashSet<>();
}
@Entity
public class Categoria {
@ManyToMany(mappedBy = "categorias")
private Set<Material> materiales = new HashSet<>();
}Consejo profesional muy repetido: @ManyToMany puro se queda corto casi siempre. En cuanto necesites un atributo en la relación —fecha en que se asignó la categoría, quién la asignó— hay que convertirla en una entidad intermedia con dos @ManyToOne. Muchos equipos modelan directamente la entidad intermedia desde el principio.
Resumen:
| Anotación | Dónde va la FK | Lado propietario | Uso en BiblioTech |
|---|---|---|---|
@ManyToOne |
En esta tabla | Este | Prestamo → Material, Prestamo → Empleado |
@OneToMany |
En la otra tabla | El otro (mappedBy) |
Empleado → Prestamo |
@OneToOne |
En una de las dos | El que tiene @JoinColumn |
Empleado → FichaBiblioteca |
@ManyToMany |
Tabla intermedia | El que tiene @JoinTable |
Material ↔ Categoria |
- Lado propietario y
mappedBy
mappedByEste es un concepto que confunde a casi todo el mundo y que provoca el bug de "he guardado y no se ha guardado nada".
En una base de datos relacional, la relación es una sola columna: la clave foránea. En Java, la relación bidireccional son dos campos. Alguien tiene que decidir cuál de los dos manda.
El lado propietario es el que tiene la clave foránea. Es el único que Hibernate mira para persistir la relación. El otro lado lleva
mappedByy es de solo lectura a efectos de persistencia.
La consecuencia:
// MAL: solo se toca el lado inverso
Empleado marta = repositorioEmpleados.findById(1L).orElseThrow();
Prestamo prestamo = new Prestamo(libro, LocalDate.now(reloj));
marta.getPrestamos().add(prestamo); // solo el lado INVERSO
repositorioPrestamos.save(prestamo);
// -> La columna empleado_id queda a NULL. La relación NO se ha guardado.// BIEN: se toca el lado propietario
prestamo.asignarEmpleado(marta); // el lado PROPIETARIO (@ManyToOne)
repositorioPrestamos.save(prestamo);
// -> empleado_id se guarda correctamente// MEJOR: método de conveniencia que sincroniza los dos lados
marta.anadirPrestamo(prestamo); // pone ambos campos
repositorioPrestamos.save(prestamo);La regla práctica: siempre que tengas una relación bidireccional, escribe métodos de conveniencia que actualicen los dos lados. Nunca manipules las colecciones directamente desde fuera.
FetchType: carga perezosa frente a ansiosa
FetchType: carga perezosa frente a ansiosaCuando cargas un Prestamo, ¿debe Hibernate cargar también su Material y su Empleado? Esa decisión es FetchType, y es la decisión con más impacto en el rendimiento de toda la lección.
FetchType |
Comportamiento | Cuándo se carga la asociación |
|---|---|---|
EAGER (ansioso) |
Se carga siempre, junto con la entidad | Inmediatamente, con un JOIN o una consulta extra |
LAZY (perezoso) |
Se pone un proxy; se carga al acceder | La primera vez que llamas a un método suyo |
Los valores por defecto de JPA son, desafortunadamente, incoherentes:
| Anotación | Valor por defecto | ¿Es buena idea? |
|---|---|---|
@ManyToOne |
EAGER |
No |
@OneToOne |
EAGER |
No |
@OneToMany |
LAZY |
Sí |
@ManyToMany |
LAZY |
Sí |
Por qué el EAGER por defecto de @ManyToOne es malo: imagina que Prestamo tiene @ManyToOne ansiosos a Material y Empleado, y que Material tiene un @ManyToOne ansioso a Editorial, que tiene otro a Pais. Cargar un préstamo arrastra cinco tablas. Y ese coste lo pagas siempre, incluso cuando solo querías la fecha de vencimiento.
Regla profesional: pon
fetch = FetchType.LAZYexplícitamente en TODAS las asociaciones. Y cuando necesites los datos relacionados, pídelos explícitamente en la consulta conJOIN FETCHo@EntityGraph.
@ManyToOne(fetch = FetchType.LAZY) // SIEMPRE explícito
@JoinColumn(name = "material_id")
private Material material;Cómo funciona un proxy perezoso, y aquí vuelve el módulo 10: cuando cargas un Prestamo, Hibernate no pone un Material en el campo. Pone un objeto de una subclase generada de Material que no tiene datos, solo el id. La primera vez que llamas a material.getTitulo(), esa subclase intercepta la llamada, lanza el SELECT y devuelve el valor.
Es tu proxy dinámico de 10-03 otra vez, y de ahí salen dos consecuencias que ya conoces:
- Una entidad no puede ser
final: no se podría generar la subclase. - Al depurar verás objetos llamados
Material$HibernateProxy$xYz123. Es tu material, sin cargar.
- La
LazyInitializationException
LazyInitializationExceptionLa contrapartida de la carga perezosa, y una de las excepciones más famosas del mundo Java.
@Service
public class ServicioInformes {
@Transactional(readOnly = true)
public Prestamo obtener(Long id) {
return repositorio.findById(id).orElseThrow();
} // <- LA TRANSACCIÓN TERMINA AQUÍ. El contexto de persistencia se cierra.
}
// En otro sitio:
Prestamo prestamo = servicio.obtener(1L);
System.out.println(prestamo.getMaterial().getTitulo()); // BOOMorg.hibernate.LazyInitializationException: could not initialize proxy
[com.nexussoftware.bibliotech.dominio.Material#7] - no SessionQué ha pasado, paso a paso:
- Dentro de la transacción, Hibernate cargó el préstamo y puso un proxy en el campo
material. - La transacción terminó y el contexto de persistencia se cerró.
- Al llamar a
getTitulo(), el proxy intenta lanzar elSELECT... y ya no hay sesión con la que hacerlo.
Las soluciones, de mejor a peor:
1. Traer lo que necesitas en la consulta (la correcta):
@Query("SELECT p FROM Prestamo p JOIN FETCH p.material JOIN FETCH p.empleado WHERE p.id = :id")
Optional<Prestamo> buscarConDetalles(@Param("id") Long id);2. Devolver un DTO en vez de la entidad (la mejor arquitectónicamente):
@Transactional(readOnly = true)
public FichaPrestamo obtener(Long id) {
Prestamo p = repositorio.findById(id).orElseThrow();
// Se accede a todo DENTRO de la transacción y se devuelve un objeto desconectado
return new FichaPrestamo(p.getId(), p.getMaterial().getTitulo(),
p.getEmpleado().getNombre(), p.getFechaVencimiento());
}Ese FichaPrestamo puede ser perfectamente un record — donde sí encajan de maravilla.
3. Ampliar la transacción hasta cubrir el uso: solución válida a veces, pero mantener transacciones abiertas más tiempo del necesario tiene su propio coste.
4. spring.jpa.open-in-view=true: desaconsejada, aunque es el valor por defecto de Spring Boot. Mantiene el contexto de persistencia abierto durante toda la petición HTTP, de modo que la excepción no aparece nunca... y a cambio esconde el problema N+1, mantiene conexiones ocupadas más tiempo y provoca consultas desde la capa de presentación. Por eso en el application.yml del apartado 5 aparece explícitamente:
Consejo: mantenerlo en false hace que la LazyInitializationException aparezca en tu máquina en vez de que el problema de rendimiento aparezca en producción.
- El problema N+1
El problema de rendimiento número uno de los ORM. Y es tan fácil de provocar que casi todo el mundo lo tiene sin saberlo.
@Transactional(readOnly = true)
public void informeDePrestamos() {
List<Prestamo> prestamos = repositorio.findAll(); // 1 consulta
for (Prestamo p : prestamos) {
System.out.println(p.getMaterial().getTitulo()); // ¡1 consulta CADA VEZ!
}
}Con 500 préstamos, el log de show-sql muestra:
-- 1: la consulta principal
select p.id, p.material_id, p.empleado_id, ... from prestamo p
-- N: una por cada préstamo, al acceder a su material
select m.id, m.isbn, m.titulo, ... from material m where m.id=1
select m.id, m.isbn, m.titulo, ... from material m where m.id=2
select m.id, m.isbn, m.titulo, ... from material m where m.id=3
-- ... 500 veces501 consultas donde debería haber una. Con 2 ms de latencia por consulta, un segundo entero perdido. Y si añades el empleado, son 1001.
Lo insidioso del asunto: en desarrollo, con 5 préstamos de prueba, es instantáneo. El problema aparece en producción con datos reales, y para entonces la causa está en un bucle que parece inofensivo.
graph TD
A["findAll()<br/>1 consulta: 500 préstamos"] --> B["Bucle sobre los 500"]
B --> C["p.getMaterial() -> proxy"]
C --> D["getTitulo() dispara SELECT"]
D --> E["500 consultas adicionales"]
E --> F["TOTAL: 501 consultas<br/>en vez de 1"]
Nota importante: cambiar a EAGER no lo arregla. Con EAGER, Hibernate carga las asociaciones siempre, pero muchas veces lo hace igualmente con una consulta por entidad. Lo único que consigues es tener el problema siempre, incluso cuando no ibas a usar los materiales.
Cómo detectarlo:
spring.jpa.show-sql=truey contar las consultas en el log.- Estadísticas de Hibernate:
spring.jpa.properties.hibernate.generate_statistics=true. - En producción: métricas y trazas distribuidas (12-07).
- Resolverlo:
JOIN FETCH y @EntityGraph
JOIN FETCH y @EntityGraphDos soluciones, ambas de la especificación.
JOIN FETCH en JPQL
public interface PrestamoRepository extends JpaRepository<Prestamo, Long> {
@Query("""
SELECT p FROM Prestamo p
JOIN FETCH p.material
JOIN FETCH p.empleado
WHERE p.estado = :estado
""")
List<Prestamo> buscarActivosConDetalles(@Param("estado") EstadoPrestamo estado);
}Ahora se genera una sola consulta:
select p.*, m.*, e.*
from prestamo p
join material m on p.material_id = m.id
join empleado e on p.empleado_id = e.id
where p.estado = 'ACTIVO'De 501 consultas a 1.
Ojo con un detalle: JOIN FETCH sobre una colección puede duplicar filas. Si traes un empleado con sus 5 préstamos, el JOIN devuelve 5 filas y podrías obtener el empleado repetido. Se resuelve con SELECT DISTINCT o pidiendo un Set. Y una limitación conocida: no se puede paginar un JOIN FETCH de colección de forma eficiente; Hibernate avisa de que está paginando en memoria. En ese caso, la solución es traer los ids paginados en una consulta y las entidades completas en otra.
@EntityGraph: declarativo
public interface PrestamoRepository extends JpaRepository<Prestamo, Long> {
@EntityGraph(attributePaths = { "material", "empleado" })
List<Prestamo> findByEstado(EstadoPrestamo estado);
@EntityGraph(attributePaths = { "material", "material.categorias" })
Optional<Prestamo> findById(Long id);
}Ventaja: el mismo método puede tener varios grafos, y no hay que escribir JPQL.
Comparación
| Solución | Cuándo | Ventaja | Inconveniente |
|---|---|---|---|
JOIN FETCH |
Consulta concreta a medida | Control total | JPQL a mano |
@EntityGraph |
Sobre métodos derivados | Declarativo, reutilizable | Menos flexible |
@BatchSize(size = 25) |
Cuando el perezoso es inevitable | De 501 a 21 consultas | Sigue sin ser 1 |
| DTO por proyección | Consultas de solo lectura | Trae solo las columnas necesarias | No son entidades gestionadas |
EAGER |
Nunca | — | Empeora el problema |
La proyección a DTO merece mención aparte porque es la solución más eficiente para informes:
@Query("""
SELECT new com.nexussoftware.bibliotech.dominio.FichaPrestamo(
p.id, m.titulo, e.nombre, p.fechaVencimiento)
FROM Prestamo p JOIN p.material m JOIN p.empleado e
WHERE p.estado = :estado
""")
List<FichaPrestamo> fichasActivas(@Param("estado") EstadoPrestamo estado);Una consulta, solo cuatro columnas, sin entidades gestionadas y sin riesgo de N+1. Y FichaPrestamo es un record — que por fin encuentra su sitio en la capa de persistencia.
CascadeType y orphanRemoval
CascadeType y orphanRemovalLas cascadas propagan operaciones del padre a los hijos.
CascadeType |
Qué propaga | Ejemplo en BiblioTech |
|---|---|---|
PERSIST |
Guardar el padre guarda los hijos nuevos | Guardar Empleado guarda sus Prestamo |
MERGE |
Fusionar el padre fusiona los hijos | Actualizar un grafo desconectado |
REMOVE |
Borrar el padre borra los hijos | Peligroso: borrar un Material borraría su historial |
REFRESH |
Recargar en cascada | Raro |
DETACH |
Separar en cascada | Raro |
ALL |
Los cinco | Cómodo y peligroso |
@OneToMany(mappedBy = "empleado",
cascade = { CascadeType.PERSIST, CascadeType.MERGE },
orphanRemoval = true)
private List<Prestamo> prestamos = new ArrayList<>();orphanRemoval = true significa: si quitas un hijo de la colección, bórralo de la base de datos.
empleado.getPrestamos().remove(prestamo);
// Con orphanRemoval = true -> DELETE FROM prestamo WHERE id = ...
// Sin él -> UPDATE prestamo SET empleado_id = NULL (o error si es NOT NULL)Diferencia con CascadeType.REMOVE:
CascadeType.REMOVE |
orphanRemoval = true |
|
|---|---|---|
| Se dispara al | Borrar el padre | Quitar el hijo de la colección |
| Semántica | "Al borrar el todo, borra las partes" | "Un hijo sin padre no tiene sentido" |
Aviso serio: CascadeType.ALL sobre relaciones donde el hijo tiene vida propia es una fuente de pérdida de datos. Si Material tuviera cascade = ALL sobre sus préstamos, dar de baja un libro borraría todo su historial de préstamos, con sus multas y su auditoría. Usa cascadas solo cuando la relación sea de composición real (el hijo no existe sin el padre).
- El contexto de persistencia y el
EntityManager
EntityManagerAquí está el corazón conceptual de JPA, y lo que separa a quien entiende Hibernate de quien lo sufre.
El EntityManager es la puerta de entrada a JPA. Y gestiona un contexto de persistencia: una caché de entidades donde cada entidad cargada o guardada queda gestionada mientras dure la transacción.
Tres propiedades del contexto de persistencia, todas con consecuencias:
- Es una caché de primer nivel. Si pides dos veces la misma entidad en la misma transacción, la segunda no genera consulta.
- Garantiza identidad. Dentro de una transacción, la misma fila es siempre el mismo objeto Java:
a == bestrue. Eso resuelve el problema de identidad del apartado 1. - Detecta cambios automáticamente. Es el apartado 21, y es lo que más sorprende.
@Transactional
public void ejemploContexto(Long id) {
Material a = entityManager.find(Material.class, id); // SELECT
Material b = entityManager.find(Material.class, id); // sin consulta: caché
System.out.println(a == b); // true: MISMO objeto
}En Spring, el EntityManager se inyecta así:
@Repository
public class RepositorioMaterialesJpa {
@PersistenceContext
private EntityManager entityManager; // Spring inyecta un proxy por transacción
}Ese EntityManager inyectado no es una instancia real: es un proxy que en cada llamada busca el contexto de persistencia asociado a la transacción del hilo actual. Otra vez el mismo mecanismo del módulo 10.
- Los cuatro estados de una entidad
Toda entidad está en uno de cuatro estados, y saber en cuál es la clave para entender qué va a hacer Hibernate.
stateDiagram-v2
[*] --> Nueva: new Material(...)
Nueva --> Gestionada: persist()
Gestionada --> Separada: cierre de la transacción<br/>o detach()
Separada --> Gestionada: merge()
Gestionada --> Eliminada: remove()
Eliminada --> Nueva: persist()
Eliminada --> [*]: flush / commit
Gestionada --> Gestionada: los cambios se detectan solos
[*] --> Gestionada: find() / consulta
| Estado | Qué significa | ¿Hibernate la vigila? | ¿Tiene id? |
|---|---|---|---|
| Nueva (transient) | Recién creada con new |
No | No |
| Gestionada (managed) | En el contexto de persistencia | Sí | Sí |
| Separada (detached) | Estuvo gestionada, ya no | No | Sí |
| Eliminada (removed) | Marcada para borrar | Sí | Sí |
El ejemplo que lo aclara todo:
@Transactional
public void demostracion() {
// NUEVA: solo un objeto Java. La base de datos no sabe nada.
Material libro = new Libro("978-0000000001", "Java Efectivo", 3);
// GESTIONADA: entra en el contexto. Se insertará al confirmar.
entityManager.persist(libro);
// Gestionada: este cambio se guardará SOLO, sin llamar a nada.
libro.setTitulo("Java Efectivo, 3ª edición");
} // COMMIT: INSERT con el título ya corregido
// Fuera del método: SEPARADA. Los cambios ya no se detectan.Y el error clásico que produce:
// En un controlador o servicio, fuera de transacción
Material material = servicio.buscarPorIsbn("978-0000000001"); // separada
material.setTitulo("Otro título"); // no pasa NADA
// El cambio se queda en memoria y se pierde.Para que un cambio sobre una entidad separada se guarde, hay que reincorporarla con merge (apartado 23).
- La comprobación de cambios automática
Esto es lo que más sorprende de JPA, y a la vez lo más potente.
@Transactional
public void renovarPrestamo(Long prestamoId, int dias) {
Prestamo prestamo = repositorio.findById(prestamoId).orElseThrow();
prestamo.renovar(dias); // cambia fechaVencimiento
// NO hay ningún save(). NO hay ningún update().
} // Y sin embargo, al confirmar se ejecuta un UPDATE.¿Cómo? El mecanismo se llama dirty checking y funciona así:
- Al cargar la entidad, Hibernate guarda una instantánea del estado de todos sus campos.
- Al hacer
flush(normalmente al confirmar), compara el estado actual con la instantánea. - Para cada campo distinto, genera el
UPDATEcorrespondiente.
Consecuencias prácticas, todas importantes:
Uno: no hace falta llamar a save sobre entidades gestionadas. Muchísimo código Spring Data llama a save() innecesariamente. No hace daño, pero es ruido.
Dos: cualquier modificación accidental se persiste. Si dentro de una transacción tocas un campo de una entidad gestionada "solo para calcular algo", ese cambio va a la base de datos. Es una fuente real de bugs desconcertantes.
Tres: readOnly = true lo desactiva y ahorra trabajo.
@Transactional(readOnly = true) // sin instantáneas, sin comparaciones: más rápido
public List<Prestamo> listar() { ... }En consultas que devuelven muchas entidades, la diferencia es medible. Es una buena costumbre marcar readOnly = true en todos los métodos de solo lectura.
Cuatro: el coste crece con el número de entidades gestionadas. Cargar 100.000 entidades en una transacción hace que cada flush compare 100.000 instantáneas. Para procesos masivos hay que limpiar el contexto periódicamente con entityManager.clear().
flush y el orden de las operaciones
flush y el orden de las operacionesflush es el momento en que Hibernate escribe realmente el SQL pendiente en la base de datos. No es lo mismo que commit.
Cuándo ocurre automáticamente:
- Al confirmar la transacción (siempre).
- Antes de ejecutar una consulta que pudiera verse afectada por los cambios pendientes.
- Cuando llamas a
entityManager.flush()explícitamente.
Y aquí viene un detalle que sorprende: Hibernate no ejecuta el SQL en el orden en que tú llamas a los métodos. Reordena las operaciones así:
INSERTde entidades, en orden depersistUPDATE- Borrado de elementos de colecciones
- Inserción de elementos de colecciones
DELETEde entidades
Por eso este código puede fallar de forma incomprensible:
@Transactional
public void reemplazarMaterial(String isbn, Material nuevo) {
Material viejo = repositorio.buscarPorIsbn(isbn).orElseThrow();
entityManager.remove(viejo); // "borra" el que tiene isbn = X
entityManager.persist(nuevo); // "inserta" otro con isbn = X
} // Al confirmar: PRIMERO el INSERT, DESPUÉS el DELETE
// -> violación de la restricción de unicidad de isbnLa solución es forzar el orden:
entityManager.remove(viejo);
entityManager.flush(); // ejecuta el DELETE ahora
entityManager.persist(nuevo);Entender que existe un "SQL pendiente" que se ejecuta más tarde y reordenado explica la mitad de los comportamientos raros de Hibernate.
- Las operaciones:
persist, merge, find, getReference, remove
persist, merge, find, getReference, remove| Operación | Qué hace | Estado resultante | Cuándo |
|---|---|---|---|
persist(e) |
Marca una entidad nueva para insertar | Gestionada (la misma instancia) | Entidades nuevas |
merge(e) |
Copia el estado de una entidad separada al contexto | Gestionada (otra instancia) | Entidades separadas |
find(C, id) |
Busca por clave primaria | Gestionada o null |
Necesitas los datos |
getReference(C, id) |
Devuelve un proxy sin cargar | Gestionada (proxy) | Solo necesitas la referencia |
remove(e) |
Marca para borrar | Eliminada | Borrado |
detach(e) |
Saca del contexto | Separada | Procesos masivos |
refresh(e) |
Recarga desde la base de datos | Gestionada | Descartar cambios locales |
La diferencia entre persist y merge (que provoca bugs reales)
// persist: la MISMA instancia queda gestionada
Material libro = new Libro("978-0000000003", "Refactorización", 2);
entityManager.persist(libro);
System.out.println(libro.getId()); // ya tiene id: es la instancia gestionada
// merge: devuelve OTRA instancia. La original sigue separada.
Material separado = new Libro(...); separado.setId(7L);
Material gestionado = entityManager.merge(separado);
gestionado.setTitulo("A"); // SE GUARDA
separado.setTitulo("B"); // NO se guarda: sigue separada
System.out.println(gestionado == separado); // falseLa regla: usa siempre el objeto que devuelve merge. Ignorar el valor de retorno es un error frecuentísimo.
getReference: la optimización que se olvida
// Necesitas asociar un préstamo a un material, pero no necesitas sus datos.
// Opción A: find -> ejecuta un SELECT innecesario
Material material = entityManager.find(Material.class, materialId);
prestamo.asignarMaterial(material);
// Opción B: getReference -> NO consulta; solo crea un proxy con el id
Material material = entityManager.getReference(Material.class, materialId);
prestamo.asignarMaterial(material); // basta para poner la FKLo único que necesita el INSERT es el material_id, y getReference lo tiene. Ahorras un SELECT por operación. La contrapartida: si accedes a cualquier propiedad del proxy fuera de la sesión, tendrás una LazyInitializationException, y si el id no existe, el error llega tarde (EntityNotFoundException al acceder, no al pedir la referencia).
- Consultas: JPQL
JPQL (Jakarta Persistence Query Language) parece SQL pero opera sobre entidades y sus atributos Java, no sobre tablas y columnas.
// SQL: nombres de TABLA y COLUMNA
SELECT p.* FROM prestamo p WHERE p.fecha_vencimiento < ?
// JPQL: nombres de ENTIDAD y ATRIBUTO
SELECT p FROM Prestamo p WHERE p.fechaVencimiento < :fechaEjemplos completos sobre BiblioTech:
@Repository
public class RepositorioConsultas {
@PersistenceContext
private EntityManager em;
// Consulta simple con parámetro con nombre
public List<Prestamo> vencidos(LocalDate fecha) {
return em.createQuery("""
SELECT p FROM Prestamo p
WHERE p.fechaVencimiento < :fecha
AND p.fechaDevolucion IS NULL
ORDER BY p.fechaVencimiento
""", Prestamo.class)
.setParameter("fecha", fecha)
.getResultList();
}
// JOIN FETCH para evitar el N+1
public List<Prestamo> vencidosConDetalles(LocalDate fecha) {
return em.createQuery("""
SELECT p FROM Prestamo p
JOIN FETCH p.material
JOIN FETCH p.empleado
WHERE p.fechaVencimiento < :fecha
""", Prestamo.class)
.setParameter("fecha", fecha)
.getResultList();
}
// Agregación con GROUP BY, devolviendo un record directamente
public List<ConteoPorEmpleado> prestamosPorEmpleado() {
return em.createQuery("""
SELECT new com.nexussoftware.bibliotech.dominio.ConteoPorEmpleado(
e.nombre, COUNT(p))
FROM Prestamo p JOIN p.empleado e
GROUP BY e.id, e.nombre
HAVING COUNT(p) > 2
ORDER BY COUNT(p) DESC
""", ConteoPorEmpleado.class)
.getResultList();
}
// Paginación
public List<Material> pagina(int numero, int tamano) {
return em.createQuery("SELECT m FROM Material m ORDER BY m.titulo", Material.class)
.setFirstResult(numero * tamano)
.setMaxResults(tamano)
.getResultList();
}
// Modificación masiva: NO pasa por el contexto de persistencia
@Modifying
public int marcarVencidos(LocalDate fecha) {
return em.createQuery("""
UPDATE Prestamo p SET p.estado = :vencido
WHERE p.fechaVencimiento < :fecha AND p.fechaDevolucion IS NULL
""")
.setParameter("vencido", EstadoPrestamo.VENCIDO)
.setParameter("fecha", fecha)
.executeUpdate();
}
}Aviso sobre esa última: las consultas de modificación masiva se ejecutan directamente en la base de datos y no actualizan las entidades ya cargadas en el contexto de persistencia, que quedan desincronizadas. Después de una así, conviene em.clear().
Diferencias clave con SQL:
| JPQL | SQL |
|---|---|
FROM Prestamo p (entidad) |
FROM prestamo p (tabla) |
p.fechaVencimiento (atributo) |
p.fecha_vencimiento (columna) |
JOIN p.material (navega la relación) |
JOIN material ON ... (condición explícita) |
SELECT p devuelve entidades |
SELECT * devuelve filas |
| Portable entre motores | Depende del dialecto |
- Consultas con nombre, API de criterios y SQL nativo
Consultas con nombre
Se declaran en la entidad y se validan al arrancar, no al ejecutarse:
@Entity
@NamedQuery(name = "Prestamo.vencidos",
query = """
SELECT p FROM Prestamo p
WHERE p.fechaVencimiento < :fecha AND p.fechaDevolucion IS NULL
""")
public class Prestamo { ... }List<Prestamo> vencidos = em.createNamedQuery("Prestamo.vencidos", Prestamo.class)
.setParameter("fecha", LocalDate.now(reloj))
.getResultList();Ventaja real: un error de sintaxis en el JPQL impide arrancar la aplicación, en vez de estallar el día que alguien invoque ese informe.
La API de criterios, mencionada
Para consultas construidas dinámicamente —un buscador con cinco filtros opcionales—, concatenar JPQL a mano es feo y peligroso. JPA ofrece una API con tipos:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Prestamo> consulta = cb.createQuery(Prestamo.class);
Root<Prestamo> p = consulta.from(Prestamo.class);
List<Predicate> filtros = new ArrayList<>();
if (isbn != null) filtros.add(cb.equal(p.get("material").get("isbn"), isbn));
if (desde != null) filtros.add(cb.greaterThanOrEqualTo(p.get("fechaPrestamo"), desde));
consulta.where(cb.and(filtros.toArray(new Predicate[0])));
List<Prestamo> resultado = em.createQuery(consulta).getResultList();Es verbosa, y por eso mucha gente prefiere las Specification de Spring Data o QueryDSL. Se menciona para que sepas que existe y para qué sirve; en la práctica se usa poco.
SQL nativo
Cuando necesitas una función específica del motor, una consulta muy optimizada o un informe complejo:
List<Object[]> filas = em.createNativeQuery("""
SELECT m.titulo, COUNT(p.id) AS total
FROM material m LEFT JOIN prestamo p ON p.material_id = m.id
WHERE p.fecha_prestamo >= :desde
GROUP BY m.id, m.titulo
ORDER BY total DESC
FETCH FIRST 10 ROWS ONLY
""")
.setParameter("desde", desde)
.getResultList();Es perfectamente legítimo. Un ORM no obliga a hacerlo todo con él, y pretender lo contrario lleva a JPQL retorcidos que nadie entiende. La única pérdida es la portabilidad entre motores.
- Inyección SQL: por qué nunca se concatena
Esto no es una recomendación de estilo. Es una vulnerabilidad de seguridad.
// NUNCA. JAMÁS.
public List<Prestamo> buscarPorEmpleado(String correo) {
return em.createQuery(
"SELECT p FROM Prestamo p WHERE p.empleado.correo = '" + correo + "'",
Prestamo.class).getResultList();
}Si correo viene de un formulario o de un parámetro de una API, un atacante puede enviar:
Y la consulta se convierte en:
que devuelve todos los préstamos de todos los empleados. Con SQL nativo, las consecuencias son peores: dependiendo del motor y de los permisos, se puede llegar a leer otras tablas o modificar datos.
La solución es siempre la misma, y es trivial:
// Parámetro con nombre
em.createQuery("SELECT p FROM Prestamo p WHERE p.empleado.correo = :correo", Prestamo.class)
.setParameter("correo", correo)
.getResultList();
// O posicional
em.createQuery("SELECT p FROM Prestamo p WHERE p.empleado.correo = ?1", Prestamo.class)
.setParameter(1, correo)
.getResultList();Con parámetros, el valor nunca se interpreta como parte de la consulta: viaja aparte y se trata como un dato literal, pase lo que pase dentro. Es la misma razón por la que el ejemplo de JDBC del apartado 3 usaba PreparedStatement con ? y setString.
La regla, sin excepciones: todo valor que venga de fuera va como parámetro. Ni siquiera "cuando sé que es un número" —porque mañana ese código se copia a otro sitio donde no lo es.
Lo único que no se puede parametrizar son los nombres de tabla y columna y el sentido de un ORDER BY. Si necesitas que sean dinámicos, valídalos contra una lista blanca de valores permitidos, nunca los concatenes tal cual.
La seguridad de aplicaciones —validación de entrada, autorización, gestión de secretos, cabeceras— se trata en 12-07. Aquí basta con la regla: parámetros siempre.
- Transacciones:
@Transactional con base de datos real
@Transactional con base de datos realAhora que hay base de datos, el @Transactional de 11-02 hace algo.
Una transacción cumple las propiedades ACID:
| Propiedad | Qué garantiza |
|---|---|
| Atomicidad | O todas las operaciones o ninguna |
| Consistencia | Se respetan las restricciones de integridad |
| Aislamiento | Las transacciones concurrentes no se pisan |
| Durabilidad | Lo confirmado sobrevive a una caída |
El caso de BiblioTech:
@Service
public class GestorPrestamos {
@Transactional
public Prestamo prestar(String isbn, String correoEmpleado) {
Material material = repositorioMateriales.buscarPorIsbn(isbn)
.orElseThrow(() -> new MaterialNoEncontradoException(isbn));
Empleado empleado = repositorioEmpleados.buscarPorCorreo(correoEmpleado)
.orElseThrow(() -> new EmpleadoNoEncontradoException(correoEmpleado));
if (repositorioPrestamos.contarActivos(empleado) >= propiedades.prestamo().maximoPorEmpleado()) {
throw new LimitePrestamosException(correoEmpleado);
}
material.prestarUnEjemplar(); // UPDATE (por dirty checking)
Prestamo prestamo = new Prestamo(material, empleado,
LocalDate.now(reloj), propiedades.prestamo().diasPorDefecto());
repositorioPrestamos.save(prestamo); // INSERT
registroAuditoria.anotar(prestamo); // INSERT
return prestamo;
}
@Transactional(readOnly = true) // optimizado: sin dirty checking
public List<Prestamo> activosDe(String correo) {
return repositorioPrestamos.buscarActivosPorEmpleado(correo);
}
}Si registroAuditoria.anotar falla, se revierte todo: el ejemplar vuelve a estar disponible y no queda ningún préstamo huérfano. Sin transacción, quedaría un ejemplar menos y un préstamo sin auditar, y nadie sabría por qué.
Recuerda las dos reglas de 11-02, que siguen valiendo aquí: rollback solo ante excepciones no comprobadas por defecto, y las llamadas internas no pasan por el proxy.
- Propagación y aislamiento
Propagación: qué hacer si ya hay una transacción abierta
| Propagación | Comportamiento | Uso |
|---|---|---|
REQUIRED (por defecto) |
Se une a la existente; si no hay, la crea | El 95 % de los casos |
REQUIRES_NEW |
Suspende la actual y abre una independiente | Auditoría que debe persistir aunque lo demás falle |
SUPPORTS |
Se une si hay; si no, sin transacción | Consultas |
MANDATORY |
Exige que ya haya una; si no, error | Métodos internos que nunca deben llamarse solos |
NOT_SUPPORTED |
Suspende la actual y ejecuta sin transacción | Operaciones muy largas |
NEVER |
Error si hay transacción | Raro |
NESTED |
Punto de guardado dentro de la actual | Soporte limitado |
El caso donde REQUIRES_NEW es la respuesta correcta:
@Service
public class RegistroAuditoria {
// Se quiere registrar el INTENTO aunque el préstamo se revierta
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void registrarIntento(String isbn, String empleado, String resultado) {
em.persist(new EntradaAuditoria(isbn, empleado, resultado, Instant.now(reloj)));
}
}Cuidado: REQUIRES_NEW consume una segunda conexión del pool mientras la primera sigue abierta. Abusar de él agota el pool y provoca interbloqueos. Úsalo solo cuando la independencia sea un requisito real.
Aislamiento: cómo se ven las transacciones concurrentes
| Nivel | Lectura sucia | Lectura no repetible | Lectura fantasma | Coste |
|---|---|---|---|---|
READ_UNCOMMITTED |
Posible | Posible | Posible | Mínimo |
READ_COMMITTED |
No | Posible | Posible | Bajo (por defecto en PostgreSQL, Oracle, H2) |
REPEATABLE_READ |
No | No | Posible | Medio (por defecto en MySQL) |
SERIALIZABLE |
No | No | No | Alto |
Los tres fenómenos, con BiblioTech:
- Lectura sucia: lees un ejemplar como prestado y esa transacción se revierte. Leíste algo que nunca existió.
- Lectura no repetible: lees
ejemplares_disponibles = 1, otro presta, vuelves a leer y hay0. Dos lecturas, dos resultados. - Lectura fantasma: cuentas 3 préstamos activos, otro inserta uno, repites la consulta y hay 4.
@Transactional(isolation = Isolation.REPEATABLE_READ)
public InformeMensual generar(YearMonth mes) { ... }En la práctica, casi nunca se toca el aislamiento. READ_COMMITTED con bloqueo optimista (siguiente apartado) resuelve el 99 % de los casos con mucho menos coste que subir el nivel.
- Bloqueo optimista con
@Version
@VersionEl caso concreto, que retoma directamente el módulo 8:
Marta Ruiz y Diego Alonso abren a la vez la ficha de "Java Efectivo", que tiene 1 ejemplar disponible. Los dos pulsan "Prestar" en el mismo segundo.
sequenceDiagram
participant M as Marta
participant BD as Base de datos
participant D as Diego
M->>BD: SELECT material 1 (disponibles=1)
D->>BD: SELECT material 1 (disponibles=1)
M->>BD: UPDATE disponibles=0
D->>BD: UPDATE disponibles=0
Note over BD: Dos préstamos, un ejemplar.<br/>ACTUALIZACIÓN PERDIDA
Es exactamente la condición de carrera de 08-04, pero distribuida: aquí synchronized no sirve de nada, porque los dos usuarios pueden estar en instancias distintas de la aplicación.
Dos estrategias:
| Bloqueo pesimista | Bloqueo optimista | |
|---|---|---|
| Suposición | Habrá conflicto | Casi nunca habrá conflicto |
| Cómo | SELECT ... FOR UPDATE: bloquea la fila |
Columna version comprobada al actualizar |
| Coste | Alto: bloqueos, esperas, interbloqueos | Muy bajo |
| Cuándo | Conflictos frecuentes y caros | Casi siempre |
| JPA | @Lock(LockModeType.PESSIMISTIC_WRITE) |
@Version |
El bloqueo optimista es una sola anotación:
@Entity
public class Material {
@Id @GeneratedValue private Long id;
@Version // Hibernate la gestiona sola
private Long version;
@Column(name = "ejemplares_disponibles", nullable = false)
private int ejemplaresDisponibles;
}Con eso, cada UPDATE incluye la versión leída en la condición:
Si otro ya actualizó, la fila tiene version=6 y la condición no encuentra ninguna fila. Hibernate lo detecta y lanza:
Manejarlo bien:
@Service
public class GestorPrestamos {
@Transactional
public Prestamo prestar(String isbn, String correo) {
// ... como antes
}
public Resultado<Prestamo> prestarConReintento(String isbn, String correo) {
for (int intento = 1; intento <= 3; intento++) {
try {
return Resultado.exito(self.prestar(isbn, correo));
} catch (OptimisticLockingFailureException e) {
log.warn("Conflicto de concurrencia en {} (intento {}/3)", isbn, intento);
}
}
return Resultado.error("El material está siendo modificado. Inténtalo de nuevo.");
}
}Ese Resultado<T> es el tipo genérico que escribiste en 10-01, y aquí encaja perfectamente. Y ese bucle de reintentos es, conceptualmente, tu ProxyReintentos de 10-03: en un proyecto real lo declararías con @Retryable de Spring Retry.
Bloqueo pesimista, cuando la operación es cara y el conflicto probable:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM Material m WHERE m.isbn = :isbn")
Optional<Material> buscarParaActualizar(@Param("isbn") String isbn);Genera SELECT ... FOR UPDATE: el segundo espera al primero. Correcto, pero con coste real de concurrencia.
- Cachés de primer y segundo nivel
| Caché | Ámbito | Activada por defecto | Qué guarda |
|---|---|---|---|
| Primer nivel | El contexto de persistencia (la transacción) | Sí, siempre | Entidades gestionadas |
| Segundo nivel | El EntityManagerFactory (toda la aplicación) |
No | Entidades entre transacciones |
| De consulta | Global | No | Resultados de consultas |
La de primer nivel ya la conoces: es el contexto de persistencia del apartado 19. No se puede desactivar y es la que garantiza la identidad de objetos.
La de segundo nivel es opcional y compartida por todas las transacciones. Necesita un proveedor (Ehcache, Caffeine, Hazelcast):
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Material { ... }spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
region.factory_class: org.hibernate.cache.jcache.JCacheRegionFactoryCuándo activarla: datos que se leen mucho y cambian poco. El catálogo de materiales o las categorías de BiblioTech son buenos candidatos; los préstamos, no.
Advertencias que hay que tener presentes:
- Coherencia: si otra aplicación modifica la base de datos, tu caché queda obsoleta y sirve datos viejos.
- En clúster: con varias instancias, cada una tiene su caché. O usas una caché distribuida o tendrás incoherencias.
- Mide antes: activar la caché de segundo nivel "por si acaso" añade complejidad sin beneficio demostrado. Es exactamente lo que 10-07 decía: medir antes de optimizar.
ddl-auto y por qué nunca update en producción
ddl-auto y por qué nunca update en producciónHibernate puede generar el esquema a partir de tus entidades. Es cómodo y peligroso.
| Valor | Qué hace | Cuándo usarlo |
|---|---|---|
none |
Nada | Producción |
validate |
Comprueba que el esquema coincide con las entidades; falla si no | Producción (recomendado) |
update |
Intenta modificar el esquema para adaptarlo | Nunca en producción |
create |
Borra y crea el esquema al arrancar | Pruebas |
create-drop |
Como create, y borra al cerrar |
Desarrollo con H2, pruebas |
Por qué update no se usa nunca en producción, con motivos concretos:
- Nunca borra nada. Si quitas un campo, la columna se queda ahí para siempre. Si renombras
tituloanombre, añadenombrey dejatitulo— y no copia los datos. - No versiona nada. No hay registro de qué cambios se aplicaron ni cuándo, y no hay forma de deshacerlos.
- Es impredecible. El SQL que genera depende del dialecto, de la versión de Hibernate y del estado actual del esquema.
- No se puede revisar antes. Nadie ha visto el
ALTER TABLEque va a ejecutarse contra la base de datos de producción. - No puede hacer migraciones de datos. Dividir
nombre_completoennombreyapellidosrequiere lógica que ninguna herramienta automática puede inventar.
La configuración correcta por entorno:
# application-dev.yml — H2 en memoria, esquema regenerado cada arranque
spring:
jpa:
hibernate:
ddl-auto: create-drop# application-prod.yml — el esquema lo gestiona una herramienta de migración
spring:
jpa:
hibernate:
ddl-auto: validatevalidate es especialmente valioso: si el esquema real no coincide con las entidades, la aplicación no arranca, y eso es mucho mejor que descubrirlo con un error a las tres de la mañana.
Flyway y Liquibase, mencionados
En producción, el esquema se gestiona con migraciones versionadas: ficheros SQL numerados que se aplican en orden y quedan registrados en una tabla de control.
-- src/main/resources/db/migration/V1__esquema_inicial.sql
CREATE TABLE material (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
isbn VARCHAR(20) NOT NULL UNIQUE,
titulo VARCHAR(200) NOT NULL,
ejemplares_disponibles INT NOT NULL DEFAULT 0,
version BIGINT NOT NULL DEFAULT 0
);-- V2__anadir_categoria.sql
ALTER TABLE material ADD COLUMN categoria VARCHAR(50);
UPDATE material SET categoria = 'GENERAL' WHERE categoria IS NULL;Ventajas: el cambio de esquema está en git, es revisable, es reproducible y se puede probar antes. Se desarrolla en 12-06, junto con el despliegue.
- Spring Data JPA
Última pieza, y la que más código elimina.
Con JPA puro, un repositorio son unas ochenta líneas. Con Spring Data JPA:
package com.nexussoftware.bibliotech.persistencia;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;
public interface PrestamoRepository extends JpaRepository<Prestamo, Long> {
// Y ya está. Sin implementación.
}Eso ya te da save, findById, findAll, deleteById, count, existsById, paginación y ordenación.
Métodos derivados del nombre
public interface PrestamoRepository extends JpaRepository<Prestamo, Long> {
List<Prestamo> findByEstado(EstadoPrestamo estado);
List<Prestamo> findByEmpleadoCorreo(String correo);
List<Prestamo> findByFechaVencimientoBeforeAndFechaDevolucionIsNull(LocalDate fecha);
long countByEmpleadoAndEstado(Empleado empleado, EstadoPrestamo estado);
Optional<Prestamo> findFirstByMaterialIsbnOrderByFechaPrestamoDesc(String isbn);
boolean existsByMaterialIsbnAndEstado(String isbn, EstadoPrestamo estado);
}Spring Data analiza el nombre del método y genera la consulta. Las palabras clave habituales:
| Palabra clave | JPQL generado |
|---|---|
findBy, readBy, getBy |
SELECT ... |
And, Or |
AND, OR |
Between, LessThan, GreaterThan, Before, After |
Comparaciones |
IsNull, IsNotNull |
IS NULL |
Like, Containing, StartingWith |
LIKE |
In, NotIn |
IN |
OrderBy...Asc/Desc |
ORDER BY |
countBy, existsBy, deleteBy |
Agregación, existencia, borrado |
First, Top3 |
LIMIT |
Consejo práctico: los nombres derivados son fantásticos hasta que dejan de serlo. findByFechaVencimientoBeforeAndFechaDevolucionIsNullAndEstadoNot es ilegible. Cuando el nombre pase de tres condiciones, usa @Query.
@Query
@Query("""
SELECT p FROM Prestamo p
JOIN FETCH p.material m
JOIN FETCH p.empleado e
WHERE p.fechaVencimiento < :fecha AND p.fechaDevolucion IS NULL
ORDER BY p.fechaVencimiento
""")
List<Prestamo> vencidosConDetalles(@Param("fecha") LocalDate fecha);
@Modifying
@Transactional
@Query("UPDATE Prestamo p SET p.estado = :estado WHERE p.id = :id")
int actualizarEstado(@Param("id") Long id, @Param("estado") EstadoPrestamo estado);
@Query(value = "SELECT * FROM prestamo WHERE fecha_prestamo > ?1", nativeQuery = true)
List<Prestamo> nativaDesde(LocalDate desde);Paginación
Pageable pagina = PageRequest.of(0, 20, Sort.by("fechaVencimiento").descending());
Page<Prestamo> resultado = repositorio.findByEstado(EstadoPrestamo.ACTIVO, pagina);
resultado.getContent(); // los 20 de esta página
resultado.getTotalElements(); // total (ejecuta un COUNT adicional)
resultado.getTotalPages();
resultado.hasNext();Si no necesitas el total, Slice<T> evita el COUNT y es más rápido.
Cómo funciona: proxies dinámicos otra vez
PrestamoRepository es una interfaz sin implementación. ¿Quién ejecuta findByEstado?
En el arranque, Spring Data:
- Escanea las interfaces que extienden
Repository. - Para cada una, crea una implementación al vuelo con
Proxy.newProxyInstance— el mismo de 10-03. - El
InvocationHandlerrecibe cada llamada, mira si el método tiene@Query(y usa ese JPQL) o analiza su nombre (y genera el JPQL), lo ejecuta con elEntityManagery adapta el resultado.
graph LR
A["gestor.repositorio<br/>.findByEstado(ACTIVO)"] --> B["Proxy JDK<br/>(Spring Data)"]
B --> C["Analiza el nombre<br/>o lee @Query"]
C --> D["Genera JPQL"]
D --> E["EntityManager"]
E --> F["Hibernate -> SQL"]
F --> G[("H2")]
Es literalmente lo que escribiste en 10-03: un proxy sobre una interfaz que interpreta los metadatos del método y actúa. La diferencia es la sofisticación del análisis, no el mecanismo.
Y hay una consecuencia práctica: el repositorio se puede sustituir por un simulado en las pruebas sin ninguna dificultad, porque es una interfaz. Es exactamente lo que hará 11-06.
- BiblioTech: de CSV a H2
El resultado completo de la migración.
// dominio/Material.java — ya no puede ser sealed record: es una entidad
package com.nexussoftware.bibliotech.dominio;
import jakarta.persistence.*;
@Entity
@Table(name = "material")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) // una tabla para toda la jerarquía
@DiscriminatorColumn(name = "tipo", discriminatorType = DiscriminatorType.STRING, length = 20)
public abstract class Material {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 20)
private String isbn;
@Column(nullable = false, length = 200)
private String titulo;
@Column(name = "ejemplares_disponibles", nullable = false)
private int ejemplaresDisponibles;
@Version
private Long version;
protected Material() { }
protected Material(String isbn, String titulo, int ejemplares) {
this.isbn = isbn;
this.titulo = titulo;
this.ejemplaresDisponibles = ejemplares;
}
public void prestarUnEjemplar() {
if (ejemplaresDisponibles <= 0) throw new SinEjemplaresException(isbn);
ejemplaresDisponibles--;
}
public void devolverUnEjemplar() { ejemplaresDisponibles++; }
// getters...
}
@Entity @DiscriminatorValue("LIBRO")
public class Libro extends Material {
@Column(length = 120) private String autor;
protected Libro() { }
public Libro(String isbn, String titulo, int ejemplares, String autor) {
super(isbn, titulo, ejemplares);
this.autor = autor;
}
}
@Entity @DiscriminatorValue("REVISTA")
public class Revista extends Material {
private Integer numero;
protected Revista() { }
}
@Entity @DiscriminatorValue("DVD")
public class Dvd extends Material {
@Column(name = "duracion_minutos") private Integer duracionMinutos;
protected Dvd() { }
}Aquí se paga uno de los precios reales del ORM, y hay que decirlo con claridad: la jerarquía sealed de 10-06 desaparece. Una entidad JPA no puede ser sealed ni record ni final. Se pierden los switch exhaustivos verificados por el compilador y hay que sustituirlos por polimorfismo o por comprobaciones con instanceof. Es un compromiso consciente: se gana persistencia real, consultas e integridad; se pierde parte de la expresividad del modelo de tipos. Un diseño alternativo —y frecuente en proyectos que valoran mucho el dominio— mantiene el dominio puro y usa entidades JPA separadas con un mapeo entre ambos. Esa discusión pertenece a 12-02.
Las tres estrategias de herencia, para que sepas qué elegiste:
| Estrategia | Cómo | Ventajas | Inconvenientes |
|---|---|---|---|
SINGLE_TABLE (por defecto) |
Una tabla, columna discriminadora | Rápida, sin JOIN |
Columnas de subclases anulables |
JOINED |
Una tabla por clase, unidas por PK | Normalizada, sin nulos | JOIN en cada consulta |
TABLE_PER_CLASS |
Tabla completa por subclase | Sin JOIN para consultas concretas |
UNION al consultar la superclase |
Los repositorios:
public interface MaterialRepository extends JpaRepository<Material, Long> {
Optional<Material> findByIsbn(String isbn);
List<Material> findByTituloContainingIgnoreCase(String fragmento);
List<Material> findByEjemplaresDisponiblesGreaterThan(int minimo);
}
public interface EmpleadoRepository extends JpaRepository<Empleado, Long> {
Optional<Empleado> findByCorreo(String correo);
}
public interface PrestamoRepository extends JpaRepository<Prestamo, Long> {
@EntityGraph(attributePaths = { "material", "empleado" })
List<Prestamo> findByEstado(EstadoPrestamo estado);
long countByEmpleadoAndEstado(Empleado empleado, EstadoPrestamo estado);
@Query("""
SELECT p FROM Prestamo p JOIN FETCH p.material JOIN FETCH p.empleado
WHERE p.fechaVencimiento < :fecha AND p.fechaDevolucion IS NULL
""")
List<Prestamo> vencidos(@Param("fecha") LocalDate fecha);
}Datos iniciales para desarrollo, en src/main/resources/data.sql:
INSERT INTO empleado (correo, nombre) VALUES
('[email protected]', 'Marta Ruiz'),
('[email protected]', 'Diego Alonso'),
('[email protected]', 'Nuria Vidal');
INSERT INTO material (tipo, isbn, titulo, ejemplares_disponibles, version, autor) VALUES
('LIBRO', '978-0000000001', 'Java Efectivo', 3, 0, 'J. Bloch'),
('LIBRO', '978-0000000002', 'Patrones de Diseño', 2, 0, 'GoF'),
('LIBRO', '978-0000000003', 'Refactorización', 1, 0, 'M. Fowler');Ejecución:
mvn spring-boot:run -Dspring-boot.run.profiles=dev
# Consola web de H2: http://localhost:8080/h2-console
# JDBC URL: jdbc:h2:mem:bibliotech | Usuario: sa | Sin contraseñaEl antes y el después:
| Aspecto | CSV (módulo 7) | JPA sobre H2 |
|---|---|---|
| Formato | Texto plano | Base de datos relacional |
| Consultas | Cargar todo y filtrar en memoria | SQL con índices |
| Transacciones | Ninguna | ACID |
| Integridad referencial | Ninguna | Claves foráneas |
| Concurrencia | Dos procesos lo corrompen | Bloqueo optimista con @Version |
| Escritura | EscrituraAtomica a mano |
Gestionada por el motor |
| Código de acceso | LectorCsv + EscritorCsv + mapeo |
Tres interfaces sin implementación |
| Consultas nuevas | Programar el filtrado | Un método más en la interfaz |
| Escalabilidad | Miles de filas | Millones |
- Errores Comunes y Consejos
Error: javax.persistence en vez de jakarta.persistence. El error de copia y pega número uno hoy. Spring Boot 3 y Hibernate 6 usan jakarta.
Error: dejar @Enumerated por defecto (ORDINAL). Reordenar un enum corrompe silenciosamente todos los datos históricos. Siempre EnumType.STRING.
Error: dejar @ManyToOne en EAGER. Es el valor por defecto y es malo. Pon LAZY explícitamente en todas las asociaciones y trae lo que necesites con JOIN FETCH.
Error: el problema N+1 sin darse cuenta. Un bucle sobre entidades accediendo a una relación perezosa. Activa show-sql en desarrollo y cuenta las consultas.
Error: spring.jpa.open-in-view=true. Es el valor por defecto de Spring Boot y esconde el N+1 hasta producción. Ponlo a false.
Error: modificar solo el lado inverso de una relación. empleado.getPrestamos().add(p) sin p.setEmpleado(empleado) no persiste nada. Escribe métodos de conveniencia.
Error: ignorar el retorno de merge. merge(e) devuelve otra instancia; la original sigue separada y sus cambios se pierden.
Error: CascadeType.ALL sin pensarlo. Borrar un material podría borrar su historial completo de préstamos.
Error: ddl-auto=update en producción. No borra, no versiona, no migra datos y nadie revisa el SQL que va a ejecutar. validate más Flyway.
Error: concatenar valores en una consulta. Es inyección SQL. Parámetros siempre, sin excepciones.
Error: entidades final, record o con métodos final. Hibernate necesita generar subclases proxy. No compilará el modelo o fallará la carga perezosa.
Error: equals y hashCode basados en el id generado. Antes de persistir, el id es null; después cambia, y una entidad metida en un HashSet deja de encontrarse. Usa una clave de negocio (el isbn) o el patrón recomendado de Hibernate.
Consejo: activa show-sql mientras aprendes. Ver el SQL generado es la mitad del aprendizaje, y te enseña a detectar el N+1 al instante.
Consejo: marca readOnly = true en los métodos de consulta. Desactiva la comprobación de cambios y ahorra trabajo real.
Consejo: usa DTO (record) para lo que sale del servicio. Devolver entidades gestionadas fuera de la transacción es la causa directa de la LazyInitializationException, y además acopla tu API interna al esquema.
Consejo: @Version en toda entidad que se modifique concurrentemente. Cuesta una anotación y evita actualizaciones perdidas.
Consejo: no lo hagas todo con el ORM. Para informes y agregaciones, SQL nativo o JdbcTemplate son mejores herramientas. Es legítimo y profesional.
Consejo: getReference cuando solo necesites la referencia para asignar una FK. Ahorra un SELECT por operación.
- Ejercicios
Ejercicio 1: mapear la entidad Reserva
BiblioTech tiene reservas: cuando no hay ejemplares, un empleado reserva y se le avisa al devolverse uno. Este es el record del módulo 10:
public record Reserva(Long id, String isbn, String correoEmpleado,
LocalDate fechaSolicitud, LocalDate fechaCaducidad,
Optional<Instant> instanteAviso, EstadoReserva estado) { }
public enum EstadoReserva { PENDIENTE, AVISADA, COMPLETADA, CADUCADA }Conviértelo en una entidad JPA que cumpla:
- Tabla
reservacon clave primaria autogenerada. - Relaciones
@ManyToOneperezosas aMaterialyEmpleado, no cadenas. - El enum guardado de forma segura.
instanteAvisoanulable en la base de datos pero expuesto comoOptional.- Bloqueo optimista.
- Un índice sobre
(material_id, estado)para la consulta de pendientes. - Un método de dominio
avisar(Clock reloj)que cambie el estado y registre el instante, con validación. - El repositorio Spring Data con: pendientes de un material ordenadas por antigüedad, caducadas antes de una fecha, y contar pendientes de un empleado.
Ejercicio 2: diagnosticar y arreglar un N+1
Este servicio funciona bien con los 5 préstamos de desarrollo y tarda 9 segundos en producción con 800.
@Service
public class ServicioInformes {
private final PrestamoRepository repositorio;
@Transactional(readOnly = true)
public List<String> informeVencidos(LocalDate fecha) {
List<Prestamo> vencidos = repositorio.findByFechaVencimientoBefore(fecha);
return vencidos.stream()
.map(p -> String.format("%s | %s | %s | %d días",
p.getMaterial().getTitulo(),
p.getMaterial().getCategorias().stream()
.map(Categoria::getNombre)
.collect(Collectors.joining(", ")),
p.getEmpleado().getNombre(),
ChronoUnit.DAYS.between(p.getFechaVencimiento(), fecha)))
.toList();
}
}Se pide:
- Calcula cuántas consultas SQL se ejecutan con 800 préstamos, desglosadas.
- Explica exactamente por qué.
- Propón tres soluciones distintas con su código, indicando cuántas consultas quedan en cada una.
- Di cuál elegirías y por qué.
Ejercicio 3: el ejemplar disputado
Marta Ruiz y Diego Alonso intentan prestar simultáneamente el último ejemplar de "Refactorización" (978-0000000003, 1 ejemplar).
@Service
public class GestorPrestamos {
@Transactional
public Prestamo prestar(String isbn, String correo) {
Material material = materialRepository.findByIsbn(isbn).orElseThrow();
Empleado empleado = empleadoRepository.findByCorreo(correo).orElseThrow();
if (material.getEjemplaresDisponibles() <= 0) {
throw new SinEjemplaresException(isbn);
}
material.setEjemplaresDisponibles(material.getEjemplaresDisponibles() - 1);
Prestamo prestamo = new Prestamo(material, empleado, LocalDate.now(reloj), 15);
return prestamoRepository.save(prestamo);
}
}Se pide:
- Describe la secuencia exacta de eventos que produce dos préstamos con un solo ejemplar.
- Explica por qué
synchronized(08-04) no resuelve esto en un despliegue real. - Resuélvelo con bloqueo optimista, incluyendo el manejo de la excepción con reintento.
- Resuélvelo con bloqueo pesimista.
- Compara ambas en una tabla y recomienda una.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.dominio;
import jakarta.persistence.*;
import java.time.*;
import java.util.Optional;
@Entity
@Table(name = "reserva",
indexes = @Index(name = "idx_reserva_material_estado", // (6)
columnList = "material_id, estado"))
public class Reserva {
@Id // (1)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // (2)
@JoinColumn(name = "material_id", nullable = false)
private Material material;
@ManyToOne(fetch = FetchType.LAZY, optional = false) // (2)
@JoinColumn(name = "empleado_id", nullable = false)
private Empleado empleado;
@Column(name = "fecha_solicitud", nullable = false)
private LocalDate fechaSolicitud;
@Column(name = "fecha_caducidad", nullable = false)
private LocalDate fechaCaducidad;
@Column(name = "instante_aviso") // (4) anulable
private Instant instanteAviso;
@Enumerated(EnumType.STRING) // (3) NUNCA ORDINAL
@Column(nullable = false, length = 20)
private EstadoReserva estado;
@Version // (5)
private Long version;
protected Reserva() { } // exigido por JPA
public Reserva(Material material, Empleado empleado, LocalDate solicitud, int diasValidez) {
this.material = material;
this.empleado = empleado;
this.fechaSolicitud = solicitud;
this.fechaCaducidad = solicitud.plusDays(diasValidez);
this.estado = EstadoReserva.PENDIENTE;
}
// (7) comportamiento de dominio con validación
public void avisar(Clock reloj) {
if (estado != EstadoReserva.PENDIENTE) {
throw new EstadoReservaInvalidoException(
"Solo se puede avisar una reserva PENDIENTE; estado actual: " + estado);
}
this.estado = EstadoReserva.AVISADA;
this.instanteAviso = Instant.now(reloj); // Clock inyectado (10-05)
}
public void completar() {
if (estado != EstadoReserva.AVISADA) {
throw new EstadoReservaInvalidoException("Solo se completa una reserva AVISADA");
}
this.estado = EstadoReserva.COMPLETADA;
}
public boolean haCaducado(LocalDate hoy) {
return estado == EstadoReserva.PENDIENTE && fechaCaducidad.isBefore(hoy);
}
// (4) el campo es anulable; la API pública devuelve Optional
public Optional<Instant> getInstanteAviso() {
return Optional.ofNullable(instanteAviso);
}
public Long getId() { return id; }
public Material getMaterial() { return material; }
public Empleado getEmpleado() { return empleado; }
public EstadoReserva getEstado() { return estado; }
public LocalDate getFechaSolicitud() { return fechaSolicitud; }
public LocalDate getFechaCaducidad() { return fechaCaducidad; }
}Repositorio (8):
public interface ReservaRepository extends JpaRepository<Reserva, Long> {
// Pendientes de un material, la más antigua primero (cola FIFO de reservas)
@EntityGraph(attributePaths = { "empleado" }) // evita N+1 al leer el nombre
List<Reserva> findByMaterialIsbnAndEstadoOrderByFechaSolicitudAsc(
String isbn, EstadoReserva estado);
// Caducadas
List<Reserva> findByEstadoAndFechaCaducidadBefore(EstadoReserva estado, LocalDate fecha);
// Contar pendientes de un empleado
long countByEmpleadoCorreoAndEstado(String correo, EstadoReserva estado);
}Decisiones destacables:
record→ clase: obligatorio, porque unrecordesfinaly sin constructor vacío.- Relaciones en vez de cadenas:
MaterialyEmpleadoen vez deisbnycorreoEmpleado. Da integridad referencial (no se puede reservar un material inexistente) y navegación. El precio es tener que gestionar la pereza. Optionalfuera, campo anulable dentro: se conserva la garantía de 10-04 sin pelearse con JPA.- El comportamiento vive en la entidad:
avisar,completaryhaCaducadovalidan las transiciones. La entidad no es una bolsa de datos. @EntityGraphen la primera consulta: se sabe que se va a leer el nombre del empleado, así que se trae de una vez.
Solución 2
1. Cuántas consultas.
| Concepto | Consultas |
|---|---|
findByFechaVencimientoBefore |
1 |
p.getMaterial() — proxy perezoso, 800 veces |
800 |
p.getMaterial().getCategorias() — colección perezosa, 800 veces |
800 |
p.getEmpleado() — proxy perezoso, 800 veces |
800 |
| Total | 2.401 |
Con 3-4 ms de latencia por consulta, entre 7 y 10 segundos. Coincide con el síntoma.
2. Por qué. Las tres asociaciones son perezosas. La consulta inicial trae solo las filas de prestamo, con proxies en material y empleado y una colección perezosa en categorias. Cada acceso dentro del map dispara su propio SELECT. En desarrollo, con 5 préstamos, son 16 consultas y nadie lo nota.
Solución A — JOIN FETCH (1 consulta si se limita a lo escalar, 2 con la colección):
public interface PrestamoRepository extends JpaRepository<Prestamo, Long> {
@Query("""
SELECT DISTINCT p FROM Prestamo p
JOIN FETCH p.material m
LEFT JOIN FETCH m.categorias
JOIN FETCH p.empleado
WHERE p.fechaVencimiento < :fecha
""")
List<Prestamo> vencidosCompletos(@Param("fecha") LocalDate fecha);
}DISTINCT es necesario porque el JOIN FETCH de la colección de categorías duplica filas de préstamo. 1 consulta. Limitación: no se puede paginar eficientemente con un JOIN FETCH de colección.
Solución B — @EntityGraph (declarativa, mismo resultado):
@EntityGraph(attributePaths = { "material", "material.categorias", "empleado" })
List<Prestamo> findByFechaVencimientoBefore(LocalDate fecha);Ventaja: no se toca el código del servicio y el mismo método puede tener otro grafo en otra consulta. 1-2 consultas.
Solución C — proyección a DTO (la más eficiente):
public record LineaInforme(String tituloMaterial, String nombreEmpleado,
LocalDate fechaVencimiento) {
public String formatear(LocalDate referencia) {
return "%s | %s | %d días".formatted(tituloMaterial, nombreEmpleado,
ChronoUnit.DAYS.between(fechaVencimiento, referencia));
}
}@Query("""
SELECT new com.nexussoftware.bibliotech.dominio.LineaInforme(
m.titulo, e.nombre, p.fechaVencimiento)
FROM Prestamo p JOIN p.material m JOIN p.empleado e
WHERE p.fechaVencimiento < :fecha
ORDER BY p.fechaVencimiento
""")
List<LineaInforme> lineasVencidos(@Param("fecha") LocalDate fecha);1 consulta, y solo 3 columnas en vez de todas las de tres tablas. Sin entidades gestionadas, sin comprobación de cambios, sin riesgo de N+1 posterior. Para las categorías haría falta una consulta adicional o STRING_AGG en SQL nativo.
Comparación:
| Solución | Consultas | Datos transferidos | Flexibilidad | Riesgo de reincidir |
|---|---|---|---|---|
A: JOIN FETCH |
1-2 | Todas las columnas | Media | Medio |
B: @EntityGraph |
1-2 | Todas las columnas | Alta | Medio |
| C: DTO | 1 | Solo lo necesario | Baja | Ninguno |
4. Cuál elegir. Para un informe de solo lectura, la C. Razones: trae solo lo necesario, no hay entidades gestionadas ni comprobación de cambios, y —lo más importante— es estructuralmente imposible que reaparezca el N+1, porque no hay proxies que puedan dispararse. Con A y B, alguien que mañana añada un p.getMaterial().getEditorial().getPais() al formato vuelve a tener el problema.
Si el método tuviera que devolver entidades para modificarlas después, la B, por ser declarativa y reutilizable.
Solución 3
1. La secuencia.
| Momento | Transacción de Marta | Transacción de Diego | BD |
|---|---|---|---|
| t1 | SELECT material → disponibles = 1 |
1 | |
| t2 | SELECT material → disponibles = 1 |
1 | |
| t3 | comprobación 1 > 0 → OK |
1 | |
| t4 | comprobación 1 > 0 → OK |
1 | |
| t5 | UPDATE disponibles = 0 |
0 | |
| t6 | INSERT prestamo |
||
| t7 | COMMIT | 0 | |
| t8 | UPDATE disponibles = 0 |
0 | |
| t9 | INSERT prestamo |
||
| t10 | COMMIT | 0, con 2 préstamos |
Es una actualización perdida: el UPDATE de Diego se calculó a partir de un valor obsoleto. Con READ_COMMITTED, ninguna de las dos transacciones ve nada anómalo.
2. Por qué synchronized no sirve. synchronized sincroniza hilos dentro de una misma JVM. En un despliegue real hay varias instancias de BiblioTech tras un balanceador (12-06), y Marta y Diego pueden estar atendidos por procesos distintos, en máquinas distintas. Un monitor de la JVM A no bloquea nada en la JVM B. Además, aunque hubiera una sola instancia, serializar todos los préstamos de todos los materiales por un único cerrojo sería un cuello de botella innecesario. La coordinación tiene que estar donde está el dato compartido: en la base de datos.
3. Bloqueo optimista.
@Entity
public class Material {
@Version
private Long version; // única adición al modelo
public void prestarUnEjemplar() { // lógica DENTRO de la entidad
if (ejemplaresDisponibles <= 0) throw new SinEjemplaresException(isbn);
ejemplaresDisponibles--;
}
}@Service
public class GestorPrestamos {
private static final Logger log = LoggerFactory.getLogger(GestorPrestamos.class);
private final GestorPrestamos self; // para que el reintento pase por el proxy (11-02)
@Transactional
public Prestamo prestar(String isbn, String correo) {
Material material = materialRepository.findByIsbn(isbn)
.orElseThrow(() -> new MaterialNoEncontradoException(isbn));
Empleado empleado = empleadoRepository.findByCorreo(correo)
.orElseThrow(() -> new EmpleadoNoEncontradoException(correo));
material.prestarUnEjemplar(); // valida y decrementa
Prestamo prestamo = new Prestamo(material, empleado, LocalDate.now(reloj), 15);
return prestamoRepository.save(prestamo);
} // COMMIT: update material ... where id=? and version=?
// Reintento: cada intento es una transacción NUEVA
public Resultado<Prestamo> prestarConReintento(String isbn, String correo) {
for (int intento = 1; intento <= 3; intento++) {
try {
return Resultado.exito(self.prestar(isbn, correo)); // pasa por el proxy
} catch (OptimisticLockingFailureException e) {
log.warn("Conflicto en {} (intento {}/3)", isbn, intento);
} catch (SinEjemplaresException e) {
return Resultado.error("No quedan ejemplares de " + isbn);
}
}
return Resultado.error("El material está siendo modificado; inténtalo de nuevo.");
}
}Qué pasa ahora en t8: el UPDATE de Diego lleva where id=1 and version=0, pero la fila ya tiene version=1. Afecta a 0 filas, Hibernate lanza OptimisticLockException y Spring la traduce a OptimisticLockingFailureException. El reintento vuelve a leer, ve disponibles=0 y devuelve un error de negocio correcto.
Resultado<T> es el tipo genérico de 10-01, y el bucle de reintentos es tu ProxyReintentos de 10-03 — que en un proyecto real sería @Retryable(retryFor = OptimisticLockingFailureException.class, maxAttempts = 3).
4. Bloqueo pesimista.
public interface MaterialRepository extends JpaRepository<Material, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM Material m WHERE m.isbn = :isbn")
Optional<Material> buscarParaPrestar(@Param("isbn") String isbn);
}@Transactional
public Prestamo prestar(String isbn, String correo) {
// SELECT ... FOR UPDATE: bloquea la fila hasta el COMMIT
Material material = materialRepository.buscarParaPrestar(isbn)
.orElseThrow(() -> new MaterialNoEncontradoException(isbn));
// Diego espera aquí hasta que Marta confirme; luego lee disponibles = 0
material.prestarUnEjemplar(); // lanza SinEjemplaresException correctamente
return prestamoRepository.save(new Prestamo(material, empleado, LocalDate.now(reloj), 15));
}Conviene añadir un tiempo máximo de espera para no bloquear indefinidamente:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))5. Comparación y recomendación.
| Criterio | Optimista (@Version) |
Pesimista (FOR UPDATE) |
|---|---|---|
| Coste sin conflicto | Nulo | Bloqueo en cada operación |
| Concurrencia admitida | Alta | Baja: se serializan |
| Qué ocurre en conflicto | Excepción y reintento | Espera |
| Riesgo de interbloqueo | Ninguno | Sí, si se bloquean varias filas |
| Riesgo de espera indefinida | No | Sí, sin timeout |
| Complejidad del código | Manejo de la excepción | Ninguna aparente |
| Funciona en varias instancias | Sí | Sí |
| Bueno para | Conflictos poco frecuentes | Conflictos frecuentes y caros |
Recomendación: bloqueo optimista. En BiblioTech, la probabilidad de que dos personas presten el mismo ejemplar en el mismo segundo es bajísima, y el bloqueo pesimista haría pagar el coste de la serialización a las miles de operaciones que nunca chocan. @Version cuesta una anotación, no penaliza el caso normal y funciona igual con una instancia que con veinte.
El bloqueo pesimista se reserva para operaciones donde el conflicto sea la norma y el trabajo perdido sea caro: por ejemplo, un proceso nocturno que recalcula todas las multas y no quiere reintentar cinco mil veces.
Conclusión
Los CSV de BiblioTech han desaparecido. Hay una base de datos de verdad debajo, y sabes exactamente qué está pasando en ella.
Entiendes el desajuste objeto-relacional que un ORM existe para gestionar: identidad doble, herencia que en SQL no existe, navegación que en Java es gratis y en SQL cuesta consultas, granularidad, colecciones y tipos. Y sabes qué ganas y qué pierdes frente a JDBC directo —que viste en veinte líneas, con su mapeo columna a columna, su conversión de tipos y su gestión de recursos— con la regla profesional que se deriva: ORM para el CRUD del dominio, SQL directo para informes y procesos masivos, y ambos conviviendo sin conflicto.
Distingues JPA de Hibernate: especificación (jakarta.persistence) frente a implementación, con la recomendación de programar contra la especificación y la advertencia de versiones que hoy causa más errores de compilación que ninguna otra —jakarta, nunca javax.
Sabes mapear entidades: @Entity, @Table, @Id, las estrategias de @GeneratedValue con el motivo real por el que SEQUENCE supera a IDENTITY (permite agrupar inserciones), @Column con sus atributos, y los requisitos que impone JPA —constructor sin argumentos, nada final— que explican por qué un record no puede ser una entidad. Conoces la regla absoluta de @Enumerated(EnumType.STRING), porque ORDINAL es el valor por defecto y reordenar un enum corrompe silenciosamente todos los datos históricos. Mapeas LocalDate e Instant de forma nativa con la misma distinción de 10-05, escribes conversores con AttributeConverter para objetos de valor como Isbn, y usas @Embeddable para tener objetos pequeños en Java sin fragmentar el esquema.
Modelas relaciones completas con sus cuatro tipos, sabes que el lado propietario es el que tiene la clave foránea y que tocar solo el lado inverso no persiste nada —de ahí los métodos de conveniencia—, y dominas la decisión que más impacto tiene en el rendimiento: FetchType. Sabes que los valores por defecto son incoherentes, que @ManyToOne viene en EAGER y que la regla profesional es poner LAZY explícitamente en todo y pedir lo que necesites en la consulta. Conoces las dos consecuencias: la LazyInitializationException, con sus cuatro soluciones ordenadas y el consejo de dejar open-in-view en false para que el problema aparezca en tu máquina y no en producción; y el problema N+1, que convierte una consulta en 501 y que es invisible con cinco filas de prueba, con sus soluciones —JOIN FETCH, @EntityGraph, @BatchSize y, la más eficiente para informes, la proyección a DTO, donde los record encuentran por fin su sitio—. Y sabes que pasar a EAGER no arregla nada: lo empeora.
Entiendes el contexto de persistencia, que es el corazón conceptual de JPA: una caché de primer nivel que garantiza que la misma fila es el mismo objeto, con los cuatro estados de una entidad y sus transiciones. Y con ello la comprobación de cambios automática, que sorprende a todo el mundo: modificas una entidad gestionada, no llamas a nada, y al confirmar aparece un UPDATE. Sabes cómo funciona (instantánea y comparación), sus cuatro consecuencias —incluido que un cambio accidental se persiste— y por qué readOnly = true la desactiva y ahorra trabajo real. Conoces flush, que reordena las operaciones y explica la mitad de los comportamientos raros de Hibernate, y la diferencia entre persist y merge que provoca el bug de "ignoré el valor de retorno".
Escribes consultas: JPQL sobre entidades y atributos, consultas con nombre validadas en el arranque, la API de criterios para lo dinámico, SQL nativo cuando toca, y proyecciones a record en una sola línea. Y tienes interiorizada la regla que no admite excepciones: todo valor externo va como parámetro, porque concatenar es inyección SQL —con la lista blanca como única salida cuando lo dinámico es un nombre de columna—.
Manejas transacciones con base de datos real: ACID, @Transactional con sus dos reglas heredadas de 11-02, readOnly, propagación con el caso legítimo de REQUIRES_NEW y su coste en conexiones, y aislamiento con los tres fenómenos explicados sobre BiblioTech y el consejo de casi nunca tocarlo. Y resuelves el caso del ejemplar disputado con bloqueo optimista con @Version, entendiendo por qué synchronized del módulo 8 no sirve cuando hay varias instancias: la coordinación tiene que estar donde está el dato compartido. Con reintento sobre Resultado<T>, que es tu genérico de 10-01 y tu ProxyReintentos de 10-03 hechos producción.
Conoces las cachés de primer y segundo nivel y cuándo la segunda merece la pena —datos que se leen mucho y cambian poco, medido antes—; y sabes por qué ddl-auto=update no se usa nunca en producción: no borra, no versiona, no migra datos y nadie revisa el ALTER TABLE que va a ejecutar. validate más migraciones versionadas con Flyway, que llegan en 12-06.
Y has visto Spring Data JPA convertir ochenta líneas de repositorio en una interfaz vacía, con métodos derivados del nombre, @Query para lo que no cabe en un nombre, y paginación. Con el mecanismo al descubierto: Proxy.newProxyInstance sobre una interfaz, exactamente el de 10-03, analizando metadatos del método y generando la consulta.
BiblioTech tiene ahora entidades reales, relaciones con integridad referencial, transacciones ACID, bloqueo optimista y consultas con índices sobre H2. Arranca con mvn spring-boot:run y su esquema se crea solo.
Y sigue sin tener ni una sola prueba automática.
Esa es ahora, con diferencia, la deuda más grave. Acabas de hacer una migración enorme: has cambiado el modelo de dominio, has convertido record en clases, has sustituido la persistencia entera y has introducido concurrencia optimista con reintentos. ¿Cómo sabes que el cálculo de multas sigue dando el mismo resultado? ¿Que el límite de tres préstamos por empleado se sigue respetando? ¿Que un préstamo que vence en domingo sigue avisando el lunes? La única respuesta honesta hoy es: arrancando la aplicación y mirando.
Y hay algo peor. Ese Clock inyectable que introdujiste en 10-05, que pasó por Spring en 11-02 como un @Bean y que en esta lección ha aparecido en cada LocalDate.now(reloj) y cada Instant.now(reloj), existe exclusivamente para poder probar el código sin esperar quince días. Lleva dos módulos esperando a que alguien lo aproveche.
En la próxima lección se aprovecha. Verás qué es una prueba automática y por qué el coste de no tenerlas ya lo estás pagando; conocerás la pirámide de pruebas y JUnit 5 completo, desde @Test y el patrón Preparar-Actuar-Comprobar hasta las pruebas parametrizadas que verifican doce casos del cálculo de multas en seis líneas; compararás las aserciones de JUnit con las de AssertJ; y descubrirás qué hace que un diseño sea testeable — y por qué todo lo que has hecho en los dos últimos módulos (inyección por constructor, interfaces en las fronteras, el Clock inyectado, el dominio libre de anotaciones) apuntaba exactamente ahí.
BiblioTech va a tener su primera red de seguridad.
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
