Día 15 de 30 Fase 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.

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í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; }
}
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.

Se conecta con Clave foráneacascadeorphanRemoval

Cuatro bloques cronometrados. No pases al siguiente sin tener el resultado esperado.

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

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

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

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

  1. Haz bidireccional la relación pedido–líneas con un método addLinea y orphanRemoval.
  2. Añade GET /pedidos?estado=&page=&size= paginado y elimina el N+1 al mostrar el nombre del cliente.
  3. 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.
Se guarda automáticamente

Fuentes oficiales

Revisado el . Este día en Markdown