@Transactional : tout ou rien, et ses pièges

Publié le · Mis à jour le

L'idée en une phrase

Une transaction regroupe plusieurs opérations en base de données en un seul bloc : soit tout est enregistré, soit rien ne l'est. Dans Spring Boot, l'annotation @Transactional sur une méthode de service s'en charge pour toi. Elle est simple à poser, mais elle a des règles précises : savoir quand elle annule, quand elle ne fait rien, et ce qu'il ne faut pas mettre dedans.

Une image pour comprendre : le virement bancaire

Un virement de 100 € d'un compte A vers un compte B, c'est deux opérations :

  1. retirer 100 € du compte A ;
  2. ajouter 100 € sur le compte B.

Si le serveur plante entre les deux, les 100 € ont disparu : retirés de A, jamais arrivés sur B. Inacceptable.

La banque traite donc le virement comme un tout : soit les deux opérations sont faites, soit aucune. Si quelque chose échoue en cours de route, tout ce qui a été fait est annulé, comme si le virement n'avait jamais commencé.

C'est exactement ce que fait une transaction :

Mot Signification
commit « valider » : toutes les modifications sont enregistrées définitivement
rollback « annuler » : toutes les modifications depuis le début de la transaction sont effacées

@Transactional dans Spring Boot

@Service
public class VirementService {

    private final CompteRepository compteRepository;

    // constructeur...

    @Transactional
    public void virer(Long depuisId, Long versId, BigDecimal montant) {
        Compte depuis = compteRepository.findById(depuisId).orElseThrow();
        Compte vers = compteRepository.findById(versId).orElseThrow();

        depuis.debiter(montant);  // peut lancer SoldeInsuffisantException
        vers.crediter(montant);
    } // fin de la méthode sans exception → commit
      // exception (unchecked) → rollback : aucun des deux comptes n'est modifié
}

Ce que fait Spring autour de la méthode :

appel de virer(...)
   │
   ├─ 1. ouvre une transaction
   ├─ 2. exécute la méthode
   │
   ├─ 3a. tout s'est bien passé  → COMMIT
   └─ 3b. une exception est sortie → ROLLBACK

Pas de save() ici : les comptes sont lus dans la transaction, donc gérés par Hibernate, et leurs modifications sont enregistrées automatiquement au commit (voir la fiche sur les entités JPA).

Note : il existe deux annotations @Transactional, l'une de Spring (org.springframework.transaction.annotation) et l'autre de Jakarta (jakarta.transaction). Dans un projet Spring, utilise celle de Spring : c'est elle qui propose readOnly, rollbackFor, etc. Vérifie l'import proposé par ton IDE.

Où mettre @Transactional ?

Sur les méthodes publiques des services.

Couche @Transactional ? Pourquoi
contrôleur ❌ il reçoit la requête et délègue ; il ne connaît pas la base
service ✅ c'est lui qui sait quelles opérations forment un tout
repository Spring Data inutile chaque méthode (save, findById…) a déjà sa propre transaction

La raison est simple : seul le service sait que « débiter A » et « créditer B » vont ensemble. Si chaque appel au repository avait sa propre transaction, le débit serait validé tout seul, avant même le crédit.

Sur la classe ou sur la méthode ?

Posée sur la classe, l'annotation s'applique à toutes ses méthodes publiques. Une méthode peut la redéfinir :

@Service
@Transactional(readOnly = true)      // par défaut : lecture seule
public class CompteService {

    public CompteDto afficher(Long id) { ... }        // lecture seule

    @Transactional                                     // celle-ci écrit
    public void cloturer(Long id) { ... }
}

C'est une convention répandue : tout en lecture seule par défaut, et l'écriture explicitement sur les méthodes qui modifient.

readOnly = true pour les lectures

@Transactional(readOnly = true)
public List<CompteDto> lister() { ... }

Cette option indique qu'il n'y aura aucune écriture. Hibernate ne cherche alors pas les modifications à enregistrer à la fin, ce qui est plus rapide, et certaines bases optimisent aussi les lectures. Elle documente surtout l'intention : quelqu'un qui lit la méthode sait qu'elle ne modifie rien.

Pourquoi mettre une transaction sur une simple lecture ? Pour que toutes les lectures de la méthode se fassent dans la même session Hibernate : les relations LAZY peuvent être chargées pendant la construction du DTO, sans LazyInitializationException (voir la fiche sur les entités JPA).

Quand la transaction est-elle annulée ?

La règle par défaut

L'exception qui sort de la méthode est… Résultat
unchecked (RuntimeException et ses sous-classes) rollback
une Error rollback
checked (IOException, Exception…) ⚠️ commit

Le dernier cas surprend souvent (voir la fiche sur les exceptions) :

@Transactional
public void importer(Path fichier) throws IOException {
    commandeRepository.save(premiereCommande);
    Files.readString(fichier); // IOException (checked)
} // ⚠️ commit : la première commande est enregistrée malgré l'erreur

Deux solutions :

  • préférer des exceptions unchecked, en enveloppant les exceptions checked (la solution habituelle) ;
  • ou demander explicitement le rollback : @Transactional(rollbackFor = Exception.class).

Une exception attrapée n'annule rien

Le rollback se déclenche quand une exception sort de la méthode. Si tu l'attrapes à l'intérieur, Spring ne la voit jamais :

@Transactional
public void virer(...) {
    depuis.debiter(montant);
    try {
        vers.crediter(montant);
    } catch (RuntimeException e) {
        log.warn("Crédit impossible", e); // ❌ l'exception est avalée…
    }
} // …donc COMMIT : le compte A est débité, le compte B n'a rien reçu

Dans une méthode @Transactional, ne rattrape pas une exception pour continuer comme si de rien n'était. Laisse-la sortir, ou relance-la.

Le piège du « rollback-only »

Une variante plus sournoise. Une méthode transactionnelle en appelle une autre, qui échoue :

@Transactional
public void traiterLot(List<Long> ids) {
    for (Long id : ids) {
        try {
            commandeService.valider(id); // @Transactional aussi, et lance une exception
        } catch (RegleMetierException e) {
            log.warn("Commande {} ignorée", id); // on veut continuer avec les suivantes
        }
    }
}

On s'attend à ce que les commandes valides soient enregistrées. À la place, tout est annulé, avec cette erreur :

UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only

L'explication : valider() rejoint la transaction de traiterLot() (c'est le comportement par défaut, détaillé juste après). Quand l'exception traverse valider(), Spring marque toute la transaction comme « à annuler ». Le catch de traiterLot() n'y change rien : au moment du commit, Spring refuse et annule tout.

La solution est de ne pas envelopper la boucle dans une transaction : chaque appel à valider() a alors sa propre transaction, et un échec n'annule que la commande concernée.

public void traiterLot(List<Long> ids) { // ✅ pas de @Transactional ici
    for (Long id : ids) {
        try {
            commandeService.valider(id); // une transaction par commande
        } catch (RegleMetierException e) {
            log.warn("Commande {} ignorée : {}", id, e.getMessage());
        }
    }
}

Une transaction dans une transaction : la propagation

Quand une méthode @Transactional en appelle une autre, que se passe-t-il ? C'est la propagation. Deux comportements suffisent dans la plupart des projets :

Propagation Comportement Usage
REQUIRED (par défaut) s'il y a déjà une transaction, la rejoindre ; sinon, en ouvrir une presque tout le temps
REQUIRES_NEW toujours ouvrir une transaction indépendante, qui sera validée ou annulée séparément une trace qui doit rester même si l'opération principale échoue
@Service
public class JournalService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void tracer(String evenement) {
        journalRepository.save(new EntreeJournal(evenement)); // validée même si l'appelant est annulé
    }
}

Avec REQUIRED, l'entrée du journal serait annulée en même temps que l'opération qu'elle devait tracer. Avec REQUIRES_NEW, elle reste.

Note : il existe d'autres modes de propagation (MANDATORY, NESTED…). Tu les croiseras rarement. Si tu penses en avoir besoin, parles-en à l'équipe.

Quand @Transactional ne fait rien

@Transactional fonctionne grâce à un proxy placé devant ton service (voir la fiche sur les proxies Spring). Les mêmes règles s'appliquent, et l'annotation est ignorée sans aucune erreur dans ces cas :

Situation Pourquoi
la méthode est appelée depuis la même classe (this.valider()) l'appel ne passe pas par le proxy
la méthode est private le proxy ne peut pas la redéfinir
la méthode ou la classe est final idem
l'objet a été créé avec new ce n'est pas un bean, il n'a pas de proxy

Le premier cas est le plus fréquent :

@Service
public class CommandeService {

