Módulo 15: Relaciones y herencia

Objetivos del módulo

  • Entender las asociaciones entre entidades: multiplicidad, dirección (unidireccional y bidireccional) y lado propietario (mappedBy)
  • Mapear @ManyToOne y @OneToMany con las tablas de EMPRESA (Empleado–Departamento, Pedido–LineaPedido)
  • Mapear @OneToOne y @ManyToMany, y convertir una N:M con atributos en una entidad intermedia (Asignacion con @EmbeddedId y @MapsId)
  • Modelar autorrelaciones (Empleado.jefe, Categoria.padre)
  • Escribir métodos de sincronización que mantengan coherentes los dos lados de una relación
  • Elegir entre carga LAZY y EAGER y entender (y evitar) la LazyInitializationException
  • Usar cascadas y orphanRemoval correctamente
  • Trabajar con colecciones List y Set y ordenarlas con @OrderBy
  • Mapear herencia con SINGLE_TABLE, JOINED, TABLE_PER_CLASS y @MappedSuperclass, comparando el SQL que genera cada estrategia
  • Implementar equals y hashCode de forma segura en entidades

1. Asociaciones entre entidades

En el módulo 14 mapeamos las claves ajenas como simples números (Integer deptId). Funciona, pero no es orientado a objetos: para saber el nombre del departamento de un empleado había que hacer otro find a mano. Ahora vamos a sustituir esos números por referencias a objetos, de modo que puedas escribir empleado.getDepartamento().getNombre() y dejar que Hibernate genere el SQL.

📘 Concepto: Una asociación es una relación entre dos entidades que en la base de datos se materializa mediante una clave ajena (o una tabla de unión). Se caracteriza por su multiplicidad (cuántos objetos hay a cada lado) y su dirección (desde qué lado se puede navegar).

Multiplicidad Ejemplo en EMPRESA Anotaciones Cómo se guarda
Muchos a uno (N:1) Muchos empleados → un departamento @ManyToOne Clave ajena en la tabla del lado “muchos” (empleados.dept_id)
Uno a muchos (1:N) Un departamento → muchos empleados @OneToMany(mappedBy = ...) La misma clave ajena, vista desde el otro lado
Uno a uno (1:1) Un empleado → una credencial @OneToOne Clave ajena única o clave primaria compartida
Muchos a muchos (N:M) Empleados ↔ habilidades @ManyToMany + @JoinTable Tabla de unión con dos claves ajenas
N:M con atributos Empleados ↔ proyectos (con horas y función) Entidad intermedia + 2 @ManyToOne Tabla asignaciones con su propia entidad

⚠️ Importante: EMPRESA no tiene ninguna relación 1:1 ni ninguna N:M “pura” (sin atributos). Para practicarlas usaremos tres tablas de laboratorio (credenciales, habilidades y empleado_habilidad) que crearás con un script en la sección 7 y borrarás al terminar el módulo. Para la herencia usaremos tablas que crea y borra Hibernate (create-drop). EMPRESA no se modifica.


2. Dirección de una asociación y lado propietario

Una asociación puede ser:

  • Unidireccional: solo un lado conoce al otro. Empleado tiene un atributo departamento, pero Departamento no tiene lista de empleados.
  • Bidireccional: ambos lados se conocen. Empleado.departamento y Departamento.empleados.

En una bidireccional hay dos atributos Java pero una sola clave ajena. ¿Cuál de los dos manda cuando no coinciden? El lado propietario.

Empleado es el lado propietario con @JoinColumn; Departamento es el lado inverso con mappedBy

Figura 1. Lado propietario y lado inverso de una relación bidireccional.

📘 Concepto: El lado propietario (owning side) es el que contiene la información de mapeo de la clave ajena (@JoinColumn). Hibernate solo mira ese lado para escribir en la base de datos. El lado inverso se marca con mappedBy = "nombreDelAtributoEnElOtroLado" y solo sirve para navegar: sus cambios se ignoran al guardar.

Reglas prácticas:

Relación Lado propietario Lado inverso
@ManyToOne / @OneToMany Siempre el @ManyToOne (la tabla del lado “muchos” tiene la FK) El @OneToMany(mappedBy = ...)
@OneToOne El que tiene la clave ajena (@JoinColumn o @MapsId) El que lleva mappedBy
@ManyToMany El que declara @JoinTable El que lleva mappedBy

💡 Consejo: No hagas bidireccional una relación “por si acaso”. Añade el lado inverso solo si de verdad vas a navegar en esa dirección. Cada lado inverso es una colección más que mantener sincronizada y una posible fuente de consultas inesperadas.


3. @ManyToOne y @OneToMany: empleados y departamentos

Diagrama de clases Empleado y Departamento y tablas empleados y departamentos con la clave ajena dept_id

Figura 2. Relación N:1 / 1:N entre objetos y entre tablas.

💡 Consejo: Como en el módulo 14, los ejemplos omiten las líneas package e import (salvo en Empleado): IntelliJ las añade con Alt+Intro. El proyecto es el del módulo anterior, con las mismas JpaUtil, persistence.xml y pom.xml.

3.1 El lado propietario: Empleado

package es.curso.empresa.modelo;

import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;

@Entity
@Table(name = "empleados")
public class Empleado {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "emp_id")
    private Integer id;

    // ... nombre, apellidos, email, telefono, fechaAlta, oficio, salario, comision (como en el módulo 14)

    // Lado PROPIETARIO de la relación con Departamento: aquí está la columna dept_id
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "dept_id")
    private Departamento departamento;

    // Autorrelación: muchos empleados tienen un mismo jefe
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "jefe_id")
    private Empleado jefe;

    @OneToMany(mappedBy = "jefe")
    @OrderBy("id")
    private List<Empleado> subordinados = new ArrayList<>();

    // N:M con atributos a través de la entidad Asignacion (sección 9)
    @OneToMany(mappedBy = "empleado", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<Asignacion> asignaciones = new HashSet<>();

    // N:M simple (tabla de unión empleado_habilidad, sección 8)
    @ManyToMany
    @JoinTable(name = "empleado_habilidad",
            joinColumns = @JoinColumn(name = "emp_id"),
            inverseJoinColumns = @JoinColumn(name = "habilidad_id"))
    private Set<Habilidad> habilidades = new HashSet<>();

    protected Empleado() { }
    // constructor, getters, setters...
}
Elemento Significado
@ManyToOne Muchos empleados apuntan a un departamento
fetch = FetchType.LAZY No cargar el departamento hasta que se use (sección 5)
@JoinColumn(name = "dept_id") Nombre de la columna de clave ajena en empleados. Sin ella, Hibernate buscaría departamento_dept_id

3.2 El lado inverso: Departamento

@Entity
@Table(name = "departamentos")
public class Departamento {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "dept_id")
    private Integer id;

    @Column(nullable = false, length = 40, unique = true)
    private String nombre;

    @Column(length = 40)
    private String localidad;

    @Column(precision = 12, scale = 2)
    private BigDecimal presupuesto;

    // Lado INVERSO: la clave ajena dept_id la gestiona Empleado.departamento
    @OneToMany(mappedBy = "departamento")
    @OrderBy("apellidos")
    private List<Empleado> empleados = new ArrayList<>();

    @OneToMany(mappedBy = "departamento")
    private List<Proyecto> proyectos = new ArrayList<>();

    protected Departamento() { }

    public Departamento(String nombre, String localidad) {
        this.nombre = nombre;
        this.localidad = localidad;
    }

    // --- Métodos de sincronización: mantienen coherentes AMBOS lados ---
    public void addEmpleado(Empleado e) {
        empleados.add(e);
        e.setDepartamento(this);
    }

    public void removeEmpleado(Empleado e) {
        empleados.remove(e);
        e.setDepartamento(null);
    }
    // getters...
}

⚠️ Importante: Inicializa siempre las colecciones (= new ArrayList<>()). Un objeto nuevo creado con new no pasa por Hibernate, y si la colección fuera null, addEmpleado lanzaría un NullPointerException.

3.3 Navegar de un empleado a su departamento (y a sus jefes)

public class Ejemplo01ManyToOne {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            System.out.println("--- find(Empleado, 121) ---");
            Empleado raquel = em.find(Empleado.class, 121);
            System.out.println(raquel.getNombreCompleto());

            // El departamento es LAZY: de momento es un proxy sin datos
            var dep = raquel.getDepartamento();
            System.out.println("Clase del departamento: " + dep.getClass().getSimpleName());
            System.out.println("Id (no necesita SQL): " + dep.getId());

            System.out.println("--- getNombre() del departamento ---");
            System.out.println("Departamento: " + dep.getNombre());

            System.out.println("--- Subimos por la cadena de jefes ---");
            Empleado actual = raquel;
            while (actual.getJefe() != null) {
                actual = actual.getJefe();
                System.out.println("Jefe: " + actual.getNombreCompleto());
            }
        }
        JpaUtil.cerrar();
    }
}

Salida real (las tres consultas de jefes son idénticas; se muestra solo la primera):

--- find(Empleado, 121) ---
Hibernate: 
    select
        e1_0.emp_id,
        e1_0.apellidos,
        e1_0.comision,
        e1_0.dept_id,
        e1_0.email,
        e1_0.fecha_alta,
        e1_0.jefe_id,
        e1_0.nombre,
        e1_0.oficio,
        e1_0.salario,
        e1_0.telefono 
    from
        empleados e1_0 
    where
        e1_0.emp_id=?
Raquel Molina Prieto
Clase del departamento: Departamento$HibernateProxy
Id (no necesita SQL): 30
--- getNombre() del departamento ---
Hibernate: 
    select
        d1_0.dept_id,
        d1_0.localidad,
        d1_0.nombre,
        d1_0.presupuesto 
    from
        departamentos d1_0 
    where
        d1_0.dept_id=?
Departamento: INFORMÁTICA
--- Subimos por la cadena de jefes ---
Hibernate: 
    select
        e1_0.emp_id,
        ...
    from
        empleados e1_0 
    where
        e1_0.emp_id=?
Jefe: Pablo Moreno Herrera
Hibernate: 
    select ... (igual)
Jefe: Marta Sánchez Gil
Hibernate: 
    select ... (igual)
Jefe: Ana García López

Fíjate en tres cosas:

  1. El primer SELECT solo lee empleados: el departamento no se carga todavía. Hibernate guarda en departamento un proxy, un objeto de una subclase generada (Departamento$HibernateProxy) que solo conoce el id.
  2. Pedir el id al proxy no genera SQL (ya lo tiene: es el valor de dept_id). Pedir el nombre sí: en ese momento el proxy se inicializa con un SELECT.
  3. Cada salto a un jefe es una consulta. Para una cadena corta no importa; en un listado de cien empleados sería un problema serio (el N+1 del módulo 16).

3.4 Recorrer la colección de un departamento

public class Ejemplo02OneToMany {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            System.out.println("--- find(Departamento, 30) ---");
            Departamento informatica = em.find(Departamento.class, 30);

            var lista = informatica.getEmpleados();
            System.out.println("Clase de la colección: " + lista.getClass().getSimpleName());

            System.out.println("--- Recorremos la colección ---");
            for (Empleado e : lista) {
                System.out.println("  " + e.getApellidos() + ", " + e.getNombre());
            }
            System.out.println("Total: " + lista.size() + " empleados");
        }
        JpaUtil.cerrar();
    }
}

Salida real:

--- find(Departamento, 30) ---
Hibernate: select ... from departamentos d1_0 where d1_0.dept_id=?
Clase de la colección: PersistentBag
--- Recorremos la colección ---
Hibernate: 
    select
        e1_0.dept_id,
        e1_0.emp_id,
        ...
        e1_0.telefono 
    from
        empleados e1_0 
    where
        e1_0.dept_id=? 
    order by
        e1_0.apellidos
  Castro Méndez, Iván
  Molina Prieto, Raquel
  Moreno Herrera, Pablo
  Ortiz Delgado, Sara
  Rubio Marín, Hugo
  Sánchez Gil, Marta
Total: 6 empleados

La lista que devuelve getEmpleados() no es tu ArrayList: Hibernate la sustituye por una colección propia (PersistentBag) que se carga sola la primera vez que la usas. Y el @OrderBy("apellidos") se traduce en un order by e1_0.apellidos.


4. Mantener sincronizados los dos lados

💻 Ejemplo: El error más típico con relaciones bidireccionales es modificar solo el lado inverso. Este programa crea un departamento y dos empleados: a uno lo añade solo a la lista (mal) y al otro con el método de sincronización (bien).

public class Ejemplo03LadoPropietario {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            em.getTransaction().begin();
            Departamento calidad = new Departamento("CALIDAD", "Toledo");
            em.persist(calidad);

            // ERROR TÍPICO: solo tocamos el lado inverso (la colección)
            Empleado e1 = new Empleado("Olga", "Prieto Sanz", "olga.prieto@empresa.es",
                    "AUDITOR", new BigDecimal("2400"));
            calidad.getEmpleados().add(e1);          // ¡no se rellena dept_id!
            em.persist(e1);

            // CORRECTO: método de sincronización (fija también e2.departamento)
            Empleado e2 = new Empleado("Raúl", "Vega Ramos", "raul.vega@empresa.es",
                    "AUDITOR", new BigDecimal("2400"));
            calidad.addEmpleado(e2);
            em.persist(e2);
            em.getTransaction().commit();

            em.clear();     // olvidamos los objetos para releer de la BD
            Departamento releido = em.find(Departamento.class, calidad.getId());
            System.out.println("Empleados de CALIDAD según la BD: " + releido.getEmpleados());

            // Limpieza: dejamos EMPRESA como estaba
            em.clear();
            em.getTransaction().begin();
            em.remove(em.find(Empleado.class, e1.getId()));
            em.remove(em.find(Empleado.class, e2.getId()));
            em.remove(em.find(Departamento.class, calidad.getId()));
            System.out.println("--- limpieza ---");
            em.getTransaction().commit();
        }
        JpaUtil.cerrar();
    }
}

Salida real (sin las consultas de la limpieza):

Hibernate: 
    insert 
    into
        departamentos
        (localidad, nombre, presupuesto) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        empleados
        (apellidos, comision, dept_id, email, fecha_alta, jefe_id, nombre, oficio, salario, telefono) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: insert into empleados (apellidos, comision, dept_id, email, fecha_alta, jefe_id, nombre, oficio, salario, telefono) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: 
    select ... from departamentos d1_0 where d1_0.dept_id=?
Hibernate: 
    select ... from empleados e1_0 where e1_0.dept_id=? order by e1_0.apellidos
Empleados de CALIDAD según la BD: [Empleado[164, Raúl Vega Ramos]]
--- limpieza ---
Hibernate: 
    delete from empleado_habilidad where emp_id=?
Hibernate: delete from empleado_habilidad where emp_id=?
Hibernate: 
    delete from empleados where emp_id=?
Hibernate: delete from empleados where emp_id=?
Hibernate: 
    delete from departamentos where dept_id=?

Los dos INSERT de empleados son idénticos, pero en el de Olga el parámetro dept_id vale NULL, porque su atributo departamento (el lado propietario) era null. Al releer de la base de datos, solo Raúl pertenece a CALIDAD. Observa también que, al borrar un empleado, Hibernate borra antes sus filas de la tabla de unión empleado_habilidad (sección 8): de esa tabla se encarga él.

💡 Consejo: Escribe métodos addXxx / removeXxx en el lado que tenga la colección y úsalos siempre. Así el modelo de objetos en memoria es coherente con lo que se guardará y no te llevarás sorpresas dentro de la misma transacción (donde todavía no has releído de la base de datos).


5. Carga perezosa (LAZY) y ansiosa (EAGER)

📘 Concepto: La estrategia de carga (fetch) indica cuándo se leen los objetos asociados: EAGER (ansiosa) los carga siempre junto con la entidad; LAZY (perezosa) los carga solo cuando se accede a ellos, mediante un proxy o una colección persistente.

Anotación Valor por defecto Recomendación
@ManyToOne EAGER Cambia a LAZY explícitamente
@OneToOne EAGER Cambia a LAZY explícitamente
@OneToMany LAZY Déjalo así
@ManyToMany LAZY Déjalo así

⚠️ Importante: Los valores por defecto de JPA para las relaciones “a uno” son una herencia histórica poco afortunada. La regla profesional es: todas las asociaciones LAZY, y cuando una pantalla necesite los datos asociados, pídelos explícitamente en la consulta (JOIN FETCH o @EntityGraph, módulo 16).

5.1 EAGER en acción

En Producto dejamos a propósito @ManyToOne sin fetch (es decir, EAGER):

@ManyToOne                                       // EAGER por defecto
@JoinColumn(name = "categoria_id", nullable = false)
private Categoria categoria;
public class Ejemplo05Eager {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            // Producto.categoria es @ManyToOne sin fetch => EAGER
            System.out.println("--- find(Producto, 7): EAGER usa un JOIN ---");
            Producto p = em.find(Producto.class, 7);
            System.out.println(p.getNombre() + " -> " + p.getCategoria().getNombre());

            // Con una consulta JPQL, EAGER provoca una consulta EXTRA por cada categoría distinta
            System.out.println("--- JPQL: productos de menos de 100 euros ---");
            var lista = em.createQuery(
                    "select p from Producto p where p.precio < 100", Producto.class)
                    .getResultList();
            lista.forEach(x -> System.out.println("  " + x.getNombre() + " (" + x.getCategoria().getNombre() + ")"));
        }
        JpaUtil.cerrar();
    }
}

Salida real:

--- find(Producto, 7): EAGER usa un JOIN ---
Hibernate: 
    select
        p1_0.producto_id,
        p1_0.categoria_id,
        ...
        p1_0.stock 
    from
        productos p1_0 
    join
        categorias c1_0 
            on c1_0.categoria_id=p1_0.categoria_id 
    where
        p1_0.producto_id=?
Teclado mecánico -> Ratones y teclados
--- JPQL: productos de menos de 100 euros ---
Hibernate: 
    select
        p1_0.producto_id,
        p1_0.categoria_id,
        ...
        p1_0.stock 
    from
        productos p1_0 
    where
        p1_0.precio<100
Hibernate: 
    select
        c1_0.categoria_id,
        c1_0.nombre,
        c1_0.categoria_padre_id 
    from
        categorias c1_0 
    where
        c1_0.categoria_id=?
Hibernate: 
    select ... (igual, otra categoría)
Hibernate: 
    select ... (igual, otra categoría)
  Ratón inalámbrico (Ratones y teclados)
  Teclado mecánico (Ratones y teclados)
  Pack teclado y ratón (Ratones y teclados)
  Auriculares Bluetooth (Audio)
  Altavoz Inteligente (Audio)
  Webcam HD (Periféricos)
  Disco SSD 1TB (Informática)
  • Con find, EAGER se resuelve con un JOIN: aceptable.
  • Con JPQL, Hibernate ejecuta la consulta tal cual (sin JOIN) y luego, antes de devolver la lista, lanza una consulta por cada categoría que no tenga ya en el contexto: tres consultas extra (Audio, Periféricos e Informática; “Ratones y teclados” ya estaba cargada por el find anterior). Y lo hace aunque no vayas a usar las categorías. Ese es el gran problema de EAGER: no se puede desactivar en una consulta concreta.

5.2 LazyInitializationException

La carga perezosa tiene una condición: el proxy necesita un EntityManager abierto para lanzar su SELECT. Si intentas usarlo después de cerrarlo, obtienes la excepción más famosa de Hibernate.

Línea de tiempo: el proxy se inicializa con el EntityManager abierto y falla con él cerrado

Figura 3. Carga perezosa con proxies y causa de la LazyInitializationException.

public class Ejemplo04LazyInit {

    static Empleado buscar(int id) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            return em.find(Empleado.class, id);
        }   // el EntityManager se cierra: el empleado queda DESCONECTADO
    }

    public static void main(String[] args) {
        Empleado e = buscar(122);
        System.out.println("Empleado: " + e.getNombreCompleto());
        System.out.println("¿Departamento inicializado? " + Hibernate.isInitialized(e.getDepartamento()));
        try {
            System.out.println(e.getDepartamento().getNombre());
        } catch (RuntimeException ex) {
            System.out.println(ex.getClass().getName());
            System.out.println(ex.getMessage());
        }

        // Solución 1: cargar lo necesario con JOIN FETCH (módulo 16)
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            Empleado conDep = em.createQuery("""
                    select e from Empleado e
                    join fetch e.departamento
                    where e.id = :id""", Empleado.class)
                    .setParameter("id", 122)
                    .getSingleResult();
            em.close();
            System.out.println("JOIN FETCH -> " + conDep.getDepartamento().getNombre());
        }

        // Solución 2: inicializar explícitamente antes de cerrar
        Departamento dep;
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            dep = em.find(Departamento.class, 40);
            Hibernate.initialize(dep.getEmpleados());
        }
        System.out.println("Hibernate.initialize -> " + dep.getEmpleados().size() + " empleados en "
                + dep.getNombre());
        JpaUtil.cerrar();
    }
}

Salida real (las columnas de los select se han abreviado):

Hibernate: 
    select
        e1_0.emp_id,
        ...
    from
        empleados e1_0 
    where
        e1_0.emp_id=?
Empleado: Iván Castro Méndez
¿Departamento inicializado? false
org.hibernate.LazyInitializationException
Could not initialize proxy [es.curso.empresa.modelo.Departamento#30] - no session
Hibernate: 
    select
        e1_0.emp_id,
        e1_0.apellidos,
        ...
        e1_0.telefono 
    from
        empleados e1_0 
    join
        departamentos d1_0 
            on d1_0.dept_id=e1_0.dept_id 
    where
        e1_0.emp_id=?
JOIN FETCH -> INFORMÁTICA
Hibernate: 
    select ... from departamentos d1_0 where d1_0.dept_id=?
Hibernate: 
    select ... from empleados e1_0 where e1_0.dept_id=? order by e1_0.apellidos
Hibernate.initialize -> 3 empleados en CONTABILIDAD

El mensaje lo dice todo: no session. Con JOIN FETCH, la misma consulta trae empleado y departamento a la vez, y después de cerrar el EntityManager el dato ya está en memoria.

⚠️ Importante: La “solución” de poner EAGER para que no salte la excepción es la peor de todas: cargarás el departamento en todas las consultas de empleados, lo necesites o no. Tampoco uses la propiedad hibernate.enable_lazy_load_no_trans: abre una conexión nueva por cada proxy y esconde un problema de diseño.


6. Cascadas y orphanRemoval

📘 Concepto: Una cascada propaga una operación del EntityManager (persist, merge, remove…) desde una entidad a las entidades asociadas. Se declara en la relación con cascade = ....

CascadeType Propaga Ejemplo de uso
PERSIST persist() Guardar un pedido nuevo guarda sus líneas nuevas
MERGE merge() Fusionar un pedido desconectado fusiona sus líneas
REMOVE remove() Borrar un pedido borra sus líneas
REFRESH refresh() Releer el pedido relee sus líneas
DETACH detach() Desconectar el pedido desconecta sus líneas
ALL Todas las anteriores Composición fuerte: la parte no existe sin el todo

📘 Concepto: orphanRemoval = true borra de la base de datos una entidad hija cuando se quita de la colección del padre (se queda “huérfana”). CascadeType.REMOVE actúa cuando se borra el padre; orphanRemoval, cuando se rompe la relación.

⚠️ Importante: Usa cascadas solo en relaciones de composición (el padre es el dueño del ciclo de vida del hijo): Pedido → LineaPedido, Empleado → Asignacion. Nunca pongas CascadeType.REMOVE en un @ManyToOne (borrar un empleado borraría su departamento… y con él a todos sus compañeros) ni en un @ManyToMany.

6.1 Pedido y LineaPedido con @MapsId

Recuerda que la clave de lineas_pedido es (pedido_id, num_linea) y que pedido_id es también la clave ajena hacia pedidos. @MapsId le dice a Hibernate que la columna de la relación y la de la clave son la misma, y que debe copiar el id del pedido en la clave de la línea:

@Embeddable
public class LineaPedidoId implements Serializable {

    @Column(name = "pedido_id")
    private Integer pedidoId;       // lo rellena @MapsId a partir del pedido

    @Column(name = "num_linea")
    private Integer numLinea;

    protected LineaPedidoId() { }

    public LineaPedidoId(Integer pedidoId, Integer numLinea) {
        this.pedidoId = pedidoId;
        this.numLinea = numLinea;
    }

    public Integer getPedidoId() { return pedidoId; }
    public Integer getNumLinea() { return numLinea; }

    @Override
    public boolean equals(Object o) {
        return o instanceof LineaPedidoId otro
                && Objects.equals(pedidoId, otro.pedidoId)
                && Objects.equals(numLinea, otro.numLinea);
    }

    @Override
    public int hashCode() { return Objects.hash(pedidoId, numLinea); }
}
@Entity
@Table(name = "lineas_pedido")
public class LineaPedido {

    @EmbeddedId
    private LineaPedidoId id;

    // La parte "pedidoId" de la clave se toma del pedido asociado
    @MapsId("pedidoId")
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "pedido_id")
    private Pedido pedido;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "producto_id", nullable = false)
    private Producto producto;

    @Column(nullable = false)
    private int cantidad;

    @Column(name = "precio_unitario", nullable = false, precision = 8, scale = 2)
    private BigDecimal precioUnitario;

    @Column(nullable = false, precision = 4, scale = 2)
    private BigDecimal descuento;

    protected LineaPedido() { }

    LineaPedido(Pedido pedido, int numLinea, Producto producto, int cantidad,
                BigDecimal precioUnitario, BigDecimal descuento) {
        this.id = new LineaPedidoId(null, numLinea);   // pedidoId lo pone @MapsId
        this.pedido = pedido;
        this.producto = producto;
        this.cantidad = cantidad;
        this.precioUnitario = precioUnitario;
        this.descuento = descuento;
    }

    public BigDecimal getImporte() {
        BigDecimal bruto = precioUnitario.multiply(BigDecimal.valueOf(cantidad));
        return bruto.multiply(BigDecimal.ONE.subtract(descuento.movePointLeft(2)))
                    .setScale(2, RoundingMode.HALF_UP);
    }
    // getters y toString
}

💡 Consejo: En el módulo 14 la clave era un record. Aquí la escribimos como clase normal porque, con @MapsId, Hibernate necesita rellenar pedidoId después de insertar el pedido (cuando IDENTITY le da el id), y los atributos de un record son final.

Y en Pedido, la colección con cascada y orphanRemoval, más los métodos que sincronizan los dos lados y numeran las líneas:

@Entity
@Table(name = "pedidos")
public class Pedido {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "pedido_id")
    private Integer id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "cliente_id", nullable = false)
    private Cliente cliente;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "empleado_id")
    private Empleado empleado;

    @Column(name = "fecha_pedido", nullable = false)
    private LocalDate fechaPedido;

    @Column(name = "fecha_envio")
    private LocalDate fechaEnvio;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 12)
    private EstadoPedido estado;

    // Las líneas "pertenecen" al pedido: se guardan y borran con él
    @OneToMany(mappedBy = "pedido", cascade = CascadeType.ALL, orphanRemoval = true)
    @OrderBy("id.numLinea")
    private List<LineaPedido> lineas = new ArrayList<>();

    protected Pedido() { }

    public Pedido(Cliente cliente, Empleado empleado, LocalDate fecha) {
        this.cliente = cliente;
        this.empleado = empleado;
        this.fechaPedido = fecha;
        this.estado = EstadoPedido.PENDIENTE;
    }

    /** Añade una línea numerándola automáticamente y sincroniza los dos lados. */
    public LineaPedido addLinea(Producto producto, int cantidad, BigDecimal descuento) {
        int num = lineas.size() + 1;
        LineaPedido linea = new LineaPedido(this, num, producto, cantidad, producto.getPrecio(), descuento);
        lineas.add(linea);
        return linea;
    }

    public void removeLinea(LineaPedido linea) {
        lineas.remove(linea);
        linea.setPedido(null);
    }

    public BigDecimal getTotal() {
        return lineas.stream().map(LineaPedido::getImporte).reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    // getters...
}

6.2 Cascadas en acción

public class Ejemplo06Cascada {

    public static void main(String[] args) {
        Integer pedidoId;
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            em.getTransaction().begin();
            // getReference: un proxy sin SELECT (solo necesitamos la clave ajena)
            Cliente cliente = em.getReference(Cliente.class, 1004);
            Empleado vendedor = em.getReference(Empleado.class, 111);
            Producto raton = em.find(Producto.class, 6);
            Producto monitor = em.find(Producto.class, 9);

            Pedido pedido = new Pedido(cliente, vendedor, LocalDate.of(2026, 10, 4));
            pedido.addLinea(raton, 10, BigDecimal.ZERO);
            pedido.addLinea(monitor, 2, new BigDecimal("5"));
            System.out.println("--- persist(pedido): CascadeType.PERSIST guarda también las líneas ---");
            em.persist(pedido);
            em.getTransaction().commit();
            pedidoId = pedido.getId();
            System.out.println("Pedido " + pedidoId + " con total " + pedido.getTotal());

            System.out.println("--- Quitamos la línea 1 de la lista: orphanRemoval la borra ---");
            em.getTransaction().begin();
            pedido.removeLinea(pedido.getLineas().get(0));
            em.getTransaction().commit();

            System.out.println("--- remove(pedido): CascadeType.REMOVE borra sus líneas ---");
            em.getTransaction().begin();
            em.remove(pedido);
            em.getTransaction().commit();
        }
        JpaUtil.cerrar();
    }
}

Salida real (sin los dos select de productos del principio):

--- persist(pedido): CascadeType.PERSIST guarda también las líneas ---
Hibernate: 
    insert 
    into
        pedidos
        (cliente_id, empleado_id, estado, fecha_envio, fecha_pedido) 
    values
        (?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        lineas_pedido
        (cantidad, descuento, precio_unitario, producto_id, num_linea, pedido_id) 
    values
        (?, ?, ?, ?, ?, ?)
Hibernate: insert into lineas_pedido (cantidad, descuento, precio_unitario, producto_id, num_linea, pedido_id) values (?, ?, ?, ?, ?, ?)
Pedido 5025 con total 463.10
--- Quitamos la línea 1 de la lista: orphanRemoval la borra ---
Hibernate: 
    delete 
    from
        lineas_pedido 
    where
        num_linea=? 
        and pedido_id=?
--- remove(pedido): CascadeType.REMOVE borra sus líneas ---
Hibernate: delete from lineas_pedido where num_linea=? and pedido_id=?
Hibernate: 
    delete 
    from
        pedidos 
    where
        pedido_id=?
  • Un solo persist(pedido) ha generado tres INSERT. El de pedidos va primero; en cuanto MySQL devuelve el id (5025), @MapsId lo copia en la clave de cada línea.
  • getReference no ha generado ningún SELECT de cliente ni de empleado: para el INSERT solo hacen falta sus ids.
  • Quitar una línea de la lista basta para borrarla (orphanRemoval).
  • Al borrar el pedido, Hibernate borra primero la línea restante y después el pedido, en el orden que exigen las claves ajenas.

7. @OneToOne: empleado y credencial

7.1 Tablas de laboratorio

Ejecuta este script en tu base de datos empresa (lo usaremos en las secciones 7 y 8):

-- Tablas de laboratorio del módulo 15 (no forman parte de EMPRESA)
CREATE TABLE credenciales (
    emp_id         INT PRIMARY KEY,
    usuario        VARCHAR(30) NOT NULL,
    ultimo_acceso  DATETIME(6),
    CONSTRAINT uq_cre_usuario UNIQUE (usuario),
    CONSTRAINT fk_cre_emp FOREIGN KEY (emp_id) REFERENCES empleados (emp_id)
);

CREATE TABLE habilidades (
    habilidad_id  INT AUTO_INCREMENT PRIMARY KEY,
    nombre        VARCHAR(40) NOT NULL,
    CONSTRAINT uq_hab_nombre UNIQUE (nombre)
);

CREATE TABLE empleado_habilidad (
    emp_id        INT,
    habilidad_id  INT,
    CONSTRAINT pk_emp_hab PRIMARY KEY (emp_id, habilidad_id),
    CONSTRAINT fk_eh_emp FOREIGN KEY (emp_id) REFERENCES empleados (emp_id),
    CONSTRAINT fk_eh_hab FOREIGN KEY (habilidad_id) REFERENCES habilidades (habilidad_id)
);

INSERT INTO habilidades (nombre) VALUES ('Java'), ('SQL'), ('Docker'), ('Inglés');
INSERT INTO empleado_habilidad VALUES (121, 1), (121, 2), (122, 1), (122, 3), (120, 2), (120, 4);
INSERT INTO credenciales VALUES (120, 'pmoreno', '2026-10-01 08:15:00'), (121, 'rmolina', NULL);

Al terminar el módulo, bórralas:

DROP TABLE empleado_habilidad;
DROP TABLE habilidades;
DROP TABLE credenciales;

7.2 Clave primaria compartida con @MapsId

Cada empleado puede tener como mucho una credencial para entrar en la intranet. La forma más sólida de garantizarlo es que la clave primaria de credenciales sea el emp_id:

Credencial comparte la clave primaria con Empleado mediante @MapsId

Figura 4. Relación 1:1 con clave primaria compartida.

@Entity
@Table(name = "credenciales")
public class Credencial {

    @Id
    @Column(name = "emp_id")
    private Integer id;                 // mismo valor que el id del empleado

    @MapsId                             // la PK se copia de la relación
    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "emp_id")
    private Empleado empleado;

    @Column(nullable = false, length = 30, unique = true)
    private String usuario;

    @Column(name = "ultimo_acceso")
    private LocalDateTime ultimoAcceso;

    protected Credencial() { }

    public Credencial(Empleado empleado, String usuario) {
        this.empleado = empleado;
        this.usuario = usuario;
    }
    // getters y setters
}
public class Ejemplo09OneToOne {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            Credencial c = em.find(Credencial.class, 120);
            System.out.println(c.getUsuario() + " -> " + c.getEmpleado().getNombreCompleto());

            System.out.println("--- Credencial nueva para el empleado 122 ---");
            em.getTransaction().begin();
            Empleado ivan = em.getReference(Empleado.class, 122);
            Credencial nueva = new Credencial(ivan, "icastro");
            nueva.setUltimoAcceso(LocalDateTime.of(2026, 10, 4, 9, 0));
            em.persist(nueva);
            em.getTransaction().commit();
            System.out.println("Id de la credencial = id del empleado: " + nueva.getId());

            em.getTransaction().begin();
            em.remove(nueva);
            em.getTransaction().commit();
        }
        JpaUtil.cerrar();
    }
}

Salida real:

Hibernate: 
    select
        c1_0.emp_id,
        c1_0.ultimo_acceso,
        c1_0.usuario 
    from
        credenciales c1_0 
    where
        c1_0.emp_id=?
Hibernate: 
    select
        e1_0.emp_id,
        ...
    from
        empleados e1_0 
    where
        e1_0.emp_id=?
pmoreno -> Pablo Moreno Herrera
--- Credencial nueva para el empleado 122 ---
Hibernate: 
    insert 
    into
        credenciales
        (ultimo_acceso, usuario, emp_id) 
    values
        (?, ?, ?)
Id de la credencial = id del empleado: 122
Hibernate: 
    delete 
    from
        credenciales 
    where
        emp_id=?

No hemos asignado el id de la credencial en ningún momento: @MapsId lo ha tomado del empleado.

⚠️ Importante: Hemos hecho la relación unidireccional a propósito. Si añades en Empleado el lado inverso (@OneToOne(mappedBy = "empleado") Credencial credencial), Hibernate no puede saber sin consultar si un empleado tiene o no credencial (no hay ninguna columna en empleados que lo indique), así que no puede usar un proxy y cargará la credencial con una consulta extra cada vez que cargue un empleado, aunque pongas LAZY. Si necesitas ir de empleado a credencial, usa em.find(Credencial.class, empleado.getId()).


8. @ManyToMany: empleados y habilidades

Relación N:M simple con tabla de unión y N:M con atributos con entidad intermedia

Figura 5. Las dos formas de mapear una relación N:M.

// En Empleado (lado propietario: define la tabla de unión)
@ManyToMany
@JoinTable(name = "empleado_habilidad",
        joinColumns = @JoinColumn(name = "emp_id"),
        inverseJoinColumns = @JoinColumn(name = "habilidad_id"))
private Set<Habilidad> habilidades = new HashSet<>();

public void addHabilidad(Habilidad h) {
    habilidades.add(h);
    h.getEmpleados().add(this);
}

public void removeHabilidad(Habilidad h) {
    habilidades.remove(h);
    h.getEmpleados().remove(this);
}
@Entity
@Table(name = "habilidades")
public class Habilidad {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "habilidad_id")
    private Integer id;

    @Column(nullable = false, length = 40, unique = true)
    private String nombre;

    @ManyToMany(mappedBy = "habilidades")      // lado inverso
    private Set<Empleado> empleados = new HashSet<>();

    protected Habilidad() { }

    public Habilidad(String nombre) { this.nombre = nombre; }

    // Clave natural: el nombre es único y no cambia
    @Override
    public boolean equals(Object o) {
        return o instanceof Habilidad h && Objects.equals(nombre, h.nombre);
    }

    @Override
    public int hashCode() { return Objects.hashCode(nombre); }

    @Override
    public String toString() { return nombre; }
    // getters...
}
public class Ejemplo10ManyToMany {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            Empleado raquel = em.find(Empleado.class, 121);
            System.out.println("--- Habilidades de Raquel ---");
            System.out.println(raquel.getHabilidades());

            Habilidad docker = em.createQuery(
                    "select h from Habilidad h where h.nombre = 'Docker'", Habilidad.class)
                    .getSingleResult();

            System.out.println("--- Añadimos Docker (lado propietario: Empleado) ---");
            em.getTransaction().begin();
            raquel.addHabilidad(docker);
            em.getTransaction().commit();

            System.out.println("--- Quitamos Docker ---");
            em.getTransaction().begin();
            raquel.removeHabilidad(docker);
            em.getTransaction().commit();
        }
        JpaUtil.cerrar();
    }
}

Salida real (sin el primer select del empleado):

--- Habilidades de Raquel ---
Hibernate: 
    select
        h1_0.emp_id,
        h1_1.habilidad_id,
        h1_1.nombre 
    from
        empleado_habilidad h1_0 
    join
        habilidades h1_1 
            on h1_1.habilidad_id=h1_0.habilidad_id 
    where
        h1_0.emp_id=?
[Java, SQL]
Hibernate: 
    select
        h1_0.habilidad_id,
        h1_0.nombre 
    from
        habilidades h1_0 
    where
        h1_0.nombre='Docker'
--- Añadimos Docker (lado propietario: Empleado) ---
Hibernate: 
    select
        e1_0.habilidad_id,
        e1_1.emp_id,
        ...
    from
        empleado_habilidad e1_0 
    join
        empleados e1_1 
            on e1_1.emp_id=e1_0.emp_id 
    where
        e1_0.habilidad_id=?
Hibernate: 
    insert 
    into
        empleado_habilidad
        (emp_id, habilidad_id) 
    values
        (?, ?)
--- Quitamos Docker ---
Hibernate: 
    delete 
    from
        empleado_habilidad 
    where
        emp_id=? 
        and habilidad_id=?

Observa que la carga de la colección hace un JOIN entre la tabla de unión y habilidades, y que añadir o quitar un elemento del Set se traduce en un único INSERT o DELETE en empleado_habilidad. El select intermedio lo provoca h.getEmpleados().add(this) en el método de sincronización: para añadir al lado inverso, Hibernate tiene que cargar esa colección.

⚠️ Importante: Usa Set (no List) en las colecciones @ManyToMany. Con una List sin columna de orden (bag), al quitar un elemento Hibernate borra todas las filas de la tabla de unión de ese empleado y vuelve a insertar las que quedan. Con un Set borra solo la fila afectada, como acabas de ver.

💡 Consejo: En cuanto la relación necesite un dato más (desde cuándo tiene la habilidad, con qué nivel…), abandona @ManyToMany y crea una entidad intermedia. Es lo que hace EMPRESA con las asignaciones a proyectos.


9. N:M con atributos: la entidad Asignacion

La tabla asignaciones relaciona empleados y proyectos, pero además guarda la fecha, las horas y la función. Eso la convierte en una entidad con dos relaciones @ManyToOne cuya clave primaria está formada por las dos claves ajenas.

@Embeddable
public class AsignacionId implements Serializable {

    @Column(name = "emp_id")
    private Integer empId;

    @Column(name = "proyecto_id")
    private Integer proyectoId;

    protected AsignacionId() { }

    public AsignacionId(Integer empId, Integer proyectoId) {
        this.empId = empId;
        this.proyectoId = proyectoId;
    }
    // getters, equals y hashCode con los dos atributos
}
/** Entidad intermedia de la relación N:M Empleado-Proyecto, con atributos propios. */
@Entity
@Table(name = "asignaciones")
public class Asignacion {

    @EmbeddedId
    private AsignacionId id;

    @MapsId("empId")                       // empId de la clave = id del empleado
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "emp_id")
    private Empleado empleado;

    @MapsId("proyectoId")                  // proyectoId de la clave = id del proyecto
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "proyecto_id")
    private Proyecto proyecto;

    @Column(name = "fecha_asignacion", nullable = false)
    private LocalDate fechaAsignacion;

    private Integer horas;

    @Column(length = 30)
    private String funcion;

    protected Asignacion() { }

    public Asignacion(Empleado empleado, Proyecto proyecto, String funcion, LocalDate fecha) {
        this.id = new AsignacionId(empleado.getId(), proyecto.getId());
        this.empleado = empleado;
        this.proyecto = proyecto;
        this.funcion = funcion;
        this.fechaAsignacion = fecha;
        this.horas = 0;
    }

    // Igualdad por clave: los dos ids identifican la asignación
    @Override
    public boolean equals(Object o) {
        return o instanceof Asignacion otra && id != null && id.equals(otra.id);
    }

    @Override
    public int hashCode() { return Objects.hashCode(id); }
    // getters...
}

Proyecto tiene la colección inversa @OneToMany(mappedBy = "proyecto", cascade = CascadeType.ALL, orphanRemoval = true) Set<Asignacion> asignaciones, igual que Empleado.

public class Ejemplo07Asignaciones {

    public static void main(String[] args) {
        try (EntityManager em = JpaUtil.crearEntityManager()) {
            Empleado pablo = em.find(Empleado.class, 120);
            System.out.println("--- Proyectos de " + pablo.getNombreCompleto() + " ---");
            for (Asignacion a : pablo.getAsignaciones()) {
                System.out.println("  " + a.getProyecto().getNombre() + ": " + a.getFuncion()
                        + ", " + a.getHoras() + " h");
            }

            System.out.println("--- Asignamos a Hugo (124) al proyecto 2 ---");
            em.getTransaction().begin();
            Empleado hugo = em.find(Empleado.class, 124);
            Proyecto tienda = em.find(Proyecto.class, 2);
            Asignacion nueva = new Asignacion(hugo, tienda, "Administrador de sistemas",
                    LocalDate.of(2026, 10, 4));
            hugo.getAsignaciones().add(nueva);           // sincronizamos los dos lados
            tienda.getAsignaciones().add(nueva);
            em.getTransaction().commit();                // cascade=ALL en Empleado.asignaciones

            System.out.println("--- La quitamos (orphanRemoval) ---");
            em.getTransaction().begin();
            hugo.getAsignaciones().remove(nueva);
            tienda.getAsignaciones().remove(nueva);
            em.getTransaction().commit();
        }
        JpaUtil.cerrar();
    }
}

Salida real (columnas abreviadas):

Hibernate: 
    select ... from empleados e1_0 where e1_0.emp_id=?
--- Proyectos de Pablo Moreno Herrera ---
Hibernate: 
    select
        a1_0.emp_id,
        a1_0.proyecto_id,
        ...
        a1_0.horas 
    from
        asignaciones a1_0 
    where
        a1_0.emp_id=?
Hibernate: 
    select ... from proyectos p1_0 where p1_0.proyecto_id=?
  Migración a la nube: Jefe de proyecto, 450 h
Hibernate: 
    select ... from proyectos p1_0 where p1_0.proyecto_id=?
  Nueva tienda online: Jefe de proyecto, 300 h
--- Asignamos a Hugo (124) al proyecto 2 ---
Hibernate: 
    select ... from empleados e1_0 where e1_0.emp_id=?
Hibernate: 
    select ... from asignaciones a1_0 where a1_0.emp_id=?
Hibernate: 
    select ... from asignaciones a1_0 where a1_0.proyecto_id=?
Hibernate: 
    insert 
    into
        asignaciones
        (fecha_asignacion, funcion, horas, emp_id, proyecto_id) 
    values
        (?, ?, ?, ?, ?)
--- La quitamos (orphanRemoval) ---
Hibernate: 
    delete 
    from
        asignaciones 
    where
        emp_id=? 
        and proyecto_id=?

El proyecto 2 no generó SELECT al hacer find: ya estaba en el contexto de persistencia porque lo habíamos cargado al recorrer las asignaciones de Pablo.


10. Autorrelaciones: jefes y categorías

Una autorrelación es una asociación de una entidad consigo misma. En EMPRESA hay dos: empleados.jefe_id → empleados.emp_id y categorias.categoria_padre_id → categorias.categoria_id. Se mapean exactamente igual que cualquier otra relación; simplemente, los dos extremos son la misma clase.

Empleado con atributo jefe y lista de subordinados; tabla con jefe_id; árbol de objetos

Figura 6. Autorrelación: la tabla tiene una clave ajena hacia sí misma y en memoria se forma un árbol.

@Entity
@Table(name = "categorias")
public class Categoria {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "categoria_id")
    private Integer id;

    @Column(nullable = false, length = 40, unique = true)
    private String nombre;

    // Autorrelación: la categoría padre está en la misma tabla
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "categoria_padre_id")
    private Categoria padre;

    @OneToMany(mappedBy = "padre")
    @OrderBy("nombre")
    private List<Categoria> hijas = new ArrayList<>();

    @OneToMany(mappedBy = "categoria")
    @OrderBy("nombre")
    private List<Producto> productos = new ArrayList<>();

    protected Categoria() { }
    // getters...
}

💻 Ejemplo: Recorrido recursivo del árbol de categorías a partir de las raíces (las que no tienen padre). Esta vez desactivamos el log de SQL pasando una propiedad al crear la fábrica, para que se vea solo el árbol:

public class Ejemplo08Arbol {

    static void imprimir(Categoria c, int nivel) {
        System.out.println("  ".repeat(nivel) + "- " + c.getNombre());
        for (Categoria hija : c.getHijas()) {
            imprimir(hija, nivel + 1);              // recursión por la autorrelación
        }
    }

    public static void main(String[] args) {
        // Sin el log de SQL para que se vea solo el árbol
        try (var emf = Persistence.createEntityManagerFactory("empresa",
                Map.of("hibernate.show_sql", "false"));
             EntityManager em = emf.createEntityManager()) {
            List<Categoria> raices = em.createQuery(
                    "select c from Categoria c where c.padre is null order by c.nombre", Categoria.class)
                    .getResultList();
            raices.forEach(r -> imprimir(r, 0));
        }
    }
}

Salida real:

- Electrónica
  - Audio
  - Telefonía
- Informática
  - Ordenadores sobremesa
  - Periféricos
    - Monitores
    - Ratones y teclados
  - Portátiles
- Oficina
  - Mobiliario

⚠️ Importante: Este recorrido lanza una consulta por cada categoría para cargar sus hijas (12 en total). Con 11 categorías no importa; con un árbol de miles de nodos, sí. Para árboles grandes, carga todas las categorías con una sola consulta y construye el árbol en memoria, o usa una consulta recursiva WITH RECURSIVE con SQL nativo (módulo 16).


11. Colecciones: List, Set y @OrderBy

Tipo Clase que usa Hibernate Semántica Cuándo usarlo
List sin índice PersistentBag Admite duplicados, orden no garantizado @OneToMany con mappedBy cuando quieres un orden con @OrderBy
List + @OrderColumn PersistentList Guarda la posición en una columna Raro: cuando la posición es un dato (EMPRESA no la tiene)
Set PersistentSet Sin duplicados (usa equals/hashCode) @ManyToMany y colecciones de entidades con buena igualdad
SortedSet + @SortNatural PersistentSortedSet Ordenado en memoria Pocas veces
Map + @MapKey PersistentMap Acceso por clave Por ejemplo, asignaciones indexadas por proyecto
Anotación Dónde ordena Ejemplo
@OrderBy("apellidos") En la base de datos (ORDER BY) al cargar la colección Departamento.empleados
@OrderBy("id.numLinea") Puede usar atributos de un @EmbeddedId Pedido.lineas
@OrderColumn(name = "posicion") Guarda y lee una columna de posición —

⚠️ Importante: No reasignes nunca la colección de una entidad gestionada (pedido.setLineas(new ArrayList<>())). Hibernate perdería el rastro de la colección original y, con orphanRemoval, lanzaría la excepción A collection with cascade=”all-delete-orphan” was no longer referenced. Modifica la colección existente (clear(), add(), remove()).


12. Herencia

Supón que EMPRESA quiere registrar cobros de los pedidos, que pueden ser con tarjeta (últimos dígitos y titular) o por transferencia (IBAN). En Java es natural una clase abstracta Pago con dos subclases. Pero las tablas no tienen herencia: hay que elegir cómo se guarda la jerarquía. JPA ofrece tres estrategias de @Inheritance y una cuarta opción, @MappedSuperclass.

💡 Consejo: Las tablas de esta sección (pagos, pagos_tarjeta, pagos_transferencia, pagos_seq, avisos, tareas) no existen en EMPRESA: cada estrategia usa su propia unidad de persistencia con hibernate.hbm2ddl.auto = create-drop, así que Hibernate las crea al arrancar y las borra al cerrar. Es la mejor forma de ver el DDL que genera cada estrategia.

El programa de prueba es el mismo para las tres estrategias: guarda un pago de cada tipo, hace una consulta polimórfica (select p from Pago p, que devuelve objetos de las dos subclases) y una consulta de una sola subclase.

public class Ejemplo11Herencia {

    public static void main(String[] args) {
        String estrategia = args.length > 0 ? args[0] : "single";
        LocalDate hoy = LocalDate.of(2026, 10, 4);
        System.out.println("##### Estrategia: " + estrategia);

        try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("herencia-" + estrategia)) {
            emf.runInTransaction(em -> {
                // (se crea un PagoTarjeta y un PagoTransferencia del paquete de la estrategia)
                em.persist(new PagoTarjeta(5022, new BigDecimal("1499.00"), hoy, "4321", "Diseños Creativos S.L."));
                em.persist(new PagoTransferencia(5024, new BigDecimal("1497.00"), hoy, "ES9121000418450200051332"));
            });

            System.out.println("--- Consulta polimórfica: select p from Pago p ---");
            emf.runInTransaction(em -> {
                List<Pago> pagos = em.createQuery("select p from Pago p order by p.importe desc", Pago.class)
                        .getResultList();
                for (Pago p : pagos) {
                    System.out.println("  " + p.getClass().getSimpleName() + " -> " + p.descripcion());
                }
            });

            System.out.println("--- Consulta de una subclase: select t from PagoTarjeta t ---");
            emf.runInTransaction(em ->
                    System.out.println("  Pagos con tarjeta: " + em.createQuery(
                            "select t from PagoTarjeta t", PagoTarjeta.class).getResultList().size()));
        }
    }
}

📘 Concepto: El ejemplo real del proyecto tiene las tres jerarquías en paquetes distintos (herencia.single, herencia.joined, herencia.tpc) y elige la clase con un switch; arriba se muestra simplificado. Las subclases son idénticas en las tres estrategias; solo cambian las anotaciones de la superclase y, en JOINED y TABLE_PER_CLASS, el nombre de tabla de cada subclase.

12.1 SINGLE_TABLE: una tabla para toda la jerarquía

@Entity
@Table(name = "pagos")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)       // toda la jerarquía en UNA tabla
@DiscriminatorColumn(name = "tipo", length = 15)            // columna que dice qué subclase es
public abstract class Pago {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "pedido_id", nullable = false)
    private Integer pedidoId;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal importe;

    @Column(nullable = false)
    private LocalDate fecha;

    protected Pago() { }

    protected Pago(Integer pedidoId, BigDecimal importe, LocalDate fecha) {
        this.pedidoId = pedidoId;
        this.importe = importe;
        this.fecha = fecha;
    }

    public abstract String descripcion();
    // getters...
}
@Entity
@DiscriminatorValue("TARJETA")
public class PagoTarjeta extends Pago {

    @Column(name = "ultimos_digitos", length = 4)
    private String ultimosDigitos;

    @Column(length = 60)
    private String titular;

    protected PagoTarjeta() { }

    public PagoTarjeta(Integer pedidoId, BigDecimal importe, LocalDate fecha,
                       String ultimosDigitos, String titular) {
        super(pedidoId, importe, fecha);
        this.ultimosDigitos = ultimosDigitos;
        this.titular = titular;
    }

    @Override
    public String descripcion() { return "Tarjeta **** " + ultimosDigitos + " (" + titular + ")"; }
}
@Entity
@DiscriminatorValue("TRANSFERENCIA")
public class PagoTransferencia extends Pago {

    @Column(length = 34)
    private String iban;

    protected PagoTransferencia() { }

    public PagoTransferencia(Integer pedidoId, BigDecimal importe, LocalDate fecha, String iban) {
        super(pedidoId, importe, fecha);
        this.iban = iban;
    }

    @Override
    public String descripcion() { return "Transferencia desde " + iban; }
}

Jerarquía Pago con dos subclases guardada en una única tabla pagos con columna tipo

Figura 7. Estrategia SINGLE_TABLE.

Salida real (sin los drop table con los que create-drop empieza y termina):

##### Estrategia: single
Hibernate: 
    create table pagos (
        fecha date not null,
        importe decimal(10,2) not null,
        pedido_id integer not null,
        ultimos_digitos varchar(4),
        id bigint not null auto_increment,
        tipo varchar(15) not null check ((tipo in ('TARJETA','TRANSFERENCIA'))),
        iban varchar(34),
        titular varchar(60),
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    insert 
    into
        pagos
        (fecha, importe, pedido_id, titular, ultimos_digitos, tipo) 
    values
        (?, ?, ?, ?, ?, 'TARJETA')
Hibernate: 
    insert 
    into
        pagos
        (fecha, importe, pedido_id, iban, tipo) 
    values
        (?, ?, ?, ?, 'TRANSFERENCIA')
--- Consulta polimórfica: select p from Pago p ---
Hibernate: 
    select
        p1_0.id,
        p1_0.tipo,
        p1_0.fecha,
        p1_0.importe,
        p1_0.pedido_id,
        p1_0.titular,
        p1_0.ultimos_digitos,
        p1_0.iban 
    from
        pagos p1_0 
    order by
        p1_0.importe desc
  PagoTarjeta -> Tarjeta **** 4321 (Diseños Creativos S.L.)
  PagoTransferencia -> Transferencia desde ES9121000418450200051332
--- Consulta de una subclase: select t from PagoTarjeta t ---
Hibernate: 
    select
        pt1_0.id,
        pt1_0.fecha,
        pt1_0.importe,
        pt1_0.pedido_id,
        pt1_0.titular,
        pt1_0.ultimos_digitos 
    from
        pagos pt1_0 
    where
        pt1_0.tipo='TARJETA'
  Pagos con tarjeta: 1

El discriminador (tipo) lo rellena Hibernate con un literal en el INSERT y lo usa al leer para saber qué subclase instanciar. Las columnas de las subclases (ultimos_digitos, titular, iban) deben admitir NULL, porque en cada fila solo se usan las de una subclase.

12.2 JOINED: una tabla por clase

@Entity
@Table(name = "pagos")
@Inheritance(strategy = InheritanceType.JOINED)     // una tabla por clase, unidas por la PK
public abstract class Pago {
    // mismos atributos que en SINGLE_TABLE (sin discriminador)
}

@Entity
@Table(name = "pagos_tarjeta")
@PrimaryKeyJoinColumn(name = "pago_id")        // PK que a la vez es FK hacia pagos.id
public class PagoTarjeta extends Pago {

    @Column(name = "ultimos_digitos", nullable = false, length = 4)
    private String ultimosDigitos;

    @Column(nullable = false, length = 60)
    private String titular;
    // constructores y descripcion()
}

@Entity
@Table(name = "pagos_transferencia")
@PrimaryKeyJoinColumn(name = "pago_id")
public class PagoTransferencia extends Pago {

    @Column(nullable = false, length = 34)
    private String iban;
    // constructores y descripcion()
}

Tabla pagos con los atributos comunes y tablas pagos_tarjeta y pagos_transferencia enlazadas por pago_id

Figura 8. Estrategia JOINED.

Salida real (al principio, Hibernate intenta borrar unas claves ajenas de tablas que todavía no existen y muestra dos avisos WARN ... Table 'pagos_tarjeta' doesn't exist con su traza; son inofensivos y se han omitido, igual que los drop table):

##### Estrategia: joined
Hibernate: 
    create table pagos (
        fecha date not null,
        importe decimal(10,2) not null,
        pedido_id integer not null,
        id bigint not null auto_increment,
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    create table pagos_tarjeta (
        ultimos_digitos varchar(4) not null,
        pago_id bigint not null,
        titular varchar(60) not null,
        primary key (pago_id)
    ) engine=InnoDB
Hibernate: 
    create table pagos_transferencia (
        pago_id bigint not null,
        iban varchar(34) not null,
        primary key (pago_id)
    ) engine=InnoDB
Hibernate: 
    alter table pagos_tarjeta 
       add constraint FKcte1k9072j4x2eh2tyho97x72 
       foreign key (pago_id) 
       references pagos (id)
Hibernate: 
    alter table pagos_transferencia 
       add constraint FKr46m2ialb53xawgigr8ue2yul 
       foreign key (pago_id) 
       references pagos (id)
Hibernate: 
    insert 
    into
        pagos
        (fecha, importe, pedido_id) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        pagos_tarjeta
        (titular, ultimos_digitos, pago_id) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        pagos
        (fecha, importe, pedido_id) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        pagos_transferencia
        (iban, pago_id) 
    values
        (?, ?)
--- Consulta polimórfica: select p from Pago p ---
Hibernate: 
    select
        p1_0.id,
        case 
            when p1_1.pago_id is not null 
                then 1 
            when p1_2.pago_id is not null 
                then 2 
        end,
        p1_0.fecha,
        p1_0.importe,
        p1_0.pedido_id,
        p1_1.titular,
        p1_1.ultimos_digitos,
        p1_2.iban 
    from
        pagos p1_0 
    left join
        pagos_tarjeta p1_1 
            on p1_0.id=p1_1.pago_id 
    left join
        pagos_transferencia p1_2 
            on p1_0.id=p1_2.pago_id 
    order by
        p1_0.importe desc
  PagoTarjeta -> Tarjeta **** 4321 (Diseños Creativos S.L.)
  PagoTransferencia -> Transferencia desde ES9121000418450200051332
--- Consulta de una subclase: select t from PagoTarjeta t ---
Hibernate: 
    select
        pt1_0.pago_id,
        pt1_1.fecha,
        pt1_1.importe,
        pt1_1.pedido_id,
        pt1_0.titular,
        pt1_0.ultimos_digitos 
    from
        pagos_tarjeta pt1_0 
    join
        pagos pt1_1 
            on pt1_0.pago_id=pt1_1.id
  Pagos con tarjeta: 1

Cada pago necesita dos INSERT (uno en pagos y otro en la tabla de su subclase) y la consulta polimórfica hace un LEFT JOIN por subclase y un CASE para averiguar el tipo. A cambio, el esquema está normalizado y las columnas de las subclases pueden ser NOT NULL.

12.3 TABLE_PER_CLASS: una tabla completa por clase concreta

@Entity
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)   // una tabla COMPLETA por clase concreta
public abstract class Pago {

    // IDENTITY no sirve aquí: los ids deben ser únicos entre todas las tablas
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "gen_pagos")
    @SequenceGenerator(name = "gen_pagos", sequenceName = "pagos_seq", allocationSize = 1)
    private Long id;
    // resto de atributos comunes
}

@Entity
@Table(name = "pagos_tarjeta")
public class PagoTarjeta extends Pago { /* ultimosDigitos, titular */ }

@Entity
@Table(name = "pagos_transferencia")
public class PagoTransferencia extends Pago { /* iban */ }

Dos tablas independientes con todas las columnas, sin tabla para la clase abstracta

Figura 9. Estrategia TABLE_PER_CLASS.

Salida real (sin los drop table iniciales y finales):

##### Estrategia: tpc
Hibernate: 
    create table pagos_seq (
        next_val bigint
    ) engine=InnoDB
Hibernate: 
    insert into pagos_seq ( next_val ) values ( 1 )
Hibernate: 
    create table pagos_tarjeta (
        fecha date not null,
        importe decimal(10,2) not null,
        pedido_id integer not null,
        ultimos_digitos varchar(4) not null,
        id bigint not null,
        titular varchar(60) not null,
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    create table pagos_transferencia (
        fecha date not null,
        importe decimal(10,2) not null,
        pedido_id integer not null,
        id bigint not null,
        iban varchar(34) not null,
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    select
        next_val as id_val 
    from
        pagos_seq 
    for
        update
Hibernate: 
    update
        pagos_seq 
    set
        next_val= ? 
    where
        next_val=?
   (... lo mismo para el segundo pago ...)
Hibernate: 
    insert 
    into
        pagos_tarjeta
        (fecha, importe, pedido_id, titular, ultimos_digitos, id) 
    values
        (?, ?, ?, ?, ?, ?)
Hibernate: 
    insert 
    into
        pagos_transferencia
        (fecha, importe, pedido_id, iban, id) 
    values
        (?, ?, ?, ?, ?)
--- Consulta polimórfica: select p from Pago p ---
Hibernate: 
    select
        p1_0.id,
        p1_0.clazz_,
        p1_0.fecha,
        p1_0.importe,
        p1_0.pedido_id,
        p1_0.titular,
        p1_0.ultimos_digitos,
        p1_0.iban 
    from
        (select
            fecha,
            importe,
            pedido_id,
            ultimos_digitos,
            id,
            titular,
            null as iban,
            1 as clazz_ 
        from
            pagos_tarjeta 
        union
        all select
            fecha,
            importe,
            pedido_id,
            null as ultimos_digitos,
            id,
            null as titular,
            iban,
            2 as clazz_ 
        from
            pagos_transferencia
    ) p1_0 
order by
    p1_0.importe desc
  PagoTarjeta -> Tarjeta **** 4321 (Diseños Creativos S.L.)
  PagoTransferencia -> Transferencia desde ES9121000418450200051332
--- Consulta de una subclase: select t from PagoTarjeta t ---
Hibernate: 
    select
        pt1_0.id,
        pt1_0.fecha,
        pt1_0.importe,
        pt1_0.pedido_id,
        pt1_0.titular,
        pt1_0.ultimos_digitos 
    from
        pagos_tarjeta pt1_0
  Pagos con tarjeta: 1

MySQL no tiene secuencias, así que Hibernate emula pagos_seq con una tabla de una fila que bloquea (for update) e incrementa. La consulta polimórfica une todas las tablas con UNION ALL, rellenando con null las columnas que no tiene cada una. La consulta de una subclase, en cambio, es la más sencilla de las tres estrategias.

12.4 @MappedSuperclass: herencia de código, no de tablas

A veces no quieres una jerarquía de entidades, sino reutilizar atributos comunes (id, fechas de auditoría, versión…) en varias entidades sin relación entre sí. Para eso está @MappedSuperclass: la superclase no es una entidad, no tiene tabla y no se puede consultar, pero sus atributos y anotaciones se copian en cada subclase.

/** No es una entidad: solo aporta atributos (y su mapeo) a sus subclases. */
@MappedSuperclass
public abstract class EntidadAuditada {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "creado_en", nullable = false, updatable = false)
    private LocalDateTime creadoEn;

    @Column(name = "modificado_en")
    private LocalDateTime modificadoEn;

    @PrePersist                       // callback: justo antes del INSERT
    protected void alCrear() {
        creadoEn = LocalDateTime.of(2026, 10, 4, 10, 0);    // en la práctica: LocalDateTime.now()
    }

    @PreUpdate                        // callback: justo antes de cada UPDATE
    protected void alModificar() {
        modificadoEn = LocalDateTime.of(2026, 10, 4, 12, 30);
    }

    public Long getId() { return id; }
    public LocalDateTime getCreadoEn() { return creadoEn; }
    public LocalDateTime getModificadoEn() { return modificadoEn; }
}

@Entity
@Table(name = "avisos")
public class Aviso extends EntidadAuditada {
    @Column(nullable = false, length = 200)
    private String texto;
    // ...
}

@Entity
@Table(name = "tareas")
public class Tarea extends EntidadAuditada {
    @Column(nullable = false, length = 100)
    private String titulo;
    private boolean hecha;
    // ...
}

EntidadAuditada aporta id y fechas a Aviso y Tarea, que tienen tablas independientes

Figura 10. @MappedSuperclass.

public class Ejemplo12Mapped {

    public static void main(String[] args) {
        try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("herencia-mapped")) {
            Long id = emf.callInTransaction(em -> {
                Aviso a = new Aviso("Inventario anual el día 15");
                em.persist(a);                                   // @PrePersist rellena creadoEn
                em.persist(new Tarea("Revisar stock de monitores"));
                return a.getId();
            });
            emf.runInTransaction(em -> em.find(Aviso.class, id).setTexto("Inventario anual el día 16"));
            emf.runInTransaction(em -> {
                Aviso a = em.find(Aviso.class, id);
                System.out.println(a.getTexto() + " | creado " + a.getCreadoEn()
                        + " | modificado " + a.getModificadoEn());
            });
            // Una @MappedSuperclass NO es una entidad: no se puede consultar
            try {
                emf.runInTransaction(em -> em.createQuery("select e from EntidadAuditada e").getResultList());
            } catch (IllegalArgumentException e) {
                System.out.println("Error: " + e.getMessage());
            }
        }
    }
}

Salida real (sin los drop table iniciales y finales):

Hibernate: 
    create table avisos (
        creado_en datetime(6) not null,
        id bigint not null auto_increment,
        modificado_en datetime(6),
        texto varchar(200) not null,
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    create table tareas (
        hecha bit not null,
        creado_en datetime(6) not null,
        id bigint not null auto_increment,
        modificado_en datetime(6),
        titulo varchar(100) not null,
        primary key (id)
    ) engine=InnoDB
Hibernate: 
    insert 
    into
        avisos
        (creado_en, modificado_en, texto) 
    values
        (?, ?, ?)
Hibernate: 
    insert 
    into
        tareas
        (creado_en, hecha, modificado_en, titulo) 
    values
        (?, ?, ?, ?)
Hibernate: 
    select ... from avisos a1_0 where a1_0.id=?
Hibernate: 
    update
        avisos 
    set
        modificado_en=?,
        texto=? 
    where
        id=?
Hibernate: 
    select ... from avisos a1_0 where a1_0.id=?
Inventario anual el día 16 | creado 2026-10-04T10:00 | modificado 2026-10-04T12:30
Error: org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'EntidadAuditada'

Dos detalles: el UPDATE no incluye creado_en porque la marcamos con updatable = false, y @PreUpdate ha rellenado modificado_en sin que el código de negocio lo pida. Los callbacks del ciclo de vida (@PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove, @PostLoad) son muy útiles para la auditoría.

12.5 ¿Qué estrategia elijo?

Criterio SINGLE_TABLE JOINED TABLE_PER_CLASS @MappedSuperclass
Tablas 1 1 + una por subclase Una por clase concreta Una por entidad
Consulta polimórfica Rápida (sin JOIN) LEFT JOIN por subclase UNION ALL No se puede
Consulta de una subclase WHERE tipo = ... JOIN con la tabla padre Una tabla Una tabla
INSERT 1 2 1 1
NOT NULL en atributos de subclase No Sí Sí Sí
Relaciones hacia la superclase (@ManyToOne Pago) Sí Sí Sí (con UNION) No
Generación de id Cualquiera Cualquiera No IDENTITY Cualquiera
Cuándo usarla Por defecto: pocas subclases y pocos atributos propios Subclases con muchos atributos propios e integridad estricta Casi nunca Atributos comunes sin polimorfismo

💡 Consejo: Empieza por SINGLE_TABLE. Pásate a JOINED si las subclases tienen muchos atributos propios o necesitas restricciones NOT NULL en ellos. TABLE_PER_CLASS rara vez compensa. Y si en realidad no necesitas consultas polimórficas, @MappedSuperclass es lo más simple.


13. equals y hashCode en entidades

Las entidades acaban en Set (colecciones @ManyToMany, @OneToMany con Set) y en el propio contexto de persistencia, así que su igualdad importa. El problema es que el id de una entidad nueva es null hasta el persist y después cambia.

public class Ejemplo13EqualsHashCode {

    /** Entidad de ejemplo (no persistida) con equals/hashCode basados en el id generado. */
    static class Mala {
        Integer id;
        @Override public boolean equals(Object o) { return o instanceof Mala m && Objects.equals(id, m.id); }
        @Override public int hashCode() { return Objects.hashCode(id); }
    }

    /** Patrón recomendado: igualdad por id, pero hashCode constante por clase. */
    static class Buena {
        Integer id;
        @Override public boolean equals(Object o) {
            return o instanceof Buena b && id != null && id.equals(b.id);
        }
        @Override public int hashCode() { return Buena.class.hashCode(); }
    }

    public static void main(String[] args) {
        Set<Mala> malas = new HashSet<>();
        Mala m = new Mala();
        malas.add(m);                 // se guarda con hash(null)
        m.id = 42;                    // persist() asigna el id -> cambia el hashCode
        System.out.println("Mala:  contains = " + malas.contains(m) + ", size = " + malas.size());

        Set<Buena> buenas = new HashSet<>();
        Buena b = new Buena();
        buenas.add(b);
        b.id = 42;
        System.out.println("Buena: contains = " + buenas.contains(b) + ", size = " + buenas.size());

        Buena b1 = new Buena(), b2 = new Buena();      // dos objetos nuevos (sin id)
        System.out.println("Dos nuevas sin id iguales? " + b1.equals(b2));
    }
}

Salida real:

Mala:  contains = false, size = 1
Buena: contains = true, size = 1
Dos nuevas sin id iguales? false

Con la versión “mala”, el objeto queda perdido dentro del Set: está ahí (size = 1), pero contains no lo encuentra porque se guardó en el “cajón” de hash(null) y ahora se busca en el de hash(42).

Estrategia Ventajas Inconvenientes
No sobrescribir (identidad de objeto) Sencilla; válida dentro de un mismo EntityManager Dos copias de la misma fila (de distintos EntityManager) no son iguales
Clave natural inmutable (Habilidad.nombre, NIF, email) La mejor si existe Hace falta una clave natural que no cambie nunca
Id + hashCode constante por clase (Buena) Siempre correcta Todos los objetos de la clase caen en el mismo “cajón” del HashSet (aceptable para colecciones pequeñas)
Id generado en hashCode (Mala) — Incorrecta con ids generados por la base de datos

⚠️ Importante: Nunca uses en equals/hashCode atributos que sean relaciones perezosas (provocarían consultas o LazyInitializationException) ni colecciones. Y no uses un record ni Objects.hash(todosLosAtributos) para una entidad: sus atributos cambian.


14. Buenas prácticas y errores comunes

Error o síntoma Causa Solución
La clave ajena se guarda como NULL Solo se modificó el lado inverso (mappedBy) Modifica el lado propietario; usa métodos add/remove
LazyInitializationException ... no session Acceso a un proxy o colección LAZY con el EntityManager cerrado JOIN FETCH, @EntityGraph, DTO, o acceder antes de cerrar
Muchas consultas inesperadas al listar Relaciones EAGER o acceso LAZY en bucle (N+1) Todo LAZY + JOIN FETCH donde haga falta (módulo 16)
TransientPropertyValueException: ... references an unsaved transient instance Se guarda una entidad que apunta a otra nueva que no se ha persistido persist de la otra antes, o cascada PERSIST si es composición
Borrar un hijo borra datos que no debía CascadeType.REMOVE/ALL en @ManyToOne o @ManyToMany Cascadas solo de padre a hijos en composiciones
A collection with cascade="all-delete-orphan" was no longer referenced Se reemplazó la colección con setXxx(new ...) Modifica la colección existente
@ManyToMany borra y reinserta todas las filas Colección List (bag) Usa Set
Bucle infinito en toString()/JSON Relación bidireccional que se imprime en ambos sentidos Excluye las colecciones y relaciones de toString; usa DTO
Cannot use identity column key generation with <union-subclass> IDENTITY con TABLE_PER_CLASS Usa SEQUENCE o TABLE

💡 Consejo: Resumen de reglas de oro: (1) todas las relaciones LAZY; (2) bidireccional solo si hace falta, con métodos de sincronización; (3) cascadas solo en composiciones; (4) Set para @ManyToMany; (5) N:M con datos = entidad intermedia; (6) SINGLE_TABLE como primera opción de herencia; (7) cuidado con equals/hashCode; (8) mira siempre el SQL.


15. Ejercicios

Los ejercicios usan el modelo completo de este módulo. Para que la salida sea breve, los ejercicios 1 a 5 crean la fábrica sin el log de SQL con esta pequeña clase auxiliar:

/** Fábrica de la unidad "empresa" sin el log de SQL, para que la salida de los ejercicios sea breve. */
final class SinSql {
    private SinSql() { }

    static EntityManagerFactory emf() {
        return Persistence.createEntityManagerFactory("empresa", Map.of("hibernate.show_sql", "false"));
    }
}

Ejercicio 1: Resumen por departamento navegando relaciones

Sin escribir ninguna consulta, solo con find y navegando por las colecciones, muestra para cada departamento (10 a 70) su número de empleados, la suma de sus salarios y su número de proyectos.

Ver solución
public class Ejercicio1 {

    public static void main(String[] args) {
        try (EntityManagerFactory emf = SinSql.emf()) {
            emf.runInTransaction(em -> {
                System.out.printf("%-14s %9s %12s %9s%n", "DEPARTAMENTO", "EMPLEADOS", "SALARIOS", "PROYECTOS");
                for (int id = 10; id <= 70; id += 10) {
                    Departamento d = em.find(Departamento.class, id);
                    BigDecimal masa = d.getEmpleados().stream()
                            .map(Empleado::getSalario)
                            .reduce(BigDecimal.ZERO, BigDecimal::add);
                    System.out.printf("%-14s %9d %12s %9d%n", d.getNombre(),
                            d.getEmpleados().size(), masa, d.getProyectos().size());
                }
            });
        }
    }
}

Salida real:

DEPARTAMENTO   EMPLEADOS     SALARIOS PROYECTOS
DIRECCIÓN              1      6500.00         1
VENTAS                 6     15000.00         0
INFORMÁTICA            6     18800.00         2
CONTABILIDAD           3      8450.00         0
MARKETING              3      9300.00         1
LOGÍSTICA              4      8950.00         1
INVESTIGACIÓN          0            0         0

Funciona, pero cuesta 21 consultas (7 find + 7 colecciones de empleados + 7 de proyectos). Navegar por relaciones es cómodo para una entidad; para un informe, en el módulo 16 harás lo mismo con una única consulta JPQL con GROUP BY. El 0 de INVESTIGACIÓN es BigDecimal.ZERO (la reducción de una colección vacía), por eso no tiene decimales.

Ejercicio 2: Organigrama recursivo

A partir de la directora general (empleado 100), imprime el organigrama completo de la empresa recorriendo subordinados recursivamente, con sangría por nivel y el oficio y departamento de cada persona (“sin departamento” si no tiene). Cuenta el total de personas.

Ver solución
public class Ejercicio2 {

    static int contar(Empleado e, int nivel) {
        String depto = e.getDepartamento() == null ? "sin departamento" : e.getDepartamento().getNombre();
        System.out.println("  ".repeat(nivel) + e.getNombreCompleto() + " - " + e.getOficio() + " (" + depto + ")");
        int total = 1;
        for (Empleado sub : e.getSubordinados()) {
            total += contar(sub, nivel + 1);
        }
        return total;
    }

    public static void main(String[] args) {
        try (EntityManagerFactory emf = SinSql.emf()) {
            emf.runInTransaction(em -> {
                Empleado ana = em.find(Empleado.class, 100);
                int total = contar(ana, 0);
                System.out.println("Personas en el organigrama: " + total);

                Empleado marta = em.find(Empleado.class, 102);
                System.out.println("Subordinados directos de " + marta.getNombre() + ": "
                        + marta.getSubordinados().size());
            });
        }
    }
}

Salida real:

Ana García López - DIRECTOR GENERAL (DIRECCIÓN)
  Luis Martínez Ruiz - DIRECTOR (VENTAS)
    Carmen Ruiz Ortega - VENDEDOR (VENTAS)
    David Torres Romero - VENDEDOR (VENTAS)
    Elena Navarro Castro - VENDEDOR (VENTAS)
    Sergio Jiménez Vidal - VENDEDOR (VENTAS)
    Lucía Romero Serrano - ADMINISTRATIVO (VENTAS)
  Marta Sánchez Gil - DIRECTOR (INFORMÁTICA)
    Pablo Moreno Herrera - ANALISTA (INFORMÁTICA)
      Raquel Molina Prieto - PROGRAMADOR (INFORMÁTICA)
      Iván Castro Méndez - PROGRAMADOR (INFORMÁTICA)
      Sara Ortiz Delgado - PROGRAMADOR (INFORMÁTICA)
    Hugo Rubio Marín - TÉCNICO SISTEMAS (INFORMÁTICA)
    Tomás Reyes Fuentes - BECARIO (sin departamento)
  Pedro Gómez Díaz - DIRECTOR (CONTABILIDAD)
    Isabel Domínguez Vega - CONTABLE (CONTABILIDAD)
    Jorge Vázquez Ramos - ADMINISTRATIVO (CONTABILIDAD)
  Laura Fernández Moreno - DIRECTOR (MARKETING)
    Nuria Gil Soto - DISEÑADOR (MARKETING)
    Álvaro Peña Cano - ANALISTA (MARKETING)
  Javier López Navarro - DIRECTOR (LOGÍSTICA)
    Rosa Iglesias Cruz - OPERARIO (LOGÍSTICA)
    Manuel Herrero Lozano - OPERARIO (LOGÍSTICA)
    Cristina Medina Blanco - ADMINISTRATIVO (LOGÍSTICA)
Personas en el organigrama: 24
Subordinados directos de Marta: 3

La recursión sigue la autorrelación hacia abajo (subordinados, ordenados por id gracias a @OrderBy("id")). Salen los 24 empleados, así que nadie está “suelto” sin jefe salvo Ana. El find de Marta no genera consulta: ya está en el contexto de persistencia. Tomás aparece “sin departamento” porque su departamento es null, y hay que comprobarlo antes de pedir el nombre.

Ejercicio 3: Pedido completo con cascada

Crea para la clienta 1003 (María José Herrera), con el vendedor 111, un pedido de 1 portátil básico (producto 3), 2 ratones (6) y 1 auriculares (13) con un 10 % de descuento en los auriculares, guardando solo el pedido. En otra transacción, muéstralo con sus líneas, el total y el número de pedidos de la clienta. Después bórralo y comprueba con una consulta que no quedan líneas.

Ver solución
public class Ejercicio3 {

    public static void main(String[] args) {
        try (EntityManagerFactory emf = SinSql.emf()) {
            Integer id = emf.callInTransaction(em -> {
                Cliente cliente = em.find(Cliente.class, 1003);
                Empleado vendedor = em.getReference(Empleado.class, 111);
                Pedido p = new Pedido(cliente, vendedor, LocalDate.of(2026, 10, 4));
                p.addLinea(em.find(Producto.class, 3), 1, BigDecimal.ZERO);
                p.addLinea(em.find(Producto.class, 6), 2, BigDecimal.ZERO);
                p.addLinea(em.find(Producto.class, 13), 1, new BigDecimal("10"));
                em.persist(p);                        // las líneas se guardan en cascada
                return p.getId();
            });

            emf.runInTransaction(em -> {
                Pedido p = em.find(Pedido.class, id);
                System.out.println("Pedido " + p.getId() + " de " + p.getCliente().getNombre()
                        + " (vendedor: " + p.getEmpleado().getNombreCompleto() + ")");
                p.getLineas().forEach(l -> System.out.println("  " + l));
                System.out.println("  TOTAL: " + p.getTotal() + " €");
                System.out.println("Pedidos de este cliente: " + p.getCliente().getPedidos().size());
            });

            emf.runInTransaction(em -> em.remove(em.find(Pedido.class, id)));   // y se borran en cascada
            Long lineas = emf.callInTransaction(em -> em.createQuery(
                    "select count(l) from LineaPedido l where l.pedido.id = :id", Long.class)
                    .setParameter("id", id).getSingleResult());
            System.out.println("Líneas que quedan del pedido " + id + ": " + lineas);
        }
    }
}

Salida real:

Pedido 5026 de María José Herrera (vendedor: David Torres Romero)
  Linea 1: 1 x Portátil Básico 15 = 499.00
  Linea 2: 2 x Ratón inalámbrico = 39.80
  Linea 3: 1 x Auriculares Bluetooth = 53.91
  TOTAL: 592.71 €
Pedidos de este cliente: 3
Líneas que quedan del pedido 5026: 0

addLinea toma el precio actual del producto y numera las líneas; un único persist(p) guarda el pedido y sus tres líneas (cascada PERSIST), y un único remove borra todo (cascada REMOVE). La clienta tenía ya dos pedidos (5003 y 5015), por eso ahora tiene tres. El número del pedido depende del AUTO_INCREMENT de tu base de datos.

Ejercicio 4: Árbol de categorías con totales

Imprime el árbol de categorías con, para cada una, el número de productos y el stock total incluyendo todas sus subcategorías. Usa dos métodos recursivos: uno que calcule los totales y otro que imprima.

Ver solución
public class Ejercicio4 {

    /** Devuelve {productos, stock} de la categoría sumando todas sus descendientes. */
    static int[] totales(Categoria c) {
        int productos = c.getProductos().size();
        int stock = c.getProductos().stream().mapToInt(Producto::getStock).sum();
        for (Categoria hija : c.getHijas()) {
            int[] t = totales(hija);
            productos += t[0];
            stock += t[1];
        }
        return new int[] { productos, stock };
    }

    static void imprimir(Categoria c, int nivel) {
        int[] t = totales(c);
        System.out.printf("%-28s %2d productos, stock %4d%n",
                "  ".repeat(nivel) + c.getNombre(), t[0], t[1]);
        c.getHijas().forEach(h -> imprimir(h, nivel + 1));
    }

    public static void main(String[] args) {
        try (EntityManagerFactory emf = SinSql.emf()) {
            emf.runInTransaction(em -> {
                for (int raiz : new int[] { 1, 7, 10 }) {
                    imprimir(em.find(Categoria.class, raiz), 0);
                }
            });
        }
    }
}

Salida real:

Informática                  13 productos, stock  510
  Ordenadores sobremesa       2 productos, stock   19
  Periféricos                 7 productos, stock  346
    Monitores                 2 productos, stock   47
    Ratones y teclados        3 productos, stock  260
  Portátiles                  3 productos, stock   75
Electrónica                   5 productos, stock  152
  Audio                       2 productos, stock   80
  Telefonía                   2 productos, stock   58
Oficina                       2 productos, stock   26
  Mobiliario                  2 productos, stock   26

Fíjate en que los números de un padre no son solo la suma de sus hijas: Informática tiene 13 productos porque al disco SSD (producto 19) se le asignó directamente la categoría 1, y Periféricos incluye la webcam y la impresora, que cuelgan de ella sin subcategoría. totales se recalcula para cada nodo, pero no genera consultas nuevas: las colecciones ya cargadas se reutilizan del contexto de persistencia.

Ejercicio 5: Asignaciones de un departamento

Para cada empleado de INFORMÁTICA (departamento 30), muestra el total de horas asignadas y la lista de sus proyectos (nombre, función, horas y fecha), ordenados por fecha de asignación. Indica “(sin proyectos)” cuando no tenga ninguno.

Ver solución
public class Ejercicio5 {

    public static void main(String[] args) {
        try (EntityManagerFactory emf = SinSql.emf()) {
            emf.runInTransaction(em -> {
                Departamento informatica = em.find(Departamento.class, 30);
                for (Empleado e : informatica.getEmpleados()) {
                    int horas = e.getAsignaciones().stream().mapToInt(Asignacion::getHoras).sum();
                    System.out.println(e.getNombreCompleto() + " - " + horas + " h");
                    if (e.getAsignaciones().isEmpty()) {
                        System.out.println("    (sin proyectos)");
                    }
                    e.getAsignaciones().stream()
                            .sorted(Comparator.comparing(Asignacion::getFechaAsignacion))
                            .forEach(a -> System.out.println("    " + a.getProyecto().getNombre()
                                    + " [" + a.getFuncion() + "] " + a.getHoras() + " h desde "
                                    + a.getFechaAsignacion()));
                }
            });
        }
    }
}

Salida real:

Iván Castro Méndez - 520 h
    Nueva tienda online [Desarrollador] 520 h desde 2025-06-01
Raquel Molina Prieto - 600 h
    Migración a la nube [Desarrollador] 600 h desde 2025-01-15
Pablo Moreno Herrera - 750 h
    Migración a la nube [Jefe de proyecto] 450 h desde 2025-01-07
    Nueva tienda online [Jefe de proyecto] 300 h desde 2025-06-01
Sara Ortiz Delgado - 410 h
    Nueva tienda online [Desarrollador] 410 h desde 2025-07-01
Hugo Rubio Marín - 380 h
    Migración a la nube [Administrador de sistemas] 380 h desde 2025-02-01
Marta Sánchez Gil - 0 h
    (sin proyectos)

Como asignaciones es un Set (sin orden), ordenamos en memoria con un Comparator del módulo 03. Los empleados salen por apellidos gracias al @OrderBy de Departamento.empleados. Desde cada Asignacion se navega al Proyecto: es la ventaja de haber convertido la N:M en una entidad con dos @ManyToOne.

Ejercicio 6: Una subclase nueva en SINGLE_TABLE

Añade a la jerarquía SINGLE_TABLE una subclase PagoBizum (discriminador BIZUM, atributo telefono). Crea una unidad de persistencia de laboratorio herencia-ejercicio con las cuatro clases y create-drop, guarda un pago de cada tipo (dos de Bizum), lista todos los pagos con su descripción, calcula el total por tipo y cuenta los pagos Bizum con JPQL.

Ver solución
@Entity
@DiscriminatorValue("BIZUM")
public class PagoBizum extends Pago {

    @Column(length = 15)
    private String telefono;

    protected PagoBizum() { }

    public PagoBizum(Integer pedidoId, BigDecimal importe, LocalDate fecha, String telefono) {
        super(pedidoId, importe, fecha);
        this.telefono = telefono;
    }

    @Override
    public String descripcion() { return "Bizum desde el " + telefono; }
}
<persistence-unit name="herencia-ejercicio" transaction-type="RESOURCE_LOCAL">
    <class>es.curso.empresa.herencia.single.Pago</class>
    <class>es.curso.empresa.herencia.single.PagoTarjeta</class>
    <class>es.curso.empresa.herencia.single.PagoTransferencia</class>
    <class>es.curso.empresa.herencia.single.PagoBizum</class>
    <exclude-unlisted-classes>true</exclude-unlisted-classes>
    <properties>
        <property name="jakarta.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/empresa"/>
        <property name="jakarta.persistence.jdbc.user" value="curso"/>
        <property name="jakarta.persistence.jdbc.password" value="curso"/>
        <property name="hibernate.hbm2ddl.auto" value="create-drop"/>
    </properties>
</persistence-unit>
public class Ejercicio6 {

    public static void main(String[] args) {
        LocalDate hoy = LocalDate.of(2026, 10, 4);
        try (EntityManagerFactory emf = Persistence.createEntityManagerFactory("herencia-ejercicio")) {
            emf.runInTransaction(em -> {
                em.persist(new PagoTarjeta(5022, new BigDecimal("1499.00"), hoy, "4321", "Diseños Creativos S.L."));
                em.persist(new PagoTransferencia(5024, new BigDecimal("1497.00"), hoy, "ES9121000418450200051332"));
                em.persist(new PagoBizum(5023, new BigDecimal("418.80"), hoy, "600123123"));
                em.persist(new PagoBizum(5021, new BigDecimal("50.00"), hoy, "600456456"));
            });
            emf.runInTransaction(em -> {
                List<Pago> pagos = em.createQuery("select p from Pago p", Pago.class).getResultList();
                Map<String, BigDecimal> porTipo = new TreeMap<>();
                for (Pago p : pagos) {
                    porTipo.merge(p.getClass().getSimpleName(), p.getImporte(), BigDecimal::add);
                    System.out.println(p.getId() + " " + p.descripcion());
                }
                System.out.println("Total por tipo: " + porTipo);
                long bizums = em.createQuery("select count(b) from PagoBizum b", Long.class).getSingleResult();
                System.out.println("Pagos Bizum: " + bizums);
            });
        }
    }
}

Salida real:

1 Tarjeta **** 4321 (Diseños Creativos S.L.)
2 Transferencia desde ES9121000418450200051332
3 Bizum desde el 600123123
4 Bizum desde el 600456456
Total por tipo: {PagoBizum=468.80, PagoTarjeta=1499.00, PagoTransferencia=1497.00}
Pagos Bizum: 2

Añadir una subclase a SINGLE_TABLE solo añade una columna (telefono) y un valor posible del discriminador; el código que trabaja con Pago no cambia (polimorfismo: cada objeto responde a descripcion() a su manera). Map.merge acumula los importes por tipo y el TreeMap los ordena por nombre.

Ejercicios propuestos

Ejercicio 7: Pedido → Cliente bidireccional

Añade a Cliente los métodos addPedido/removePedido y un método getTotalComprado() que sume getTotal() de sus pedidos no cancelados. Muestra el total comprado por los clientes 1001, 1002 y 1010. ¿Cuántas consultas genera? Anótalo para compararlo con la versión JPQL del módulo 16.

Ejercicio 8: Habilidades de un proyecto

Usando las tablas de laboratorio, escribe un método que, dado un proyecto, devuelva el conjunto (Set<Habilidad>) de habilidades que reúne su equipo (las de todos los empleados asignados). Pruébalo con los proyectos 1 y 2.

Ejercicio 9: JOINED con una tercera subclase

Repite el ejercicio 6 con la estrategia JOINED. Compara el DDL y las consultas con las de SINGLE_TABLE: ¿cuántas tablas y cuántos JOIN aparecen ahora?

Ejercicio 10: Mover empleados entre departamentos

Escribe un método trasladar(int empId, int deptDestino) que use removeEmpleado/addEmpleado para mover a un empleado de departamento manteniendo los dos lados coherentes. Comprueba en la misma transacción (sin releer) que ambas colecciones son correctas y después revierte el cambio.

Ejercicio 11: Auditoría con @MappedSuperclass y @EntityListeners

Crea una clase AuditoriaListener con métodos @PrePersist y @PreUpdate que reciban un Object y, si es una EntidadAuditada, rellenen sus fechas con LocalDateTime.now(). Regístrala con @EntityListeners(AuditoriaListener.class) en la superclase y elimina los métodos de callback de EntidadAuditada. ¿Qué ventaja tiene sacar esa lógica a una clase aparte?

Ejercicio 12: Comentarios de pedidos con @ElementCollection

Investiga @ElementCollection y @CollectionTable y úsalos para añadir a Pedido una lista de notas (List<String>) guardada en una tabla de laboratorio pedido_notas (pedido_id, nota). Crea la tabla con un script SQL, prueba a añadir y quitar notas observando el SQL, y bórrala al terminar. ¿En qué se diferencia de una relación @OneToMany con una entidad Nota?


Resumen

Concepto Sintaxis / clave
Muchos a uno @ManyToOne(fetch = LAZY) @JoinColumn(name = "dept_id") (lado propietario)
Uno a muchos @OneToMany(mappedBy = "departamento") (lado inverso)
Lado propietario El que tiene la FK (@JoinColumn, @JoinTable, @MapsId); el único que se escribe
Sincronizar Métodos addXxx/removeXxx que actualizan ambos lados
Uno a uno @OneToOne(fetch = LAZY) + @MapsId (PK compartida)
Muchos a muchos @ManyToMany + @JoinTable(joinColumns, inverseJoinColumns), con Set
N:M con atributos Entidad intermedia + @EmbeddedId + dos @ManyToOne con @MapsId
Autorrelación @ManyToOne Empleado jefe + @OneToMany(mappedBy = "jefe")
Carga LAZY en todo; EAGER por defecto en @ManyToOne/@OneToOne
Proxy getReference, relaciones LAZY; se inicializa al acceder (EM abierto)
LazyInitializationException Acceso LAZY con el EM cerrado: JOIN FETCH, Hibernate.initialize, DTO
Cascadas cascade = CascadeType.ALL solo en composición (padre → hijos)
Huérfanos orphanRemoval = true: quitar de la colección = DELETE
Orden @OrderBy("apellidos"), @OrderBy("id.numLinea")
Herencia @Inheritance(SINGLE_TABLE \| JOINED \| TABLE_PER_CLASS), @DiscriminatorColumn/Value, @PrimaryKeyJoinColumn
Atributos comunes @MappedSuperclass (no es entidad, no se consulta)
Callbacks @PrePersist, @PreUpdate, @PostLoad…
equals/hashCode Clave natural, o id con hashCode constante; nunca relaciones LAZY