CodeGym /Cours /Hibernate deep-dive /@Transactional, Session et flush

@Transactional, Session et flush

Hibernate deep-dive
Niveau 17, Leçon 0
Disponible

1. Rôle de @Transactional

Les entités managées et detached, le dirty checking, flush, le lazy loading et les décisions de mapping nous ont déjà donné la mécanique interne d’Hibernate. Il manque maintenant la pièce suivante du puzzle : où ce contexte de persistance commence et se termine exactement dans une application Spring classique. C’est de cette frontière que dépend le fait qu’Hibernate voie vos modifications, quand le flush se déclenchera et pourquoi la même entité est encore managée à un endroit et déjà detached à un autre.

Pour le dire simplement, @Transactional n’est pas une « annotation de sauvegarde », mais la frontière d’une opération complète et unique. À l’intérieur de cette frontière, Spring et Hibernate s’accordent : « voici notre unité de travail, un seul contexte de persistance, et nous irons ensemble jusqu’au bout ». C’est pourquoi, dans une application fortement Hibernate, la question « où mettre @Transactional ? » n’est pas cosmétique mais fait partie de l’architecture.

Pour que cela ne reste pas trop abstrait, attachons cela à notre projet Commerce Persistence Lab. Celui-ci contient un produit (Product), une commande (PurchaseOrder) et des stocks (InventoryItem). Imaginez le scénario métier « renommer un produit ». Naïvement, on pense que c’est simple : on trouve le produit, on modifie name, on sauvegarde. Mais voici les questions qui apparaissent immédiatement :

  1. Où vit le Product chargé après findById() ?
  2. Pourquoi Hibernate devrait-il « remarquer » que nous avons modifié name ?
  3. Quand l’UPDATE sera-t-il envoyé – immédiatement sur le setter, sur save(), sur flush() ou à la sortie de la méthode ?
  4. Que se passe-t-il si une erreur survient au milieu de l’opération ?

Les réponses à ces questions constituent « ce que fait réellement @Transactional autour d’Hibernate ».

2. Que fait Spring autour de la méthode

Si vous imaginiez auparavant une transaction comme un « BEGIN…COMMIT dans la base », ce n’est que la moitié de l’histoire. Dans Spring + Hibernate, la transaction est aussi le cycle de vie du contexte de persistance : il faut le créer (ou l’attacher), le laisser vivre à l’intérieur de l’opération et le fermer correctement à la fin. Et c’est là que @Transactional devient le chef d’orchestre : il définit où commence et où se termine votre « temps de travail ».

Ci-dessous, prenons un happy-path classique : un appel externe à une méthode public d’un bean Spring via une proxy Spring. C’est exactement à cette frontière que Spring peut ouvrir une transaction et attacher le contexte JPA/Hibernate au thread.

Nous n’avons pas besoin de plonger maintenant dans les arcanes de Spring AOP et des classes internes – un modèle technique suffit : avant d’entrer dans la méthode, Spring ouvre une transaction et attache le contexte JPA/Hibernate au thread courant ; à l’intérieur de la méthode, les repositories travaillent avec le même contexte ; à la sortie, un flush a lieu (si nécessaire), puis un commit ou un rollback, après quoi le contexte est fermé. Si vous gardez cette séquence en tête, la moitié des « étrangetés d’Hibernate » cesse soudainement d’être étrange.

Voici un schéma qu’il est commode de faire défiler mentalement chaque fois que vous voyez @Transactional :

sequenceDiagram
    participant C as "Appel externe (ex. controller/test)"
    participant S as "Méthode de service (@Transactional)"
    participant EM as "EntityManager / Hibernate Session"
    participant DB as PostgreSQL

    C->>S: appel de la méthode
    S->>DB: "BEGIN (ouverture de la transaction)"
    S->>EM: créer/attacher le contexte de persistance
    S->>EM: "exécution du code de la méthode (find/mutate/query)"
    EM->>DB: "SQL envoyé lors du flush (AUTO/manuel/avant commit)"
    S->>DB: "COMMIT (ou ROLLBACK)"
    S->>EM: fermer le contexte de persistance
    S-->>C: retourner le résultat / lever une exception

Fixons cela sous forme de tableau pour que ce soit plus simple à mémoriser.

Phase Ce que vous voyez dans le code Ce qui se passe réellement
Avant l’entrée vous appelez simplement la méthode il n’y a pas encore de transaction (dans le contexte de cet appel)
Entrée dans @Transactional rien de « particulier » Spring commence une transaction, prépare le contexte JPA
À l’intérieur de la méthode find(), setters, appels aux repositories les entités deviennent managed, les modifications s’accumulent dans le contexte de persistance
Sortie « réussie » la méthode se termine Hibernate fait un flush (si nécessaire), puis Spring fait un commit
Sortie « avec erreur » une exception est levée Spring fait un rollback, le contexte est fermé

Point clé : un seul contexte de persistance vit à l’intérieur de la méthode transactionnelle, et cela affecte directement le dirty checking, le cache de premier niveau, flush et le lazy loading.

3. Un seul @Transactional – un seul contexte de persistance

Quand on dit « dans une transaction, un seul contexte de persistance », cela semble tout droit sorti d’un manuel. Mais en réalité, c’est une chose très pratique : vous obtenez soudainement la garantie qu’à l’intérieur de l’opération, Hibernate se comporte comme un gestionnaire d’objets normal. Il ne crée pas sans raison de nouvelles copies de la même entité, il n’oublie pas ce qu’il a déjà chargé et peut correctement comparer « ce qui était » et « ce qui est devenu ». Et c’est précisément cela le fondement du dirty checking, et non une « magie save ».

Pour le voir concrètement, prenons l’exemple le plus simple de notre projet : lire deux fois le même Product par son id à l’intérieur d’une même méthode de service.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;

    public CatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void loadSameProductTwice(Long productId) {
        // Les deux lectures sont effectuées dans la même transaction et le même contexte de persistance
        Product p1 = productRepository.findById(productId).orElseThrow();

        // Le deuxième findById avec le même id retournera le même objet managé depuis le cache L1
        Product p2 = productRepository.findById(productId).orElseThrow();

        // Important : == compare les références (le même objet en mémoire)
        System.out.println(p1 == p2); // true
    }
}

Ce qu’il est important de noter ici. En Java, l’opérateur == compare les références, c’est-à-dire « est-ce le même objet en mémoire ? ». Et à l’intérieur d’une même transaction, la réponse sera true, car le contexte de persistance fonctionne comme une identity map : une ligne de table → un objet managé.

Ce n’est pas seulement « amusant ». Cela signifie qu’Hibernate peut faire en toute sécurité des choses comme :

Il prend un snapshot de l’état de p1, puis vous modifiez p1.setName(...), et à la fin de l’opération, Hibernate compare le snapshot et l’état actuel du même objet. Si vous aviez soudainement deux objets différents « sur le même produit », tout deviendrait beaucoup moins prévisible : quelles modifications considérer comme réelles, quel objet mettre à jour, comment ne pas perdre une partie des changements.

Et voici une conclusion très utile : @Transactional est un moyen de dire à Hibernate « maintiens le contexte sur l’ensemble de l’opération, et non sur un appel de repository au hasard ».

4. Dirty checking : save() n’est pas obligatoire

Après le sujet du dirty checking, beaucoup commencent à soupçonner Hibernate de télépathie. « Je n’ai pas appelé save(), pourquoi a-t-il envoyé un UPDATE ? » – cela ressemble au début d’une bonne histoire au coin du feu, mais en réalité c’est simplement la conséquence du fait que l’entité était managée dans le contexte de persistance, et que la transaction a donné à Hibernate la possibilité de mener l’unité de travail à son terme.