    public void traiterLot(List<Long> ids) {
        ids.forEach(this::valider); // ❌ appel interne : aucune transaction n'est ouverte
    }

    @Transactional
    public void valider(Long id) { ... }
}

La solution : appeler la méthode depuis une autre classe, comme traiterLot dans la section précédente.

Vérifier qu'une transaction est bien ouverte

logging.level.org.springframework.transaction.interceptor=TRACE

Chaque ouverture et fermeture de transaction apparaît dans la console, avec le nom de la méthode. Si ta méthode n'y figure pas, l'annotation est ignorée.

Ce qu'il ne faut pas mettre dans une transaction

Les appels lents

Une transaction occupe une connexion à la base pendant toute sa durée. Or il n'y en a que dix par défaut (voir la fiche sur les threads). Une transaction qui appelle une API externe lente, attend une réponse, ou traite un gros fichier bloque une connexion pendant tout ce temps : sous charge, les autres requêtes font la queue.

Une transaction doit être courte : lire, vérifier, modifier, valider. Ce qui est lent se fait avant ou après.

Les actions qu'on ne peut pas annuler

@Transactional
public void creerCompte(CreationCompteDto dto) {
    Compte compte = compteRepository.save(new Compte(dto.email()));
    emailService.envoyerBienvenue(dto.email()); // ❌ envoyé tout de suite…
    abonnementService.activer(compte);          // …et si ceci échoue → rollback
}

Le rollback annule ce qui est en base. Il ne peut pas rappeler un e-mail déjà envoyé, ni annuler un appel à une API externe. Résultat : un e-mail de bienvenue pour un compte qui n'existe pas.

La solution : déclencher ces actions après le commit, avec un événement :

@Transactional
public void creerCompte(CreationCompteDto dto) {
    Compte compte = compteRepository.save(new Compte(dto.email()));
    abonnementService.activer(compte);
    evenements.publishEvent(new CompteCree(dto.email())); // ✅ publié, mais pas encore traité
}
@Component
public class BienvenueListener {

    @TransactionalEventListener // ✅ appelé seulement APRÈS un commit réussi
    public void envoyerBienvenue(CompteCree evenement) {
        emailService.envoyerBienvenue(evenement.email());
    }
}

evenements est un ApplicationEventPublisher, injecté par le constructeur. Si la transaction est annulée, l'événement n'est jamais traité : pas d'e-mail.

@Transactional dans les tests

Dans un test Spring (@SpringBootTest, @DataJpaTest), @Transactional sur la classe de test a un comportement particulier : chaque test est annulé à la fin. La base revient à son état initial, et les tests ne se perturbent pas entre eux.

C'est pratique, mais attention : comme rien n'est jamais validé, certains problèmes ne se voient pas dans ces tests. Une LazyInitializationException, par exemple, peut ne jamais apparaître, puisque la session reste ouverte pendant tout le test.

Les bonnes habitudes

  1. @Transactional sur les méthodes publiques des services, jamais sur les contrôleurs.
  2. L'annotation de Spring, pas celle de Jakarta.
  3. readOnly = true sur les méthodes qui ne font que lire.
  4. Des exceptions unchecked, pour que le rollback se déclenche.
  5. N'avale pas une exception dans une méthode transactionnelle pour continuer.
  6. Pour traiter un lot élément par élément, une transaction par élément, pas une pour tout le lot.
  7. Appelle les méthodes transactionnelles depuis une autre classe, jamais via this.
  8. Des transactions courtes : pas d'appel lent ni de traitement long à l'intérieur.
  9. E-mails, messages et appels externes après le commit, avec @TransactionalEventListener.

À retenir

  • Une transaction, c'est tout ou rien : commit si tout réussit, rollback sinon.
  • @Transactional se met sur les méthodes publiques des services, qui savent quelles opérations forment un tout.
  • Par défaut, le rollback ne se déclenche que pour une exception unchecked qui sort de la méthode. Une exception checked, ou attrapée à l'intérieur, donne un commit.
  • Par défaut, une méthode transactionnelle rejoint la transaction en cours ; REQUIRES_NEW en ouvre une indépendante.
  • L'annotation est ignorée sans erreur sur un appel interne, une méthode private ou final, ou un objet créé avec new.
  • Le rollback n'annule que la base : les e-mails et appels externes se déclenchent après le commit.

Avis sur l'article

Pas encore de note : soyez le premier à donner votre avis.

Les avis sont publiés après validation par le modérateur.

Votre note