Día 15 de 30Fase 3: El Salto a Spring Boot 3 y APIs REST
JPA en Producción: Relaciones, N+1, Paginación y Migraciones
@ManyToOne y @OneToMany, LAZY y EAGER, JOIN FETCH y @EntityGraph, Pageable y Flyway
Para dominar. Al terminar deberías poder hacerlo sin ayuda.
4h de estudio
5 conceptos
3 preguntas
2 katas
Java 17 LTSJava 21 LTSSpring Data JPAHibernate 6N+1Flyway
Al terminar este día sabrás 4
Mapear relaciones @ManyToOne y @OneToMany identificando el lado propietario.
Detectar un problema N+1 en los logs y resolverlo sin pasar a EAGER.
Paginar consultas con Pageable sin cargar tablas completas en memoria.
Versionar el esquema con Flyway y dejar ddl-auto en validate.
1
Relaciones: @ManyToOne, @OneToMany y el Lado Propietario
@ManyToOne (muchos pedidos de un cliente) guarda la clave foránea y es el lado propietario. @OneToMany(mappedBy = "cliente") es el lado inverso: no tiene columna, solo refleja la relación. Hibernate solo guarda los cambios hechos en el lado propietario.
En una línea@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "cliente_id") private Cliente cliente;
Agregado Pedido con sus líneasjava
@EntitypublicclassPedido {
@Id@GeneratedValueprivate Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)// lado propietario hacia Cliente@JoinColumn(name = "cliente_id")private Cliente cliente;
@OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL, orphanRemoval = true)privateList<LineaPedido> lineas=newArrayList<>(); // lado inverso: sin columnapublicvoidaddLinea(LineaPedido linea) {
lineas.add(linea);
linea.setPedido(this); // sincroniza el lado propietario
}
publicvoidremoveLinea(LineaPedido linea) {
lineas.remove(linea);
linea.setPedido(null); // orphanRemoval la borrará
}
publicList<LineaPedido> getLineas() { return Collections.unmodifiableList(lineas); }
}
@EntitypublicclassLineaPedido {
@Id@GeneratedValueprivate Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)@JoinColumn(name = "pedido_id")// aquí vive la clave foráneaprivate Pedido pedido;
voidsetPedido(Pedido pedido) { this.pedido = pedido; }
}
Cómo funciona por dentro
En una relación entre tablas, la clave foránea vive en una sola de ellas: lineas_pedido.pedido_id. En JPA, la entidad que tiene esa columna es el lado propietario y se mapea con @ManyToOne (y @JoinColumn). El otro lado, Pedido.lineas con @OneToMany(mappedBy = "pedido"), es opcional y solo sirve para navegar: Hibernate lo ignora al decidir qué escribir.
En las relaciones bidireccionales tienes que mantener los dos lados coherentes en memoria. Lo habitual es encapsular la colección y ofrecer métodos addLinea y removeLinea que actualizan la lista y el campo pedido a la vez. cascade = CascadeType.ALL propaga persist y remove del padre a los hijos, y orphanRemoval = true borra una línea cuando la quitas de la colección.
No todas las relaciones necesitan ser bidireccionales. Un @ManyToOne unidireccional suele bastar; la colección @OneToMany solo merece la pena cuando el padre controla el ciclo de vida de los hijos (un pedido y sus líneas), no para relaciones con miles de elementos (un cliente y todos sus pedidos).
Muchos errores con JPA vienen de no saber qué lado manda: añades un elemento a la lista del lado inverso y no se guarda nada.
Relaciones bidireccionales sin métodos que mantengan los dos lados sincronizados (un addLinea que también haga linea.setPedido(this)).
Igual con Hibernate 6 en Java 17 y 21.
Se conecta conClave foráneacascadeorphanRemoval
2
LAZY, EAGER y el Problema N+1
Con LAZY, la relación se carga al acceder a ella; con EAGER, siempre. El problema N+1 aparece al cargar N entidades y acceder a una relación LAZY de cada una: una consulta para la lista y N más, una por elemento.
En una línea@ManyToOne y @OneToOne son EAGER por defecto: márcalos siempre como LAZY.
Cómo aparece el N+1java
// application-dev.properties// logging.level.org.hibernate.SQL=DEBUG// spring.jpa.properties.hibernate.generate_statistics=true@Transactional(readOnly = true)publicList<ResumenPedido> resumen() {
return pedidoRepository.findAll().stream() // 1 consulta: los pedidos
.map(p -> newResumenPedido(
p.getId(),
p.getCliente().getNombre())) // +1 consulta POR pedido (proxy LAZY)
.toList();
}
// Log con 3 pedidos de clientes distintos:// select p1_0.id, p1_0.cliente_id from pedido p1_0// select c1_0.id, c1_0.nombre from cliente c1_0 where c1_0.id=?// select c1_0.id, c1_0.nombre from cliente c1_0 where c1_0.id=?// select c1_0.id, c1_0.nombre from cliente c1_0 where c1_0.id=?
Cómo funciona por dentro
Una relación LAZY se rellena con un proxy (en @ManyToOne) o con una colección persistente sin inicializar (en @OneToMany). La consulta real se lanza la primera vez que accedes a sus datos, siempre que la sesión de Hibernate siga abierta; si ya se cerró, obtienes LazyInitializationException.
El N+1 surge al combinar una lista con ese acceso perezoso. findAll() trae 200 pedidos en una consulta, y el bucle que pinta pedido.getCliente().getNombre() dispara una consulta por cada cliente. Lo mismo ocurre al serializar a JSON una entidad con relaciones. Los valores por defecto empeoran el problema: @ManyToOne y @OneToOne son EAGER, y al cargar 200 pedidos con una consulta JPQL, Hibernate lanza después las consultas de sus clientes de todas formas.
Para verlo, activa los logs de SQL en desarrollo (logging.level.org.hibernate.SQL=DEBUG) o las estadísticas de Hibernate (hibernate.generate_statistics=true), que cuentan las consultas por sesión. Un test que fija el número de consultas evita que el problema vuelva.
Es el problema de rendimiento más común con JPA: en desarrollo, con 10 filas, no se nota; en producción, con 5.000, son 5.001 consultas.
Pasarlo todo a EAGER para «arreglar» un LazyInitializationException: cargas datos de más en cada consulta y el N+1 sigue apareciendo por otro lado.
Igual en Java 17 y 21.
Se conecta conFetchTypeProxies de HibernateLazyInitializationException
3
JOIN FETCH y @EntityGraph
JOIN FETCH en JPQL carga la entidad y su relación en una sola consulta. @EntityGraph(attributePaths = "lineas") en un método de repositorio consigue lo mismo sin escribir JPQL. Cada caso de uso decide qué cargar.
En una línea@Query("select p from Pedido p join fetch p.lineas where p.id = :id") Optional<Pedido> conLineas(Long id);
Tres formas de evitar el N+1java
publicinterfacePedidoRepositoryextendsJpaRepository<Pedido, Long> {
// 1. JPQL con JOIN FETCH@Query("select p from Pedido p join fetch p.cliente where p.estado = :estado")List<Pedido> conClientePorEstado(Estado estado);
// 2. EntityGraph sobre un método derivado@EntityGraph(attributePaths = {"cliente", "lineas"})Optional<Pedido> findWithDetalleById(Long id);
// 3. Proyección directa a DTO: solo las columnas necesarias@Query("""
select new com.app.pedidos.ResumenPedido(p.id, c.nombre, p.total)
from Pedido p join p.cliente c
where p.estado = :estado
""")List<ResumenPedido> resumenPorEstado(Estado estado);
}
Cómo funciona por dentro
JOIN FETCH le dice a Hibernate que traiga la relación en la misma consulta y rellene con ella la entidad: select p from Pedido p join fetch p.cliente. Con @EntityGraph(attributePaths = {"cliente"}) sobre un método de Spring Data obtienes el mismo resultado sin escribir JPQL, y puedes seguir usando métodos derivados (findByEstado).
Con colecciones hay que tener cuidado. Un JOIN FETCH de lineas repite el pedido en cada fila de línea; Hibernate 6 vuelve a agrupar las entidades raíz, así que ya no hace falta DISTINCT. Pero dos colecciones a la vez producen el producto cartesiano de ambas y, si son List, Hibernate lanza MultipleBagFetchException. En ese caso se cargan en consultas separadas o con @BatchSize, que carga las colecciones de varios padres a la vez con un IN (...).
Cuando la pantalla solo necesita unos campos, la opción más eficiente es una proyección: una consulta que construye directamente un DTO (select new com.app.ResumenPedido(p.id, c.nombre) from Pedido p join p.cliente c). No crea entidades gestionadas, no tiene relaciones que cargar y trae solo las columnas necesarias.
Es la forma correcta de resolver el N+1: mantienes LAZY por defecto y pides los datos que necesitas en cada caso.
Hacer JOIN FETCH de dos colecciones a la vez: produce un producto cartesiano y Hibernate puede lanzar MultipleBagFetchException.
Igual en Java 17 y 21. Hibernate 6 elimina automáticamente las entidades raíz duplicadas al hacer JOIN FETCH de colecciones: ya no hace falta DISTINCT.
Se conecta conSpring Data JPAProyecciones a DTOJPQL
4
Paginación con Pageable
Pageable (página, tamaño y orden) y Page<T> devuelven los resultados por partes. Spring Data traduce la petición a LIMIT y OFFSET y lanza una consulta extra para contar el total. Slice<T> evita esa consulta de recuento.
En una líneaPage<TareaDto> findByEstado(Estado estado, Pageable pageable); con PageRequest.of(0, 20, Sort.by("creada").descending()).
Spring Data acepta un parámetro Pageable en cualquier método de repositorio y lo traduce a LIMIT y OFFSET con el orden indicado. Si el método devuelve Page<T>, lanza además una consulta count para saber el total de elementos y de páginas; si devuelve Slice<T>, pide un elemento de más para saber si hay página siguiente y se ahorra el recuento, que en tablas grandes puede ser lo más caro.
En un controlador, Spring convierte automáticamente los parámetros ?page=0&size=20&sort=creada,desc en un Pageable. Conviene limitar el tamaño máximo (spring.data.web.pageable.max-page-size) para que nadie pida un millón de filas, y devolver DTO en lugar de entidades. Desde Spring Data 3.3, para serializar una página a JSON se recomienda PagedModel, que da un formato estable.
OFFSET tiene un límite: para llegar a la página 10.000, la base de datos recorre y descarta todas las filas anteriores. En listados muy profundos o feeds infinitos se usa paginación por cursor (keyset): where creada < :ultimaVista order by creada desc limit 20, que aprovecha el índice y tarda lo mismo en cualquier página.
Una API que devuelve listas completas funciona con 100 filas y tumba el servicio con un millón. Todo listado debe ir paginado.
Paginar una consulta con JOIN FETCH de una colección: Hibernate pagina en memoria (avisa con HHH90003004) y carga todas las filas.
Igual en Java 17 y 21.
Se conecta conAPIs RESTOrdenaciónPaginación por cursor
5
Migraciones de Esquema con Flyway
Flyway aplica en orden scripts SQL versionados (V1__crear_tareas.sql, V2__prioridad.sql) y registra cuáles se han ejecutado en la tabla flyway_schema_history. Spring Boot los ejecuta al arrancar si añades la dependencia.
En una líneasrc/main/resources/db/migration/V3__indice_tareas_estado.sql con create index idx_tareas_estado on tareas(estado);
Migraciones versionadas y configuraciónsql
-- src/main/resources/db/migration/V1__esquema_inicial.sqlcreate table tareas (
id bigserial primary key,
titulo varchar(200) not null,
estado varchar(20) not null,
creada timestamptz not null
);
-- V2__prioridad_tareas.sql (nunca se edita V1: cada cambio, un script nuevo)alter table tareas addcolumn prioridad varchar(10) not nulldefault'MEDIA';
-- V3__indice_tareas_estado.sqlcreate index idx_tareas_estado on tareas (estado, creada desc);
-- application.properties-- spring.jpa.hibernate.ddl-auto=validate-- spring.flyway.locations=classpath:db/migration-- pom.xml (Flyway 10+): flyway-core y flyway-database-postgresql
Cómo funciona por dentro
Flyway busca scripts en classpath:db/migration con el formato V<versión>__<descripción>.sql, los ordena por versión y aplica los que faltan, cada uno en su transacción cuando la base de datos lo permite. En la tabla flyway_schema_history guarda la versión, la fecha y un checksum de cada script aplicado. Spring Boot ejecuta Flyway al arrancar, antes de inicializar JPA, así que Hibernate siempre ve el esquema actualizado.
Con spring.jpa.hibernate.ddl-auto=validate, Hibernate comprueba al arrancar que las entidades coinciden con las tablas y falla si no. Juntos dan un esquema que solo cambia mediante migraciones revisadas y un aviso inmediato si el código y el esquema se desalinean.
Las migraciones aplicadas son inmutables: cambiar un script ya ejecutado altera su checksum y la validación falla. Los cambios se escriben siempre en una migración nueva. Los cambios que rompen la compatibilidad (renombrar una columna con la aplicación en marcha) se hacen en varios pasos: añadir la columna nueva, copiar los datos y desplegar código que use las dos, y borrar la antigua en una versión posterior.
El esquema pasa a estar versionado junto al código, igual en todos los entornos y revisado en los pull requests. Es la alternativa a ddl-auto=update.
Editar un script de migración que ya se ejecutó: Flyway detecta que el checksum no coincide y la aplicación no arranca. Los cambios van siempre en un script nuevo.
Igual en Java 17 y 21. Desde Flyway 10 (Spring Boot 3.3+), cada base de datos necesita su módulo, por ejemplo flyway-database-postgresql.
Se conecta conddl-auto=validateCI/CDLiquibase
Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.
Bloque 1: Relaciones (0 - 25 min)
Qué estudiar.@ManyToOne, @OneToMany(mappedBy), cascade y orphanRemoval.
Pregunta a responder. ¿Qué lado de la relación escribe la clave foránea?
Resultado esperado. Entidades Pedido y LineaPedido con un método addLinea que mantiene los dos lados.
Bloque 2: Cazar un N+1 (25 - 50 min)
Qué estudiar. Logs de SQL, JOIN FETCH y @EntityGraph.
Pregunta a responder. ¿Cuántas consultas lanza tu listado antes y después del arreglo?
Resultado esperado. Test con Testcontainers que cuenta las consultas con las estadísticas de Hibernate antes y después.
Bloque 3: Paginación y Flyway (50 - 80 min)
Qué estudiar.Pageable, Page, Slice y scripts V__ de Flyway.
Pregunta a responder. ¿Por qué ddl-auto=validate y Flyway van juntos?
Resultado esperado. Endpoint paginado y dos migraciones de Flyway: creación de tablas e índice por estado.
Cierre de Consolidación (80 - 90 min)
Qué estudiar. Repaso de la rúbrica del tutor.
Pregunta a responder. Explicar el N+1 con un ejemplo y dos formas de evitarlo.
Resultado esperado. Checklist de revisión de entidades JPA para pull requests.
Tres formas de comprobar si lo has entendido, de menos a más exigente: explicarlo con tus palabras, responder preguntas trampa y resolver el reto del tutor.
Explícalo desde cero
Lee cada ficha y explícala en voz alta antes de seguir: si puedes contárselo a alguien que empieza, lo has entendido.
Qué es el problema N+1
Lo que debes entender
Pides una lista de pedidos (una consulta) y, al pintar cada uno, accedes a su cliente, que está en LAZY: Hibernate lanza una consulta por pedido. Con 500 pedidos son 501 consultas. La solución no es EAGER, sino pedir los datos juntos en esa consulta concreta.
Analogía para contarlo
Ir al supermercado una vez por cada ingrediente de la receta en lugar de llevar la lista entera.
Lo que suele explicarse mal
Decir que el N+1 lo causa LAZY. LAZY es lo correcto; el problema es no indicar qué datos necesitas en cada caso.
Señal de que aún no lo dominas
Si en los logs de SQL ves la misma consulta repetida con distintos ids.
Preguntas trampa
Responde de memoria, en voz alta o por escrito, y después abre la respuesta razonada: lo importante es el porqué, no la palabra exacta.
Nivel 1 (Básico)¿Por qué añadir una línea a pedido.getLineas() no la guarda si la relación es @OneToMany(mappedBy = "pedido") y no haces linea.setPedido(pedido)?Ver respuesta
Respuesta razonada
Porque Hibernate decide qué escribir en la columna pedido_id mirando solo el lado propietario: el campo pedido de LineaPedido. La lista lineas del pedido está marcada con mappedBy, que significa «esta relación ya está mapeada en el otro lado»: es un reflejo para navegar, no una fuente de datos.
Si añades la línea a la lista pero su campo pedido sigue en null, Hibernate inserta la línea sin clave foránea, o falla si la columna es NOT NULL. Por eso se encapsula la colección y se expone un método addLinea que actualiza los dos lados a la vez.
Qué está evaluando la pregunta
Porque mappedBy marca ese lado como inverso: Hibernate solo mira el lado propietario (@ManyToOne en Linea) para escribir la clave foránea. Hay que mantener los dos lados sincronizados, normalmente con un método addLinea en el pedido.
Nivel 2 (Intermedio)Activas los logs de SQL y ves 201 consultas al listar 200 pedidos. ¿Cómo lo arreglas sin pasar a EAGER?Ver respuesta
Respuesta razonada
201 consultas para 200 pedidos es la firma de un N+1: una consulta para la lista y una por pedido para una relación perezosa. EAGER no lo arregla y además carga la relación en todas las consultas, la necesiten o no.
La solución es indicar en esa consulta concreta qué datos necesitas: JOIN FETCH en JPQL, @EntityGraph(attributePaths = "cliente") en el método del repositorio o, si solo pintas unos campos, una proyección a DTO. Para colecciones combinadas con paginación, @BatchSize o hibernate.default_batch_fetch_size agrupan las cargas en pocas consultas con IN. Y un test que cuente las consultas evita que vuelva.
Qué está evaluando la pregunta
Es un N+1. Se resuelve cargando la relación en la misma consulta: JOIN FETCH en JPQL o @EntityGraph en el repositorio. Si solo necesitas unos campos, mejor una proyección a DTO. Para colecciones con paginación, @BatchSize o hibernate.default_batch_fetch_size agrupan las cargas en pocas consultas.
Nivel 3 (Entrevista Senior)Alguien modificó V2__crear_indices.sql después de desplegarlo y ahora la aplicación no arranca en producción. ¿Qué ha pasado y cómo se arregla?Ver respuesta
Respuesta razonada
Flyway guarda un checksum de cada migración que aplica. Al arrancar compara los scripts del classpath con lo registrado en flyway_schema_history; si V2 ya no coincide, la validación falla y Spring Boot no arranca, para evitar que entornos distintos tengan esquemas distintos con la misma versión.
Lo correcto es devolver V2 a su contenido original y escribir el cambio en una V3. Si el cambio en V2 era solo cosmético (comentarios, espacios) o ya se aplicó a mano en todos los entornos, flyway repair recalcula los checksums, pero es una decisión que hay que tomar a propósito, no para conseguir que la aplicación arranque.
Qué está evaluando la pregunta
Flyway guarda el checksum de cada migración aplicada; al ver que V2 cambió, falla la validación al arrancar. Lo correcto es devolver V2 a su estado original y crear una V3 con el cambio. Si el cambio ya se aplicó a mano y es inofensivo, flyway repair actualiza los checksums, pero debe ser una decisión consciente.
Reto del tutor
Un equipo dice: «Para evitar LazyInitializationException hemos activado spring.jpa.open-in-view=true y puesto todas las relaciones en EAGER». Evalúa la decisión.
Lo que una respuesta suele tener bien
Identifican el error y buscan una solución global.
Lo que suele quedarse en la superficie
Decir que EAGER «es más lento» sin concretar.
El concepto de fondo que suele faltar
No ver que open-in-view mantiene la conexión a la base de datos abierta durante toda la petición, incluida la serialización a JSON, y que EAGER carga relaciones en todas las consultas aunque no se usen, sin eliminar el N+1 al recorrer colecciones.
Cómo lo respondería alguien senior en una entrevista
Desactivar open-in-view (Spring Boot avisa al arrancar cuando está activo por defecto), dejar las relaciones en LAZY y, en cada caso de uso, cargar lo necesario con JOIN FETCH, @EntityGraph o proyecciones a DTO dentro de la transacción del servicio. Los controladores reciben DTO, nunca entidades.
Proyecto «Pedidos» · paso 15 de 30
Listados rápidos y esquema versionado
Que el listado de pedidos aguante datos reales y el esquema se gestione con migraciones.
De qué va el proyecto
El backend de una tienda online: clientes, catálogo de productos y pedidos con sus líneas, pagos y envíos. Empieza como un proyecto Maven con records y termina como un sistema con API REST, seguridad, mensajería, contenedores y observabilidad. Cada paso construye sobre el anterior, así que trabaja siempre en el mismo repositorio.
Qué hacer
Haz bidireccional la relación pedido–líneas con un método addLinea y orphanRemoval.
Añade GET /pedidos?estado=&page=&size= paginado y elimina el N+1 al mostrar el nombre del cliente.
Pasa el esquema a Flyway (V1__esquema_inicial.sql, V2__indices.sql) y deja ddl-auto=validate.
Está listo cuando
Listar 20 pedidos lanza un número de consultas constante, sin importar cuántos haya.
La aplicación arranca contra una base de datos vacía y Flyway crea todo el esquema.
Katas: ejercicios cortos
Reglas de oro
Todas las relaciones *ToOne en LAZY.
Carga lo que necesita cada caso de uso con JOIN FETCH, @EntityGraph o proyecciones.
Todo listado va paginado.
Las migraciones aplicadas no se editan: se añade una nueva.