Les transactions dans Spring : @Transactional et les pièges

1. Aperçu

@Transactional est l’annotation la plus fréquemment utilisée dans Spring Boot. C’est aussi une source de nombreux bugs difficiles à détecter : les données ne font pas rollback, la transaction « disparaît », ou une UnexpectedRollbackException apparaît sans raison évidente.

Cet article explique comment @Transactional fonctionne via un proxy. Ensuite, nous passons en revue chaque piège courant, avec du code d’illustration et des corrections. Après la lecture, vous saurez quand une transaction s’ouvre vraiment et quand elle fait rollback.

2. Configuration

Nous utilisons Spring Data JPA avec H2 in-memory :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Activez le log du transaction interceptor pour voir quand une transaction s’ouvre et se ferme :

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

L’entité et le repository utilisés dans cet article :

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue
    private Long id;
    private String customerName;
    private BigDecimal amount;

    protected Order() {
    }

    public Order(String customerName, BigDecimal amount) {
        this.customerName = customerName;
        this.amount = amount;
    }

    public Long getId() { return id; }
    public String getCustomerName() { return customerName; }
    public BigDecimal getAmount() { return amount; }
}

public interface OrderRepository extends JpaRepository<Order, Long> {
}

La table est nommée orders car order est un mot-clé SQL.

3. Comment fonctionne @Transactional

3.1. Exemple basique

@Service
public class PaymentService {

    public void charge(String customer, BigDecimal amount) {
        if (amount.compareTo(new BigDecimal("1000")) > 0) {
            throw new IllegalStateException("Dépassement de la limite de paiement");
        }
    }
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

Lorsque vous appelez placeOrder("An", new BigDecimal("5000")), la commande est d’abord sauvegardée. Ensuite, charge lève une IllegalStateException. Comme c’est une RuntimeException, Spring fait un rollback complet, donc la table orders reste vide.

3.2. Le proxy est au centre

Spring ne modifie pas le bytecode de OrderService. Il crée un proxy (par défaut une sous-classe CGLIB dans Spring Boot) qui l’enveloppe. Les autres beans qui injectent OrderService reçoivent en réalité ce proxy. Chaque appel passe par un TransactionInterceptor :

caller → proxy → [ouvre transaction] → OrderService.placeOrder() → [commit/rollback] → caller

Le log TRACE montre bien cela :

TRACE o.s.t.i.TransactionInterceptor : Getting transaction for [com.nonla.order.OrderService.placeOrder]
TRACE o.s.t.i.TransactionInterceptor : Completing transaction for [com.nonla.order.OrderService.placeOrder] after exception: java.lang.IllegalStateException: Dépassement de la limite de paiement

La plupart des pièges ci-dessous proviennent d’un même principe : un appel qui ne passe pas par le proxy n’a pas de transaction.

4. Piège 1 : Self-invocation

@Service
public class OrderService {

    // ... constructeur comme ci-dessus

    public void placeOrders(List<String> customers) {
        for (String customer : customers) {
            placeOrder(customer, new BigDecimal("100")); // appel via this
        }
    }

    @Transactional
    public Order placeOrder(String customer, BigDecimal amount) {
        System.out.println("Transaction active : "
                + TransactionSynchronizationManager.isActualTransactionActive());
        Order order = orderRepository.save(new Order(customer, amount));
        paymentService.charge(customer, amount);
        return order;
    }
}

Appeler orderService.placeOrders(List.of("An", "Bình")) à partir du contrôleur affiche :

Transaction active : false
Transaction active : false

placeOrders n’a pas @Transactional, donc le proxy transmet directement l’appel à l’objet réel. À l’intérieur, placeOrder(...) est équivalent à this.placeOrder(...), et this est l’objet original et non le proxy. L’annotation est complètement ignorée. Chaque save s’exécute dans sa propre transaction du SimpleJpaRepository et fait commit immédiatement. Si la deuxième commande échoue, la première reste en base.

Il y a trois façons de corriger cela, classées par ordre de priorité :

  1. Déplacer placeOrder dans un autre bean, par exemple OrderPlacementService, puis l’injecter.
  2. Ajouter @Transactional directement sur placeOrders, si vous voulez que tout le lot soit une seule transaction.
  3. Auto-injecter via @Lazy. Cette approche fonctionne mais est difficile à lire, à utiliser uniquement si vous ne pouvez pas séparer les beans.
@Service
public class OrderBatchService {

    private final OrderService orderService; // c'est le proxy

    public OrderBatchService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void placeOrders(List<String> customers) {
        customers.forEach(c -> orderService.placeOrder(c, new BigDecimal("100")));
    }
}

Maintenant, chaque appel passe par le proxy, et le log affiche Transaction active : true.

5. Piège 2 : Méthode private ou final

Depuis Spring Framework 6.0, lorsque vous utilisez un proxy basé sur une classe (CGLIB), @Transactional fonctionne sur les méthodes public, protected et package-private. Cependant, il y a encore deux cas où cela ne fonctionne pas :

  • Méthode private : la sous-classe ne peut pas la surcharger, donc le proxy ne peut pas intercepter l’appel.
  • Méthode final : CGLIB ne peut pas non plus la surcharger. L’appel s’exécute directement dans le code original, sans transaction.
@Transactional
public final Order placeVipOrder(String customer, BigDecimal amount) { // SANS transaction
    return orderRepository.save(new Order(customer, amount));
}

Spring ne lève pas d’erreur dans ce cas. Vous ne voyez qu’une ligne de log au niveau INFO ou DEBUG, très facile à manquer.

Note : Kotlin ferme (final) par défaut toutes les classes et méthodes. Utilisez le plugin kotlin-spring (all-open) pour que les beans Spring soient automatiquement ouvertes.

6. Piège 3 : Les checked exceptions ne causent pas de rollback

Par défaut, Spring ne fait un rollback que pour RuntimeException ou Error. Les checked exceptions laissent la transaction committer.

public class PaymentDeclinedException extends Exception {
    public PaymentDeclinedException(String message) {
        super(message);
    }
}

@Transactional
public Order placeOrderStrict(String customer, BigDecimal amount) throws PaymentDeclinedException {
    Order order = orderRepository.save(new Order(customer, amount));
    if (amount.signum() <= 0) {
        throw new PaymentDeclinedException("Montant invalide");
    }
    return order;
}

Appeler avec amount = -5 lève l’exception au caller, mais la commande négative est déjà sauvegardée. La correction consiste à déclarer explicitement :

@Transactional(rollbackFor = PaymentDeclinedException.class)

Beaucoup d’équipes adoptent la convention d’utiliser rollbackFor = Exception.class pour tous les services. Une approche plus simple consiste à concevoir les exceptions métier en étendant RuntimeException. L’inverse est aussi possible : noRollbackFor = SomeRuntimeException.class conserve le commit même en cas d’erreur.

7. Piège 4 : Avaler l’exception et UnexpectedRollbackException

C’est le piège le plus difficile à comprendre. Prenez un service qui réserve du stock :

@Service
public class InventoryService {

    @Transactional // propagation par défaut : REQUIRED
    public void reserve(String sku) {
        throw new IllegalStateException("Rupture de stock : " + sku);
    }
}

Et OrderService l’appelle, puis capture l’erreur avec l’intention « si la réservation échoue, créer quand même la commande » :

@Transactional
public Order placeOrderWithInventory(String customer, String sku) {
    Order order = orderRepository.save(new Order(customer, new BigDecimal("200")));
    try {
        inventoryService.reserve(sku);
    } catch (IllegalStateException ex) {
        System.out.println("Ignorer l'erreur de stock : " + ex.getMessage());
    }
    return order;
}

Résultat :

Ignorer l'erreur de stock : Rupture de stock : SKU-01
org.springframework.transaction.UnexpectedRollbackException:
  Transaction silently rolled back because it has been marked as rollback-only

Voici ce qui se passe. reserve participe à la même transaction physique que placeOrderWithInventory. Quand l’exception passe par le proxy de InventoryService, Spring marque la transaction comme rollback-only. Le fait que vous fassiez un catch ne supprime pas cette marque. Au moment du commit, Spring détecte la marque, fait un rollback, puis lève UnexpectedRollbackException pour signaler au caller qu’il croyait committer mais c’était un rollback.

La correction dépend de votre intention :

  • Si une erreur de stock doit annuler la commande entière, ne faites pas de catch. Laissez l’exception remonter.
  • Si reserve est indépendant, donnez-lui sa propre transaction via REQUIRES_NEW (voir section 8).
  • Si reserve n’a pas besoin d’écrire en base, retirez @Transactional de lui.

Note : Avaler une exception à l’intérieur d’une même méthode ne marque pas la transaction. La marque est posée uniquement quand l’exception passe par un proxy @Transactional.

8. Propagation REQUIRES_NEW : utile mais coûteux

Un cas raisonnable pour REQUIRES_NEW est l’enregistrement d’un log d’audit. Le log doit être sauvegardé même si le métier principal fait un rollback :

@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(String action) {
        auditLogRepository.save(new AuditLog(action));
    }
}

Spring suspend la transaction externe et ouvre une nouvelle transaction sur une connexion différente. Quand record se termine, cette transaction fait commit indépendamment.

Le prix à payer :

  • Chaque requête détient deux connexions simultanément. Sous charge, le pool (HikariCP a 10 connexions par défaut) peut s’épuiser et causer un deadlock au niveau du pool : tout thread possède une connexion et attend la deuxième.
  • La nouvelle transaction ne voit pas les données non-committées de la transaction externe. Si AuditLog a une clé étrangère vers Order juste créé, vous obtiendrez une erreur ou attendrez un verrou.

N’utilisez REQUIRES_NEW que si vous avez vraiment besoin d’un commit indépendant, pas comme un « band-aid » pour chaque erreur de rollback-only.

9. readOnly et transactions trop longues

@Transactional(readOnly = true) indique à Hibernate de mettre le flush mode en MANUAL et d’ignorer la dirty checking. Pour les requêtes renvoyant beaucoup d’entités, cela réduit significativement la mémoire et le CPU :

@Transactional(readOnly = true)
public List<Order> findOrdersOf(String customer) {
    return orderRepository.findAll().stream()
            .filter(o -> o.getCustomerName().equals(customer))
            .toList();
}

Deux points à retenir :

  • readOnly est une suggestion d’optimisation, pas un mécanisme de protection. Selon le driver et la base, un UPDATE natif peut quand même s’exécuter.
  • N’appelez pas d’API HTTP externes, n’envoyez pas d’e-mail ou ne faites pas de Thread.sleep à l’intérieur d’une transaction. La connexion reste détenue tout ce temps, et les verrous sur les lignes modifiées aussi. Récupérez les données de l’extérieur d’abord, puis ouvrez une transaction courte pour écrire.

10. Tester le comportement de rollback

Un dernier piège se cache dans les tests. Si vous mettez @Transactional sur la classe de test, tout s’exécute dans une transaction du test et est rollback à la fin. Cela masque si le service commit ou rollback correctement. Pour un test sur les transactions, omettez cette annotation et nettoyez les données manuellement :

@SpringBootTest
class OrderServiceTest {

    @Autowired
    private OrderService orderService;

    @Autowired
    private OrderRepository orderRepository;

    @AfterEach
    void cleanUp() {
        orderRepository.deleteAll();
    }

    @Test
    void whenPaymentFails_thenOrderIsRolledBack() {
        assertThatThrownBy(() -> orderService.placeOrder("An", new BigDecimal("5000")))
                .isInstanceOf(IllegalStateException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenInnerServiceFailsAndIsCaught_thenUnexpectedRollback() {
        assertThatThrownBy(() -> orderService.placeOrderWithInventory("Bình", "SKU-01"))
                .isInstanceOf(UnexpectedRollbackException.class);

        assertThat(orderRepository.count()).isZero();
    }

    @Test
    void whenCheckedExceptionWithoutRollbackFor_thenOrderIsCommitted() {
        assertThatThrownBy(() -> orderService.placeOrderStrict("Chi", new BigDecimal("-5")))
                .isInstanceOf(PaymentDeclinedException.class);

        assertThat(orderRepository.count()).isEqualTo(1);
    }
}

Le troisième test « verrouille » le comportement par défaut avec checked exception. Quand vous ajoutez rollbackFor, ce test devient rouge, et vous changez l’assertion en isZero(). C’est une bonne façon de documenter la décision de rollback directement dans le code.

11. Conclusion

@Transactional semble simple, mais tous ses comportements dépendent du proxy. Résumé des pièges principaux :

  • Self-invocation : appeler via this ignore le proxy. Séparez les beans.
  • Méthode private/final : n’est pas interceptée, et Spring ne signale pas d’erreur.
  • Checked exception : commit par défaut. Utilisez rollbackFor ou une RuntimeException.
  • Rollback-only : une exception passant par un proxy @Transactional intérieur marquera toute la transaction, un catch à l’extérieur ne peut pas la sauver.
  • REQUIRES_NEW : consomme une connexion supplémentaire, ne l’utilisez que pour un commit indépendant.
  • Test avec @Transactional : masque le vrai comportement de commit/rollback.

En cas de doute, activez le log du TransactionInterceptor au niveau TRACE et vérifiez TransactionSynchronizationManager.isActualTransactionActive(). Ces deux outils vous disent exactement si une transaction existe vraiment ou non.

spring-boottransactionjpaspring-datatesting