1. Colecciones después de la entidad de enlace
Si antes vivías en el mundo de @ManyToMany, es posible que ni siquiera te hubieras planteado que la colección dentro de una entidad no es simplemente «una lista normal». Después de migrar a la entidad de enlace, empiezas a trabajar no con «categorías del producto», sino con objetos de relación, y de repente resulta: un torpe add() y el grafo en memoria se divide en dos realidades paralelas. En ese momento, Hibernate no se rompe. Nos rompemos nosotros, cuando intentamos explicarnos por qué el SQL y el estado de los objetos divergen.
El problema principal suena muy humano: la relación ahora es bidireccional y compuesta. Tenemos Product con una lista de categoryAssignments, tenemos Category con una lista de productAssignments, y tenemos el propio ProductCategoryAssignment, que almacena referencias a ambos extremos. Esto ya son tres lugares que deben estar sincronizados. Si cambias solo un lugar, Hibernate puede que guarde «de alguna manera» (o que no guarde), pero casi con seguridad obtendrás asignaciones fantasma en memoria, duplicados, UPDATE inesperados, o la situación de «ya se ha eliminado en la BD, pero en la colección todavía está».
El código ingenuo suele tener este aspecto: alguien accede a la colección y empieza a «manipularla» directamente. Parece que funciona, hasta que añades un segundo escenario y una tercera prueba.
// Mala idea: el servicio modifica directamente el interior de la entidad
// Aquí se crea una asignación «vacía» que no tiene referencias a Product/Category,
// y como resultado, el grafo en memoria se vuelve inconsistente.
product.getCategoryAssignments().add(
new ProductCategoryAssignment()
);
En esta línea todo está mal desde el principio: la asignación se crea «vacía», las referencias no se establecen, la colección de Category no se actualiza, y además acabas de permitir que el código externo «haga lo que quiera» con el estado interno de Product. Como resultado, Hibernate se verá obligado a adivinar lo que querías decir. Lo adivino de antemano: Hibernate adivina peor de lo que parece. Y, por desgracia, no puede preguntar «¿estabas seguro de que querías esto?».
2. Lado propietario y ciclo de vida
Antes de escribir métodos auxiliares, conviene detenerse un minuto y «poner un cartel en la puerta»: quién es responsable de qué aquí. Esto no es filosofía, es pura mecánica del ORM. Si confundes el lado propietario y el propietario del ciclo de vida, empezarás a tratar los síntomas (SQL extraño) en lugar de corregir la causa (el modelo incorrecto de modificación del grafo).
Nuestro modelo tras la extracción de ProductCategoryAssignment tiene este aspecto: la asignación tiene dos @ManyToOne (este es el lado propietario para cada FK), y Product y Category tienen colecciones @OneToMany(mappedBy = "...") (lado inverso). Al mismo tiempo, queremos vincular el ciclo de vida del objeto de asignación al producto: el producto gestiona qué categorías se le asignan, pero no gestiona el ciclo de vida de la propia categoría (la categoría es una referencia compartida).
Para mayor comodidad, fijemos esto en un pequeño esquema:
classDiagram
class Product {
Long id
List~ProductCategoryAssignment~ categoryAssignments
}
class Category {
Long id
List~ProductCategoryAssignment~ productAssignments
}
class ProductCategoryAssignment {
Long id
Product product
Category category
int sortOrder
Instant assignedAt
}
Product "1" --> "*" ProductCategoryAssignment : "categoryAssignments (inverse)"
Category "1" --> "*" ProductCategoryAssignment : "productAssignments (inverse)"
ProductCategoryAssignment "*" --> "1" Product : "product (owning FK)"
ProductCategoryAssignment "*" --> "1" Category : "category (owning FK)"
Y un breve «mapa de responsabilidades» (sí, a veces una tabla ahorra más nervios que tres horas de depuración):
| Parte del modelo | Qué es | Quién debe cambiarlo | Por qué es importante |
|---|---|---|---|
| ProductCategoryAssignment .product | lado propietario (FK en la tabla) | se crea/poner a null dentro de los métodos auxiliares | sin esto, el FK no será gestionable y la relación se volverá «fantasma» |
| Product.categoryAssignments | lado inverso + propietario del ciclo de vida | Product.assignCategory(...) / Product.unassignCategory(...) | aquí es donde vive la regla «qué categorías tiene el producto» |
| Category.productAssignments | lado inverso para navegación | se cambia internamente, pero no «desde fuera» | para que el grafo en memoria siga siendo coherente |
3. Adición: Product.assignCategory(...)
Cuando la relación se convierte en una entidad separada, añadir una categoría a un producto ya no es «añadir a la lista de categorías», sino «crear un nuevo objeto de asignación». A partir de este momento, conviene pensar como un desarrollador que se preocupa por la mantenibilidad: queremos un único método que exprese la intención. No «poner algo en la colección», sino «asignar una categoría a un producto», y además con los datos de la relación (sortOrder, assignedAt) y con una sincronización correcta de ambos lados.
Las propias entidades no cambian: el mismo ProductCategoryAssignment sigue siendo el centro de la relación, simplemente dejamos de esparcir su construcción y sincronización por los servicios.
Casi siempre la mejor opción es crear un método auxiliar en Product, porque en nuestro dominio es el producto quien gestiona el conjunto de asignaciones. Dentro del método auxiliar, creamos ProductCategoryAssignment, establecemos las referencias, lo añadimos a Product y actualizamos cuidadosamente Category. Es decir, hacemos exactamente lo que el servicio haría manualmente, solo que ahora está encapsulado en un solo lugar y no está disperso por el código.
Mini-ejemplo del método (todavía sin invariantes complejos, los desarrollaremos en detalle en la siguiente lección):
import java.time.Instant;
public void assignCategory(Category category, int sortOrder, Instant assignedAt) {
// Creamos la asignación ya «completa»: con ambos lados y los datos de la relación
ProductCategoryAssignment assignment =
new ProductCategoryAssignment(this, category, sortOrder, assignedAt);
// Actualizamos el lado propietario del ciclo de vida (Product almacena la regla «qué categorías están asignadas»)
categoryAssignments.add(assignment);
// Actualizamos el lado inverso (Category) para mantener la coherencia del grafo en memoria
category.addAssignment(assignment); // método auxiliar interno en Category
}
Observa un par de pequeños detalles que influyen mucho en la vida.
En primer lugar, creamos la asignación ya «completa»: pasamos this (el producto), la categoría y los campos de la relación. No es un «objeto técnico» que alguien rellenará después. Es una parte pequeña pero completa del modelo de dominio.
En segundo lugar, añadimos la asignación a la colección del producto, porque esa lista refleja la «composición de categorías del producto». Con cascade = CascadeType.ALL (o al menos PERSIST), Hibernate podrá guardar automáticamente el nuevo registro de relación cuando Product esté en estado managed.
En tercer lugar, actualizamos el lado de Category a través de su método auxiliar. Formalmente, para escribir en la BD, esto puede no ser estrictamente necesario (el lado propietario es de la asignación), pero para la coherencia del grafo en memoria es crítico. De lo contrario, obtendrás de inmediato una situación en la que product.getCategoryAssignments() ya contiene la relación, pero category.getProductAssignments() aún «no lo sabe». Esta es una receta ideal para la sorpresa, especialmente si más tarde, dentro de la misma transacción, quieres recorrer la categoría y ver «qué productos están vinculados a ella».
Otro hábito de ingeniería importante: no exponer la colección mutable al exterior. Así, un «add/remove en bruto» se vuelve imposible y el mundo se convierte en un lugar un poco más seguro.
import java.util.Collections;
import java.util.List;
public List<ProductCategoryAssignment> getCategoryAssignments() {
// No exponemos la colección mutable al exterior: los cambios solo a través de métodos auxiliares.
return Collections.unmodifiableList(categoryAssignments);
}
Sí, a algunos esto les parece «un rigor innecesario». Pero es ese mismo rigor que ahorra horas de investigación en el futuro. Hibernate ya añade suficiente implícitud; no hace falta añadir también «que cualquiera pueda cambiar todo como quiera».
Navegación en Category sin propiedad
Es muy fácil, inspirado por los métodos auxiliares, empezar a convertir Category en un «segundo propietario» de la relación. Es tentador: «vamos a tener también en la categoría assignProduct(...)». Pero en nuestro modelo, esto crea rápidamente una competencia entre dos «centros de control», y por lo tanto, reglas contradictorias. Por eso la estrategia es la siguiente: Category almacena la colección de asignaciones para la navegación, pero los cambios en esta colección ocurren solo como parte de los escenarios del lado de Product.
El estilo más práctico es hacer que los métodos en Category sean lo más pequeños posible y limitar su visibilidad. Idealmente, deberían ser package-private (sin public) para que no se llamen desde fuera.
void addAssignment(ProductCategoryAssignment assignment) {
// Importante: este método se usa solo como parte del escenario del lado de Product,
// para mantener la coherencia del grafo en memoria.
productAssignments.add(assignment);
}
void removeAssignment(ProductCategoryAssignment assignment) {
// Eliminación simétrica para la coherencia del grafo en memoria
productAssignments.remove(assignment);
}
¿Por qué es importante esto precisamente en un proyecto con Hibernate, y no solo «porque es bonito»?
Porque así proteges el modelo de un escenario extraño: alguien en el servicio tomó una categoría y empezó a «asignar productos» a través de ella, olvidando añadir la asignación al producto. Al final, un lado dice «sí, la relación existe», el otro dice «no», y el SQL en el flush dependerá de qué lado es realmente el propietario. Y pasarás mucho tiempo mirando el registro, como un gato que ha tirado un jarrón, diciendo: «¿Por qué hiciste esto?».
En este punto, puedes recordar una regla simple: Category conoce las relaciones, pero Product gestiona las relaciones. Si mantienes esta idea en mente, los métodos auxiliares se vuelven naturales.
4. Eliminación de la relación y orphanRemoval
La eliminación de una relación es el lugar donde comienzan los verdaderos «dolores» del código ORM principiante. Porque en la mente es muy fácil confundir tres acciones diferentes: eliminar la relación, eliminar el objeto de asignación, eliminar la categoría. En nuestro modelo, eliminar una categoría de un producto significa: eliminar un registro de asignación, mientras que la categoría como entidad debe seguir viviendo su vida. Precisamente por eso nos alejamos del ingenuo ManyToMany: para distinguir estos conceptos no solo con palabras, sino también con código.
Queremos que la eliminación se vea como una operación clara: «quitar del producto la categoría con tal id». Internamente debemos: encontrar la asignación, eliminarla de la colección del producto, quitarla de la colección de la categoría y romper las referencias dentro de la asignación, para que el grafo en memoria no quede «medio vivo».
Una de las opciones más convenientes es buscar la asignación por categoryId. Comparamos específicamente por id, no por equals() de entidades, porque las reglas de igualdad para entidades son un mundo sutil aparte (lo analizaremos más adelante).
public void unassignCategory(Long categoryId) {
// Buscamos la asignación concreta por id de categoría (no por equals de entidades)
ProductCategoryAssignment assignment = categoryAssignments.stream()
.filter(a -> a.getCategory().getId().equals(categoryId))
.findFirst()
.orElseThrow();
// Eliminamos de la colección del producto: con orphanRemoval, esto significa «eliminar la fila huérfana de la relación»
categoryAssignments.remove(assignment);
// Eliminamos del lado inverso para mantener el grafo en memoria coherente
assignment.getCategory().removeAssignment(assignment);
// Rompemos explícitamente las referencias de dominio dentro de la propia asignación
assignment.detach();
}
Un par de matices que vale la pena explicar con palabras, en lugar de esperar que «se entienda solo».
En primer lugar, aquí juega un papel clave orphanRemoval = true en la colección Product.categoryAssignments. Cuando Product está managed y eliminamos la asignación de la colección, Hibernate lo interpreta como «la asignación ya no pertenece al producto» y, por lo tanto, debe eliminarla (porque el orphan removal y la propiedad del ciclo de vida). A nivel de SQL, esto suele convertirse en un DELETE de la tabla de asignaciones.
En segundo lugar, eliminamos la asignación también de Category.productAssignments. Esto no es necesario para que Hibernate «guarde mejor» (el lado propietario sigue siendo ManyToOne), sino para que, dentro de la transacción actual, dentro del contexto de persistencia actual, el grafo de objetos sea honesto. Si no lo haces, en memoria la categoría conservará una referencia a la asignación que ya está programada para su eliminación. Y en algún momento empezarás a depurar «por qué la categoría tiene una relación que ya no existe», cuando en realidad simplemente olvidaste sincronizar los lados.
En tercer lugar, llamamos a assignment.detach(). Por qué, lo discutiremos en la siguiente sección, porque parece un detalle menor, pero en la práctica ahorra nervios.
5. Ruptura de referencias dentro de la asignación
Cuando eliminas una asignación de la colección, Hibernate realmente puede eliminar correctamente la fila de la tabla. Pero el objeto en memoria aún existe, y dentro de él aún pueden haber referencias a Product y Category. Esto es similar a la situación «me he dado de baja del piso, pero por alguna razón me he quedado con las llaves». Formalmente, en el registro ya no vives, pero si empiezas a analizar el dominio basándote en los objetos en memoria, obtendrás confusión.
Por lo tanto, es útil tener un pequeño método que haga explícita la ruptura de la relación a nivel del grafo Java. Normalmente se llama detach(), unlink() o incluso markAsRemoved(); el nombre no es tan importante, lo importante es la intención.
public void detach() {
// Rompemos la relación de dominio: esta asignación ya no está vinculada a nadie en el grafo de objetos.
this.product = null;
this.category = null;
}
Esto parece demasiado simple para ser importante. Pero en la práctica aporta varias ventajas.
La primera ventaja es la depuración. Cuando registras el objeto de asignación (no es necesario registrar todo el grafo, pero a veces lo harás igualmente), ves que se ha «quedado huérfano» de verdad, y no simplemente «está aparte en la lista».
La segunda ventaja es la protección contra la reutilización accidental. A veces un desarrollador elimina la asignación de la colección y luego (por ejemplo, debido a una lógica errónea) intenta añadirla de nuevo o modificarla. Si dentro de la asignación aún hay referencias al producto y a la categoría, esto puede parecer «más o menos lógico» y generar efectos secundarios inesperados.
La tercera ventaja es un modelo de dominio honesto. Literalmente, con código fijamos el hecho: esta relación está rota. No «en algún sitio de la BD», sino aquí mismo, en la memoria de la aplicación.
Por supuesto, es importante recordar: el método de entidad detach() no es lo mismo que la operación de Hibernate EntityManager.detach(entity). El nombre coincide, el significado es diferente. Hibernate detach() se refiere al estado de la entidad con respecto al contexto de persistencia, mientras que nuestro assignment.detach() se refiere a la ruptura de la relación de dominio. Si la terminología te confunde, puedes llamar al método unlink().
6. Uso en servicios
Ahora tenemos métodos auxiliares en el modelo. Solo queda lo más agradable: el código de servicio se vuelve más corto y más honesto. En lugar de dispersar la lógica entre varios objetos, el servicio hace una acción clara: carga las entidades y llama al método de dominio. Hibernate, el contexto de persistencia y el dirty checking hacen el resto (dentro de la transacción).
Un ejemplo de servicio de asignación de categoría a producto podría tener este aspecto:
import java.time.Instant;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class CatalogCategoryService {
@Transactional
public void assignCategory(long productId, long categoryId, int sortOrder) {
// Product se obtiene como entidad managed para que el dirty checking y la cascada funcionen automáticamente
Product product = productRepository.findById(productId).orElseThrow();
// Category se puede obtener como referencia: nos interesa obtener un proxy/entidad por id, sin un select obligatorio
Category category = categoryRepository.getReferenceById(categoryId);
// Toda la lógica de negocio de modificación del grafo está encapsulada en el método auxiliar de dominio
product.assignCategory(category, sortOrder, Instant.now());
}
}
Observa cómo aquí encajan «elegantemente» los temas de días anteriores.
La transacción cuelga del método del servicio, por lo tanto Product está managed, los cambios entrarán en el contexto de persistencia, y cascade en categoryAssignments permitirá guardar la nueva asignación sin necesidad de llamar manualmente a entityManager.persist(...). Y, lo que es importante, el servicio no hace un «save() mecánico»; es exactamente el estilo que ya discutimos en el día sobre dirty checking: si el objeto está managed, Hibernate verá los cambios por sí mismo y los sincronizará en el flush/commit.
La eliminación es aún más simple:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void unassignCategory(long productId, long categoryId) {
// Cargamos un Product managed dentro de la transacción
Product product = productRepository.findById(productId).orElseThrow();
// La eliminación de la relación es una operación de dominio, no un «remove de la colección en algún sitio del servicio»
product.unassignCategory(categoryId);
}
Y aquí es donde empiezas a sentir el principal valor de los métodos auxiliares: transacción, lectura, modificación del grafo, flush: todo es predecible. No intentas recordar «¿hay que eliminar también de esa colección?», «¿hay que poner a null el lado propietario?», «¿hay que hacer save()?», porque está integrado en el modelo, donde esta lógica tiene su lugar.
Además, esto es muy útil para la observabilidad del SQL. Cuando ves en el registro un INSERT en product_category_assignment, puedes señalar con el dedo a assignCategory(...) y decir: «por eso apareció». Y esto, honestamente, es una de las sensaciones más agradables en el mundo de Hibernate: cuando el SQL deja de ser un misterio.
7. Errores típicos con métodos auxiliares para la entidad de enlace
Error nº1: modificar las colecciones directamente, evitando los métodos auxiliares.
La mayoría de las veces, esto comienza de forma inocente: «bueno, solo voy a añadir la asignación en un sitio». Luego ese «único sitio» se convierte en tres, luego olvidas sincronizar el segundo lado, y luego comienza un carnaval al estilo «en memoria una cosa, en la BD otra». Para evitarlo, ayuda una disciplina simple: exponer las colecciones solo como unmodifiableList, y hacer los cambios solo a través de los métodos assign.../unassign....
Error nº2: sincronizar solo un lado de la relación y pensar que es suficiente.
A veces el desarrollador añade la asignación solo en Product.categoryAssignments y no actualiza Category.productAssignments. El SQL puede ser correcto (especialmente si el lado propietario está establecido), pero el grafo de objetos dentro de la transacción se vuelve «mentiroso». Tras un par de métodos, empiezas a ver duplicados, elementos «eliminados» o comportamientos extraños en recorridos posteriores. En el código con Hibernate, es mejor mantener el grafo coherente desde el principio; de lo contrario, la depuración se convierte en un thriller psicológico.
Error nº3: intentar eliminar Category cuando solo hay que eliminar la asignación.
Esta es la confusión clásica entre «eliminar la relación» y «eliminar la entidad». Si la categoría es un catálogo y la utilizan muchos productos, entonces CascadeType.REMOVE o un mecánico categoryRepository.delete(category) en el escenario «quitar la categoría del producto» es casi un accidente garantizado. En el modelo de entidad de enlace, eliminar la relación es eliminar ProductCategoryAssignment, y es hacia eso hacia lo que debe apuntar el código.
Error nº4: olvidar orphanRemoval y esperar que «eliminar de la colección elimine el registro por sí solo».
Si orphanRemoval no está activado (o gestionas el ciclo de vida de otra manera), eliminar la asignación de la colección del producto puede no provocar un DELETE, sino un intento de poner el FK a null, o incluso que la relación «se quede en la BD» hasta que la elimines manualmente. En nuestro dominio, la asignación vive y muere con el producto, por lo que orphanRemoval=true en Product.categoryAssignments es una decisión razonable, pero hay que recordarlo y verificarlo.
Error nº5: dejar la asignación «semiviva» en memoria después de la eliminación.
Cuando has eliminado la asignación de las colecciones, pero no has roto las referencias dentro de ella, dejas un objeto que parece vinculado al producto y a la categoría, aunque la relación ya está rota. Esto no siempre rompe el SQL, pero rompe el pensamiento y el diagnóstico: luego alguien imprime un registro, ve assignment.product.id y saca conclusiones incorrectas. Un pequeño detach/unlink hace que el modelo sea más honesto y tranquilo.
GO TO FULL VERSION