1. Introduction
Commençons par une douleur simple et concrète. Dans une vraie application, une mise à jour ressemble rarement à « voilà un bel objet PurchaseOrder bien rempli, prends-le et persiste-le ». Le plus souvent, c'est un bout de données qui arrive : « change le statut », « modifie la quantité sur une ligne », « mets à jour la note du manager ». Et là, on réalise soudain qu'appliquer aveuglément l'objet entier, c'est un excellent moyen d'effacer accidentellement ce qu'on n'avait pas du tout l'intention de toucher.
merge() sait ramener un état detached dans le monde managed en le copiant dans une instance managed. Mais le problème, c'est qu'il copie précisément l'état qu'on lui a apporté. Si on lui apporte un « morceau » — un graphe partiellement rempli — on risque de copier en base non pas une modification, mais une perte de données accidentelle. Et c'est là qu'apparaît l'approche find() + mutate : on commence par récupérer la vérité actuelle depuis la base en tant qu'entité managed, puis on modifie explicitement uniquement ce qui est autorisé et vraiment nécessaire.
Il est important de comprendre : cette approche n'est pas «à la place de Hibernate» ni «parce que merge est mauvais». Elle est utile parce qu'une mise à jour métier n'est généralement pas une resauvegarde d'objet, mais une commande de modification de champs précis.
2. Le modèle : de la base à managed
Si merge() ressemble souvent à « prends ce qu'on m'a donné et fais que ce soit la vérité en base », alors find + mutate est bien plus serein. On dit à Hibernate : « Donne-moi l'entité actuelle depuis la base », on obtient un objet managed, et ensuite on travaille avec lui comme un objet normal, en sachant que le dirty checking générera lui-même l'UPDATE nécessaire.
Ce modèle est très proche de ce qu'Hibernate attend naturellement dans un unit of work classique : on trouve l'entité, on la modifie, on commit. Dans les exemples officiels, ça ressemble littéralement à entityManager.find(...), puis un appel de setter — et ensuite Hibernate envoie l'UPDATE.
Pour que ça reste bien ancré, imagine deux rôles :
La base de données — source de vérité : ce qui existe maintenant (what is).
La commande ou le DTO venant du monde extérieur — intention de modification : ce qui doit changer (what should change).
find + mutate relie précisément what is et what should change de la façon la plus directe qui soit.
Petit schéma du processus :
flowchart TD
%% Idée : on prend d'abord l'entité managed, puis on la modifie de façon ciblée — et seulement ensuite l'UPDATE part au flush/commit
A["Début de l'opération métier (unit of work)"] --> B["find(orderId) -> managed PurchaseOrder"]
B --> C["Vérifications/validation (peut-on modifier ?)"]
C --> D["mutate : on change uniquement les champs nécessaires"]
D --> E["flush/commit -> Hibernate génère le SQL UPDATE"]
Pas de magie ici. Juste une séquence d'actions qu'on peut expliquer en code review aussi facilement qu'une recette d'omelette.
3. Le squelette du scénario de mise à jour
Voilà de l'ingénierie presque quotidienne. Si tu veux que find + mutate fonctionne de façon prévisible, tu dois faire deux choses : exécuter l'opération dans un contexte de travail unique, c'est-à-dire dans une transaction, et garantir que tu travailles vraiment avec un objet managed. Dans le monde Spring, ça signifie le plus souvent une méthode de service avec @Transactional, qui définit la frontière du unit of work : à l'intérieur, tu lis et modifies les entités.
La documentation Spring Data montre explicitement l'idée d'un service ou façade qui définit les frontières transactionnelles pour des opérations avec plusieurs appels de repository. C'est exactement ce dont on a besoin : faire en sorte que find() et les modifications suivantes vivent dans la même transaction.
Squelette minimal du service — simplifié, mais dans l'esprit :
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderCommandService {
// EntityManager — c'est le point d'entrée dans le persistence context (le contexte où les entités deviennent managed)
private final EntityManager entityManager;
// On injecte EntityManager pour travailler avec find()/flush etc.
public OrderCommandService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional // Important : find() et mutate doivent vivre dans la même transaction (dans le même unit of work)
public void markPaid(long orderId) {
// find() retourne une entité managed (ou null si la ligne n'existe pas)
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
if (order == null) throw new IllegalArgumentException("Order not found: " + orderId);
// On modifie le champ de l'objet managed — Hibernate le détectera via dirty checking et générera un UPDATE au flush/commit
order.setStatus(OrderStatus.PAID);
}
}
Remarque deux détails. Premièrement, find() peut retourner null, et ce n'est pas un accident — c'est une branche de comportement normale : la ligne peut ne pas exister. Deuxièmement, après find(), l'objet est déjà managed, ce qui signifie que modifier ses champs revient à modifier l'état du persistence context, pas juste « changer une variable en mémoire et l'oublier ».
Si tu veux de la visibilité, surtout au début, tu peux vérifier temporairement la gestion :
PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
// contains(...) montre : l'objet est-il sous le contrôle du persistence context courant ?
System.out.println(entityManager.contains(order)); // true
Tu vois littéralement : oui, cet objet est sous la surveillance d'Hibernate.
4. Selective mapping : le patch sans pertes
Voici maintenant la partie la plus importante de l'approche. find + mutate va presque toujours de pair avec l'idée de selective mapping : on ne copie pas l'objet en entier, mais on ne transfère que ce qui constitue vraiment une commande de modification. Sinon, on arrive très vite à la situation : « le DTO n'avait pas de champ deliveryAddress, donc il est arrivé comme null, donc on a écrasé l'adresse de livraison en base par null ». Félicitations : tu viens d'inventer un bug qui réapparaîtra toutes les deux semaines, obligatoirement chez un client VIP.
C'est pourquoi on introduit un objet de commande séparé — quelque chose comme une commande de patch.
Petit exemple de commande pour une commande, simplifié :
import com.example.commerce.orders.entity.OrderStatus;
// Patch-commande : ne porte que ce qu'on veut potentiellement modifier
// Dans la version pédagogique, on convient de ceci : null = "champ non transmis, donc on ne touche pas"
public record OrderPatchCommand(OrderStatus status, String customerNote) {
}
Ensuite, on écrit une méthode qui applique les modifications selon les règles. La règle la plus simple pour le projet pédagogique : null signifie « on ne modifie pas ».
public void applyPatch(PurchaseOrder order, OrderPatchCommand cmd) {
// On change le statut uniquement s'il est vraiment arrivé dans la commande
if (cmd.status() != null) {
order.setStatus(cmd.status());
}
// Pareil pour la note — l'absence du champ ne doit pas effacer la valeur en base
if (cmd.customerNote() != null) {
order.setCustomerNote(cmd.customerNote());
}
}
Point important : ce code semble ennuyeux. Et c'est un compliment. Le code ennuyeux dans la persistence layer signifie généralement « prévisible ». Et la prévisibilité dans Hibernate, c'est presque comme une assurance : elle ne te réjouit pas tous les jours, mais un jour elle sauve le projet.
Il y a aussi une question plus subtile qu'on te posera tôt ou tard au travail : « Et si null est une nouvelle valeur intentionnelle, c'est-à-dire qu'on veut mettre le champ à null ? » Dans ce cas, null ne peut plus signifier « ne pas modifier ». Pour de tels cas, on introduit généralement des marqueurs séparés — par exemple Optional, un boolean «present» séparé ou un vrai JSON Patch. Mais dans le cadre de notre niveau actuel, il suffit de convenir honnêtement : null = « champ non transmis, on ne touche pas ».
5. L'objet managed et l'UPDATE sans save()
Il est important ici de relier deux choses déjà connues — dirty checking et flush — avec notre pattern du jour. Quand tu as obtenu une entité via find(), elle est devenue managed. Cela signifie qu'Hibernate la considère comme faisant partie du persistence context courant et va comparer son état actuel avec le snapshot qu'il a sauvegardé au chargement. Si tu as modifié un champ — il préparera un UPDATE.
Dans la documentation Hibernate, ce scénario est montré littéralement en une ligne : on trouve l'entité, on modifie le champ — et ensuite l'UPDATE part.
Il en découle une habitude pratique : si dans le service tu fais find + setSomething(...), le repository.save(entity) mécanique après ça n'est presque jamais nécessaire. Et parfois il est même nuisible, parce qu'il peut t'entraîner accidentellement dans la sémantique de merge si l'objet est soudainement detached.
Pour voir ce qui se passe, il est utile d'activer le SQL trace et d'observer le pattern typique :
1) SELECT ... WHERE id=? — c'est find(),
2) UPDATE ... — c'est le dirty checking lors du flush.
Et oui, l'UPDATE partira généralement non pas au moment de l'appel du setter, mais plus tard — près du flush ou du commit. Ce n'est pas un bug, c'est l'architecture du unit of work.
6. Relations et collections : modifications ciblées
Passons maintenant là où find + mutate brille particulièrement : quand une entité a des données liées. Une commande (PurchaseOrder) a presque certainement des lignes (OrderItem). Et là, un développeur débutant ressent une tentation très forte : « J'ai reçu un DTO avec une liste d'items — je vais juste faire order.setItems(newItems). » Ça a l'air beau exactement jusqu'au premier bug, quand tu supprimes soudainement des lignes que l'utilisateur ne voulait pas supprimer, ou que tu crées des doublons, ou que tu déclenches des cascades et suppressions orphelines, ou que tu obtiens simplement un volume inattendu de SQL.
L'intuition plus sûre dans l'esprit de find + mutate est la suivante : on a trouvé la commande actuelle, puis on modifie de façon ciblée la ligne souhaitée. Par exemple, on change la quantité d'un item précis par son itemId.
public void changeItemQty(PurchaseOrder order, long itemId, int qty) {
// On cherche la ligne précise dans l'état courant de la commande (pas de réécriture de toute la collection)
for (OrderItem item : order.getItems()) {
// Important : on ne modifie qu'un seul item, on ne "redessine" pas tout le graphe
if (item.getId().equals(itemId)) {
item.setQuantity(qty); // entité managed / élément de collection -> dirty checking se déclenchera au flush
return;
}
}
// On gère explicitement le cas "ligne introuvable" pour ne pas prétendre que la mise à jour a eu lieu
throw new IllegalArgumentException("Item not found: " + itemId);
}
Ce code est simple, mais il exprime une idée importante : « On modifie une ligne ou un objet précis, on ne redessine pas tout le graphe. » Pour le projet pédagogique, c'est une discipline idéale : tu dépends moins des cascades et tu risques moins de « réparer une chose en en cassant trois autres ».
Si tu dois ajouter une nouvelle ligne, l'approche est similaire : ne pas remplacer la collection entière, mais ajouter un seul élément. Dans un vrai projet, on le fait généralement via une méthode helper sur l'entité pour ne pas rompre la cohérence de la relation, mais dans le cadre du sujet du jour, l'essentiel est de comprendre le principe : la modification doit être locale et intentionnelle.
Et oui, ça peut paraître plus verbeux que merge(). Mais c'est justement l'un de ces cas où la verbosité est le prix du contrôle.
7. SQL trace : SELECT et UPDATE au flush
Relions maintenant la logique du pattern avec le SQL observable, pour que tu ne prennes pas ma parole — et en général, pour que tu ne prennes jamais la parole de personne sur les sujets ORM. find() signifie presque toujours lire l'état actuel depuis la base, c'est-à-dire un SELECT. Ensuite tu modifies les champs, mais le SQL peut ne pas partir immédiatement. Il partira au flush ou au commit, ou plus tôt si un déclencheur de flush se produit — tu as déjà étudié ça dans la journée sur le flush.
Le profil SQL ressemble schématiquement à ceci :
-- find() : on lit la "vérité" actuelle dans la table
select id, status, customer_id
from purchase_order
where id = ? -- ? sera substitué par JDBC/ORM en tant que paramètre
-- plus tard, au flush/commit : Hibernate enverra l'UPDATE minimal nécessaire
update purchase_order
set status = ?
where id = ?
C'est précisément ce caractère en deux phases — d'abord on lit la vérité managed, puis on envoie les modifications — qui rend find + mutate si prévisible. Est-ce cher ? Parfois oui, parce que ça inclut presque toujours un SELECT. Mais très souvent, c'est le même SELECT que merge() pourrait de toute façon faire en sous-main, puisqu'Hibernate peut lire l'état actuel avant d'appliquer le merge pour garantir une sémantique correcte.
La différence : avec find + mutate, tu vois ce SELECT dans le code, donc tu peux en discuter et le contrôler.
8. Quand find + mutate est particulièrement utile
Il existe une croyance dangereuse : « S'il y a un SELECT en plus, c'est qu'on a mal optimisé. » Elle est surtout populaire chez les gens qui viennent de découvrir le mot « performance » et veulent maintenant des UPDATE sans lecture partout. Dans la persistence layer, ça se finit souvent par le fait qu'on optimise non pas les performances, mais la vitesse d'apparition des bugs.
find + mutate est particulièrement utile quand :
— tu fais une mise à jour partielle et modifies quelques champs sans toucher aux autres ;
— tu as des invariants importants, par exemple on ne peut pas passer une commande à PAID si elle est déjà CANCELLED ;
— les données d'entrée sont incomplètes ou « sales » — le monde habituel des intégrations et des UI ;
— tu veux que le code soit facile à lire en code review sans devoir deviner « et qu'est-ce que merge() va toucher en cascade là-dedans ».
Hibernate souligne dans ses ressources que merge() échange une instance detached contre une instance persistent, et que l'objet source reste detached. Il note aussi qu'on peut prendre le contrôle du state retrieval lors du merge en appelant find() avant merge(). En pratique, c'est encore un indice : find n'est pas un « travail en trop », c'est un moyen fondamental de stabiliser le comportement.
En termes humains : find + mutate, c'est quand tu regardes d'abord la situation réelle — l'état actuel de la ligne — et seulement ensuite tu décides quoi changer. Pas l'inverse.
9. Erreurs typiques avec find + mutate
Erreur n° 1 : «J'ai trouvé l'objet managed et j'ai quand même fait merge()/save() par précaution.»
Ça ressemble à une assurance, mais c'est souvent juste une opération superflue et une confusion inutile. Si l'objet est managed, les modifications dirty seront de toute façon détectées. Tu fais donc soit un travail inutile, soit tu glisses implicitement vers la sémantique de merge là où elle n'est pas nécessaire. C'est particulièrement dommage quand des SELECT inattendus commencent à apparaître : tu voulais de la prévisibilité et tu l'as toi-même brouillée.
Erreur n° 2 : «null dans la commande signifie "mettre à null", mais je l'interprète comme "ne pas modifier" — ou l'inverse.»
Ce n'est pas un bug d'Hibernate, c'est un bug de convention. La commande de mise à jour doit avoir une sémantique claire : que signifie l'absence d'un champ, que signifie null, que signifie une chaîne vide. Si tu n'en as pas discuté explicitement, le selective mapping devient un générateur de surprises. Dans le projet pédagogique, le plus simple est de convenir que null = « on ne modifie pas », et de tenir cette règle rigoureusement.
Erreur n° 3 : «Je veux mettre à jour une ligne de commande, donc je remplace toute la collection items.»
C'est l'erreur logique d'échelle la plus fréquente. On a l'impression de mettre à jour une ligne, mais en réalité on modifie toute une structure de données, ce qui peut déclencher des cascades, des suppressions, des insertions et un volume de SQL énorme. find + mutate est justement utile parce qu'il pousse aux modifications ciblées : on trouve la commande, on trouve l'item, on change la quantity.
Erreur n° 4 : «J'ai oublié de gérer le cas "entité introuvable".»
find() peut retourner null. Le repository peut retourner Optional.empty(). C'est normal. Ce qui n'est pas normal — continuer comme si l'entité existait. Dans le meilleur cas, tu obtiens une NPE, dans le pire — tu essaies de créer une « mise à jour » là où elle ne devrait pas exister, et la logique métier commence à vivre en mode « eh ben, on verra ».
Erreur n° 5 : «J'ai fait find à un endroit et mutate ailleurs, déjà hors transaction.»
Tu transformes ainsi facilement le scénario managed en detached-flow, mais de façon moins visible. find + mutate suppose que la lecture et la modification se produisent dans le même unit of work. Si tu les as séparés dans des endroits différents et perdu cette frontière, tu te retrouves à nouveau dans le monde des objets detached, où les modifications « existent en mémoire », mais pas en base. Hibernate souligne séparément : lors du detachment, on peut modifier l'objet, mais le persistence context ne l'apprendra pas automatiquement, et l'application doit intervenir explicitement.
GO TO FULL VERSION