Prenons l’exemple « renommer un produit » – presque ce que nous avons dans le plan de la journée.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;

    public CatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void renameProduct(Long productId, String newName) {
        // Après findById, l’entité devient managée dans le contexte de persistance courant
        Product product = productRepository.findById(productId).orElseThrow();

        // On modifie le champ – Hibernate mémorise le changement (dirty checking)
        product.setName(newName);

        // save() n’est pas nécessaire si l’entité est managée : l’UPDATE sera envoyé lors du flush/commit
    }
}

Il n’y a pas de productRepository.save(product). Et souvent, un débutant s’inquiète : « Est-ce que cela va être sauvegardé ? ». Oui, si Product est managé (et à l’intérieur de @Transactional, il devient managé après findById()), alors Hibernate verra la modification lors de la fin de l’unité de travail et exécutera un UPDATE.

Dans le journal SQL, cela ressemble généralement à ceci (simplifié) :

-- D’abord Hibernate lit la ligne et crée l’entité managée
select p.id, p.name, p.status
from product p
where p.id = ?;

-- Ensuite (généralement lors du flush avant le commit) envoie l’UPDATE avec les modifications accumulées
update product
set name = ?
where id = ?;

Notez un point important : l’UPDATE n’est pas nécessairement envoyé immédiatement après setName(). Il sera envoyé quand Hibernate décidera de se synchroniser avec la base – le plus souvent lors du flush avant le commit. Et c’est précisément la transaction qui donne à Hibernate le moment de « clôture de l’opération » pendant lequel il peut effectuer cette synchronisation en toute tranquillité.

Si votre esprit réclame une analogie, c’est comme travailler dans un éditeur de texte : vous tapez du texte (vous modifiez l’objet), mais le fichier n’est pas obligé d’être écrit sur le disque après chaque pression de touche. Il sera sauvegardé soit quand vous appuyez sur « Enregistrer », soit quand l’éditeur décide lui-même de faire une sauvegarde automatique, soit quand vous fermez le document. Dans Hibernate, @Transactional est le « document », et flush est le « moment de sauvegarde du brouillon dans le fichier », sans que ce soit encore nécessairement définitif.

5. flush à l’intérieur d’une transaction

Le flush en lui-même nous est déjà familier, mais il est important maintenant de le relier à la transaction précisément au niveau de « qui contrôle le cycle de vie ». Le problème le plus fréquent ici est terminologique : les gens disent « ça a flusher » en sous-entendant « c’est sauvegardé pour toujours ». Et ensuite ils sont surpris qu’après une erreur, les données ne soient pas en base. Alors voilà : flush signifie l’envoi de SQL à la base, et commit signifie la validation de la transaction.

Montrons cela sur un court exemple : nous modifions le nom du produit et appelons explicitement flush().

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;
    private final EntityManager entityManager;

    public CatalogService(ProductRepository productRepository, EntityManager entityManager) {
        this.productRepository = productRepository;
        this.entityManager = entityManager;
    }

    @Transactional
    public void renameAndFlush(Long productId, String newName) {
        // L’entité sera managée dans la transaction courante
        Product product = productRepository.findById(productId).orElseThrow();
        product.setName(newName);

        // flush = « envoie le SQL maintenant », mais PAS « le rend définitif »
        entityManager.flush(); // Le SQL sera envoyé à la base maintenant

        // Important : le commit/rollback aura lieu plus tard – à la sortie de @Transactional
    }
}

Que va-t-il se passer ici ? Hibernate effectuera un dirty checking et enverra l’UPDATE à la base avant la fin de la méthode. Dans le journal SQL, vous le verrez immédiatement : update product set name=? ... apparaîtra au milieu de l’exécution, et non « quelque part après ».

