# Día 15: JPA en Producción: Relaciones, N+1, Paginación y Migraciones

> @ManyToOne y @OneToMany, LAZY y EAGER, JOIN FETCH y @EntityGraph, Pageable y Flyway

- Plan: [JavaMastery](https://aprende-java.webs-economicas.es/), día 15 de 30
- Fase 3: El Salto a Spring Boot 3 y APIs REST
- Dedicación: 4h
- Profundidad: para dominar
- Revisado: 2026-10-29
- Versión interactiva: https://aprende-java.webs-economicas.es/dias/jpa-produccion-n-1-paginacion-migraciones/

## Al terminar este día sabrás

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


## Conceptos

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

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

*Agregado Pedido con sus líneas*

```java
@Entity
public class Pedido {
    @Id @GeneratedValue private 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)
    private List<LineaPedido> lineas = new ArrayList<>();       // lado inverso: sin columna

    public void addLinea(LineaPedido linea) {
        lineas.add(linea);
        linea.setPedido(this);                                 // sincroniza el lado propietario
    }

    public void removeLinea(LineaPedido linea) {
        lineas.remove(linea);
        linea.setPedido(null);                                 // orphanRemoval la borrará
    }

    public List<LineaPedido> getLineas() { return Collections.unmodifiableList(lineas); }
}

@Entity
public class LineaPedido {
    @Id @GeneratedValue private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "pedido_id")                            // aquí vive la clave foránea
    private Pedido pedido;

    void setPedido(Pedido pedido) { this.pedido = pedido; }
}
```

- **En una línea:** `@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "cliente_id") private Cliente cliente;`
- **Por qué importa:** 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.
- **Error típico:** Relaciones bidireccionales sin métodos que mantengan los dos lados sincronizados (un `addLinea` que también haga `linea.setPedido(this)`).
- **Java 17 frente a 21:** Igual con Hibernate 6 en Java 17 y 21.
- **Se conecta con:** Clave foránea, cascade, orphanRemoval

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

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

*Cómo aparece el N+1*

```java
// application-dev.properties
// logging.level.org.hibernate.SQL=DEBUG
// spring.jpa.properties.hibernate.generate_statistics=true

@Transactional(readOnly = true)
public List<ResumenPedido> resumen() {
    return pedidoRepository.findAll().stream()                // 1 consulta: los pedidos
        .map(p -> new ResumenPedido(
            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=?
```

- **En una línea:** `@ManyToOne` y `@OneToOne` son EAGER por defecto: márcalos siempre como `LAZY`.
- **Por qué importa:** 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.
- **Error típico:** 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.
- **Java 17 frente a 21:** Igual en Java 17 y 21.
- **Se conecta con:** FetchType, Proxies de Hibernate, LazyInitializationException

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

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

*Tres formas de evitar el N+1*

```java
public interface PedidoRepository extends JpaRepository<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);
}
```

- **En una línea:** `@Query("select p from Pedido p join fetch p.lineas where p.id = :id") Optional<Pedido> conLineas(Long id);`
- **Por qué importa:** Es la forma correcta de resolver el N+1: mantienes LAZY por defecto y pides los datos que necesitas en cada caso.
- **Error típico:** Hacer `JOIN FETCH` de dos colecciones a la vez: produce un producto cartesiano y Hibernate puede lanzar `MultipleBagFetchException`.
- **Java 17 frente a 21:** 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 con:** Spring Data JPA, Proyecciones a DTO, JPQL

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

**Cómo funciona por dentro.**

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.

*Listado paginado de principio a fin*

```java
public interface TareaRepository extends JpaRepository<Tarea, Long> {
    Page<TareaResumen> findByEstado(Estado estado, Pageable pageable);   // proyección por interfaz
}

@RestController
@RequestMapping("/tareas")
class TareaController {
    private final TareaRepository repo;

    TareaController(TareaRepository repo) { this.repo = repo; }

    // GET /tareas?estado=PENDIENTE&page=0&size=20&sort=creada,desc
    @GetMapping
    PagedModel<TareaResumen> listar(
            @RequestParam Estado estado,
            @PageableDefault(size = 20, sort = "creada", direction = Sort.Direction.DESC) Pageable pageable) {
        return new PagedModel<>(repo.findByEstado(estado, pageable));     // JSON estable: content + page
    }
}

// application.properties
// spring.data.web.pageable.max-page-size=100
```

- **En una línea:** `Page<TareaDto> findByEstado(Estado estado, Pageable pageable);` con `PageRequest.of(0, 20, Sort.by("creada").descending())`.
- **Por qué importa:** Una API que devuelve listas completas funciona con 100 filas y tumba el servicio con un millón. Todo listado debe ir paginado.
- **Error típico:** Paginar una consulta con `JOIN FETCH` de una colección: Hibernate pagina en memoria (avisa con `HHH90003004`) y carga todas las filas.
- **Java 17 frente a 21:** Igual en Java 17 y 21.
- **Se conecta con:** APIs REST, Ordenación, Paginació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.

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

*Migraciones versionadas y configuración*

```sql
-- src/main/resources/db/migration/V1__esquema_inicial.sql
create 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 add column prioridad varchar(10) not null default 'MEDIA';

-- V3__indice_tareas_estado.sql
create 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
```

- **En una línea:** `src/main/resources/db/migration/V3__indice_tareas_estado.sql` con `create index idx_tareas_estado on tareas(estado);`
- **Por qué importa:** 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`.
- **Error típico:** 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.
- **Java 17 frente a 21:** 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 con:** ddl-auto=validate, CI/CD, Liquibase


## Sesión de 90 minutos

### 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?
- **Entregable:** 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?
- **Entregable:** 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?
- **Entregable:** 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.
- **Entregable:** Checklist de revisión de entidades JPA para pull requests.


## Explícalo desde cero

### Qué es el problema N+1

- **Qué 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.
- **Qué 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.
- **Ejemplo sencillo:** Ir al supermercado una vez por cada ingrediente de la receta en lugar de llevar la lista entera.
- **Señal de alerta:** Si en los logs de SQL ves la misma consulta repetida con distintos ids.


## Preguntas

### 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)`?

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.

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

### Nivel 2 (Intermedio): Activas los logs de SQL y ves 201 consultas al listar 200 pedidos. ¿Cómo lo arreglas sin pasar a EAGER?

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.

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

### 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?

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.

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


## Reto del tutor

**Reto:** 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 está bien:** Identifican el error y buscan una solución global.
- **Respuesta superficial:** Decir que EAGER «es más lento» sin concretar.
- **Lo que falta de fondo:** 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.
- **Respuesta senior:** 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.


## Katas

### Kata 15.1: Cazar y arreglar un N+1

Con Testcontainers y 50 pedidos de prueba, lista los pedidos con el nombre de su cliente. Activa `hibernate.generate_statistics`, comprueba que se lanzan 51 consultas y arréglalo con `@EntityGraph` hasta dejarlo en una. El test debe fallar si vuelve el N+1.

### Kata 15.2: Esquema con Flyway

Pasa el proyecto de `ddl-auto=update` a Flyway: `V1__esquema_inicial.sql` con las tablas actuales, `V2__prioridad_tareas.sql` que añade una columna con valor por defecto, y `ddl-auto=validate`. Comprueba que arranca contra un PostgreSQL vacío en Testcontainers.


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


## Proyecto guiado «Pedidos»: Listados rápidos y esquema versionado

Que el listado de pedidos aguante datos reales y el esquema se gestione con migraciones.

**Tareas:**

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


## Fuentes oficiales

- [Spring Data JPA: paginación y consultas](https://docs.spring.io/spring-data/jpa/reference/repositories/query-methods-details.html)
- [Documentación de Hibernate ORM](https://hibernate.org/orm/documentation/)
- [Spring Boot: inicializar la base de datos y migraciones](https://docs.spring.io/spring-boot/how-to/data-initialization.html)
- [Documentación de Flyway](https://documentation.red-gate.com/flyway)

