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
- El modelo relacional completo de CicloUrbana
- Las cuatro cardinalidades y su anotación
@ManyToOne: el lado propietario natural@OneToManyymappedBy- Lado propietario e inverso: el error clásico
@OneToOnecon@MapsId@ManyToManyy por qué casi nunca convieneFetchType.LAZYfrente aEAGERLazyInitializationException- El problema N+1
cascadeyorphanRemoval- Colecciones de valores con
@ElementCollection - Herencia de entidades
- Errores Comunes y Consejos
- Ejercicios
- 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 |
- 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.
@ManyToOne: el lado propietario natural
@ManyToOne: el lado propietario naturalMuchas 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.
@OneToMany y mappedBy
@OneToMany y mappedByDesde 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").
- 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 |
@OneToOne con @MapsId
@OneToOne con @MapsIdCada 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).
@ManyToMany y por qué casi nunca conviene
@ManyToMany y por qué casi nunca convieneEl 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.
FetchType.LAZY frente a EAGER
FetchType.LAZY frente a EAGEREsta 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.
LazyInitializationException
LazyInitializationExceptionEs la excepción más famosa de JPA:
org.hibernate.LazyInitializationException: could not initialize proxy
[com.ciclourbana.estaciones.Estacion#1] - no SessionQué 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.
- 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; -- +11 + 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:
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.
cascade y orphanRemoval
cascade y orphanRemovalcascade 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.
- Colecciones de valores con
@ElementCollection
@ElementCollectionA 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.
- 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 = LAZYen las cuatro. ConEAGER, listar los alquileres del día traería usuarios, bicicletas y estaciones completos aunque el listado solo muestre fecha e importe. AdemásBicicletayEstaciontienen sus propias relaciones, y losJOINse encadenarían.optional = falseen usuario, bicicleta y origen: un alquiler sin ellos no tiene sentido. Permite a Hibernate usarINNER JOINy documenta la regla en el esquema connullable = false.optionalpor 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 = falseen usuario, bicicleta y origen: una vez iniciado el alquiler, esos datos son inmutables. Es integridad histórica.- Índices: por
usuario_id(consulta «mis alquileres»), porbicicleta_id(historial de una bicicleta) y porinicio(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; consultarIncidenciagenera unUNION ALLde las cuatro, no se puede usarIDENTITYy 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
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