Mais voici la partie importante : la transaction n’est pas encore terminée. Si après flush() une erreur se produit et qu’un rollback est déclenché, alors le « SQL envoyé » ne deviendra pas une modification permanente des données. Il a été exécuté à l’intérieur de la transaction – or une transaction peut être annulée.

Pourquoi un flush() manuel est-il nécessaire en pratique ? Non pas parce que « c’est plus fiable », mais parce que parfois il est important que la base voie déjà les modifications à l’intérieur de la transaction en cours (par exemple, pour que la requête suivante, dans la même méthode, travaille avec l’état actuel et ne contredise pas votre logique). Mais il faut se rappeler que flush est un outil de synchronisation, pas un « coup de tampon sur le document ».

6. Rollback : base vs mémoire

Le mot rollback sonne souvent comme un bouton « annuler tout comme si rien ne s’était passé ». Et dans la base de données, c’est presque ainsi que cela fonctionne (en simplifiant) : la transaction est annulée – les modifications ne sont pas validées. Mais en ce qui concerne les objets Java, ce n’est pas aussi idyllique : si vous avez eu le temps de modifier les champs de l’objet en mémoire, ils ne reviendront pas automatiquement à leur état initial simplement parce que la transaction a été annulée.

C’est un point très important pour la pensée Hibernate : l’état de la base et l’état de vos objets sont deux choses différentes, qui sont synchronisées via flush, mais ne se transforment pas en une seule et même « matière ».

Regardons un exemple avec un crash :

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final ProductRepository productRepository;

    public CatalogService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void renameThenFail(Long productId) {
        // L’entité est managée, les modifications s’accumulent dans le contexte de persistance
        Product product = productRepository.findById(productId).orElseThrow();
        product.setName("Broken name");

        // Une RuntimeException marque généralement la transaction comme rollback-only
        throw new IllegalStateException("stop");
    }
}

Ce qu’il est important de comprendre sans entrer dans des règles fines : si la méthode échoue avec une RuntimeException (et IllegalStateException en est une), Spring marque généralement la transaction comme rollback-only et effectue un ROLLBACK. Les modifications ne seront pas conservées dans la base.

Mais l’objet product en mémoire a déjà changé de name. Il ne redeviendra pas « correct » automatiquement. De plus, après la sortie de la transaction, le contexte de persistance sera fermé et cet objet deviendra effectivement detached. Vous pouvez conserver une référence sur lui (par exemple, attraper l’exception à l’extérieur et la journaliser quelque part), mais Hibernate ne « gère » plus cet objet.

C’est l’endroit où beaucoup rencontrent pour la première fois l’idée suivante : la transaction n’est pas une machine à remonter le temps pour la mémoire Java. La transaction garantit la cohérence de l’écriture en base. Quant à vos objets en mémoire, ce sont vos objets. Si vous souhaitez « réinitialiser » leur état, c’est un problème distinct (et le plus souvent, il est plus correct de ne pas du tout transporter ces objets à l’extérieur du service).

7. @Transactional et lazy loading

Nous avons déjà vu la cause de LazyInitializationException : vous essayez d’initialiser une relation LAZY alors que le contexte de persistance est fermé. Il est important aujourd’hui de voir que @Transactional est justement le mécanisme qui crée la « fenêtre de temps » dans laquelle le lazy loading est légal et prévisible.

Supposons que dans PurchaseOrder, les lignes (items) soient chargées paresseusement. Alors, à l’intérieur de la transaction, nous pouvons nous permettre d’accéder à items (car Hibernate est encore « vivant »), alors qu’à l’extérieur, ce n’est pas possible.

Voici un cas d’utilisation de lecture honnête et sûr (oui, même pour la lecture, une transaction peut être utile, mais aujourd’hui nous n’approfondissons pas readOnly=true – restons simples) :

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {

    private final PurchaseOrderRepository orderRepository;

    public OrderQueryService(PurchaseOrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Transactional
    public int countItems(Long orderId) {
        // La commande sera managée, et les collections LAZY peuvent être chargées tant que le contexte est ouvert
        PurchaseOrder order = orderRepository.findById(orderId).orElseThrow();

        // À ce moment, Hibernate peut exécuter un SELECT supplémentaire pour items
        return order.getItems().size();
    }
}

order.getItems().size() semble inoffensif, mais nous savons déjà que cela peut déclencher l’initialisation de la collection persistante, donc une requête SQL. Et c’est précisément @Transactional qui rend cette requête possible : la session est ouverte, le contexte est actif, Hibernate peut aller en base et charger les éléments.

Ce lien entre la frontière transactionnelle et le lazy loading est l’une des raisons pour lesquelles nous insistons tant dans ce cours sur open-in-view=false. Nous voulons que la « zone sécurisée » soit là où elle doit être : dans l’opération de service, et non quelque part dans la vue, la journalisation ou un toString() aléatoire.

8. Erreurs typiques lors de l’utilisation de @Transactional

Erreur n°1 : percevoir @Transactional comme une « sauvegarde automatique ».
Un modèle mental très répandu : « si j’ai mis @Transactional, alors Hibernate sauvegardera automatiquement tout ce que je veux ». En pratique, Hibernate ne sauvegarde pas « ce que vous voulez », mais ce que vous avez réellement modifié dans les entités managées à l’intérieur du contexte de persistance. L’habitude utile est donc de ne pas penser « va sauvegarder / ne va pas sauvegarder », mais plutôt « quelles entités sont managées, quels champs ont réellement changé, quand aura lieu le flush ».

Erreur n°2 : confondre flush et commit.
Dans le langage courant, les développeurs disent parfois « tout flushe à la sortie, donc ça commite ». C’est dangereux : flush() peut avoir lieu avant la fin de la méthode (par exemple, manuellement ou avant une requête), mais la transaction peut toujours être annulée. Si vous mettez mentalement un signe égal entre flush == commit, vous commencerez inévitablement à interpréter incorrectement le journal SQL et le comportement en cas d’erreur.

Erreur n°3 : s’attendre à ce que le rollback « annule » les objets Java.
Après un rollback, la base n’est pas modifiée – c’est bien. Mais l’objet en mémoire reste modifié – et c’est normal, c’est simplement un autre niveau de réalité. Quand un développeur ne s’y attend pas, des bugs étranges apparaissent : « Pourquoi dans les logs l’objet a déjà le statut CONFIRMED alors que la transaction a été annulée ? ». Réponse : parce que vous avez modifié le champ, et le rollback n’est pas obligé de réécrire votre mémoire.

Erreur n°4 : continuer à utiliser l’entité après la sortie de la transaction comme si elle était encore managée.
Dans notre baseline, le contexte de persistance est de courte durée. Dès que la méthode se termine, l’objet devient detached, et toute attente du type « Hibernate va maintenant charger tout seul quelques champs supplémentaires » se transforme en risque de LazyInitializationException ou (pire) en tentatives implicites de corriger cela avec des béquilles architecturalement incorrectes.

Erreur n°5 : appeler mécaniquement save() « au cas où ».
Après le sujet du dirty checking, on a très envie de simplement retenir : « je modifie – je ne save pas ». Mais dans le code réel, ce qui compte davantage est autre chose : comprendre l’état de l’objet. Si l’objet est managé, save() est effectivement superflu. S’il est detached, c’est une autre discussion (que nous avons déjà eue le jour sur merge()), mais l’habitude de « save partout » empêche presque toujours de voir la véritable image de ce qui se passe.

1
Mission
Hibernate deep-dive, niveau 17, leçon 0
Bloqué
Renommage du produit dans une seule transaction
Renommage du produit dans une seule transaction
1
Mission
Hibernate deep-dive, niveau 17, leçon 0
Bloqué
`flush()` avant l'erreur et rollback de la transaction
`flush()` avant l'erreur et rollback de la transaction
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION