CodeGym /Cours /Hibernate deep-dive /Entité de liaison plutôt que ManyToMany

Entité de liaison plutôt que ManyToMany

Hibernate deep-dive
Niveau 12, Leçon 0
Disponible

1. L’attrait de @ManyToMany : « deux lignes et c’est prêt »

Si vous avez déjà réalisé un catalogue de produits (ou au moins une liste de films préférés), la relation « produit ↔ catégorie » semble être un candidat idéal pour @ManyToMany. La logique est simple : un produit a plusieurs catégories, une catégorie a plusieurs produits — donc c'est tout trouvé. Et effectivement, au début du projet, cela ressemble à une économie de temps agréable… jusqu’au moment où la relation acquiert ne serait-ce qu’un peu de vie réelle.

Imaginons une version naïve (mais très répandue) :

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

@Entity
public class Product {

    // Version naïve : la relation « produit–catégorie » est cachée dans la collection
    @ManyToMany
    @JoinTable(name = "product_category_assignment") // la table de jointure existe toujours en base
    private List<Category> categories = new ArrayList<>();
}

Le code est court, l’œil est content, la PR passe facilement : « qu’est-ce qui pourrait mal tourner ? » Le problème est que cette beauté est comme une chambre d’hôtel sur la photo : sur le cliché, c’est spacieux, mais en réalité, vous ouvrez la valise et vous comprenez qu’il sera difficile d’y vivre.

Pour continuer sur la même longueur d’onde, fixons un mini-glossaire (sans être ennuyeux, promis) :

Terme Explication en langage humain
@ManyToMany Mapping plusieurs-à-plusieurs entre deux entités.
Table de liaison (link table / join table) Table réelle en base de données contenant les paires product_id et category_id.
@JoinTable Annotation qui indique explicitement à Hibernate : « voici le nom de la table de liaison et les colonnes ».

Le point essentiel ici : @ManyToMany signifie toujours qu’il y aura une table de liaison en base. C’est simplement que dans le modèle Java, vous faites comme si elle n’existait pas. Mais Hibernate… lui, ne fait pas semblant. Il travaille honnêtement avec cette table.

2. La table de liaison existe toujours

De nombreux développeurs débutants pensent : « Dans mon Java, j’ai une List<Category>, donc en base, il doit y avoir, d’une certaine manière, une liste de catégories “à l’intérieur” de Product. » Malheureusement (ou heureusement), une base relationnelle ne fonctionne pas ainsi : dans la table product, il n’est pas possible de stocker normalement « plusieurs catégories » sans une structure séparée.

Par conséquent, en réalité, le schéma est presque toujours le suivant :

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

    product {
      bigint id
      varchar sku
      varchar name
    }

    category {
      bigint id
      varchar code
      varchar name
    }

    product_category_assignment {
      bigint product_id
      bigint category_id
    }

Et c’est là que commence le premier problème subtil de @ManyToMany : nous modélisons la relation comme une « simple liste », mais en base ce sont des lignes distinctes d’une table séparée. Toute opération sur la liste devient une opération sur les lignes de cette table.

Dans un monde très simple, c’est encore supportable : vous ajoutez une catégorie → vous insérez une ligne, vous en retirez une → vous supprimez une ligne. Mais la vie cesse rapidement d’être simple. Généralement, ce n’est pas parce que « nous aimons compliquer les choses », mais parce que le métier pose soudainement des questions humaines tout à fait normales.

3. Quand la relation devient un objet : les champs de la relation

Voici l’idée clé du jour, et elle est la suivante : si un champ se rapporte à une paire d’entités, il ne doit pas vivre dans l’une des entités, mais dans l’entité de liaison.

Imaginons les exigences de notre catalogue (très réalistes, sans fantaisie) :

Nous voulons stocker l’ordre des catégories pour un produit, car dans l’administration, quelqu’un (une personne bien réelle) déplace les catégories vers le haut ou le bas pour que la « catégorie principale » soit en première position. Nous voulons aussi savoir quand une catégorie a été attribuée au produit – parce que cela sera utile dans les rapports ultérieurs, et parce que les gens aiment poser des questions du genre « qui a changé cela et quand ? ».

Maintenant, essayez de répondre honnêtement : où placer sortOrder et assignedAt dans le modèle @ManyToMany ?

Dans Product ? Mais l’ordre et le moment de l’attribution dépendent de la catégorie spécifique. Un produit peut avoir 7 catégories – et 7 assignedAt différents. Une seule date pour tout le produit ne convient pas.

Dans Category ? Mais une catégorie peut avoir des centaines de produits. Et pour chaque produit, cette catégorie aura son propre sortOrder et son propre assignedAt. Une seule date pour toute la catégorie ne convient pas.

Il ne reste qu’une évidence (bien que désagréable pour les amateurs du « tout dans une seule annotation ») : sortOrder et assignedAt sont des propriétés de la ligne de la table de liaison, c’est-à-dire des propriétés de la relation elle-même.

Or, @ManyToMany dans sa « forme classique » ne fait pas de cette ligne un objet du domaine. Elle reste cachée dans la collection et est gérée par l’ORM comme un effet secondaire.

Voilà pourquoi au début @ManyToMany semble idéal : tant que la relation se limite à « le produit fait partie de la catégorie ». Mais dès que la relation devient un tant soit peu consistante, le mapping commence à résister. Et ce n’est pas « Hibernate qui fait des caprices », c’est nous qui essayons d’exprimer une réalité objet à travers un modèle trop plat.

Pour ressentir le problème, il suffit de ce code :

// On peut ajouter/supprimer une catégorie...
product.getCategories().add(category);
product.getCategories().remove(category);

// ...mais il n’y a pas d’endroit pour gérer la relation elle-même (sortOrder, assignedAt, audit, etc.)

Il n’y a même pas d’endroit où l’on pourrait dire : « et si on assignait sortOrder=10 et assignedAt=now lors de l’ajout ? » La collection n’accepte que des Category. La relation en tant qu’objet est absente.

4. Cascades et cycle de vie de @ManyToMany

Après le jour sur les cascade, beaucoup font une chose très humaine (et très dangereuse) : « Pour ne pas me compliquer la vie, je mets cascade = CascadeType.ALL, Hibernate se débrouillera tout seul. »

Avec @ManyToMany, c’est particulièrement perfide, car dans la plupart des modèles, les deux côtés de la relation sont partagés. La catégorie est un référentiel. Elle n’appartient pas à un seul produit. Elle vit sa propre vie. La relation « produit–catégorie » en vit une autre.

Voilà ce qu’on a envie de faire, mais qu’il ne faut pas :

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

@Entity
public class Product {

    // Dangereux : Category est généralement un référentiel partagé, et REMOVE "fuira" en dehors de la table de liaison
    @ManyToMany(cascade = CascadeType.ALL)
    private List<Category> categories = new ArrayList<>();
}

Pourquoi c’est mauvais, on peut l’expliquer simplement : si un produit est supprimé, on veut supprimer les liaisons (les lignes dans la table de jointure), mais on ne veut certainement pas supprimer les catégories en tant qu’entités. La catégorie « Ordinateurs portables » ne doit pas disparaître du référentiel simplement parce qu’un produit particulier a été supprimé. De plus, une catégorie peut être liée à des centaines d’autres produits, et une tentative de suppression en cascade se heurtera aux clés étrangères.

Hibernate dans sa documentation souligne explicitement que pour @ManyToMany, la cascade REMOVE n’a pas vraiment de sens, car la suppression peut « fuir » au-delà de la table de liaison, et l’autre côté peut être utilisé par d’autres entités – vous obtenez alors des erreurs d’intégrité (ConstraintViolationException).

Et notez le piège psychologique : le problème ne se manifeste pas immédiatement. Sur un petit jeu de données, ça « semble fonctionner » tant que la catégorie n’a vraiment qu’un seul produit. Ensuite, le projet vit, les données grandissent, quelqu’un essaie de supprimer un produit… et soudain, la production plante. Et vous vous asseyez en pensant : « Mais j’ai simplement supprimé un produit, pourquoi la catégorie est-elle supprimée ? » Parce que vous avez explicitement dit à Hibernate : « supprime tout en cascade ».

Au niveau du modèle métier, la pensée correcte est plus posée : le produit gère un ensemble d’affectations de catégories, mais il ne gère pas la vie des catégories en tant que référentiel. Ce sont des cycles de vie différents.

5. Surprise SQL : delete+reinsert

Le problème suivant de @ManyToMany ne concerne pas le « beau/pas beau », mais un profil SQL bien concret. Et il est d’autant plus vexant qu’au niveau Java tout semble innocent.

On fait ceci :

product.getCategories().remove(category);

Et on s’attend à : « Bon, Hibernate va supprimer une ligne de product_category_assignment ». Parfois c’est le cas, mais souvent la réalité est moins romantique. Dans la documentation d’Hibernate, un scénario caractéristique est décrit : lors de la modification d’une collection @ManyToMany, l’ORM peut exécuter une opération du style « supprime toutes les lignes de liaison pour ce parent et réinsère celles qui restent dans la collection en mémoire ».

En termes SQL, cela ressemble à ceci (simplifié) :

-- Schéma potentiel de l’ORM lors de la modification d’une collection ManyToMany :
-- 1) supprimer toutes les liaisons du parent
DELETE FROM product_category_assignment
WHERE product_id = 42;

-- 2) recréer ce qui reste en mémoire
INSERT INTO product_category_assignment(product_id, category_id)
VALUES (42, 5);

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

Remarquez le drame : vous avez supprimé une catégorie, mais Hibernate a potentiellement réécrit l’intégralité des liaisons pour le produit.

Pourquoi cela se produit-il ? Dans ce modèle, l’ORM n’a pas de « propriétaire réel » de la ligne de liaison en tant qu’objet séparé. Ce n’est pas un OrderItem qu’on peut supprimer directement par son id. Ce sont simplement des « éléments d’une collection », et Hibernate choisit la manière sûre (mais pas la plus précise ni la moins coûteuse) de synchroniser.

La documentation d’Hibernate qualifie explicitement cette opération d’inefficace pour la suppression d’éléments de collections via une table de liaison : pour maintenir un état correct, il supprime toutes les lignes de liaison pour le parent et recrée les lignes restantes.

Imaginez cela sur un catalogue bien réel : un produit a 30 catégories (cela arrive dans du contenu réel), vous en supprimez une. Au lieu d’un seul DELETE, vous obtenez une « modification massive » : un gros DELETE + une série d’INSERT.

Oui, la base de données supportera. Mais le problème est l’imprévisibilité : les triggers, l’audit, la réplication, le logging, les verrous, et même la simple lecture du log SQL deviennent une enquête « pourquoi a-t-il tout supprimé alors que je n’en demandais qu’une ? ».

6. Perte de contrôle avec @ManyToMany

Quand on dit « perte de contrôle », il est important de comprendre que ce n’est pas un jugement moral sur l’annotation. @ManyToMany n’est ni « mauvaise » ni « interdite ». Elle exprime simplement un modèle très spécifique : la relation comme un fait d’appartenance sans comportement ni données propres. Mais dans notre catalogue, la relation est un petit objet avec ses propres règles.

Pour fixer cela de manière très pratique, voici un tableau « besoin → ce qui se passe avec @ManyToMany » :

Ce dont nous avons besoin pour la relation Product ↔ Category Ce que donne le naïve @ManyToMany
Stocker les données de la relation (sortOrder, assignedAt) La relation n’est pas un objet, impossible de stocker (sauf bricolage dans la table de liaison sans entité).
Supprimer la relation comme une entité séparée, sans toucher à Category Les débutants incluent souvent des cascades « au cas où » et suppriment ce qu’il ne faut pas.
SQL prévisible lors de la suppression d’un élément Scénario possible « delete all + reinsert remaining » pour le parent.
Possibilité d’attacher des invariants métier à la relation Les invariants existent pour la ligne de liaison, mais pas pour l’objet Java (car il n’y a pas d’objet).

Et c’est ici qu’intervient une réflexion d’ingénierie importante : si nous avons déjà une table de liaison en base (et elle est toujours là), alors il est parfois plus honnête d’arrêter de faire semblant que ce n’est qu’une « simple collection », et d’en faire une partie à part entière du modèle.

Hibernate dans sa documentation dit explicitement que pour surmonter les limitations de @ManyToMany (y compris le problème de contrôle et d’optimisation lors des modifications de collection), il faut « sortir » la table de liaison et la représenter comme une entité séparée, en décomposant le @ManyToMany en deux relations @OneToMany/@ManyToOne.

C’est comme avec le câblage électrique dans un appartement : on peut faire une « torsion et du ruban isolant » (et ça marchera même), mais si vous voulez de la contrôlabilité et de la sécurité, vous installez une boîte de dérivation normale. Un peu plus de détails, mais prévisible.

7. Erreurs typiques avec @ManyToMany

Erreur n°1 : choisir @ManyToMany parce que « moins de code ».
C’est un réflexe normal du développeur débutant : on veut rapidement une relation fonctionnelle. Mais la relation a un prix que paient la base de données et Hibernate. Dès que des champs de relation apparaissent (ordre, dates, étiquettes) ou des règles de suppression particulières, la courte annotation commence à vous cacher la structure réelle des données, et vous perdez la maîtrise.

Erreur n°2 : penser que la catégorie est un « objet enfant » du produit.
Dans un catalogue, la catégorie est presque toujours un référentiel, c’est-à-dire une entité partagée par de nombreux produits. La suppression d’un produit ne doit pas supprimer la catégorie. Si vous mettez des cascades par habitude, vous risquez soit une tentative de suppression d’une entité partagée, soit une erreur d’intégrité due aux clés étrangères. Hibernate souligne séparément que la cascade REMOVE pour @ManyToMany est une mauvaise idée précisément parce que la suppression sort du cadre de la table de liaison.

Erreur n°3 : s’étonner du « SQL étrange » et ne pas le relier au modèle de relation.
Quand la suppression d’un élément entraîne la suppression de toutes les lignes de liaison et la réinsertion des restantes, cela ressemble à « l’ORM a perdu la tête ». En réalité, il synchronise simplement la table de liaison comme il sait le faire dans ce modèle. Dans la documentation d’Hibernate, un tel scénario est explicitement décrit pour @ManyToMany : la suppression d’un élément peut nécessiter la recréation des enregistrements de liaison pour le parent.

Erreur n°4 : essayer de stocker les données de la relation dans l’une des entités.
Parfois, les développeurs tentent de se débrouiller en ajoutant, par exemple, categoryAssignedAt dans Product, ou sortOrder dans Category. Cela tourne rapidement à l’absurde : un seul champ tente de décrire plusieurs valeurs différentes pour différentes paires product+category. Si un champ se rapporte à une paire, il doit vivre au niveau de la paire – c’est-à-dire dans le modèle de la relation.

Erreur n°5 : « traiter » le problème de @ManyToMany avec encore plus d’annotations.
C’est un point subtil : on peut essayer de peaufiner @ManyToMany avec des réglages, @OrderBy, des contraintes supplémentaires, des collections astucieuses et une discipline d’appels. Mais si vous voyez déjà que la table de liaison est devenue une partie à part entière du domaine, il est plus correct d’arrêter de faire semblant et d’extraire la relation dans une entité séparée. Hibernate indique clairement cette voie comme un moyen de surmonter les limitations de @ManyToMany lorsque le contrôle sur la relation est nécessaire.

1
Mission
Hibernate deep-dive, niveau 12, leçon 0
Bloqué
Relation film et genre via `@ManyToMany`
Relation film et genre via `@ManyToMany`
1
Mission
Hibernate deep-dive, niveau 12, leçon 0
Bloqué
Suppression d'un article sans supprimer le sujet commun
Suppression d'un article sans supprimer le sujet commun
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION