Spring Proxies

Publié le · Mis à jour le

L'idée en une phrase

Quand tu mets @Transactional, @Async ou @Cacheable sur une méthode, Spring ne modifie pas ta classe : il place un intermédiaire devant elle, appelé proxy. C'est ce proxy qui fait le travail en plus (ouvrir une transaction, lancer un thread, lire le cache), puis il appelle ta méthode.

Comprendre ce mécanisme explique la plupart des « mon annotation ne marche pas ».

Une image pour comprendre : le secrétariat

Imagine un médecin et son secrétariat.

  • Le médecin, c'est ton service : il fait le vrai travail.
  • Le secrétariat, c'est le proxy : il est placé devant le médecin.

Quand un patient arrive, il passe d'abord par le secrétariat, qui vérifie le dossier, note le rendez-vous, puis le fait entrer. À la sortie, le secrétariat encaisse.

Le médecin ne s'occupe que de soigner. Tout le reste est fait avant et après, par le secrétariat.

Mais attention : si le médecin s'envoie lui-même un patient depuis son bureau, le secrétariat n'est pas au courant. Retiens ce point, c'est le piège n°1 de cette fiche.

Ce qui se passe vraiment

Prenons ce service :

@Service
public class CommandeService {

    @Transactional
    public void valider(Long commandeId) {
        // mise à jour de la commande, du stock, etc.
    }
}

Et ce contrôleur qui l'utilise :

@RestController
public class CommandeController {

    private final CommandeService commandeService;

    public CommandeController(CommandeService commandeService) {
        this.commandeService = commandeService; // ⚠️ ce n'est pas ton CommandeService, c'est son proxy
    }

    @PostMapping("/commandes/{id}/validation")
    public void valider(@PathVariable Long id) {
        commandeService.valider(id);
    }
}

Au démarrage, Spring voit l'annotation @Transactional. Il crée alors :

  1. ton vrai objet CommandeService ;
  2. un proxy qui l'enveloppe.

C'est le proxy qui est injecté dans le contrôleur. Quand le contrôleur appelle valider(id), voici ce qui se passe :

CommandeController
      │  appelle valider(id)
      ▼
Proxy de CommandeService
      │  1. ouvre une transaction
      │  2. appelle le vrai valider(id)  ──►  CommandeService.valider(id)
      │  3. valide la transaction (commit)
      │     ou l'annule en cas d'erreur (rollback)
      ▼
retour au contrôleur

Tu peux le constater toi-même :

System.out.println(commandeService.getClass());
// class fr.exemple.CommandeService$$SpringCGLIB$$0

Le $$SpringCGLIB$$0 indique que c'est une classe générée par Spring : le proxy.

Note : techniquement, le proxy est une sous-classe de ton service, générée au démarrage (avec une bibliothèque appelée CGLIB). Elle redéfinit tes méthodes pour ajouter le travail en plus avant d'appeler les tiennes. C'est ce détail qui explique les pièges plus bas.

Quelles annotations passent par un proxy ?

Annotation Ce que fait le proxy
@Transactional ouvre une transaction, la valide ou l'annule
@Async exécute la méthode sur un autre thread
@Cacheable renvoie le résultat en cache s'il existe, sinon appelle la méthode
@PreAuthorize vérifie les droits de l'utilisateur avant l'appel
@Retryable relance la méthode en cas d'échec

Toutes ont donc les mêmes pièges.

Piège n°1 : appeler une méthode de la même classe

C'est de loin le plus fréquent.

@Service
public class CommandeService {

    public void traiterLot(List<Long> ids) {
        for (Long id : ids) {
            valider(id); // ❌ équivaut à this.valider(id) : le proxy n'est pas traversé
        }
    }

    @Transactional
    public void valider(Long commandeId) {
        // ...
    }
}

Le contrôleur appelle traiterLot() en passant par le proxy. Mais à l'intérieur, valider(id) est un appel direct sur l'objet lui-même (this). Il ne repasse pas par le proxy, donc aucune transaction n'est ouverte.

Aucune erreur, aucun avertissement : l'annotation est simplement ignorée. Pour reprendre l'image, le médecin s'est envoyé un patient sans passer par le secrétariat.

La solution recommandée : déplacer la méthode dans une autre classe. L'appel passe alors par l'injection, donc par le proxy.

@Service
public class TraitementLotService {

    private final CommandeService commandeService;

    public TraitementLotService(CommandeService commandeService) {
        this.commandeService = commandeService;
    }

    public void traiterLot(List<Long> ids) {
        for (Long id : ids) {
            commandeService.valider(id); // ✅ passe par le proxy : une transaction par commande
        }
    }
}

Astuce : si tu as besoin d'appeler une méthode annotée depuis sa propre classe, c'est souvent le signe que la classe fait trop de choses. La découper est généralement la bonne réponse, pas un contournement.

Piège n°2 : une méthode private

@Transactional
private void valider(Long commandeId) { // ❌ annotation ignorée
    // ...
}

Le proxy est une sous-classe : il ne peut pas redéfinir une méthode private. L'annotation est ignorée, encore une fois sans erreur.

Règle simple : une méthode annotée @Transactional, @Async, @Cacheable… est public.

Note : IntelliJ IDEA signale ce cas en général. Ne désactive pas cet avertissement.

Piège n°3 : final

Pour la même raison, le proxy ne peut pas redéfinir une méthode final : l'annotation est ignorée. Et si c'est la classe qui est final, Spring ne peut pas créer de sous-classe du tout : l'application refuse de démarrer.

@Service
public final class CommandeService { // ❌ Spring ne peut pas créer le proxy
    @Transactional
    public void valider(Long id) { }
}

Ne mets pas final sur une classe ou une méthode de service qui utilise ces annotations.

Comment vérifier que ça marche ?

Quand une annotation semble ne rien faire, pose-toi ces trois questions dans l'ordre :

  1. L'appel vient-il d'une autre classe, via un objet injecté ? Sinon, piège n°1.
  2. La méthode est-elle public et non final ? Sinon, pièges n°2 et 3.
  3. La classe est-elle gérée par Spring (@Service, @Component…) et injectée, pas créée avec new ? Un objet créé avec new CommandeService() n'a pas de proxy.

Pour @Transactional, tu peux aussi afficher les logs de transaction dans application.properties :

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

Chaque ouverture et fermeture de transaction apparaît alors dans la console. Si rien ne s'affiche pour ta méthode, c'est que le proxy n'a pas été traversé.

Les bonnes habitudes

  1. Annote des méthodes public, appelées depuis une autre classe.
  2. Injecte tes services, ne les crée jamais avec new.
  3. Pas de final sur les classes et méthodes de service annotées.
  4. Une classe qui s'appelle elle-même une méthode annotée : découpe-la plutôt que de chercher un contournement.
  5. Quand une annotation « ne marche pas », pense au proxy avant de chercher ailleurs.

À retenir

  • @Transactional, @Async, @Cacheable… fonctionnent grâce à un proxy placé devant ton service.
  • Le proxy n'agit que si l'appel passe par lui : depuis une autre classe, via un objet injecté.
  • Un appel interne (this.methode()), une méthode private ou final, un objet créé avec new : l'annotation est ignorée sans erreur.
  • Le proxy est une sous-classe générée par Spring : c'est pour ça que private et final posent problème.

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