CodeGym /Cours /Hibernate deep-dive /UPDATE inattendus et accidental update

UPDATE inattendus et accidental update

Hibernate deep-dive
Niveau 3, Leçon 2
Disponible

1. D'où vient l'UPDATE inattendu

Un UPDATE inattendu dans Hibernate signifie presque toujours une seule chose : quelque part à l'intérieur de la transaction, une entité managed a changé. Si tu as l'habitude du «SQL manuel», le cerveau s'attend à autre chose : un UPDATE n'apparaît que là où tu l'as écrit toi-même ou où tu as explicitement appelé save(). L'ORM brise ce réflexe. Ici, l'UPDATE n'est pas une commande dans le code, c'est la conséquence du fait qu'un objet managed a changé dans le cadre de la transaction.

Une fois que c'est compris, la question d'ingénierie suivante surgit : pourquoi une mise à jour part parfois alors que le code ressemble à une lecture ? C'est exactement ça, l'accidental update.

Fixons ça de la façon la plus concrète possible. À la fin de la transaction, Hibernate synchronise le persistence context avec la base. Pour ça, il regarde les entités managed : si l'état courant diffère du snapshot, il est tenu de préparer une mise à jour. Le piège : tu ne voulais peut-être pas modifier les données, mais tu as quand même modifié un champ sur un objet managed quelque part. Hibernate ne sait pas lire tes intentions — il sait lire les modifications en mémoire.

En image, ça ressemble à ça :

flowchart TD
    A["@Transactional : début de la transaction"] --> B["find() : on obtient une entité managed"]
    B --> C["Le code «presque lit»..."]
    C --> D["...mais modifie quelque part un champ (setter/mapper/side effect)"]
    D --> E["En fin de transaction : dirty checking"]
    E -->|différence avec le snapshot| F["On prépare un SQL UPDATE"]
    E -->|pas de différence| G["Pas d'SQL UPDATE nécessaire"]

Note : ce schéma ne comporte pas d'étape obligatoire «a appelé save()». C'est normal. Hibernate fonctionne selon le modèle «objet modifié → l'ORM synchronise».

2. Accidental update : sens et causes

Le terme accidental update ressemble presque à un diagnostic, et c'est à peu près ça. C'est la situation où un UPDATE est bien apparu dans le SQL log, mais que du point de vue de la logique métier tu le considères comme «superflu» ou «inattendu». Important de ne pas se tromper : ça ne veut pas dire qu'Hibernate a «mis à jour quelque chose tout seul». Ça veut dire que dans le cadre de la transaction, l'état d'un objet managed a réellement changé, simplement tu ne le prévoyais pas comme une modification métier.

Pour ne pas se battre avec la réalité, il est pratique de distinguer deux cas. La mise à jour intentionnelle — c'est quand le scénario est clairement une écriture : «renommer le produit», «confirmer la commande», «masquer le produit du catalogue». La mise à jour accidentelle — c'est quand le scénario ressemble à une lecture ou une préparation de réponse, mais qu'à l'intérieur il y a une «petite amélioration des données» : on a retiré des espaces, normalisé la casse, corrigé le format, recalculé un champ «au cas où», mis à jour un champ technique d'horodatage, etc.

Voici un tableau pour garder cette distinction en tête :

Scénario dans le code Intention apparente Ce qui s'est passé réellement Pourquoi Hibernate a raison
Méthode «construire le titre du produit» lecture/formatage product.setName(...) le champ a changé par rapport au snapshot
Méthode «afficher la commande dans l'admin» lecture/affichage order.setStatus(...) quelque part en profondeur le statut a changé, donc un UPDATE est nécessaire
Mapper DTO «appliquer le patch» mettre à jour 1–2 champs le mapper a mis null ou des valeurs par défaut dans d'autres champs l'état de l'entité est devenu différent

La discipline clé pour le développeur : quand tu vois un accidental update, ne demande pas «où est le bug d'Hibernate ?», demande «quel champ est devenu différent et qui l'a modifié ?». Dans cette histoire, Hibernate est généralement un comptable honnête : il voit une différence — il la note.

3. Le principal coupable : «la lecture avec effet de bord»

Dans la plupart des projets, l'accidental update n'apparaît pas à cause d'annotations exotiques ou de mécanismes cachés, mais à cause d'une habitude très humaine : «je vais corriger discrètement les données ici, au passage». Ces «au passage» se déguisent parfaitement en lecture. La méthode retourne une chaîne, un DTO ou fait juste du logging — et on a l'impression qu'elle «ne change rien». Mais si à l'intérieur il y avait un setter, l'ORM percevra ça comme une modification.

Voici un exemple — dans les projets réels, on le croise bien plus souvent qu'on ne le voudrait. Le développeur veut afficher un beau titre et décide «d'embellir» le nom au passage :

import org.springframework.transaction.annotation.Transactional;

@Transactional
public String loadProductTitle(Long id) {
    // On charge l'entité dans la transaction : elle devient managed
    Product product = entityManager.find(Product.class, id);

    // Ça semble être une «normalisation innocente» pour l'UI,
    // mais c'est une vraie modification de l'entité managed dans le persistence context
    product.setName(product.getName().trim());

    // Ici on lit la valeur modifiée, et en fin de transaction un UPDATE peut partir
    return product.getSku() + " " + product.getName();
}

Si le nom en base était " Keyboard " (avec des espaces), après trim() il deviendra "Keyboard". Pour Java, c'est juste une chaîne. Pour Hibernate, c'est un vrai changement d'état d'une entité managed. Dans le SQL log, tu verras un UPDATE, même s'il n'y a aucun save() nulle part.

Il faut souligner un point important : si trim() n'a pas changé la chaîne, l'UPDATE peut ne pas apparaître, parce que le dirty checking compare le snapshot et l'état courant. C'est pourquoi ce genre de bug «apparaît et disparaît» parfois, ce qui le rend encore plus mystérieux. Le mystère est simple : les données d'entrée sont différentes.

Voici maintenant la version sûre de la même méthode. On normalise toujours pour l'affichage, mais on ne touche pas à l'entité :

import org.springframework.transaction.annotation.Transactional;

@Transactional
public String loadProductTitleSafely(Long id) {
    // L'entité est managed, mais on ne modifie pas son état
    Product product = entityManager.find(Product.class, id);

    // On normalise seulement pour l'affichage : c'est une variable locale, pas une écriture dans l'entité
    String normalizedName = product.getName().trim();

    // L'état de l'entité dans le persistence context n'a pas changé — donc pas d'UPDATE obligatoire
    return product.getSku() + " " + normalizedName;
}

Le résultat pour l'utilisateur est identique, mais l'UPDATE n'apparaîtra pas, parce que l'état de l'objet managed n'a pas bougé. Là, on veut souvent dire : «mais c'est évident». Oui, exactement jusqu'au premier rush, quand on est fatigué et qu'il semble que setName(trim()) n'est pas une écriture mais de la «cosmétique». Hibernate répond calmement : «de la cosmétique ? Excellent, je note. »

Un autre cas de «lecture avec effet de bord» — quand la normalisation est cachée dans une méthode distincte de l'entité, et que ça semble donc encore plus légitime. Par exemple, tu as créé une méthode de domaine normalizeName() et tu l'appelles «en lecture» :

import org.springframework.transaction.annotation.Transactional;

@Transactional
public String titleWithNormalize(Long id) {
    // On charge une entité managed
    Product product = entityManager.find(Product.class, id);

    // Important : si normalizeName() contient des setters, c'est quand même une écriture
    product.normalizeName();

    // De l'extérieur la méthode ressemble à «une lecture», mais l'ORM verra les modifications des champs
    return product.getSku() + " " + product.getName();
}

Les méthodes de domaine, c'est bien dans l'absolu. Mais sur le plan des accidental updates, il faut garder en tête : si normalizeName() modifie des champs, c'est une écriture, même si le nom de la méthode est doux et plaisant. L'ORM ne lit pas les noms des méthodes, il lit les modifications des champs.

4. Setters avec surprise : quand une assignation change autre chose

La deuxième grande source d'accidental updates — les setters avec effets de bord. Dans «Java pur», un setter c'est généralement juste this.x = x;. Dans un projet réel, un setter se transforme souvent en mini-opération : il n'assigne pas seulement la valeur, il la normalise, recalcule des champs dérivés, modifie des marqueurs de service. Ça peut être justifié, mais il faut alors admettre honnêtement : un tel setter fait partie du modèle d'écriture, et on ne doit pas l'appeler dans les scénarios de lecture.

Imaginons qu'on ait décidé de mettre à jour automatiquement updatedAt lors d'une modification du nom du produit. La version naïve ressemble à ça :

import java.time.Instant;

public void setName(String name) {
    // On modifie le «champ métier»
    this.name = name;

    // Effet de bord : on modifie le champ technique d'horodatage
    // Même si name n'a pas vraiment changé, updatedAt changera presque toujours
    this.updatedAt = Instant.now();
}

Le piège ? Même si «tu n'as rien changé» au sens métier, mais que le setter a été appelé (par exemple dans un mapper), updatedAt changera presque à coup sûr. Hibernate verra la différence entre le snapshot et l'état courant et enverra un UPDATE. Dans le SQL, ça ressemble à «mise à jour du produit», alors que tu voulais peut-être «juste construire le titre» ou «appliquer un DTO où le nom était identique».

Un cas encore plus insidieux — quand le setter fait une normalisation et touche en même temps un autre champ, par exemple un champ de recherche :

import java.util.Locale;

public void setName(String name) {
    // On sauvegarde «tel quel» (ou presque)
    this.name = name;

    // Champ supplémentaire pour la recherche/indexation :
    // même si name est «le même», searchName peut être recalculé différemment à cause de trim/casse
    this.searchName = name == null ? null : name.trim().toLowerCase(Locale.ROOT);
}

Maintenant, même l'opération «innocente» setName(product.getName()) peut devenir une modification si searchName est recalculé différemment — par exemple à cause d'espaces, de casse ou de locales différentes. Le bug semble particulièrement bizarre parce que tu «assignes la même chose». Mais en fait non — tu as aussi recalculé un autre champ.

À ce stade du cours, il ne s'agit pas d'interdire les setters. Il s'agit de tracer honnêtement la frontière : si un setter a des effets de bord, ce n'est plus une «simple assignation». Il faut l'appeler aussi consciemment que tu écrirais du SQL manuel. Utiliser un tel setter dans une méthode qui se présente comme une lecture, c'est comme exécuter silencieusement DELETE FROM ... dans une méthode getReport() «pour que la table soit plus propre». Techniquement possible, mais la code review risque de t'«apprécier» de façon très... particulière.

5. Mapping DTO : mise à jour de tout

La troisième grande catégorie d'accidental updates — la copie aveugle de données depuis un DTO dans une entité. L'ironie : le mapping est généralement écrit avec de bonnes intentions : «je veux appliquer proprement les données de la requête». Mais si on copie tout en vrac, tôt ou tard on copiera quelque chose de superflu. Hibernate, dans ces cas, est aussi prévisible qu'à l'accoutumée : tu as copié quelque chose de superflu — tu as obtenu un UPDATE superflu.

Imaginons qu'on ait une requête de mise à jour d'un produit. Pour la simplicité, on prend un record Java :

// DTO de la requête : peut arriver partiellement rempli (ex. name=null si le champ n'a pas été envoyé)
public record UpdateProductRequest(String name, ProductStatus status) {
}

Maintenant, le mapper naïf (ou simplement le code de service naïf) peut ressembler à ça :

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void updateProduct(Long id, UpdateProductRequest dto) {
    // On charge l'entité managed : toute modification sera suivie
    Product product = productRepository.findById(id).orElseThrow();

    // Danger : si dto.name() == null, on écrase le champ en base
    product.setName(dto.name());

    // Danger : si on ne voulait pas changer ce champ mais qu'une valeur «par défaut» est arrivée, on l'écrase aussi
    product.setStatus(dto.status());
}

Le problème n'est pas que «c'est interdit». Le problème, c'est que cette mise à jour ne distingue pas les intentions. Si dto.name() était null (parce que le client ne voulait pas changer le nom et n'a simplement pas envoyé le champ), tu vas écraser le nom en base. Hibernate enverra un UPDATE, et il sera 100% logique. C'est seulement ton résultat métier qui sera illogique.

Second problème typique — quand le DTO contient une valeur «telle quelle» (par exemple "Keyboard ") et que le setter la normalise. Là encore, tu obtiens un accidental update, mais via le mapper : tu sembles «juste avoir réassigné le nom», mais tu as en réalité changé la valeur normalisée.

Il y a un style plus soigné — le selective mapping : appliquer uniquement les champs qui doivent vraiment changer. Dans le code, c'est souvent ennuyeux (et un code ennuyeux dans la couche persistence est généralement un bon signe) :

import java.util.Objects;

public void apply(UpdateProductRequest dto, Product product) {
    // On applique le champ seulement si il est vraiment arrivé dans la requête et s'il diffère vraiment
    if (dto.name() != null && !Objects.equals(dto.name(), product.getName())) {
        // Ainsi on ne touche pas le setter inutilement (et le setter peut avoir des effets de bord)
        product.setName(dto.name());
    }

    // Pour les enum, la comparaison par référence suffit généralement, mais on vérifie quand même null
    if (dto.status() != null && dto.status() != product.getStatus()) {
        product.setStatus(dto.status());
    }
}

Ici on fait deux choses qui économisent des heures d'investigation. D'abord, on ne transforme pas l'absence d'un champ dans le DTO en commande «écraser la valeur en base». Ensuite, on n'appelle pas le setter si la valeur est la même. Ce n'est pas seulement une question d'optimisation, bien que ça aussi c'est agréable ; c'est aussi réduire le risque de déclencher accidentellement un effet de bord dans un setter.

Un point important à comprendre : appeler un setter en lui-même ne garantit pas un UPDATE — on le sait déjà. Mais appeler un setter augmente la probabilité de modification, parce qu'à l'intérieur il peut y avoir une normalisation, une mise à jour de champs de service ou une modification de valeurs liées. C'est pourquoi réduire les «appels de setters superflus» est une vraie protection d'ingénierie, pas une question de goût.

6. Investigation d'un UPDATE inattendu : trouver la cause

Quand tu attrapes pour la première fois un UPDATE inattendu, l'envie est d'accuser Hibernate et d'éteindre le SQL log pour ne plus voir cette horreur. Mieux vaut jouer au détective. On a déjà tous les indices : l'état managed, le modèle snapshot et la trace SQL comme résultat. C'est suffisant pour trouver sans magie la ligne précise où l'état a changé.

Mieux vaut commencer l'investigation non pas par le code, mais par une simple question : «quelle table exactement a été mise à jour ?». Si l'UPDATE est parti dans products, c'est Product qui a changé. Si dans purchase_orders — c'est PurchaseOrder qui a été modifié. On regarde ensuite la liste des colonnes dans SET ... et on comprend approximativement quels champs sont potentiellement touchés. À ce stade, inutile de se disputer avec le fait «pourquoi il y a beaucoup de colonnes». Il peut effectivement y en avoir plus que prévu, et c'est normal au niveau de base — on cherche la source de la modification, pas la forme de l'UPDATE.

Étape suivante — s'assurer que l'entité était bien managed dans la transaction courante. Si on l'a obtenue via entityManager.find(...) ou repository.findById(...) dans un @Transactional, c'est presque certainement un scénario managed. Si l'objet vient d'une couche externe et est déjà detached, c'est une autre histoire avec d'autres mécanismes — mais pas le sujet d'aujourd'hui.

Ensuite, il y a deux approches très pratiques.

La première — le «thermomètre» de diagnostic Hibernate : Session.isDirty(). Il indique si Hibernate considère la session courante comme «sale», c'est-à-dire s'il y a des modifications dedans. C'est une API spécifique à Hibernate, on obtient donc la Session via unwrap() :

import org.hibernate.Session;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void debugUnexpectedUpdate(Long id) {
    // On charge l'entité managed
    Product p = entityManager.find(Product.class, id);

    // On obtient la Session Hibernate (API spécifique, pas JPA)
    Session session = entityManager.unwrap(Session.class);

    // Avant les modifications, la session est «propre»
    System.out.println(session.isDirty()); // false

    // On fait une modification «innocente» — pour Hibernate c'est déjà une raison de devenir dirty
    p.setName(p.getName().trim());

    // Après la modification, la session devient «sale» — donc au flush/commit un UPDATE est probable
    System.out.println(session.isDirty()); // true
}

Ce code est précieux parce qu'il transforme un «sentiment» en fait. Si avant ton action «innocente» isDirty() était false et qu'il est devenu true après, c'est que tu as bien modifié quelque chose à l'état managed quelque part. La question suivante n'est que technique : quoi exactement et où.

La deuxième approche — utiliser detach() comme «ruban isolant». On sait déjà depuis hier : un objet detached ne participe pas au dirty checking. Donc si tu veux tester l'hypothèse «l'UPDATE apparaît à cause d'une entité managed sale», tu peux temporairement isoler l'entité du contexte et voir si la mise à jour disparaît. C'est du diagnostic, pas un traitement, comme «couper le disjoncteur et voir si les étincelles s'arrêtent». Exemple :

import org.springframework.transaction.annotation.Transactional;

@Transactional
public String titleButDetached(Long id) {
    // D'abord l'entité est managed
    Product product = entityManager.find(Product.class, id);

    // On la «détache» du persistence context : elle devient un simple objet Java
    entityManager.detach(product);

    // Maintenant les modifications n'entreront pas dans le dirty checking et ne produiront pas d'UPDATE
    product.setName(product.getName().trim());

    // Pour la logique de l'application, ça peut être ok, mais il faut garder en tête qu'on travaille avec du detached
    return product.getSku() + " " + product.getName();
}

Si après detach() l'UPDATE a disparu, c'est que la cause était bien dans le dirty checking de l'entité managed, et non dans des «save cachés». Ça réduit très rapidement le périmètre de recherche.

Enfin, le moyen le plus direct de trouver le coupable — poser un breakpoint dans le setter précis et regarder la call stack. Ça semble basique, mais ça fonctionne presque toujours. Ta mission n'est pas de «comprendre Hibernate en général», mais de trouver qui exactement dans ta transaction a touché le champ. Très souvent, le coupable se révèle être une couche inattendue : un «formateur pour l'UI», un «normalisateur» ou un «mapper» que tu croyais inoffensif.

Pour ne pas transformer l'investigation en chaos, voici un petit tableau des outils et de leur utilité :

Outil Ce qu'il donne Comment il aide avec l'accidental update
SQL trace le fait de l'UPDATE, la table, souvent les colonnes confirme que la mise à jour a vraiment eu lieu
Session.isDirty() «la session est-elle sale ou non ?» montre le moment où les modifications sont apparues
detach() exclut l'objet du dirty checking permet de confirmer/réfuter rapidement l'hypothèse
breakpoint dans le setter call stack montre la ligne exacte du coupable

Note : aucun point ne nécessite «d'ajouter des annotations supplémentaires». L'accidental update se guérit presque jamais par une annotation. Il se guérit par la discipline : savoir où on écrit et où on lit.

7. Règles qui économisent des heures

Quand l'accidental update t'a mordu une première fois, la tentation est de passer en mode «ne jamais toucher les entités à la main». C'est aussi une extrémité. On n'a pas besoin de paranoïa, mais d'un style soigné. L'habitude la plus utile — mentalement diviser le code en chemin de lecture et chemin d'écriture, même si techniquement les deux vivent dans la même transaction et travaillent avec les mêmes entités.

Dans le chemin de lecture, évite d'appeler des méthodes qui modifient l'état de l'entité, même si elles «normalisent juste». Tout calcul pour l'affichage est mieux fait dans des variables locales : c'est plus sûr et plus lisible. Si tu veux vraiment «corriger les données» en lisant, il est plus honnête d'en faire un scénario d'écriture explicite : méthode de service séparée, intention séparée — et l'UPDATE ne surprend plus, parce que tu l'attendais.

Dans le chemin d'écriture, à l'inverse, n'aie pas peur que save() ne soit pas appelé. Si l'entité est managed, le dirty checking fera son travail. Mais sois attentif au mapping : mets à jour uniquement ce qui doit vraiment être mis à jour. Ce n'est pas du «code en trop», c'est le contrat de ton scénario. Et si une simple application de DTO provoque des modifications inattendues, considère ça comme un signal : tu as soit mélangé «données pour l'affichage» et «données pour l'écriture», soit des setters trop malins pour leur propre bien.

Et encore un principe pratique qui paraît fastidieux au début et devient adoré ensuite : si une méthode s'appelle comme une lecture (getTitle(), buildSummary(), toDisplayString()), elle ne doit pas modifier l'état de l'entité. Les surprises dans ces endroits-là sont la variété la plus coûteuse, parce qu'on ne les attend pas là.

8. Erreurs classiques dans la recherche et la correction des accidental updates

Erreur n° 1 : chercher l'UPDATE seulement là où il y a un save().
Après Spring Data, beaucoup ont l'habitude de considérer save() comme la principale source d'écriture. Dans Hibernate, la vraie écriture apparaît non pas «à cause de save», mais à cause de la différence entre le snapshot et l'état courant de l'entité managed. Donc l'absence de save() ne signifie pas l'absence d'écriture, et la présence de save() ne signifie pas que l'écriture était nécessaire.

Erreur n° 2 : faire une «normalisation innocente» via setter dans une méthode de lecture.
trim(), toUpperCase(), formatage, substitution de valeurs par défaut — tout ça ressemble à de la cosmétique, jusqu'au moment où on se rappelle que l'objet est managed. Pour Hibernate, ce n'est pas de la cosmétique, c'est une modification de données. Si la normalisation n'est nécessaire que pour l'affichage, garde-la dans une variable locale et ne touche pas l'entité.

Erreur n° 3 : mapper aveuglément un DTO dans une entité en appelant ça une «mise à jour soignée».
Un DTO arrive souvent avec des champs partiellement remplis, des null, des valeurs par défaut ou des valeurs «telles que saisies». Si tu assignes tout ça mécaniquement dans l'entité, tu obtiens des modifications non prévues. Le selective mapping est ennuyeux, mais il garde le contrat de mise à jour dans tes mains.

Erreur n° 4 : écrire des setters avec des effets de bord et oublier qu'ils sont des effets de bord.
Un setter qui met à jour updatedAt, recalcule des champs de recherche ou déclenche une logique supplémentaire devient une mini-opération métier. Parfois c'est normal, mais alors un tel setter ne peut pas être utilisé dans des endroits que tu considères comme de la «lecture». Sinon tu crées toi-même l'accidental update et tu t'étonnes ensuite de son honnêteté.

Erreur n° 5 : ignorer le moment où la session est devenue sale et essayer de «comprendre Hibernate aux étoiles».
Si tu ne fixes pas le moment d'apparition des modifications — via Session.isDirty(), des breakpoints dans les setters ou un detach() temporaire — l'investigation se transforme en devinette. Hibernate est justement pratique ici : il offre suffisamment d'observabilité, il suffit de l'utiliser.

1
Mission
Hibernate deep-dive, niveau 3, leçon 2
Bloqué
UPDATE accidentel dû à la normalisation via setter
UPDATE accidentel dû à la normalisation via setter
1
Mission
Hibernate deep-dive, niveau 3, leçon 2
Bloqué
Lecture sécurisée avec variable locale
Lecture sécurisée avec variable locale
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION