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

  1. El desajuste objeto-relacional
  2. Qué se gana y qué se pierde frente a JDBC
  3. JDBC en veinte líneas: la línea base
  4. JPA frente a Hibernate: especificación e implementación
  5. Poner en marcha JPA con H2 en BiblioTech
  6. La primera entidad: @Entity, @Table, @Id
  7. @GeneratedValue y sus estrategias
  8. @Column y el mapeo de tipos
  9. @Enumerated: por qué STRING y nunca ORDINAL
  10. LocalDate, Instant y los conversores propios
  11. @Embeddable y @Embedded
  12. Relaciones: los cuatro tipos
  13. Lado propietario y mappedBy
  14. FetchType: carga perezosa frente a ansiosa
  15. La LazyInitializationException
  16. El problema N+1
  17. Resolverlo: JOIN FETCH y @EntityGraph
  18. CascadeType y orphanRemoval
  19. El contexto de persistencia y el EntityManager
  20. Los cuatro estados de una entidad
  21. La comprobación de cambios automática
  22. flush y el orden de las operaciones
  23. Las operaciones: persist, merge, find, getReference, remove
  24. Consultas: JPQL
  25. Consultas con nombre, API de criterios y SQL nativo
  26. Inyección SQL: por qué nunca se concatena
  27. Transacciones: @Transactional con base de datos real
  28. Propagación y aislamiento
  29. Bloqueo optimista con @Version
  30. Cachés de primer y segundo nivel
  31. ddl-auto y por qué nunca update en producción
  32. Spring Data JPA
  33. BiblioTech: de CSV a H2
  34. Errores Comunes y Consejos
  35. Ejercicios

  1. 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.

  1. 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, JdbcTemplate o jOOQ) para informes, agregaciones complejas y procesos masivos.

Ambas cosas conviven perfectamente en el mismo proyecto, y eso es lo que hace BiblioTech.

  1. 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:

  1. SQL escrito a mano para cada consulta.
  2. Mapeo columna a columna, con getLong, getString, getDate.
  3. Conversión de tipos: java.sql.Date a LocalDate, null a Optional.empty().
  4. Gestión de recursos: tres try-with-resources anidados.
  5. Traducción de excepciones: SQLException a la jerarquía del módulo 6.
  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:

List<Prestamo> prestamos = repositorio.findByEmpleadoCorreo(correoEmpleado);

Y ni siquiera hay que escribir esa línea, como verás en el apartado 32.

  1. 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.* a jakarta.*. Spring Boot 3 y Hibernate 6 usan jakarta.persistence. Si copias un ejemplo con import javax.persistence.Entity, es de Spring Boot 2 o anterior y no compilará. Es el error de copia y pega más frecuente hoy.

  1. 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 15

Con eso ya está. Ejecuta:

mvn spring-boot:run -Dspring-boot.run.profiles=dev

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

  1. La primera entidad: @Entity, @Table, @Id

Convertimos 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.

  1. @GeneratedValue y sus estrategias

Quié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.

  1. @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:

@Column(name = "importe_multa", precision = 10, scale = 2)
private BigDecimal importeMulta;

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.

  1. @Enumerated: por qué STRING y nunca ORDINAL

BiblioTech tiene tres enums desde el módulo 4: Gravedad, TipoMaterial y EstadoPrestamo. Su mapeo tiene una trampa con consecuencias graves.

public enum EstadoPrestamo { ACTIVO, VENCIDO, DEVUELTO }
// 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:

public enum EstadoPrestamo { ACTIVO, RENOVADO, VENCIDO, DEVUELTO }
//                             0        1         2         3

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.

  1. LocalDate, Instant y los conversores propios

Buenas 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 UTC

La 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.

  1. @Embeddable y @Embedded

Un 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.

  1. Relaciones: los cuatro tipos

El modelo de BiblioTech:

  • Un Prestamo pertenece a un Material y a un Empleado.
  • Un Material tiene muchos Prestamo.
  • Un Empleado tiene muchos Prestamo y muchas Reserva.
  • Un Material tiene muchas Categoria y una Categoria tiene muchos Material.

@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

  1. Lado propietario y mappedBy

Este 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 mappedBy y 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.

  1. FetchType: carga perezosa frente a ansiosa

Cuando 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.LAZY explícitamente en TODAS las asociaciones. Y cuando necesites los datos relacionados, pídelos explícitamente en la consulta con JOIN FETCH o @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.

  1. La LazyInitializationException

La 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());   // BOOM
org.hibernate.LazyInitializationException: could not initialize proxy
[com.nexussoftware.bibliotech.dominio.Material#7] - no Session

Qué ha pasado, paso a paso:

  1. Dentro de la transacción, Hibernate cargó el préstamo y puso un proxy en el campo material.
  2. La transacción terminó y el contexto de persistencia se cerró.
  3. Al llamar a getTitulo(), el proxy intenta lanzar el SELECT... 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:

spring:
  jpa:
    open-in-view: false     # que los problemas se vean en desarrollo

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.

  1. 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 veces

501 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:

  1. spring.jpa.show-sql=true y contar las consultas en el log.
  2. Estadísticas de Hibernate: spring.jpa.properties.hibernate.generate_statistics=true.
  3. En producción: métricas y trazas distribuidas (12-07).

  1. Resolverlo: JOIN FETCH y @EntityGraph

Dos 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.

  1. CascadeType y orphanRemoval

Las 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).

  1. El contexto de persistencia y el EntityManager

Aquí 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:

  1. Es una caché de primer nivel. Si pides dos veces la misma entidad en la misma transacción, la segunda no genera consulta.
  2. Garantiza identidad. Dentro de una transacción, la misma fila es siempre el mismo objeto Java: a == b es true. Eso resuelve el problema de identidad del apartado 1.
  3. 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.

  1. 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).

  1. 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.
update prestamo set fecha_vencimiento=?, version=? where id=? and version=?

¿Cómo? El mecanismo se llama dirty checking y funciona así:

  1. Al cargar la entidad, Hibernate guarda una instantánea del estado de todos sus campos.
  2. Al hacer flush (normalmente al confirmar), compara el estado actual con la instantánea.
  3. Para cada campo distinto, genera el UPDATE correspondiente.

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().

  1. flush y el orden de las operaciones

flush 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:

  1. Al confirmar la transacción (siempre).
  2. Antes de ejecutar una consulta que pudiera verse afectada por los cambios pendientes.
  3. 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í:

  1. INSERT de entidades, en orden de persist
  2. UPDATE
  3. Borrado de elementos de colecciones
  4. Inserción de elementos de colecciones
  5. DELETE de 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 isbn

La 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.

  1. Las operaciones: 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);   // false

La 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 FK

Lo ú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).

  1. 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 < :fecha

Ejemplos 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

  1. 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.

  1. 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:

' OR '1'='1

Y la consulta se convierte en:

SELECT p FROM Prestamo p WHERE p.empleado.correo = '' OR '1'='1'

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.

  1. Transacciones: @Transactional con base de datos real

Ahora 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.

  1. 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 hay 0. 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.

  1. Bloqueo optimista con @Version

El 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:

update material set ejemplares_disponibles=0, version=6 where id=1 and version=5

Si otro ya actualizó, la fila tiene version=6 y la condición no encuentra ninguna fila. Hibernate lo detecta y lanza:

jakarta.persistence.OptimisticLockException:
Row was updated or deleted by another transaction

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.

  1. 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.JCacheRegionFactory

Cuá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:

  1. Coherencia: si otra aplicación modifica la base de datos, tu caché queda obsoleta y sirve datos viejos.
  2. En clúster: con varias instancias, cada una tiene su caché. O usas una caché distribuida o tendrás incoherencias.
  3. 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.

  1. ddl-auto y por qué nunca update en producción

Hibernate 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:

  1. Nunca borra nada. Si quitas un campo, la columna se queda ahí para siempre. Si renombras titulo a nombre, añade nombre y deja titulo — y no copia los datos.
  2. No versiona nada. No hay registro de qué cambios se aplicaron ni cuándo, y no hay forma de deshacerlos.
  3. Es impredecible. El SQL que genera depende del dialecto, de la versión de Hibernate y del estado actual del esquema.
  4. No se puede revisar antes. Nadie ha visto el ALTER TABLE que va a ejecutarse contra la base de datos de producción.
  5. No puede hacer migraciones de datos. Dividir nombre_completo en nombre y apellidos requiere 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: validate

validate 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.

  1. 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

Page<Prestamo> findByEstado(EstadoPrestamo estado, Pageable paginacion);
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:

  1. Escanea las interfaces que extienden Repository.
  2. Para cada una, crea una implementación al vuelo con Proxy.newProxyInstance — el mismo de 10-03.
  3. El InvocationHandler recibe cada llamada, mira si el método tiene @Query (y usa ese JPQL) o analiza su nombre (y genera el JPQL), lo ejecuta con el EntityManager y 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.

  1. 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ña

El 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

  1. 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.

  1. 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:

  1. Tabla reserva con clave primaria autogenerada.
  2. Relaciones @ManyToOne perezosas a Material y Empleado, no cadenas.
  3. El enum guardado de forma segura.
  4. instanteAviso anulable en la base de datos pero expuesto como Optional.
  5. Bloqueo optimista.
  6. Un índice sobre (material_id, estado) para la consulta de pendientes.
  7. Un método de dominio avisar(Clock reloj) que cambie el estado y registre el instante, con validación.
  8. 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:

  1. Calcula cuántas consultas SQL se ejecutan con 800 préstamos, desglosadas.
  2. Explica exactamente por qué.
  3. Propón tres soluciones distintas con su código, indicando cuántas consultas quedan en cada una.
  4. 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:

  1. Describe la secuencia exacta de eventos que produce dos préstamos con un solo ejemplar.
  2. Explica por qué synchronized (08-04) no resuelve esto en un despliegue real.
  3. Resuélvelo con bloqueo optimista, incluyendo el manejo de la excepción con reintento.
  4. Resuélvelo con bloqueo pesimista.
  5. 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 un record es final y sin constructor vacío.
  • Relaciones en vez de cadenas: Material y Empleado en vez de isbn y correoEmpleado. Da integridad referencial (no se puede reservar un material inexistente) y navegación. El precio es tener que gestionar la pereza.
  • Optional fuera, campo anulable dentro: se conserva la garantía de 10-04 sin pelearse con JPA.
  • El comportamiento vive en la entidad: avisar, completar y haCaducado validan las transiciones. La entidad no es una bolsa de datos.
  • @EntityGraph en 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

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

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

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

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

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

© Copyright 2026. Todos los derechos reservados