🎯 OBJECTIF
Comprendre comment :
cascade et orphanRemoval déclenchent des DELETE inattendus si mal maîtrisésList vs Set a un impact SQL massif (DELETE ALL + INSERT ALL)equals/hashCode incorrects provoquent des DELETE + INSERT massifs et cassent le lock optimiste@OneToOne, @ManyToMany et @JoinColumn — cas avancés et pièges de modélisation🧠 MODÈLE MENTAL
Hibernate ne manipule pas des tables SQL. Il manipule un graphe d'objets Java. Pour chaque modification, il doit décider : INSERT ? UPDATE ? DELETE ? DELETE + INSERT ? Rien ? Il se base uniquement sur l'ID, le Persistence Context, equals()/hashCode(), le type de collection et la owning side — jamais sur ton intention métier.
La owning side est la clé : seule la owning side génère du SQL. Le côté @OneToMany(mappedBy) est ignoré pour les écritures. Modifier uniquement ce côté ne produit aucun effet SQL, aucune exception, et laisse la FK à null en base. C'est le bug silencieux le plus fréquent dans les mappings JPA.
Pour les collections : Set permet à Hibernate de détecter le delta (ajout/suppression d'un élément). List sans @OrderColumn est un BAG — Hibernate ne sait pas calculer le delta, donc il fait DELETE ALL + INSERT ALL pour un seul ajout. Sur une collection de 500 éléments, c'est 500 DELETE + 501 INSERT au lieu d'1 INSERT.
@Entity
class Stock {
@Id Long id;
@ManyToOne(fetch = FetchType.LAZY) // ✓ LAZY par défaut
@JoinColumn(name = "address_id")
Address address;
}javaLa FK address_id est dans la table stock. Stock est le propriétaire de la relation (owning side). Hibernate sait exactement quoi faire — un UPDATE sur stock.address_id.
🚨 Ne jamais utiliser @OneToMany seul
// ❌ DANGEREUX — crée une table de jointure inutile
@Entity
class Address {
@OneToMany
List<Stock> stocks;
}
// → Hibernate crée address_stock(address_id, stock_id)
// → SQL complexe, performances dégradéesjava@Entity
class Address {
@OneToMany(mappedBy = "address") // ✓ lecture seule pour SQL
List<Stock> stocks = new ArrayList<>();
}
@Entity
class Stock {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "address_id") // ✓ propriétaire de la FK
Address address;
}javamappedBy = "address" signifie : "cette relation est gérée par l'autre entité" — Hibernate ne génère aucun SQL depuis ce côté. Le SQL réel est UPDATE stock SET address_id = ? WHERE id = ?.
// ❌ BUG SILENCIEUX — modifie uniquement l'inverse side
address.getStocks().add(stock);
// → aucune exception, aucun SQL, FK null en base
// ✓ PATTERN OBLIGATOIRE — toujours synchroniser les deux côtés
public void addStock(Stock stock) {
stocks.add(stock);
stock.setAddress(this); // ✓ modifie la owning side
}
public void removeStock(Stock stock) {
stocks.remove(stock);
stock.setAddress(null); // ✓ modifie la owning side
}java🚨 Règle absolue
Modifier uniquement le côté @OneToMany(mappedBy) ne produit aucun SQL. Toujours modifier le côté @ManyToOne (owning side) et exposer des méthodes helper qui synchronisent les deux côtés.
Cascade = propagation des opérations JPA, pas du SQL automatique.
| CascadeType | Déclencheur | SQL généré |
|---|---|---|
PERSIST |
persist(parent) |
INSERT parent + INSERT enfants |
MERGE |
merge(parent) |
UPDATE parent + UPDATE enfants |
REMOVE |
remove(parent) |
DELETE enfants + DELETE parent |
ALL |
tout | = PERSIST + MERGE + REMOVE + REFRESH + DETACH |
🚨 CascadeType.REMOVE est dangereux
remove(address) avec cascade = REMOVE supprime TOUS les stocks sans confirmation. À utiliser uniquement quand les enfants n'ont aucune existence autonome (ex : lignes de commande sans commande).
@OneToMany(mappedBy = "address", orphanRemoval = true)
List<Stock> stocks;javaorphanRemoval = true : un enfant retiré de la collection est automatiquement DELETE en base, même sans appeler repository.delete().
🚨 Remplacer la collection entière = DELETE massif
// ❌ CATASTROPHIQUE avec orphanRemoval = true
address.setStocks(new ArrayList<>());
// → DELETE FROM stock WHERE address_id = ? (toutes les lignes)javaToujours modifier la collection existante (add/remove), jamais la remplacer par une nouvelle instance.
| Action | Cascade REMOVE | orphanRemoval |
|---|---|---|
remove(parent) |
DELETE enfants ✅ | DELETE enfants ✅ |
collection.remove(enfant) |
rien ❌ | DELETE enfant ✅ |
| Remplacer la collection | rien ❌ | DELETE massif ✅ |
List sans @OrderColumn = BAG — Hibernate ne peut pas calculer le delta.
// ❌ List sans @OrderColumn — DELETE ALL + INSERT ALL sur tout changement
@OneToMany(mappedBy = "order")
List<OrderLine> lines; // BAG
// SQL réel pour un seul ajout :
// DELETE FROM order_line WHERE order_id = 1
// INSERT INTO order_line ... (toutes les lignes, y compris la nouvelle)java// ✓ Set — INSERT ciblé (si equals/hashCode corrects)
@OneToMany(mappedBy = "order")
Set<OrderLine> lines;
// SQL réel : INSERT INTO order_line ... (uniquement la nouvelle)java| Type | Ordre | Doublons | Perf | Usage |
|---|---|---|---|---|
Set |
❌ | ❌ | ✅ Excellent | Par défaut |
List + @OrderColumn |
✅ | ✅ | ⚠️ Moyen | Ordre métier obligatoire |
List (BAG) |
❌ | ✅ | ❌ Mauvais | À éviter |
// ❌ MAUVAIS — basé sur champs mutables → Hibernate perd l'identité de l'entité
@Override
public boolean equals(Object o) {
Stock other = (Stock) o;
return Objects.equals(location, other.location);
}
// ✓ CORRECT — basé sur ID technique
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Stock other = (Stock) o;
return Objects.equals(id, other.id);
}
@Override
public int hashCode() { return Objects.hash(id); }java@Entity
class User {
@Id Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "address_id", unique = true) // ✓ UNIQUE pour garantir le 1-1
Address address;
@OneToOne(mappedBy = "user") // ✓ côté inverse — pas de SQL
UserProfile profile;
}java@Entity
class UserProfile {
@Id Long id; // même ID que User
@OneToOne
@MapsId // ✓ utilise l'ID de User comme PK — pas de colonne FK supplémentaire
@JoinColumn(name = "id")
User user;
String bio;
String avatar;
}javaAvantages de @MapsId : pas de colonne FK supplémentaire, JOIN plus simple (même ID), garantie 1-1 par design.
🚨 LAZY ignoré côté mappedBy sur @OneToOne
Sur le côté inverse (mappedBy), fetch = LAZY est souvent ignoré par Hibernate. Pour savoir si la relation existe, Hibernate doit charger l'entité. Solution : accepter le EAGER côté inverse, ou utiliser @MapsId pour éviter ce problème structurellement.
@OneToOne uniquement pour des entités avec des cycles de vie différents : User ↔ UserProfile (optionnel, données étendues), Order ↔ Invoice, Employee ↔ Passport. Éviter pour User ↔ UserSettings ou Product ↔ ProductDetails — fusionner dans une seule table suffit.
@Entity
class Student {
@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
Set<Course> courses = new HashSet<>();
}javaProblèmes en production : pas d'attributs sur la relation (grade, date d'inscription), pas de @Version, pas d'audit, retirer un élément de la collection ≠ supprimer l'entité Course.
Solution : entité dédiée à la place de @ManyToMany
@Entity
class Enrollment {
@Id Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "student_id")
Student student;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "course_id")
Course course;
// ✓ Attributs de la relation — impossible avec @ManyToMany
LocalDate enrollmentDate;
BigDecimal grade;
@Enumerated(EnumType.STRING)
EnrollmentStatus status;
@Version Integer version;
}javaAvantages : extensible (attributs métier), versionnable, auditable, requêtable directement en JPQL. Pattern recommandé dès le début.
@ManyToOne
@JoinColumn(
name = "customer_id", // nom de la colonne FK
referencedColumnName = "code", // référence customer.code au lieu de customer.id (défaut = PK)
nullable = false, // NOT NULL
foreignKey = @ForeignKey(name = "fk_order_customer") // nom de la contrainte
)
Customer customer;java@Entity
class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
Customer customer; // ✓ écrit la FK
// ✓ expose la FK comme Long sans charger l'entité Customer
@Column(name = "customer_id", insertable = false, updatable = false)
Long customerId;
}
// Usage : lire la FK sans déclencher le lazy loading
Long customerId = order.getCustomerId(); // pas de SELECT sur customerjavaUtile pour : lire la FK sans JOIN, affichage dans DTO, mappings legacy où la FK est gérée par une autre application.
@ManyToOne
@JoinColumns({
@JoinColumn(name = "stock_product_id", referencedColumnName = "productId"),
@JoinColumn(name = "stock_location_id", referencedColumnName = "locationId")
})
Stock stock;java⚡ TL;DR — chaque concept en une ligne
Owning side vs inverse side
✓ Seule la owning side (@ManyToOne) génère du SQL — le côté @OneToMany(mappedBy) est ignoré pour les écritures.
⚠ Modifier uniquement le côté mappedBy ne produit aucune erreur mais laisse la FK à null en base silencieusement.
Cascade REMOVE / orphanRemoval
✓ Cascade REMOVE supprime les enfants quand le parent est supprimé ; orphanRemoval supprime l'enfant quand il quitte la collection.
⚠ Remplacer une collection (setList(new ArrayList<>())) avec orphanRemoval déclenche un DELETE massif sur toute la collection.
List (BAG) vs Set
✓ Set permet à Hibernate de calculer le delta exact — un ajout = un INSERT ciblé.
⚠ List sans @OrderColumn est un BAG — Hibernate fait DELETE ALL + INSERT ALL pour tout changement, même un seul ajout.
equals/hashCode
✓ Baser uniquement sur l'ID technique ou une clé métier immuable — Hibernate identifie les entités par leur equals().
⚠ Un equals() sur champs mutables casse les structures Set/Map internes d'Hibernate et provoque DELETE + INSERT au lieu d'UPDATE.
@OneToOne LAZY côté mappedBy
✓ @MapsId évite structurellement ce problème — même ID pour les deux entités, pas de colonne FK supplémentaire.
⚠ fetch = LAZY sur le côté mappedBy d'un @OneToOne est souvent ignoré — Hibernate charge l'entité pour vérifier si elle existe.
@ManyToMany ✓ Simple pour les relations N-N sans attributs métier. ⚠ Pas extensible, pas versionnable — remplacer systématiquement par une entité dédiée dès que la relation a des attributs ou un cycle de vie propre.
insertable=false, updatable=false
✓ Expose la FK comme un champ Java sans que Hibernate ne l'écrive — lecture sans JOIN.
⚠ Si la FK est mappée deux fois (relation + champ), une seule doit écrire — l'autre doit être insertable=false, updatable=false.
🎓 À retenir
@OneToMany seul crée une table de jointure inutile — toujours créer une relation bidirectionnelle avec mappedBy. La table de jointure dégrade les performances et complexifie le modèle sans apport.equals() correct, c'est 1 INSERT. Le choix de collection peut multiplier le volume SQL par 1000.@ManyToMany en production = dette technique immédiate — la question n'est pas "si" mais "quand" tu auras besoin d'un attribut sur la relation. Commencer directement avec une entité dédiée évite une migration douloureuse plus tard.