1. save() — une méthode familière et mal comprise
Si l’on demande à un développeur débutant de montrer « où sont sauvegardées les données », il pointera presque par réflexe vers repository.save(...). C’est normal : le nom de la méthode est très « humain », presque comme le bouton « Enregistrer » dans Excel. Le problème est que l’ORM cache plusieurs scénarios différents sous ce mot, chacun avec son propre coût en SQL et ses effets secondaires.
Dans Spring Data JPA, la méthode save est unique, mais les situations de vie d’une entité sont multiples. L’objet peut être nouveau (transient), déjà managed dans la transaction en cours, ou arriver en tant que detached (histoire typique : DTO → mapper → « entité », mais sans lien avec le contexte de persistance). Et chaque fois, le même save signifie quelque chose de légèrement différent.
La « traduction » la plus importante vers le langage Hibernate est la suivante : save(...) ne « sauvegarde » pas l’entité dans le vide, il aide simplement à l’introduire dans le flux de persistance approprié. Parfois, cela signifie persist, parfois merge. Parfois, rien d’important à part une ligne de code supplémentaire (oui, une « ligne supplémentaire » peut aussi être un bug, elle fait juste semblant d’être innocente pour l’instant).
2. Ce que fait save(...) : persist ou merge
Quand on dit « Spring Data a fait le CRUD pour nous », on oublie facilement qu’à l’intérieur vit toujours un bon vieux EntityManager. Et save dans un projet Spring Data JPA typique est avant tout une question : quelle opération JPA exécuter — persist ou merge. Ce n’est pas de la magie, mais… disons : de la magie avec le capot ouvert.
De manière très simplifiée (mais méthodiquement honnête), la logique ressemble à ceci :
import jakarta.persistence.EntityManager;
public class PseudoSaveLogic {
private EntityManager em;
public <T> T save(T entity, boolean isNew) {
// Si l'entité est « nouvelle », on enregistre ce même objet dans le contexte de persistance
if (isNew) {
em.persist(entity); // l'objet deviendra managed
return entity; // retourne la même instance
}
// Si l'entité n'est pas « nouvelle », il s'agit souvent de merge :
// une copie managed sera retournée (pas nécessairement le même objet)
return em.merge(entity);
}
}
Dans Spring Data JPA réel, ce choix n’est pas fait par un drapeau isNew, mais via un mécanisme de détermination de la nouveauté (newness), mais le principe est exactement le même.
Pour visualiser cela, il est pratique de garder un petit schéma en tête :
flowchart TD
A["productRepository.save(entity)"] --> B{"Entity new?"}
B -- "Oui" --> C["EntityManager.persist(entity)"]
C --> D["entity devient managed
INSERT généralement sur flush/commit"]
B -- "Non" --> E["EntityManager.merge(entity)"]
E --> F["retourne une copie managed
SELECT possible, UPDATE sur flush"]
Un détail clé que beaucoup négligent : dans la branche merge, la valeur de retour est cruciale. Ce n’est pas « le même objet, simplement sauvegardé ». C’est une instance managed dans le contexte de persistance actuel, et c’est avec elle qu’il est sûr de continuer à travailler à l’intérieur de la transaction.
3. Newness : comment Spring Data détermine une entité nouvelle
Le mot newness sonne comme si nous discutions de la philosophie de la « nouveauté », mais en pratique c’est une chose terre‑à‑terre : Spring Data doit comprendre quelle branche choisir — persist ou merge. Et il ne lit pas dans vos pensées. Il regarde l’entité et essaie de faire une supposition d’ingénierie raisonnable.
Dans la plupart des projets (y compris notre Commerce Persistence Lab), la newness est déterminée de manière assez attendue : si l’entité n’a pas d’identifiant, elle est nouvelle. Par conséquent, pour une entité avec @GeneratedValue, le signe typique d’un nouvel objet est id == null. Mais Spring Data a un autre signal important : la version (@Version). Si l’entité est versionnée, il est assez logique de considérer comme nouvelle l’entité dont version == null (si la version est un type objet comme Long, et non un primitif long).
On obtient donc une sorte de « tableau d’intuition » :
| Entité | Ce qui est généralement vérifié | Pourquoi cela fonctionne |
|---|---|---|
| Entité avec @GeneratedValue sans version | id == null → nouveau | L’ID apparaîtra après persist/flush, avant l’objet est nouveau |
| Entité avec @Version (Long version) | version == null → nouveau | La nouvelle entité n’a pas encore participé à une mise à jour versionnée |
| Entité avec assigned id (id défini manuellement) | souvent ne fonctionne pas « par défaut » | id != null, mais l’objet est toujours transient ; merge peut se comporter de manière inattendue |
La troisième ligne est la source d’« aventures » : si vous définissez manuellement l’identifiant (assigned id), on ne peut pas honnêtement dire, sur la seule base de id != null, que l’objet « existe déjà en base ». Il peut s’agir d’une nouvelle entité à laquelle vous avez préalablement attribué un identifiant. Spring Data peut alors passer par erreur par merge (ce qui peut entraîner un SELECT supplémentaire, et parfois des effets inattendus).
Dans notre projet pédagogique, nous restons délibérément sur la génération d’identifiant via Hibernate/la base de données (et non assigned), donc l’intuition de base de la newness fonctionnera de manière prévisible. Mais même dans ce cas, il est important de se rappeler que save n’est pas un « INSERT/UPDATE », c’est « persist/merge » + ensuite tout sera décidé par le cycle de flush.
Si un projet utilise délibérément des identifiants assignés, cette heuristique ne peut plus être laissée par défaut : il faut alors déterminer la newness de manière explicite, par exemple via Persistable, alors que nous restons ici sur le scénario standard avec identifiant généré et branche persist prévisible.
4. save(...) et un nouvel objet
Un nouvel objet en Java n’est pas encore une ligne dans une table. Et l’erreur de pensée la plus fréquente est de s’attendre à ce que save envoie immédiatement un INSERT. En réalité, save pour un nouvel objet signifie généralement : « rendre l’entité managed ». Le SQL peut partir immédiatement ou plus tard — cela dépend de la stratégie d’identifiant, du mode de flush et des déclencheurs de flush (que nous avons déjà abordés).
Observons la « création d’un produit » dans notre Commerce Persistence Lab. Pour simplifier, supposons que Product possède sku et name, et que id est généré.
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ProductWriteService {
private final ProductRepository productRepository;
public ProductWriteService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public Product create(String sku, String name) {
// Pour l'instant c'est juste un objet Java, il n'a pas encore de lien direct avec la base
Product product = new Product(sku, name);
// Important : save() introduit ici l'entité dans le flux de persistance (persist)
// L'INSERT aura le plus souvent lieu plus tard — lors du flush/commit
return productRepository.save(product); // SQL peut partir plus tard (au flush)
}
}
Ce qu’il est important de voir (et via une trace SQL) : après save(product), l’objet est généralement déjà managed, c’est‑à‑dire que Hibernate commence à le surveiller. Mais l’INSERT apparaît le plus souvent au moment du flush, c’est‑à‑dire soit lors du commit de la transaction, soit plus tôt — si un déclencheur de flush survient (par exemple, vous exécutez une requête qui doit voir ces modifications).
Et là se pose une question intéressante : « Pourquoi alors retourner un Product depuis create() ? » La réponse pratique : dans un scénario managed, c’est pratique pour le code appelant, afin d’obtenir immédiatement l’id (s’il est déjà attribué), et pour continuer à manipuler l’objet. Mais encore une fois : la présence de id et le fait de l’insertion SQL ne sont pas le même événement dans le temps.
5. save(...) pour une entité managed : pourquoi c’est souvent superflu
Après quelques semaines de travail avec Spring Data, beaucoup développent l’habitude : « j’ai changé un champ → j’appelle save ». Cela ressemble à de la discipline, mais en pratique c’est souvent une duplication d’un mécanisme déjà en place. Si, dans une transaction, vous avez chargé une entité, elle devient managed. Ensuite, vous modifiez les champs, Hibernate effectue le dirty checking, et lors du flush, il enverra un UPDATE. Le repository n’est plus le héros principal ici.
Comparons deux façons de mettre à jour le nom d’un produit. La première — « comme on a l’habitude » :
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameWithRitualSave(Long id, String newName) {
// findById à l'intérieur d'une transaction donne une entité managed
Product product = productRepository.findById(id).orElseThrow();
// On modifie l'état de l'objet managed
product.renameTo(newName);
// La plupart du temps c'est superflu : l'UPDATE aura de toute façon lieu au flush/commit (dirty checking)
productRepository.save(product); // le plus souvent redondant
}
La seconde — « comment l’ORM fonctionne réellement » :
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void renameManaged(Long id, String newName) {
// L'entité devient managed immédiatement après son chargement
Product product = productRepository.findById(id).orElseThrow();
// Il suffit ensuite de modifier les champs :
// Hibernate trouvera lui-même les changements et fera un UPDATE au flush/commit
product.renameTo(newName); // dirty checking produira l'UPDATE au flush/commit
}
Du point de vue du SQL final, il n’y a souvent pas de différence dans de nombreux cas : l’UPDATE aura quand même lieu. Mais la différence se situe au niveau du modèle mental. Dans la deuxième variante, vous admettez honnêtement : « c’est Hibernate qui fait la mise à jour parce que l’objet est managed ». Dans la première, vous créez l’illusion que « c’est le repository qui sauvegarde ». Et les illusions au niveau de la couche de persistance coûtent cher : elles gênent le débogage du flush, des effets de bord en masse, des états obsolètes, et de tous les « pourquoi a‑t‑il fait ce SQL ? ».
Parfois, on conserve l’appel à save sur un objet managed comme choix stylistique « pour que ce soit uniforme partout ». Dans un projet pédagogique, il est préférable d’éviter cela afin que le code reflète clairement le modèle d’exécution.
6. save(...) et l’objet detached : merge et copie
Le scénario detached est le plus perfide, car extérieurement il a l’air identique : vous avez un objet Product, vous le modifiez, vous appelez save. Tout semble simple. Mais sous le capot, save pour un objet detached se transforme presque toujours en merge. Et merge retourne une instance managed qui peut ne pas être le même objet que celui que vous avez passé.
Montrons un exemple de « mise à jour depuis l’extérieur » (par exemple, un DTO arrive, on construit une entité et on essaie de la sauvegarder) :
import org.springframework.transaction.annotation.Transactional;
@Transactional
public Product updateDetached(Product detached) {
// L'objet detached N'est PAS sous la surveillance du contexte de persistance
detached.renameTo("New name");
// save(detached) signifie ici généralement merge()
// Important : une copie managed sera retournée, et c'est avec elle qu'il faut continuer à travailler
return productRepository.save(detached); // merge : retourne une copie managed
}
Si, ensuite, dans la même transaction, vous continuez à travailler avec l’objet ancien detached, vous pouvez ressentir des sensations très étranges : « J’ai modifié le champ, pourquoi ce n’est pas en base ? » ou « Pourquoi les journaux ne montrent‑ils pas les bonnes valeurs ? ». La règle correcte semble ennuyeuse mais sauve les nerfs : après save(detached), continuez à travailler avec l’objet retourné par save.
Pour voir le piège de la manière la plus claire possible, imaginez (de manière exagérée) ce code :
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void brokenDetachedFlow(Product detached) {
// merge crée/retourne une instance managed dans le contexte de persistance actuel
Product managed = productRepository.save(detached);
// On modifie l'objet detached : ces changements peuvent ne jamais atteindre la base
detached.renameTo("Oops"); // on modifie le MAUVAIS objet
// On modifie l'objet managed : c'est ce que Hibernate verra et enregistrera lors du flush/commit
managed.renameTo("Ok"); // on modifie managed
}
Du point de vue d’Hibernate, c’est managed qui compte. Les modifications apportées à detached après le merge peuvent ne jamais atteindre le contexte de persistance, car detached n’est qu’un objet ordinaire en mémoire, « non surveillé ».
Et oui, c’est exactement le cas où find + mutation est souvent plus simple et plus sûr : vous récupérez d’abord une entité managed depuis la base, puis vous y reportez sélectivement les modifications. Nous avons abordé cela plus tôt, et save n’est pas la « meilleure » option ici — c’est juste l’une d’elles, et pas toujours la plus prévisible.
7. saveAndFlush(...) : flush précoce et SQL maintenant
saveAndFlush sonne comme « save, mais avec garantie ». En langage humain, on a envie de lire : « sauvegarde de manière fiable ». En langage d’ingénieur, cela se lit autrement : sauvegarde et force le flush, c’est‑à‑dire « envoie le SQL en base tout de suite, même si la transaction n’est pas encore terminée ». Et ce « tout de suite » est parfois critique, parfois source de problèmes supplémentaires.
Montrons un exemple minimal :
import org.springframework.transaction.annotation.Transactional;
@Transactional
public Product createAndFlush(String sku, String name) {
// Création d'un nouvel objet
Product product = new Product(sku, name);
// saveAndFlush() = save() + flush forcé ici même
// Ce n'est PAS un commit : la transaction peut toujours être annulée
return productRepository.saveAndFlush(product); // Le SQL part immédiatement au flush
}
Important : flush n’est pas égal à commit. Après saveAndFlush(...), la transaction peut toujours être annulée (rollback) et les modifications ne seront pas définitives. Mais la base voit déjà l’opération SQL à l’intérieur de la transaction : les contraintes seront vérifiées (par exemple, l’unicité de sku), des verrous peuvent être pris, des déclencheurs peuvent s’exécuter (si vous en avez), et, surtout, l’erreur peut survenir plus tôt que prévu.
Il est utile de visualiser cela comme une chronologie :
sequenceDiagram
participant S as "Service (@Transactional)"
participant EM as "Hibernate (contexte de persistance)"
participant DB as PostgreSQL
S->>EM: save(entity)
Note over EM: l’entité devient managed, SQL peut être différé
S->>EM: saveAndFlush(entity)
EM->>DB: INSERT/UPDATE (flush)
Note over DB: contraintes vérifiées, erreur peut se produire maintenant
S->>DB: COMMIT (fin de transaction)
Si vous appelez save sans flush, les erreurs d’unicité n’apparaissent souvent que près du commit (ou lors d’un auto-flush avant une requête). Si vous appelez saveAndFlush, l’erreur apparaît immédiatement après cet appel. C’est parfois pratique (fail fast), mais ce n’est pas « plus fiable », c’est « plus tôt ».
8. Où saveAndFlush(...) est‑il approprié ?
Il y a une tentation d’utiliser saveAndFlush comme un remède universel contre l’anxiété : « au cas où ça ne serait pas sauvegardé ». Mais dans le monde Hibernate, c’est comme vérifier si la porte est fermée toutes les trois secondes. Oui, vous avez l’impression de contrôler, mais le système commence à moins bien vivre : plus de flush, plus de SQL trop tôt, plus de temps de maintien des verrous, plus d’erreurs inattendues « au milieu d’une méthode ».
Un cas justifié est le plus souvent lié au besoin de connaître plus tôt le résultat de l’interaction avec la base. Par exemple, vous créez un Product avec un sku unique, et vous voulez échouer sur la contrainte d’unicité immédiatement, avant d’exécuter beaucoup d’autre logique dans la même transaction. Cela n’annule pas le fait que l’unicité doit être assurée par la base, mais cela permet d’obtenir l’erreur plus vite et de ne pas gaspiller du temps sur un travail inutile.
Un autre scénario normal est lorsqu’à l’intérieur d’une transaction, vous avez une étape qui a fondamentalement besoin que le SQL soit déjà parti (par exemple, une requête ultérieure doit voir ces modifications, et vous ne voulez pas compter sur l’auto-flush « selon les règles » ; vous voulez rendre le moment du flush explicite et lisible dans le code). C’est rare, mais parfois un tel code est effectivement plus compréhensible : « voici la frontière après laquelle la base est exactement synchronisée ».
Dans les autres cas, saveAndFlush masque souvent un problème architectural. Par exemple, quand les transactions sont trop longues et que le développeur essaie de « décharger » le milieu de la transaction par des flush ; ou quand la couche service ne contrôle pas les limites et que quelqu’un essaie d’obtenir de la prévisibilité « manuellement ». Dans ces situations, saveAndFlush ajoutera plutôt du chaos qu’il n’en enlèvera.
Pour fixer les idées, comparons rapidement save et saveAndFlush en termes de sens :
| Méthode | Ce qu’elle garantit | Ce qu’elle NE garantit PAS | Coût principal |
|---|---|---|---|
| save() | L’entité est entrée dans le flux de persistance (persist/merge) | que le SQL soit déjà parti vers la base | Le coût est déterminé par le cycle de flush |
| saveAndFlush() | SQL synchronisé avec la base à ce moment | le commit (la transaction peut toujours être annulée) | Contraintes/verrous précoces + flush supplémentaires |
9. Erreurs typiques lors du travail avec save et saveAndFlush
Les erreurs autour de save surviennent presque toujours non pas à cause d’une méconnaissance des annotations, mais à cause d’un modèle mental incorrect. Spring Data rend le code plus court, ce qui donne l’impression que « ça marche tout seul ». Mais Hibernate exécute honnêtement les règles du contexte de persistance, et si vous ne les comprenez pas, vous serez surpris par le SQL comme par des tours de magie : « comment a‑t‑il fait ça ? ». Voici les râteaux les plus fréquents sur lesquels même des gens très compétents se prennent les pieds.
Erreur n°1 : percevoir save comme un « INSERT/UPDATE immédiat ».
À cause du nom de la méthode, on a tendance à penser que le SQL part au moment de l’appel. Mais le plus souvent, le SQL part au moment du flush. Résultat : le développeur construit le code comme si l’enregistrement était déjà en base, puis s’étonne que la requête suivante déclenche un flush et échoue sur une contrainte « à un endroit inattendu ».
Erreur n°2 : appeler save après chaque modification d’une entité managed « au cas où ».
Dans un flux managed, c’est généralement superflu : le dirty checking fera son travail sans save. De plus, un appel superflu à save renforce une mauvaise habitude et nuit à la lisibilité : on ne sait plus si la vraie cause de l’UPDATE est l’état du modèle ou un « rituel du repository ».
Erreur n°3 : ignorer la valeur de retour de save dans un flux detached.
Si l’objet est detached, save se transforme souvent en merge et retourne une copie managed. Continuer à modifier l’ancien objet après save, c’est comme réparer une voiture en regardant une photo : vous faites quelque chose, mais le système réel peut ne pas changer du tout.
Erreur n°4 : utiliser saveAndFlush comme un « save plus fiable ».
saveAndFlush ne fait pas de commit. Il force simplement le SQL à partir plus tôt. Cela peut être utile pour un échec rapide (fail‑fast) et pour une synchronisation explicite « maintenant », mais si vous l’appliquez par réflexe, vous obtiendrez plus de verrous précoces, plus de SQL au milieu de la transaction et une couche de persistance plus nerveuse.
Erreur n°5 : essayer de soigner les problèmes de limites de transaction avec saveAndFlush.
Si la transaction est mal conçue (trop longue, dispersée entre les couches, ou vit « comme elle peut »), des flush précoces ne rendront pas le comportement correct — ils le rendront simplement plus imprévisible. La limite de l’unité de travail doit être au niveau du service, et saveAndFlush est un outil de contrôle ponctuel du moment de synchronisation, pas une béquille pour l’architecture.
GO TO FULL VERSION