1. Set comme test décisif pour l'égalité
Si vous avez déjà attrapé un bug du genre « j'ai supprimé l'élément de la collection, pourquoi il n'a pas été supprimé » — félicitations, vous étiez déjà dans ce club. Set en Java est une collection qui promet l'unicité, mais ne tient cette promesse qu'à une condition : vos equals() et hashCode() se comportent correctement et de manière prévisible. Dans les DTO ordinaires, c'est souvent « bon, d'accord », mais dans le monde Hibernate, cela devient une partie de la correction du modèle.
Dans notre projet Commerce Persistence Lab, ce problème devient très tangible sur des entités comme PurchaseOrder et OrderItem. Nous voulons souvent que les lignes de commande vivent comme « composition de la commande », et non comme des lignes aléatoires : ajoutées — elles sont là, retirées — elles disparaissent (et de préférence aussi de la base de données). Et là, Set se transforme soudain en test décisif : si l'égalité est mauvaise, vous verrez un comportement étrange déjà au niveau des opérations de collection ordinaires.
Pour ne pas parler trop abstraitement, regardons un fragment typique du modèle de commande (raccourci, seulement ce qui est important pour la leçon) :
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.HashSet;
import java.util.Set;
@Entity
class PurchaseOrder {
@OneToMany(
mappedBy = "order", // commentaire : le owning side se trouve dans OrderItem (ManyToOne)
cascade = CascadeType.ALL, // commentaire : les opérations sur la commande sont en cascade sur les items
orphanRemoval = true // commentaire : retiré des items -> Hibernate supprimera la ligne enfant de la base de données
)
private Set<OrderItem> items = new HashSet<>(); // commentaire : Set repose sur des equals/hashCode stables
}
Ici tout semble « beau » : Set promet l'unicité, orphanRemoval=true promet que si l'on retire un élément de items, Hibernate supprimera la ligne order_item lors du flush(). Mais en réalité, cela ne fonctionne que si Set peut réellement find l'élément requis et comprend correctement ce qu'est « le même élément ».
2. Algorithme de HashSet : hashCode() → equals()
Beaucoup se souviennent de equals(), mais hashCode() est souvent perçu comme « un truc que l'IDE a généré, et que je ne touche pas pour ne pas réveiller un mal ancien ».
Le problème est que HashSet et HashMap construisent littéralement leur vie autour de hashCode(). D'abord, elles décident « dans quel emplacement mettre l'objet », et ensuite seulement elles vérifient « c'est bien lui ? » via equals().
Le modèle de comportement peut être représenté ainsi :
flowchart TD
A["HashSet.contains(x)"] --> B["1) calculer x.hashCode()"]
B --> C["2) trouver le bucket selon le hash"]
C --> D["3) comparer equals() avec les candidats du bucket"]
D --> E["true/false"]
Le problème survient lorsque l'objet se trouve déjà dans un HashSet et que son hashCode() a changé. Alors HashSet continue de penser que l'objet « se trouve » dans un bucket, tandis que vous essayez de le trouver dans un autre. Le résultat ressemble à de la magie : l'objet existe, mais contains() renvoie false et remove() ne supprime pas. Ce n'est pas de la magie Hibernate — c'est du pur Java.
Faisons une mini-démo sur une classe simple. Dans un vrai projet, vous n'écrirez pas comme ça, mais l'effet dont nous avons besoin est comme sous un microscope :
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
class ProductKey {
String sku;
ProductKey(String sku) { this.sku = sku; }
@Override public int hashCode() {
// commentaire : hashCode dépend de sku
return Objects.hash(sku);
}
@Override public boolean equals(Object o) {
// commentaire : equals compare aussi sku, donc la logique d'identité est liée au champ
return (o instanceof ProductKey other) && Objects.equals(sku, other.sku);
}
}
Set<ProductKey> set = new HashSet<>();
ProductKey p = new ProductKey("SKU-1");
set.add(p); // commentaire : on met l'objet dans le HashSet avec un hashCode dépendant de sku
p.sku = "SKU-2"; // commentaire : le champ participant à hashCode() a changé -> l'objet a « déménagé » logiquement dans un autre bucket
System.out.println(set.contains(p)); // false (commentaire : HashSet cherche dans le bucket selon le nouveau hash, alors que l'objet se trouve dans l'ancien)
Maintenant, transposons cette idée dans le contexte Hibernate. Le « mutateur aléatoire » le plus fréquent pour une entité est l'id généré. Tant que l'entité est nouvelle, id est souvent null. Ensuite, vous faites persist() ou sauvegardez un agrégat, Hibernate fait flush(), et soudainement id devient non null. Si votre hashCode() utilise id, alors le hash change « au milieu de la vie de l'objet ».
Et c'est particulièrement gênant lorsque vous utilisez Set pour les entités enfants à l'intérieur d'un agrégat : vous avez ajouté OrderItem à items, puis lors de la sauvegarde de la commande, Hibernate a attribué un id aux items — et maintenant certains items peuvent devenir « invisibles » pour les opérations sur Set.
3. Méthodes helper add()/remove() et l'égalité
Dans les jours précédents, nous nous sommes déjà habitués à l'idée qu'une relation bidirectionnelle n'est pas « deux annotations sur deux champs », mais une discipline de cohérence. Nous écrivons des méthodes helper pour qu'une seule opération dans le modèle métier mette à jour les deux côtés : la collection sur le parent et la référence au parent chez l'enfant. Et c'est là que commence la partie délicate : la méthode helper appelle souvent Set.remove(...), et dépend donc entièrement de la correction de equals()/hashCode().
Une paire typique de méthodes helper pour une commande ressemble à ceci (fortement raccourci, mais la logique est réelle) :
import java.util.Objects;
class PurchaseOrder {
private final Set<OrderItem> items = new HashSet<>();
public void addItem(OrderItem item) {
Objects.requireNonNull(item); // commentaire : se protéger d'un null accidentel
items.add(item); // commentaire : le succès du add dépend des equals/hashCode de l'élément
item.setOrder(this); // commentaire : synchroniser le owning side (OrderItem.order)
}
public void removeItem(OrderItem item) {
Objects.requireNonNull(item); // commentaire : se protéger d'un null accidentel
items.remove(item); // commentaire : si les equals/hashCode sont « tordus », remove peut ne pas trouver l'élément
item.setOrder(null); // commentaire : on peut désynchroniser les côtés si remove n'a pas fonctionné
}
}
Du point de vue humain, c'est lisible. Du point de vue Java, il y a un piège caché : items.remove(item) retourne un boolean. Si remove() n'a pas trouvé l'élément (à cause d'une égalité incorrecte), il renverra false. Mais nous faisons quand même item.setOrder(null). De ce fait, nous pouvons créer une désynchronisation : dans la collection items, l'élément est toujours présent, mais pour l'élément order == null. Hibernate n'aime pas cet état, et le développeur l'aime encore moins, car ensuite cela « se comporte étrangement lors du flush ».
Une méthode helper plus sûre (et honnête) pour un projet pédagogique ressemble à ceci : si remove n'a pas fonctionné, nous voulons le savoir immédiatement, et non trois jours plus tard en production.
import java.util.Objects;
class PurchaseOrder {
private final Set<OrderItem> items = new HashSet<>();
public void removeItem(OrderItem item) {
Objects.requireNonNull(item); // commentaire : vérification explicite au lieu d'un NPE quelque part profondément
boolean removed = items.remove(item); // commentaire : remove = signal clé que la collection a réellement changé
if (!removed) {
// commentaire : pour un projet pédagogique, mieux vaut échouer immédiatement que d'obtenir silencieusement un modèle « cassé »
throw new IllegalStateException("OrderItem was not found in order.items");
}
item.setOrder(null); // commentaire : on ne rompt le owning side que si la collection s'est définitivement mise à jour
}
}
Ce n'est pas une « vérité ultime pour tous les projets du monde », mais c'est une excellente assurance pédagogique : elle transforme un « bug silencieux dans la collection » en une exception bruyante et compréhensible à l'endroit même où vous pensiez supprimer un élément. Et donc, vous associez plus rapidement le symptôme à la cause : « equals()/hashCode() se comportent incorrectement ».
4. orphanRemoval=true et le diff de la collection
Au niveau des annotations, orphanRemoval=true sonne presque comme de la magie : « retiré de la collection — Hibernate supprimera de la base de données ». Et c'est vrai, mais avec une condition très importante : Hibernate doit voir que l'enfant a réellement cessé de faire partie de la collection du parent, et doit reconnaître correctement quel élément a disparu. Si la collection se comporte étrangement à cause de l'égalité, alors orphanRemoval commence à « ne pas fonctionner » ou fonctionne différemment de ce que vous attendez.
Rappelons-nous un endroit de mapping typique où orphanRemoval se manifeste. La commande a des lignes :
import jakarta.persistence.CascadeType;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.HashSet;
import java.util.Set;
@Entity
class PurchaseOrder {
@OneToMany(
mappedBy = "order", // commentaire : owning side du champ OrderItem.order
cascade = CascadeType.ALL, // commentaire : persist/merge/remove sont propagés aux éléments enfants
orphanRemoval = true // commentaire : « retiré de la collection » -> suppression de la base de données
)
private Set<OrderItem> items = new HashSet<>(); // commentaire : un diff correct dépend d'un remove() correct
}
Et la ligne a une référence vers la commande (le owning side vit sur ManyToOne, nous en avons déjà parlé dans le sujet owning side) :
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;
@Entity
class OrderItem {
@ManyToOne(fetch = FetchType.LAZY)
private PurchaseOrder order; // commentaire : c'est le owning side, c'est ce champ qui détermine la valeur de la FK dans la base de données
public void setOrder(PurchaseOrder order) {
this.order = order; // commentaire : en modifiant ce champ, vous modifiez réellement le owning side de la relation
}
}
Représentons maintenant le « scénario idéal » du point de vue de Hibernate. D'abord, nous chargeons la commande dans la transaction, puis nous supprimons une ligne de items, et lors du flush(), Hibernate fait un DELETE sur la table order_item. Très grossièrement, on peut le représenter comme une chaîne :
sequenceDiagram
participant S as "Service (@Transactional)"
participant PC as "Contexte de persistance"
participant DB as "PostgreSQL"
S->>PC: "charger PurchaseOrder (géré)"
PC->>S: "order.removeItem(item)"
PC->>PC: "enregistre que l'item n'est plus dans la collection"
S->>PC: flush / commit
PC->>DB: "DELETE FROM order_item WHERE id = ?"
L'étape clé ici est « enregistre que l'item n'est plus dans la collection ». Si Set.remove() n'a pas supprimé l'élément (parce qu'il n'a pas pu le trouver), Hibernate ne verra pas le « diff » attendu de la collection. Et alors, le DELETE pourrait ne pas arriver dans la base de données. Vous regarderez le code et penserez : « Mais j'ai bien appelé remove… ». Hibernate regardera l'état de la collection et pensera : « Et moi, je vois que l'élément est toujours là, qu'est-ce que tu veux ? ».
Parfois, cela se termine encore plus joyeusement : à cause d'un mauvais equals(), vous pouvez supprimer un élément qui n'est pas celui que vous aviez prévu. Par exemple, si l'égalité est trop « large » et considère comme égaux des objets différents, Set.remove() peut retirer un « semblable » au lieu du « bon ». Pour orphanRemoval, cela signifie non seulement « ne s'est pas supprimé », mais « s'est supprimé le mauvais » — et c'est une catégorie de bugs très désagréable.
5. Symptômes dans le projet
Maintenant, nous allons rassembler tout cela en un ensemble de symptômes reconnaissables. C'est exactement ainsi que ces problèmes se rencontrent généralement : non pas comme « oh, j'ai violé le contrat equals()/hashCode() », mais comme « pourquoi la taille de items est étrange » ou « pourquoi orphanRemoval n'a pas supprimé la ligne ». Notre tâche est d'apprendre à reconnaître rapidement ces symptômes et à soupçonner immédiatement l'égalité, plutôt que de commencer à bricoler avec flush() ou de modifier cascade au hasard.
Symptôme 1 : deux nouveaux objets enfants ont fusionné en un seul à l'intérieur d'un Set.
C'est un cas classique d'égalité basée uniquement sur l'id, où Objects.equals(id, other.id) considère null égal à null. En conséquence, deux nouveaux objets différents deviennent égaux.
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
class OrderItem {
Long id;
String sku;
OrderItem(Long id, String sku) { this.id = id; this.sku = sku; }
@Override public boolean equals(Object o) {
// commentaire : null == null -> true, donc « deux nouvelles entités » deviennent égales
return (o instanceof OrderItem other) && Objects.equals(id, other.id);
}
@Override public int hashCode() {
// commentaire : hashCode est aussi basé sur id, donc les nouveaux objets ont le même hashCode (en substance, à partir de null)
return Objects.hash(id);
}
}
Set<OrderItem> items = new HashSet<>();
items.add(new OrderItem(null, "SKU-1")); // commentaire : premier nouvel objet
items.add(new OrderItem(null, "SKU-2")); // commentaire : second nouvel objet, mais equals le considère comme « le même »
System.out.println(items.size()); // 1 (commentaire : la deuxième ligne « ne s'est pas ajoutée »)
C'est terrible parce que cela ne ressemble pas à une erreur, mais plutôt à « bon, c'est un Set, donc il a supprimé le doublon ». Sauf que le « doublon » est faux : vous avez perdu des données, alors que la logique métier ne les avait pas dupliquées. Dans une vraie commande, vous obtenez soudainement une seule ligne au lieu de deux.
Symptôme 2 : l'élément est devenu « invisible » après l'attribution de l'id (ou après la modification d'un champ participant à hashCode()).
En JPA, l'id apparaît souvent après flush(). Si hashCode() dépend de l'id, l'objet peut devenir invisible pour remove()/contains().
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
class Item {
Long id;
@Override public int hashCode() {
// commentaire : hashCode dépend de id
return Objects.hash(id);
}
@Override public boolean equals(Object o) {
// commentaire : equals dépend aussi de id
return (o instanceof Item other) && Objects.equals(id, other.id);
}
}
Set<Item> set = new HashSet<>();
Item x = new Item();
set.add(x); // commentaire : on a ajouté un objet avec id == null
x.id = 10L; // commentaire : on simule « id attribué plus tard » -> hashCode change
System.out.println(set.contains(x)); // false (commentaire : HashSet cherche maintenant dans un autre bucket)
En réalité, Hibernate attribuera l'id non pas via x.id = ..., mais à l'intérieur de l'ORM, mais l'effet pour le HashSet est le même : le hachage a changé, et l'objet ne se trouve plus là où on le cherche.
Symptôme 3 : la méthode helper a « supprimé », mais remove() a renvoyé false, et l'état est devenu contradictoire.
C'est l'un des bugs les plus désagréables, car vous créez vous-même un modèle tordu : le parent pense que l'enfant est encore à l'intérieur, tandis que l'enfant pense qu'il ne l'est plus.
class PurchaseOrder {
private final Set<OrderItem> items = new HashSet<>();
public void removeItem(OrderItem item) {
boolean removed = items.remove(item); // commentaire : false signifie que la collection n'a pas changé
System.out.println("removed = " + removed); // removed = false
item.setOrder(null); // commentaire : c'est ici qu'apparaît la désynchronisation des côtés
}
}
Dans le contexte d'orphanRemoval, une telle incohérence conduit souvent à l'absence de DELETE. Et parfois, elle conduit à des UPDATE inattendus (par exemple, la FK devient null), si vous avez accidentellement « touché » au owning side, mais la collection est restée inchangée. D'où la sensation que « Hibernate fait quelque chose d'étrange », alors que la cause première est que la collection n'a pas pu exécuter correctement remove().
Symptôme 4 : vous comptiez sur l'« unicité » du Set, mais elle ne concerne pas le domaine.
C'est une chose plus philosophique, mais très pratique. Set garantit l'unicité selon equals(), mais equals() d'une entité doit refléter l'identité de l'entité, et non pas « l'unicité d'une ligne de commande selon une règle métier ». Si OrderItem a une égalité basée sur id, alors avant la sauvegarde id == null, et Set ne vous empêchera pas d'ajouter deux lignes identiques. Si vous commencez à construire l'égalité sur « l'unicité métier de la ligne » (par exemple, productSkuSnapshot), vous risquez de casser l'identité globale de l'entité et d'obtenir des bizarreries où des lignes de commandes différentes deviennent soudainement « égales ». Il est donc très important : la collection ne doit pas être votre seul mécanisme de respect des invariants du domaine, surtout pour les nouveaux objets.
Symptôme 5 : orphanRemoval n'a « pas fonctionné », et une ligne est restée dans la base de données.
C'est le point culminant de tous les symptômes précédents. Le code dans le service semble plausible : on charge la commande, on retire une ligne, on commit. Mais le DELETE n'a pas eu lieu, parce que Hibernate n'a pas vu le changement attendu de la collection (ou l'a vu incorrectement). Et voilà qu'il reste une ligne dans la table order_item que vous pensiez supprimée. Au mieux, vous le remarquerez dans les tests et via la trace SQL. Au pire, quelqu'un verra une « ligne supplémentaire » dans un rapport, et une enquête amusante commencera.
6. Erreurs typiques avec Set et orphanRemoval
Dans ce sujet, ce qui est frustrant, c'est que les erreurs semblent souvent « logiques » et même belles dans le code. En général, le développeur n'essaie pas de mal faire — il transfère simplement les habitudes des classes Java ordinaires aux entités JPA et ne tient pas compte du fait que Hibernate change les règles du jeu : l'id n'apparaît pas immédiatement, les proxys existent, les collections sont gérées par l'ORM, et orphanRemoval dépend d'un diff correct de la collection. Ci-dessous, un ensemble de pièges typiques qui se rencontrent particulièrement souvent autour de Set et de la suppression des enfants.
Erreur n°1 : utiliser une égalité basée uniquement sur l'id à la manière de Objects.equals(id, other.id) sans tenir compte de null.
Deux nouvelles entités avec id == null deviennent soudainement égales. Dans un Set, cela se transforme en « pourquoi la deuxième ligne ne s'est-elle pas ajoutée », et dans les méthodes helper en « pourquoi remove supprime le mauvais » ou « pourquoi des éléments disparaissent ».
Erreur n°2 : inclure l'id généré dans hashCode(), puis placer l'objet dans un HashSet avant que l'id ne soit stabilisé.
Cela provoque des éléments « invisibles » : vous avez ajouté l'objet, puis Hibernate lui a attribué un id lors du flush, et maintenant contains()/remove() peuvent cesser de fonctionner. Dans les relations PurchaseOrder.items, c'est particulièrement douloureux, car l'opération métier « retirer une ligne » cesse d'être fiable, et orphanRemoval commence à ressembler à de la « magie aléatoire ».
Erreur n°3 : ne pas vérifier le résultat de remove() dans la méthode helper et quand même rompre la relation du côté owning.
Ainsi, vous créez vous-même une incohérence entre les côtés de la relation bidirectionnelle. Hibernate essaiera ensuite de « réconcilier » cet état lors du flush, et le résultat peut différer de vos attentes. C'est le cas où un petit boolean removed économise des heures de débogage.
Erreur n°4 : essayer de faire en sorte que Set garantisse l'unicité du domaine, en substituant ainsi le modèle correct d'identité de l'entité.
Set est unique selon equals(), mais equals() d'une entité doit répondre à la question « est-ce la même entité ? », et non « ces deux lignes de commande décrivent-elles le même produit ? ». Si vous mélangez ces idées, vous obtiendrez que des lignes de commandes différentes deviennent soudainement « égales », puis des effets très étranges commenceront.
Erreur n°5 : construire l'égalité sur des champs qui peuvent changer (même si « en théorie ils ne devraient pas »).
Cela peut être status, name, un « champ temporaire », ou toute propriété que quelqu'un modifiera un jour dans la réalité. Dès que le champ participant à hashCode() a changé — l'objet dans le HashSet devient un problème. Et dans un projet Hibernate, ces modifications se produisent souvent non seulement à la main du développeur, mais aussi via le mapping DTO → entity, ce qui rend le bug encore plus désagréable.
GO TO FULL VERSION