Las entidades de CicloUrbana ya viven en tablas, pero están aisladas. Una Bicicleta no sabe en qué estación de Ribalta está aparcada; un Alquiler guarda usuarioId y bicicletaId como números sueltos, sin ninguna garantía de que apunten a algo real. El modelo relacional que dibujamos en 04-01 tiene flechas y todavía no hemos trazado ninguna.

Esta lección las traza. Es, con diferencia, la parte de JPA donde más proyectos se tuercen, porque las relaciones introducen dos comportamientos que no existen en un modelo en memoria: la carga perezosa —un objeto que finge estar ahí y solo va a buscar sus datos cuando lo tocas— y la propagación en cascada —una operación que se contagia a las entidades vecinas—. Mal entendidos producen la LazyInitializationException que todo el mundo ha sufrido y el problema N+1, la causa individual más común de lentitud en aplicaciones con ORM. Vamos a modelar cada relación con su anotación correcta y, sobre todo, con la comprensión de qué SQL genera.

Contenido

  1. El modelo relacional completo de CicloUrbana
  2. Las cuatro cardinalidades y su anotación
  3. @ManyToOne: el lado propietario natural
  4. @OneToMany y mappedBy
  5. Lado propietario e inverso: el error clásico
  6. @OneToOne con @MapsId
  7. @ManyToMany y por qué casi nunca conviene
  8. FetchType.LAZY frente a EAGER
  9. LazyInitializationException
  10. El problema N+1
  11. cascade y orphanRemoval
  12. Colecciones de valores con @ElementCollection
  13. Herencia de entidades
  14. Errores Comunes y Consejos
  15. Ejercicios

  1. El modelo relacional completo de CicloUrbana

erDiagram
    ESTACIONES ||--o{ BICICLETAS : "alberga (0..n)"
    ESTACIONES ||--o{ ALQUILERES : "origen"
    ESTACIONES ||--o{ ALQUILERES : "destino"
    BICICLETAS ||--|| FICHAS_TECNICAS : "tiene (1..1)"
    BICICLETAS ||--o{ ALQUILERES : "se alquila en"
    BICICLETAS ||--o{ INCIDENCIAS : "acumula"
    USUARIOS ||--o{ ALQUILERES : "realiza"
    USUARIOS }o--o{ PROMOCIONES : "disfruta"

Cada flecha se traduce en una anotación distinta:

Relación Cardinalidad Anotación en CicloUrbana
Bicicleta → Estacion Muchas a una @ManyToOne en Bicicleta
Estacion → bicicletas Una a muchas @OneToMany(mappedBy = "estacion")
Bicicleta → FichaTecnica Una a una @OneToOne con @MapsId
Alquiler → Usuario, Bicicleta, estaciones Muchas a una (×4) Cuatro @ManyToOne
Usuario ↔ Promocion Muchas a muchas @ManyToMany (o entidad intermedia)
Bicicleta → incidencias Una a muchas con cascada @OneToMany + cascade + orphanRemoval

  1. Las cuatro cardinalidades y su anotación

Cardinalidad Anotación Dónde va la clave ajena fetch por defecto Ejemplo
Muchos a uno @ManyToOne En la tabla del lado «muchos» EAGER Bicicleta → Estación
Uno a muchos @OneToMany En la otra tabla (lado propietario) LAZY Estación → bicicletas
Uno a uno @OneToOne En la tabla del lado propietario EAGER Bicicleta → ficha técnica
Muchos a muchos @ManyToMany En una tabla de unión LAZY Usuario ↔ promociones

Dos columnas merecen atención inmediata. La de fetch por defecto es una trampa: @ManyToOne y @OneToOne cargan EAGER, es decir, traen la entidad relacionada siempre, la uses o no; es el origen de la mayoría de problemas de rendimiento con JPA y en el apartado 8 veremos que la solución es ponerlas todas en LAZY. Y la clave ajena vive siempre en un solo sitio: en una relación bidireccional la columna física está en una de las dos tablas, y esa es la que manda. Se le llama lado propietario, y es el concepto del apartado 5.

  1. @ManyToOne: el lado propietario natural

Muchas bicicletas están en una estación. En el modelo relacional, la tabla bicicletas tiene una columna estacion_id:

@Entity
@Table(name = "bicicletas")
public class Bicicleta extends EntidadAuditable {

    // ... id, matricula, estado, nivelBateria, version

    @ManyToOne(fetch = FetchType.LAZY, optional = true)
    @JoinColumn(name = "estacion_id",
                foreignKey = @ForeignKey(name = "fk_bicicletas_estacion"))
    private Estacion estacion;

    public Estacion getEstacion() { return estacion; }
    public void setEstacion(Estacion e) { this.estacion = e; }
}
Elemento Qué hace
@ManyToOne Muchas bicicletas apuntan a una estación
fetch = LAZY No cargar la estación hasta que se use
optional = true La columna admite nulos: una bici en la calle no tiene estación
@JoinColumn(name = ...) Nombre de la columna de clave ajena
foreignKey = @ForeignKey(name = ...) Nombra la restricción, en vez de FK7a3b1c...

@ManyToOne es el lado propietario natural y no hay elección posible: la clave ajena solo puede estar en la tabla del lado «muchos», porque si estuviera en estaciones una fila tendría que apuntar a varias bicicletas. La consecuencia práctica: para asignar una bicicleta a una estación basta con bicicleta.setEstacion(estacion), la única operación que escribe en la base de datos.

optional no es cosmética: con optional = false, Hibernate sabe que la asociación nunca es nula, puede usar INNER JOIN en vez de LEFT JOIN y, en algunos casos, optimizar la carga perezosa.

El Alquiler acumula cuatro relaciones de este tipo, y es un buen ejemplo de por qué @JoinColumn necesita nombre explícito:

@Entity
@Table(name = "alquileres")
public class Alquiler extends EntidadAuditable {

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "usuario_id", nullable = false) private Usuario usuario;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "bicicleta_id", nullable = false) private Bicicleta bicicleta;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "estacion_origen_id", nullable = false) private Estacion estacionOrigen;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "estacion_destino_id")
    private Estacion estacionDestino;   // nulo mientras el alquiler está en curso
}

Dos relaciones distintas apuntan a Estacion: sin @JoinColumn explícito, ambas generarían nombres derivados que podrían colisionar o resultar incomprensibles. Y estacionDestino es nula mientras el alquiler está en curso, lo que modela un hecho del negocio, no un descuido.

  1. @OneToMany y mappedBy

Desde la estación queremos navegar a sus bicicletas. Como la clave ajena ya está en bicicletas, este lado es inverso: no escribe nada, solo lee.

@Entity
@Table(name = "estaciones")
public class Estacion extends EntidadAuditable {

    // ... id, nombre, direccion, capacidad, ubicacion, version

    @OneToMany(mappedBy = "estacion", fetch = FetchType.LAZY)
    private Set<Bicicleta> bicicletas = new HashSet<>();

    public Set<Bicicleta> getBicicletas() { return Collections.unmodifiableSet(bicicletas); }

    /** Métodos auxiliares: sincronizan AMBOS lados de la relación. */
    public void anadirBicicleta(Bicicleta bicicleta) {
        bicicletas.add(bicicleta);
        bicicleta.setEstacion(this);
    }

    public void quitarBicicleta(Bicicleta bicicleta) {
        bicicletas.remove(bicicleta);
        bicicleta.setEstacion(null);
    }
}

mappedBy = "estacion" significa literalmente: «esta relación ya está mapeada por el campo estacion de Bicicleta; yo solo la reflejo». Sin mappedBy, JPA asumiría que son dos relaciones independientes y crearía una tabla de unión estaciones_bicicletas que nadie quiere.

Por qué los métodos auxiliares son obligatorios. Java no sincroniza referencias por su cuenta. Si haces solo estacion.getBicicletas().add(bicicleta), la colección en memoria contiene la bicicleta, pero bicicleta.getEstacion() sigue siendo null y, sobre todo, la columna estacion_id no se actualiza: al releer desde la base de datos, la bicicleta no está en la estación. El objeto y la fila se contradicen. anadirBicicleta y quitarBicicleta encapsulan la doble escritura para que sea imposible olvidarla, y devolver la colección como unmodifiableSet refuerza la regla: quien quiera modificarla, que pase por el método.

Por qué Set y no List. Con List, Hibernate puede borrar toda la colección y reinsertarla al eliminar un solo elemento. Con Set y un equals/hashCode correcto (apartado 11 de 04-03) el comportamiento es predecible. Si el orden importa, List con @OrderBy("matricula").

  1. Lado propietario e inverso: el error clásico

Es el concepto que hay que tener grabado, y se resume en una frase:

Solo el lado propietario escribe en la base de datos. El lado inverso, el que tiene mappedBy, se ignora por completo al generar el SQL.

El error clásico, en versión ejecutable: destino.getBicicletas().add(bici) dentro de un método @Transactional. Solo toca el lado inverso, así que no se genera ningún UPDATE y la columna estacion_id no cambia. El método no falla, no avisa y no hace nada: la colección en memoria cambia hasta que termina la transacción y después todo vuelve a estar como estaba. La versión correcta:

@Transactional
public void moverBicicleta(Long biciId, Long estacionId) {
    Bicicleta bici = bicicletaRepositorio.findById(biciId)
            .orElseThrow(() -> new RecursoNoEncontradoException("Bicicleta", biciId));
    Estacion destino = estacionRepositorio.findById(estacionId)
            .orElseThrow(() -> new RecursoNoEncontradoException("Estación", estacionId));

    if (destino.getBicicletas().size() >= destino.getCapacidad()) {
        throw new EstacionLlenaException(estacionId);
    }
    destino.anadirBicicleta(bici);   // escribe AMBOS lados
    // El dirty checking genera: UPDATE bicicletas SET estacion_id = ? WHERE id = ?
}

EstacionLlenaException es la excepción del dominio que definimos en 03-06; el @RestControllerAdvice la convierte en un 409 con ProblemDetail. Fíjate también en que no hay ninguna llamada a save(): la bicicleta es una entidad gestionada y el dirty checking se encarga (04-07).

Situación ¿Se escribe en la BD?
bici.setEstacion(destino) (propietario) Sí
destino.getBicicletas().add(bici) (inverso) No
destino.anadirBicicleta(bici) (ambos) Sí, y la memoria queda coherente

  1. @OneToOne con @MapsId

Cada bicicleta de CicloUrbana tiene una ficha técnica: modelo, fabricante, número de serie, fecha de compra. Son datos voluminosos que casi nunca se consultan, así que separarlos en su propia tabla mantiene ágil la consulta habitual de bicicletas.

@Entity
@Table(name = "fichas_tecnicas")
public class FichaTecnica {

    @Id
    private Long id;   // sin @GeneratedValue: lo aporta @MapsId

    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @MapsId
    @JoinColumn(name = "id", foreignKey = @ForeignKey(name = "fk_fichas_bicicleta"))
    private Bicicleta bicicleta;

    @Column(name = "modelo", nullable = false, length = 80) private String modelo;
    @Column(name = "numero_serie", nullable = false, length = 40, updatable = false)
    private String numeroSerie;
    @Column(name = "fecha_compra", nullable = false) private LocalDate fechaCompra;
}

Y en Bicicleta, el lado inverso:

@OneToOne(mappedBy = "bicicleta", fetch = FetchType.LAZY,
          cascade = CascadeType.ALL, orphanRemoval = true)
private FichaTecnica fichaTecnica;

Qué aporta @MapsId. Sin él, fichas_tecnicas tendría dos columnas: su propio id y una bicicleta_id. Con @MapsId, la clave primaria es la clave ajena: la ficha de la bicicleta 42 tiene id = 42. Eso ahorra una columna y un índice, garantiza la unicidad en el esquema —es imposible que dos fichas apunten a la misma bicicleta— y hace que los JOIN vayan por clave primaria, el camino más rápido.

Advertencia sobre el LAZY en @OneToOne. El lado inverso (Bicicleta.fichaTecnica) no puede ser realmente perezoso: para devolver un proxy, Hibernate necesitaría saber si existe la fila relacionada, y para saberlo tiene que consultarla. Al cargar una bicicleta se ejecuta una consulta extra a fichas_tecnicas aunque nadie use la ficha. El lado propietario sí es perezoso de verdad, porque la clave ajena está en su propia fila. La solución práctica: evita los @OneToOne bidireccionales; deja la relación solo en el lado propietario y pide la ficha al repositorio con findById(bicicletaId).

  1. @ManyToMany y por qué casi nunca conviene

El ayuntamiento de Ribalta lanza promociones (bono estudiante, mes gratuito) y un usuario puede tener varias, y cada promoción varios usuarios. Es una relación muchos a muchos de libro:

@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
    name = "usuarios_promociones",
    joinColumns = @JoinColumn(name = "usuario_id"),
    inverseJoinColumns = @JoinColumn(name = "promocion_id"),
    uniqueConstraints = @UniqueConstraint(
        name = "uk_usuario_promocion", columnNames = {"usuario_id", "promocion_id"})
)
private Set<Promocion> promociones = new HashSet<>();

Y en Promocion, el lado inverso: @ManyToMany(mappedBy = "promociones") private Set<Usuario> usuarios;.

Funciona. Y aun así la recomendación es sustituirlo por una entidad intermedia, por un motivo que se descubre siempre demasiado tarde: la tabla de unión no puede tener atributos propios. En cuanto el negocio pregunta «¿cuándo se aplicó?» o «¿cuántos usos quedan?», @ManyToMany se queda corto y hay que rehacer el modelo con datos ya en producción. La versión con entidad intermedia:

@Entity
@Table(name = "promociones_usuario",
       uniqueConstraints = @UniqueConstraint(name = "uk_promocion_usuario",
                                             columnNames = {"usuario_id", "promocion_id"}))
public class PromocionUsuario extends EntidadAuditable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "prom_usuario_seq")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "usuario_id", nullable = false)
    private Usuario usuario;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "promocion_id", nullable = false)
    private Promocion promocion;

    @Column(name = "aplicada_en", nullable = false) private Instant aplicadaEn;
    @Column(name = "usos_restantes", nullable = false) private int usosRestantes;
}
Aspecto @ManyToMany Entidad intermedia
Atributos en la relación Imposible Sí
Consultable directamente No Sí, con su repositorio
Auditoría (creadoEn) No Sí, hereda de EntidadAuditable
Complejidad inicial Menor Algo mayor
Coste de migrar después Alto —

La regla de CicloUrbana: usa @ManyToMany solo si estás seguro de que la relación jamás tendrá atributos. Etiquetas de una incidencia, sí. Cualquier cosa con matices de negocio, entidad intermedia.

  1. FetchType.LAZY frente a EAGER

Esta es la decisión de rendimiento más importante de toda la lección.

Modo Cuándo carga la relación Coste
EAGER Siempre, junto con la entidad Un JOIN o una consulta extra en cada carga
LAZY Solo al acceder al campo Nada hasta que se usa; puede fallar fuera de la transacción

Y los valores por defecto de la especificación:

Anotación Por defecto ¿Correcto?
@ManyToOne EAGER No, cámbialo
@OneToOne EAGER No, cámbialo
@OneToMany LAZY Sí
@ManyToMany LAZY Sí

Por qué EAGER es una mala idea por defecto, con un caso de CicloUrbana: si Alquiler dejara sus cuatro @ManyToOne en EAGER, consultar un alquiler dispararía:

select ... from alquileres a
  left join usuarios u on u.id = a.usuario_id
  left join bicicletas b on b.id = a.bicicleta_id
  left join estaciones eo on eo.id = a.estacion_origen_id
  left join estaciones ed on ed.id = a.estacion_destino_id
 where a.id = ?

Y como Bicicleta tiene a su vez una Estacion y una FichaTecnica en EAGER, esos JOIN se encadenan: con cuatro o cinco niveles, una consulta que debía leer una fila lee un producto cartesiano de varios miles. Lo peor es que no se puede desactivar por consulta: EAGER obliga siempre, incluso cuando solo quieres el importe.

La regla de CicloUrbana, sin excepciones: escribe fetch = FetchType.LAZY en todas las asociaciones, aunque en @OneToMany y @ManyToMany sea redundante. Cuando una consulta concreta necesite datos relacionados, se piden explícitamente con JOIN FETCH o @EntityGraph (04-06). Es la diferencia entre decidir en cada consulta y sufrir una decisión tomada en la entidad.

  1. LazyInitializationException

Es la excepción más famosa de JPA:

org.hibernate.LazyInitializationException: could not initialize proxy
[com.ciclourbana.estaciones.Estacion#1] - no Session

Qué ocurre. Con LAZY, Hibernate no pone la entidad real en el campo sino un proxy: un objeto de una subclase generada que solo guarda el id y una referencia a la sesión. Al llamar a cualquiera de sus métodos, el proxy va a la base de datos a completarse; si la sesión —el contexto de persistencia— ya está cerrada, no puede, y lanza la excepción.

public EstacionDetalleResponse verDetalle(Long id) {   // ¡sin @Transactional!
    Estacion estacion = estacionRepositorio.findById(id).orElseThrow();
    // aquí la transacción implícita del repositorio ya terminó
    return mapper.aDetalle(estacion, estacion.getBicicletas()); // ¡BOOM!
}

Por qué desactivar open-in-view la hace visible antes. Con open-in-view: true (el valor por defecto, que en 04-02 desactivamos), el contexto sigue abierto durante toda la petición HTTP, así que el acceso perezoso funciona... disparando consultas durante la serialización JSON, sin que nadie lo vea. Con open-in-view: false, la excepción salta en desarrollo, señalando exactamente dónde falta cargar datos. Es un fallo ruidoso que sustituye a una lentitud silenciosa: un cambio excelente.

Las cuatro soluciones, de mejor a peor:

Solución Cuándo usarla Valoración
Cargar lo necesario en la consulta (JOIN FETCH, @EntityGraph) Casi siempre La correcta
Mapear a DTO dentro de la transacción Siempre, como complemento La correcta
Ampliar la transacción con @Transactional en el servicio Cuando el trabajo pertenece al servicio Aceptable
Poner la relación en EAGER Nunca Cambia un error por lentitud permanente
Reactivar open-in-view Nunca Esconde el problema hasta producción

La versión correcta del ejemplo anterior:

@Transactional(readOnly = true)
public EstacionDetalleResponse verDetalle(Long id) {
    Estacion estacion = estacionRepositorio.buscarConBicicletas(id)   // JOIN FETCH
            .orElseThrow(() -> new RecursoNoEncontradoException("Estación", id));
    return mapper.aDetalle(estacion);   // el mapeo ocurre DENTRO de la transacción
}

Convergen aquí dos ideas ya asentadas: el servicio devuelve DTOs completamente mapeados (03-05) y el mapeo ocurre dentro de la transacción. Con esa disciplina, la LazyInitializationException deja de aparecer.

  1. El problema N+1

Es el problema de rendimiento característico de los ORM, y merece entenderse con un caso concreto.

El ayuntamiento pide un listado de las cuatro estaciones con el número de bicicletas de cada una:

@Transactional(readOnly = true)
public List<EstacionResponse> listar() {
    List<Estacion> estaciones = estacionRepositorio.findAll();   // 1 consulta
    return estaciones.stream()
            .map(e -> new EstacionResponse(e.getId(), e.getNombre(),
                    e.getBicicletas().size()))                   // N consultas
            .toList();
}

El SQL resultante:

select e1_0.id, e1_0.nombre, ... from estaciones e1_0;              -- 1
select b1_0.id, ... from bicicletas b1_0 where b1_0.estacion_id=1;  -- +1
select b1_0.id, ... from bicicletas b1_0 where b1_0.estacion_id=2;  -- +1
select b1_0.id, ... from bicicletas b1_0 where b1_0.estacion_id=3;  -- +1
select b1_0.id, ... from bicicletas b1_0 where b1_0.estacion_id=4;  -- +1

1 + N consultas, de ahí el nombre. Con las 4 estaciones de Ribalta son 5 y nadie lo nota; cuando la red crezca a 200 serán 201, y a 2 ms de ida y vuelta cada una el endpoint pasa de 10 ms a más de 400 ms sin que ninguna consulta individual sea lenta. Ahí está la traición: el profiler no encuentra culpable porque todas son rápidas.

Cómo detectarlo. Con la configuración de logs de 04-02 (org.hibernate.SQL: DEBUG y hibernate.generate_statistics: true), al final de cada petición aparece:

Session Metrics { 201 JDBC statements, 200 collections fetched, 1204 entities loaded }

Doscientas una sentencias para listar estaciones es un N+1 de manual. La regla mental: el número de consultas de un endpoint debe ser constante, no proporcional al número de resultados.

Las tres soluciones, que desarrollaremos en 04-06 y 09-01:

// 1. JOIN FETCH: una sola consulta con JOIN. La más directa.
@Query("select distinct e from Estacion e left join fetch e.bicicletas")
List<Estacion> buscarTodasConBicicletas();

// 2. @EntityGraph: declarativo, sin escribir JPQL.
@EntityGraph(attributePaths = "bicicletas") List<Estacion> findAll();

// 3. @BatchSize (Hibernate): agrupa las N consultas en N/tamaño.
@OneToMany(mappedBy = "estacion") @BatchSize(size = 25)
private Set<Bicicleta> bicicletas = new HashSet<>();
Solución Consultas Ventaja Inconveniente
JOIN FETCH 1 Óptimo en número de viajes Rompe la paginación con colecciones
@EntityGraph 1 Declarativo y reutilizable Mismo límite con la paginación
@BatchSize 1 + N/tamaño Compatible con la paginación No llega a una sola consulta

La advertencia sobre la paginación es importante: al hacer JOIN FETCH de una colección, el LIMIT de SQL se aplica a las filas del producto cartesiano, no a las estaciones. Hibernate lo detecta, avisa con HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory y carga todo en memoria para paginar después. Ahí @BatchSize es la respuesta correcta.

Y hay una cuarta solución, a menudo la mejor: no cargar entidades en absoluto. Si solo necesitas el recuento, pídelo con una proyección (04-06):

@Query("""
       select new com.ciclourbana.estaciones.dto.EstacionResumen(
              e.id, e.nombre, count(b))
         from Estacion e left join e.bicicletas b
        group by e.id, e.nombre
       """)
List<EstacionResumen> resumenDeOcupacion();

Una sola consulta, sin entidades gestionadas y sin traer datos que nadie va a mirar.

  1. cascade y orphanRemoval

cascade propaga una operación del padre a los hijos.

Tipo Qué propaga Uso típico en CicloUrbana
PERSIST Guardar el padre guarda los hijos nuevos Alquiler → incidencias creadas con él
MERGE Fusionar el padre fusiona los hijos Actualizaciones en bloque
REMOVE Borrar el padre borra los hijos Bicicleta → su ficha técnica
REFRESH Recargar el padre recarga los hijos Poco frecuente
DETACH Separar el padre separa los hijos Poco frecuente
ALL Los cinco anteriores Solo en composición estricta

Cuándo aplicar cascada. La pregunta correcta no es técnica sino de dominio: ¿el hijo existe por sí mismo o solo tiene sentido dentro del padre? Una FichaTecnica no existe sin su bicicleta (cascade = ALL, orphanRemoval = true); una Incidencia, tampoco (cascade = {PERSIST, MERGE}, orphanRemoval = true). En cambio una Bicicleta sí existe sin su estación —se mueve a otra o se lleva al taller, y borrar una estación no debe borrar sus bicicletas—, igual que un Usuario existe al margen de sus alquileres: sin cascada en ambos casos.

@Entity
@Table(name = "bicicletas")
public class Bicicleta extends EntidadAuditable {

    @OneToMany(mappedBy = "bicicleta",
               cascade = {CascadeType.PERSIST, CascadeType.MERGE},
               orphanRemoval = true,
               fetch = FetchType.LAZY)
    private Set<Incidencia> incidencias = new HashSet<>();

    public void reportarIncidencia(Incidencia incidencia) {
        incidencias.add(incidencia);
        incidencia.setBicicleta(this);
        if (incidencia.esBloqueante()) this.estado = EstadoBicicleta.RETIRADA;
    }

    public void resolverIncidencia(Incidencia incidencia) {
        incidencias.remove(incidencia);   // con orphanRemoval, se BORRA de la BD
    }
}

orphanRemoval frente a CascadeType.REMOVE. Se confunden constantemente:

CascadeType.REMOVE orphanRemoval = true
Borra los hijos al borrar el padre Sí Sí
Borra un hijo al quitarlo de la colección No (queda huérfano con FK nula o falla) Sí

orphanRemoval es más fuerte y expresa mejor la composición: el hijo no puede existir fuera del padre. Por eso resolverIncidencia basta con quitarla de la colección para que desaparezca de la tabla.

Advertencia: cascade = REMOVE sobre una colección grande carga todas las entidades hijas en memoria y emite un DELETE por cada una. Borrar una bicicleta con 5 000 registros de sensores generaría 5 001 sentencias. Para volúmenes así, un DELETE masivo con @Modifying (04-06) o un ON DELETE CASCADE en el esquema (04-08) son mucho mejores.

  1. Colecciones de valores con @ElementCollection

A veces una entidad necesita una lista de valores simples que no merecen ser entidades: las etiquetas de una incidencia, por ejemplo.

@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "incidencia_etiquetas",
                 joinColumns = @JoinColumn(name = "incidencia_id"),
                 foreignKey = @ForeignKey(name = "fk_etiquetas_incidencia"))
@Column(name = "etiqueta", length = 40)
private Set<String> etiquetas = new HashSet<>();

Se crea una tabla incidencia_etiquetas con dos columnas, pero sus filas no son entidades: sin id propio, sin repositorio y no consultables por separado.

Aspecto @ElementCollection @OneToMany a una entidad
Identidad propia No Sí
Repositorio No Sí
Consultable por separado No Sí
Al modificar la colección Hibernate borra e inserta todo UPDATE selectivo
Adecuado para Valores simples, listas cortas Cualquier cosa con vida propia

Ese «borra e inserta todo» es la limitación clave: modificar un elemento de una colección de 500 valores genera 501 sentencias. @ElementCollection es para listas cortas y estables. También admite @Embeddable: por ejemplo, el histórico de posiciones GPS de un alquiler como colección de Ubicacion.

  1. Herencia de entidades

Las incidencias de CicloUrbana no son todas iguales: una batería agotada, un acto de vandalismo y una avería mecánica comparten campos pero tienen datos propios. JPA ofrece tres estrategias.

@Entity
@Table(name = "incidencias")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "tipo", discriminatorType = DiscriminatorType.STRING,
                     length = 20)
public abstract class Incidencia extends EntidadAuditable {

    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "incidencias_seq")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "bicicleta_id", nullable = false)
    private Bicicleta bicicleta;

    @Column(name = "descripcion", nullable = false, length = 500)
    private String descripcion;

    public abstract boolean esBloqueante();
}

@Entity
@DiscriminatorValue("BATERIA")
public class IncidenciaBateria extends Incidencia {
    @Column(name = "nivel_detectado") private Integer nivelDetectado;
    @Override public boolean esBloqueante() { return nivelDetectado < 5; }
}

@Entity
@DiscriminatorValue("VANDALISMO")
public class IncidenciaVandalismo extends Incidencia {
    @Column(name = "denuncia_policial", length = 40) private String denunciaPolicial;
    @Override public boolean esBloqueante() { return true; }
}
Estrategia Cómo se guarda Consultas NOT NULL en hijos Cuándo elegirla
SINGLE_TABLE (por defecto) Una tabla con todas las columnas y un discriminador Rápidas, sin JOIN Imposible Pocos campos propios, prioridad al rendimiento
JOINED Una tabla base + una por subclase JOIN por cada nivel Sí Muchos campos propios, prioridad a la integridad
TABLE_PER_CLASS Una tabla completa por subclase UNION ALL al consultar el padre Sí Casi nunca; complica claves y consultas polimórficas

Para CicloUrbana elegimos SINGLE_TABLE, con su contrapartida asumida: las columnas específicas (nivel_detectado, denuncia_policial) deben admitir nulos, porque una fila de vandalismo no tiene nivel de batería. Es un precio razonable con pocos campos propios; si cada tipo acumulara diez columnas obligatorias, JOINED sería la elección correcta.

Conviene recordar además la distinción con 04-03: @MappedSuperclass no es herencia de entidades. EntidadAuditable no es consultable ni polimórfica, solo aporta columnas a cada tabla hija. Incidencia sí es una entidad, y incidenciaRepositorio.findAll() devuelve instancias de sus subclases.

Errores Comunes y Consejos

Dejar @ManyToOne y @OneToOne en su fetch por defecto. Son EAGER, y ese olvido genera JOIN encadenados en cada consulta. Escribe fetch = FetchType.LAZY siempre, aunque parezca redundante.

Modificar solo el lado inverso. No genera SQL. El síntoma es «guardo y no se guarda» sin ningún error. Usa siempre métodos auxiliares que sincronicen ambos lados.

Olvidar mappedBy en un @OneToMany. JPA crea una tabla de unión inesperada. Si en la consola de H2 aparece una tabla estaciones_bicicletas, ese es el diagnóstico.

Confundir orphanRemoval con CascadeType.REMOVE. El primero borra el hijo al quitarlo de la colección; el segundo solo al borrar el padre.

Poner cascade = ALL por costumbre. Borrar una estación borraría todas sus bicicletas. Aplica cascada solo cuando el hijo no tenga vida propia.

Usar @ManyToMany para relaciones con matices. Cuando el negocio pida una fecha o un contador en la relación, habrá que migrar el modelo con datos en producción.

Consejo: cuenta las consultas de tus endpoints. Activa generate_statistics y comprueba que el número de sentencias no crece con el número de resultados. Es la mejor red contra el N+1.

Consejo: usa Set en las colecciones y equals/hashCode correctos. Con List, Hibernate puede borrar y reinsertar la colección entera al eliminar un elemento.

Consejo: no modeles relaciones que nadie navega. Cada asociación bidireccional añade complejidad. Si CicloUrbana nunca necesita ir de un usuario a sus alquileres en memoria, deja solo Alquiler → Usuario y consulta por repositorio.

Ejercicios

Ejercicio 1: modelar Alquiler completo

Escribe la entidad Alquiler con sus cuatro relaciones (Usuario, Bicicleta, estación de origen y estación de destino), sabiendo que la de destino es nula mientras el alquiler está en curso. Justifica el fetch, el optional y la ausencia o presencia de cascada en cada una. Indica también qué índices declararías.

Ejercicio 2: diagnosticar y corregir un N+1

Este endpoint de CicloUrbana tarda 1,2 segundos con 200 estaciones. Identifica el problema, calcula el número de consultas y propón tres soluciones distintas indicando cuál elegirías y por qué.

@GetMapping("/api/v1/estaciones/ocupacion")
public List<OcupacionResponse> ocupacion() {
    return estacionRepositorio.findAll().stream()
            .map(e -> new OcupacionResponse(
                    e.getNombre(),
                    e.getCapacidad(),
                    e.getBicicletas().stream()
                        .filter(b -> b.getEstado() == EstadoBicicleta.DISPONIBLE)
                        .count()))
            .toList();
}

Ejercicio 3: elegir la estrategia de herencia

El ayuntamiento amplía las incidencias con cuatro tipos: batería (nivel detectado), vandalismo (denuncia policial, fotos, coste estimado de reparación), avería mecánica (componente, gravedad, taller asignado, fecha prevista) y accidente (parte de accidente, aseguradora, lesionados, informe policial, coste). Elige la estrategia de herencia adecuada y justifícala frente a las otras dos.

Soluciones

Solución 1.

@Entity
@Table(name = "alquileres", indexes = {
    @Index(name = "idx_alquileres_usuario", columnList = "usuario_id"),
    @Index(name = "idx_alquileres_bicicleta", columnList = "bicicleta_id"),
    @Index(name = "idx_alquileres_inicio", columnList = "inicio")
})
public class Alquiler extends EntidadAuditable {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "alquileres_seq")
    @SequenceGenerator(name = "alquileres_seq", sequenceName = "alquileres_id_seq",
                       allocationSize = 50)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "usuario_id", nullable = false, updatable = false,
                foreignKey = @ForeignKey(name = "fk_alquileres_usuario"))
    private Usuario usuario;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "bicicleta_id", nullable = false, updatable = false,
                foreignKey = @ForeignKey(name = "fk_alquileres_bicicleta"))
    private Bicicleta bicicleta;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "estacion_origen_id", nullable = false, updatable = false,
                foreignKey = @ForeignKey(name = "fk_alquileres_estacion_origen"))
    private Estacion estacionOrigen;

    @ManyToOne(fetch = FetchType.LAZY)   // optional = true por defecto
    @JoinColumn(name = "estacion_destino_id",
                foreignKey = @ForeignKey(name = "fk_alquileres_estacion_destino"))
    private Estacion estacionDestino;

    @Column(name = "inicio", nullable = false, updatable = false) private Instant inicio;
    @Column(name = "fin") private Instant fin;
    @Column(name = "importe", precision = 8, scale = 2) private BigDecimal importe;
    @Version @Column(name = "version", nullable = false) private Long version;

    protected Alquiler() { }
}

Justificación de cada decisión:

  • fetch = LAZY en las cuatro. Con EAGER, listar los alquileres del día traería usuarios, bicicletas y estaciones completos aunque el listado solo muestre fecha e importe. Además Bicicleta y Estacion tienen sus propias relaciones, y los JOIN se encadenarían.
  • optional = false en usuario, bicicleta y origen: un alquiler sin ellos no tiene sentido. Permite a Hibernate usar INNER JOIN y documenta la regla en el esquema con nullable = false.
  • optional por defecto (true) en el destino: es nulo durante todo el alquiler y se rellena al devolver la bicicleta. Es un hecho del negocio, no una omisión.
  • Sin cascada en ninguna. El usuario, la bicicleta y las estaciones existen por sí mismos. Cascada aquí significaría que borrar un alquiler borra al usuario: un desastre.
  • updatable = false en usuario, bicicleta y origen: una vez iniciado el alquiler, esos datos son inmutables. Es integridad histórica.
  • Índices: por usuario_id (consulta «mis alquileres»), por bicicleta_id (historial de una bicicleta) y por inicio (informes por rango de fechas). La clave ajena no crea índice automáticamente en PostgreSQL, al contrario que en MySQL: hay que declararlo.

Solución 2.

El problema es un N+1. findAll() ejecuta 1 consulta y getBicicletas() dispara una consulta perezosa por cada estación: 201 consultas con 200 estaciones. A ~5 ms cada ida y vuelta, unos 1,2 segundos, sin que ninguna consulta individual sea lenta.

Solución A — JOIN FETCH: @Query("select distinct e from Estacion e left join fetch e.bicicletas"). Una sola consulta, pero trae todas las bicicletas de todas las estaciones a memoria aunque solo se necesite un recuento.

Solución B — @EntityGraph: @EntityGraph(attributePaths = "bicicletas") sobre findAll(). Equivalente en resultado, declarativo y sin JPQL de asociaciones; mismo inconveniente.

Solución C — proyección con agregación (la que elegiría):

@Query("""
       select new com.ciclourbana.estaciones.dto.OcupacionResponse(
              e.nombre, e.capacidad, count(b.id))
         from Estacion e
         left join e.bicicletas b on b.estado = com.ciclourbana.bicicletas.EstadoBicicleta.DISPONIBLE
        group by e.id, e.nombre, e.capacidad
        order by e.nombre
       """)
List<OcupacionResponse> consultarOcupacion();

Por qué la C. Las tres eliminan el N+1, pero A y B cargan todas las bicicletas de la red —con 200 estaciones y 30 bicicletas cada una, 6 000 entidades gestionadas en el contexto de persistencia— para acabar contando. La C hace que la base de datos cuente, que es exactamente para lo que está optimizada, y devuelve 200 filas con tres columnas. Menos SQL, menos memoria, menos recolección de basura y ningún riesgo de LazyInitializationException porque no hay entidades gestionadas. Además el resultado es directamente el DTO de respuesta.

Regla general: si solo necesitas datos agregados, no cargues entidades. Las proyecciones se desarrollan en 04-06.

Solución 3. La estrategia adecuada es JOINED.

Contando campos propios: batería 1, vandalismo 4, avería 4, accidente 5. Son 14 columnas específicas repartidas entre cuatro subclases.

Con SINGLE_TABLE, la tabla incidencias tendría esas 14 columnas más las comunes, y todas las específicas obligatoriamente nulables. Consecuencias: no se puede exigir en el esquema que una incidencia de accidente tenga aseguradora, cada fila desperdicia espacio en columnas vacías, y cualquier persona que consulte la tabla directamente necesita saber qué columnas aplican a cada tipo. Con cuatro tipos y campos obligatorios distintos, la integridad quedaría enteramente en manos del código Java.

Con JOINED hay una tabla incidencias con lo común (id, bicicleta, descripción, fecha, auditoría) y cuatro tablas hijas —incidencias_bateria, incidencias_vandalismo, incidencias_averia, incidencias_accidente— cuya clave primaria es también clave ajena a la base, y cada una declara sus NOT NULL donde corresponde. El esquema queda normalizado y describe fielmente el dominio. El coste es un JOIN por nivel al consultar; con un solo nivel de herencia y un volumen de incidencias bajo comparado con los alquileres, es asumible, y las consultas más frecuentes —«incidencias abiertas de esta bicicleta»— solo tocan la tabla base.

Por qué no las otras dos:

  • SINGLE_TABLE: 14 columnas nulables y ninguna garantía de integridad en la base de datos. Sería correcta si cada subclase tuviera uno o dos campos propios y el volumen fuera muy alto.
  • TABLE_PER_CLASS: cuatro tablas independientes que repiten las columnas comunes; consultar Incidencia genera un UNION ALL de las cuatro, no se puede usar IDENTITY y las claves ajenas hacia la tabla base son imposibles. Prácticamente nunca es la respuesta.

Conclusión

El modelo de CicloUrbana ya tiene flechas. Sabes traducir cada cardinalidad a su anotación y, más importante, sabes dónde vive la clave ajena y qué implica: el lado propietario es el único que escribe, el lado con mappedBy solo refleja, y modificar únicamente el inverso es el error que no falla, no avisa y no guarda nada. Has escrito @ManyToOne con @JoinColumn nombrada —imprescindible cuando Alquiler apunta dos veces a Estacion—, @OneToMany con mappedBy y sus métodos auxiliares que sincronizan ambos lados, @OneToOne con @MapsId para que la clave primaria sea la ajena, y sabes por qué conviene evitar los @OneToOne bidireccionales. Conoces @ManyToMany y, sobre todo, por qué casi siempre se sustituye por una entidad intermedia: en cuanto el negocio pregunta «¿cuándo?» o «¿cuántas veces?», la tabla de unión se queda corta y la migración ya es cara.

Tienes las dos reglas que gobiernan el rendimiento con un ORM. La primera: fetch = FetchType.LAZY en todas las asociaciones, contra los valores por defecto EAGER de @ManyToOne y @OneToOne, porque EAGER toma en la entidad una decisión que corresponde a cada consulta. La segunda: el número de consultas de un endpoint debe ser constante, no proporcional al número de resultados, que es la formulación operativa del problema N+1, detectable con generate_statistics y resoluble con JOIN FETCH, @EntityGraph, @BatchSize o —a menudo la mejor— no cargando entidades en absoluto. Y entiendes la LazyInitializationException no como un enemigo sino como un aviso valioso que llega antes gracias a haber desactivado open-in-view en 04-02. Has aplicado cascade y orphanRemoval según un criterio de dominio —¿el hijo existe por sí mismo?—, modelado colecciones de valores con @ElementCollection y elegido entre las tres estrategias de herencia para las incidencias de Ribalta.

Lo que sigue faltando es cómo se usan todas estas entidades. EstacionRepositorioEnMemoria sigue ahí, con su ConcurrentHashMap y sus métodos escritos a mano, y hemos ido escribiendo consultas de ejemplo sobre un repositorio que todavía no existe. La lección 04-05, Uso de Repositorios de Spring Data, lo resuelve: veremos la jerarquía de interfaces y cuál elegir, cómo Spring fabrica la implementación en el arranque mediante proxies, y sustituiremos EstacionRepositorioEnMemoria por EstacionRepositorio extends JpaRepository<Estacion, Long> comprobando cuánto código desaparece de golpe. Repasaremos la semántica exacta de cada método heredado —incluida la diferencia entre findById y getReferenceById, y el hecho de que save hace merge si la entidad ya tiene id—, añadiremos paginación y ordenación a la API de Ribalta con Pageable, y veremos por qué nunca se devuelve un Page directamente al cliente.

Curso de Spring Boot

Módulo 1: Introducción a Spring Boot

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados