CodeGym /Cursos /Hibernate deep-dive /Entidad Link en lugar de ManyToMany

Entidad Link en lugar de ManyToMany

Hibernate deep-dive
Nivel 12, Lección 0
Disponible

1. El señuelo @ManyToMany: «dos líneas y listo»

Si alguna vez hiciste un catálogo de productos (o al menos una lista de películas favoritas), la relación «producto ↔ categoría» parece el candidato perfecto para @ManyToMany. La lógica es simple: un producto tiene muchas categorías, una categoría tiene muchos productos, por lo tanto, ahí está. Y de hecho, al principio del proyecto parece un ahorro de tiempo agradable… exactamente hasta el momento en que la relación tiene aunque sea una pizca de vida real.

Consideremos una variante ingenua (pero muy común):

import jakarta.persistence.Entity;
import jakarta.persistence.JoinTable;
import jakarta.persistence.ManyToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Product {

    // Variante ingenua: la relación «producto–categoría» está oculta dentro de la colección
    @ManyToMany
    @JoinTable(name = "product_category_assignment") // la join-table sigue existiendo en la BD
    private List<Category> categories = new ArrayList<>();
}

El código es corto, el ojo se alegra, el PR pasa fácil: «¿qué podría salir mal aquí?». El problema es que esta belleza es como una habitación de hotel en la foto: en la foto es espaciosa, pero en realidad abres la maleta y te das cuenta de que vivir allí va a ser difícil.

Para hablar el mismo idioma de ahora en adelante, fijemos un mini‑glosario (sin ser pesados, lo prometo):

Término Explicación humana
@ManyToMany Mapeo «muchos‑a‑muchos» entre dos entidades.
Tabla de enlace (link table / join table) Tabla real en la BD donde se almacenan los pares product_id y category_id.
@JoinTable Anotación con la que le indicas explícitamente a Hibernate: «aquí está el nombre de la tabla de enlace y las columnas».

Lo más importante aquí: @ManyToMany siempre significa que en la base de datos habrá una tabla de enlace. Simplemente en el modelo Java finges que no existe. Pero Hibernate… él no finge. Él trabaja honestamente con la tabla.

2. La tabla de enlace siempre existe

Muchos desarrolladores principiantes piensan: «En Java tengo List<Category>, entonces en la BD, supongo, de alguna manera está almacenado “dentro de Product” una lista de categorías». Desafortunadamente (o afortunadamente), la base de datos relacional no funciona así: en la tabla product no se puede almacenar correctamente “muchas categorías” sin una estructura separada.

Por lo tanto, en la realidad el esquema es casi siempre así:

erDiagram
    product ||--o{ product_category_assignment : links
    category ||--o{ product_category_assignment : links

    product {
      bigint id
      varchar sku
      varchar name
    }

    category {
      bigint id
      varchar code
      varchar name
    }

    product_category_assignment {
      bigint product_id
      bigint category_id
    }

Y aquí comienza el primer problema sutil de @ManyToMany: estamos modelando la relación como “simplemente una lista”, pero en la base de datos son filas separadas de una tabla separada. Y cualquier operación con la lista se convierte en operaciones con filas de la tabla.

En el mundo más simple esto todavía es tolerable: añadiste una categoría — insertaste una fila, la quitaste — eliminaste una fila. Pero la vida rápidamente deja de ser simple. Generalmente no porque “nos guste complicar”, sino porque el negocio de repente plantea preguntas humanas normales.

3. Cuando la relación se convierte en objeto: campos de relación

Ahora llegará la idea clave del día, y suena así: si un campo se refiere a un par de entidades, debe vivir no en una de las entidades, sino en la entidad de relación.

Imagina los requisitos de nuestro catálogo (muy reales, sin ciencia ficción):

Queremos almacenar el orden de las categorías de un producto, porque en el panel de administración alguien (una persona muy real) arrastra las categorías hacia arriba y hacia abajo para que la «categoría principal» sea la primera. Y además queremos saber cuándo se asignó la categoría al producto — porque luego será útil en los informes y porque a la gente le gusta hacer preguntas del tipo «¿quién y cuándo cambió esto?».

Ahora intenta responder honestamente: ¿dónde poner sortOrder y assignedAt en el modelo @ManyToMany?

¿En Product? Pero el orden y el momento de la asignación dependen de la categoría concreta. Un producto puede tener 7 categorías — y 7 fechas assignedAt diferentes. Una sola fecha para todo el producto no sirve.

¿En Category? Pero una categoría tiene cientos de productos. Y para cada producto, esa categoría tendrá su propio sortOrder y su propio assignedAt. Una sola fecha para toda la categoría no sirve.

Queda lo obvio (aunque desagradable para los amantes de «todo en una anotación»): sortOrder y assignedAt son propiedades de la fila de la tabla de enlace, es decir, propiedades de la propia relación.

Y @ManyToMany en su «forma clásica» no convierte esa fila en un objeto de dominio. Está oculta dentro de la colección y es gestionada por el ORM como un efecto secundario.

Por eso al principio @ManyToMany parece ideal: mientras la relación sea solo «el producto está en la categoría». Pero tan pronto como la relación se vuelve un poco significativa, el mapeo comienza a resistirse. Y no es que «Hibernate sea quisquilloso», es que intentamos expresar una realidad objetual a través de un modelo demasiado plano.

Para sentir el problema, basta con este código:

// Se puede añadir/eliminar una categoría...
product.getCategories().add(category);
product.getCategories().remove(category);

// ...pero no hay dónde gestionar la propia relación (sortOrder, assignedAt, auditoría, etc.)

Aquí ni siquiera hay un lugar donde decir: «al añadir, asignemos sortOrder=10 y assignedAt=now». La colección solo acepta Category. La relación como objeto está ausente.

4. Cascadas y ciclo de vida de @ManyToMany

Después del día sobre cascade muchos hacen una cosa muy humana (y muy peligrosa): «Para no sufrir, pondré cascade = CascadeType.ALL, que Hibernate lo haga todo solo».

Con @ManyToMany esto es especialmente traicionero, porque en la mayoría de los modelos ambos lados de la relación son compartidos. La categoría es un directorio. No pertenece a un solo producto. Vive su propia vida. Y la relación «producto–categoría» vive otra vida.

Así es como se quiere hacer, pero no se debe:

import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
public class Product {

    // Peligroso: Category suele ser un directorio compartido, y REMOVE se "fuga" más allá de la tabla de enlace
    @ManyToMany(cascade = CascadeType.ALL)
    private List<Category> categories = new ArrayList<>();
}

Por qué esto es malo se puede explicar con los dedos: si se elimina un producto, queremos eliminar las relaciones (filas en la join-table), pero definitivamente no queremos eliminar las categorías como entidades. La categoría «Portátiles» no debe desaparecer del directorio solo porque se eliminó un producto concreto. Más aún, la categoría puede estar relacionada con cientos de otros productos, e intentar eliminarla en cascada se topará con las claves foráneas.

Hibernate en su documentación señala directamente que para @ManyToMany el cascading REMOVE no tiene sentido normal, porque la eliminación puede "filtrarse" más allá de la tabla de enlace, y el otro lado puede estar siendo usado por otras entidades — y entonces obtienes errores de integridad (ConstraintViolationException).

Y presta atención a la trampa psicológica: el problema no surge de inmediato. En un conjunto de datos pequeño «parece funcionar», mientras la categoría realmente tenga solo un producto. Luego el proyecto vive, los datos crecen, alguien intenta eliminar un producto… y de repente el entorno productivo se cae. Y te sientas a pensar: «Pero solo eliminé un producto, ¿por qué se elimina la categoría?» Porque le dijiste explícitamente a Hibernate: «elimínalo todo en cascada».

A nivel de modelo de dominio, la idea correcta suena más tranquila: el producto gestiona el conjunto de asignaciones de categorías, pero no gestiona la vida de las categorías como directorio. Son ciclos de vida diferentes.

5. Sorpresa en SQL: delete+reinsert

El siguiente problema de @ManyToMany no es sobre «bonito/feo», sino sobre un perfil SQL muy concreto. Y es especialmente molesto porque a nivel de Java todo parece inocente.

Hacemos esto:

product.getCategories().remove(category);

Y esperamos: «Bueno, Hibernate eliminará una fila de product_category_assignment». A veces será así, pero a menudo la realidad es menos romántica. En la documentación de Hibernate se describe un escenario característico: al modificar una colección @ManyToMany, el ORM puede ejecutar una operación al estilo «eliminar todas las filas de enlace para este padre y volver a insertar las que quedaron en la colección en memoria».

En términos SQL se ve más o menos así (simplificado):

-- Patrón potencial del ORM al modificar una colección ManyToMany:
-- 1) eliminar todos los enlaces del padre
DELETE FROM product_category_assignment
WHERE product_id = 42;

-- 2) recrear lo que queda en memoria
INSERT INTO product_category_assignment(product_id, category_id)
VALUES (42, 5);

INSERT INTO product_category_assignment(product_id, category_id)
VALUES (42, 9);

Nota el dramatismo: eliminaste una categoría, pero Hibernate potencialmente reescribió todo el conjunto de enlaces del producto.

¿Por qué sucede esto? Porque en este modelo el ORM no tiene un "verdadero propietario de la fila de enlace" como objeto separado. No es un OrderItem que se pueda eliminar de forma dirigida por su id. Son simplemente «elementos de colección», y Hibernate elige una forma segura (aunque no la más precisa ni barata) de sincronizar.

La documentación de Hibernate llama directamente a esta operación ineficiente para eliminar elementos de colecciones a través de una link-table: para mantener el estado correcto, elimina todas las filas de enlace del padre y vuelve a crear las que quedan.

Y ahora imagínalo en un catálogo real: un producto tiene 30 categorías (esto pasa en contenido real), eliminas una. En lugar de un solo DELETE, obtienes una «edición masiva»: un gran DELETE + un montón de INSERT.

Sí, la base de datos lo soportará. Pero el coste es la impredecibilidad: los triggers, la auditoría, la replicación, el registro, los bloqueos, y simplemente leer el log SQL se convierten en un misterio «¿por qué lo borró todo, si solo le pedí uno?».

6. Pérdida de control en @ManyToMany

Cuando decimos «pierde control», es importante entender que no es una valoración moral de la anotación. @ManyToMany no es "mala" ni "prohibida". Simplemente expresa un modelo muy concreto: la relación como un hecho de pertenencia sin comportamiento ni datos propios. Pero en nuestro catálogo la relación es un pequeño objeto con sus propias reglas.

Para fijarlo de la forma más práctica posible, aquí hay una tabla «requisito → qué ocurre en @ManyToMany»:

Qué necesitamos de la relación Product ↔ Category Qué ofrece el ingenuo @ManyToMany
Almacenar datos de la relación (sortOrder, assignedAt) La relación no es un objeto, no hay dónde almacenar (excepto con parches en la join-table sin entidad).
Eliminar la relación como una cosa separada, sin tocar Category Los novatos suelen incluir cascadas "por si acaso" y obtienen una eliminación de lo que no deben.
SQL predecible al eliminar un solo elemento Posible escenario "delete all + reinsert remaining" para el padre.
Poder aplicar invariantes de dominio a la relación Los invariantes de la fila de enlace existen, pero no hay objeto Java (porque no hay objeto).

Y aquí llega una idea ingenieril importante: si en la base de datos ya tenemos una tabla de enlace (y siempre la hay), a veces es más honesto dejar de fingir que es "solo una colección" y convertirla en una parte completa del modelo.

Hibernate en la documentación dice directamente que para superar las limitaciones de @ManyToMany (incluido el problema de control y optimización al modificar la colección), la tabla de enlace debe "sacarse" y presentarse como una entidad separada, dividiendo @ManyToMany en dos relaciones @OneToMany/@ManyToOne.

Es como con el cableado en un piso: se puede hacer un "empalme y cinta aislante" (y funcionará), pero si quieres control y seguridad, haces una caja de distribución normal. Un poco más de piezas, pero predecible.

7. Errores típicos con @ManyToMany

Error nº1: elegir @ManyToMany porque "tiene menos código".
Es un reflejo normal de un desarrollador principiante: querer obtener rápidamente una relación funcional. Pero la relación tiene un coste que pagan la base de datos y Hibernate. Tan pronto como aparecen campos de relación (orden, fechas, etiquetas) o reglas especiales de eliminación, la anotación corta empieza a ocultarte la estructura real de datos y pierdes control.

Error nº2: pensar que la categoría es un "objeto hijo" del producto.
En un catálogo, la categoría casi siempre es un directorio, es decir, una entidad que comparten muchos productos. La eliminación de un producto no debe eliminar la categoría. Si pones cascadas por costumbre, te arriesgas a intentar eliminar una entidad compartida o a un error de integridad por claves foráneas. Hibernate subraya por separado que el cascading REMOVE para @ManyToMany es una mala idea precisamente porque la eliminación va más allá de la link-table.

Error nº3: sorprenderse por un "SQL extraño" y no relacionarlo con el modelo de relación.
Cuando la eliminación de un solo elemento provoca la eliminación de todas las filas de enlace y la reinserción de las restantes, parece que "el ORM se ha vuelto loco". En realidad, solo está sincronizando la link-table como sabe hacerlo en ese modelo. En la documentación de Hibernate se describe directamente ese escenario para @ManyToMany: la eliminación de un elemento puede requerir la recreación de los registros de enlace del padre.

Error nº4: intentar almacenar datos de la relación en una de las entidades.
A veces los desarrolladores intentan salir del paso y añaden, por ejemplo, categoryAssignedAt en Product, o sortOrder en Category. Esto rápidamente se vuelve absurdo: un campo intenta describir muchos valores diferentes para distintos pares product+category. Si un campo se refiere al par, debe vivir a nivel del par, es decir, en el modelo de relación.

Error nº5: "curar" el problema de @ManyToMany con más anotaciones.
Este es un punto sutil: se puede intentar ajustar @ManyToMany con configuraciones, @OrderBy, restricciones adicionales, colecciones ingeniosas y disciplina de llamadas. Pero si ya ves que la tabla de enlace se ha convertido en una parte completa del dominio, es mejor dejar de fingir y sacar la relación a una entidad separada. Hibernate indica directamente este camino como forma de superar las limitaciones de @ManyToMany cuando se necesita control sobre la relación.

1
Tarea
Hibernate deep-dive, nivel 12, lección 0
Bloqueada
Relacion de pelicula y genero mediante `@ManyToMany`
Relacion de pelicula y genero mediante `@ManyToMany`
1
Tarea
Hibernate deep-dive, nivel 12, lección 0
Bloqueada
Eliminacion de articulo sin eliminar el tema comun
Eliminacion de articulo sin eliminar el tema comun
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION