Spring, Vue, Doctrine et Laravel ont un point commun que leurs tutoriels d'introduction mentionnent rarement. Entre ton code et certains de tes objets, ils glissent un intermédiaire. Tu appelles une méthode ou tu modifies une propriété, et c'est cet intermédiaire qui reçoit l'appel avant de le transmettre.
Le code, lui, se lit comme si tu parlais directement à l'objet. Rien dans le fichier ne signale la présence de cet intermédiaire : il est créé à l'exécution, par le framework, à partir d'une annotation ou d'un appel de fonction anodin.
Le blocage vient de là. Les bugs qu'il provoque ne lèvent aucune erreur, ils produisent un comportement absent : une transaction qui ne s'ouvre pas, ou un écran qui ne se met plus à jour. Et comme le mécanisme n'a pas de nom dans le code, on cherche la cause partout sauf au bon endroit.
Ce mécanisme porte un nom : le Proxy, l'une des 23 cases du tableau périodique des design patterns. Cet article part de quatre symptômes réels, montre le principe commun avec deux exemples exécutés, puis donne une règle à écrire pour que les assistants de code ne reproduisent pas ces erreurs. Il s'adresse aux personnes en reconversion, aux développeurs juniors et aux curieux non-informaticiens qui commencent à lire du code de framework.
Quatre bugs sans message d'erreur
La transaction Spring qui ne s'ouvre jamais
Un service importe un fichier de commandes. Chaque commande doit être enregistrée dans sa propre transaction : si l'une est invalide, elle est annulée et les autres restent en base. La méthode qui importe une commande porte @Transactional, la méthode qui boucle sur le fichier l'appelle. En production, une commande invalide laisse derrière elle une commande vide et la moitié de ses lignes. Aucune exception non gérée, aucun avertissement au démarrage.
L'objet Vue qui n'est pas égal à lui-même
Un panier est passé à reactive(), puis comparé à l'objet d'origine avec ===. Le résultat est faux. Plus loin, un composant récupère le total par déstructuration, et l'affichage reste figé alors que le panier change.
L'entité Doctrine au nom de classe inconnu
Sur un projet Symfony plus ancien, un contrôle compare get_class($client) à Client::class. Le test passe quand le client est chargé directement, et échoue quand il arrive par la relation d'une commande, avec un nom de classe généré qui contient __CG__.
La « Facade » Laravel qui n'en est pas une
Moins un bug qu'un piège de vocabulaire. Cache::get() ressemble à un appel statique, et Laravel appelle cette mécanique une Facade. Sa propre documentation, en version 13, la décrit pourtant comme un « proxy statique » vers un objet du conteneur de services. Qui cherche le pattern Façade pour la comprendre part sur la mauvaise piste.
Quatre frameworks, quatre langages, et une seule explication, que la section suivante détaille avant de revenir sur chaque cas avec du code.
Le principe : un intermédiaire qui ne voit que ce qui passe par lui
Un Proxy est un objet qui se fait passer pour un autre. Il expose la même interface que l'objet réel, si bien que le code appelant ne fait pas la différence, et il décide de ce qui se passe avant, après ou à la place de l'appel transmis.
Le Gang of Four distingue plusieurs usages, et les frameworks en exploitent deux en permanence. Le proxy virtuel retarde la création ou le chargement d'un objet coûteux jusqu'au moment où on s'en sert : c'est le chargement paresseux de Doctrine. Le proxy d'interception entoure chaque appel d'un comportement supplémentaire, ouvrir une transaction ou enregistrer une dépendance pour mettre l'écran à jour : c'est le cas de Spring et de Vue.
Toute la suite de l'article découle d'une seule règle, qu'il vaut la peine de lire deux fois : un proxy ne voit que les appels qui le traversent. Une fois l'appel transmis à l'objet réel, ce qui se passe à l'intérieur de cet objet, y compris les appels qu'il se fait à lui-même, échappe complètement au proxy.
Deux conséquences pratiques en découlent. Ce qui contourne le proxy perd son comportement, que ce soit un appel interne, l'objet d'origine manipulé directement ou une valeur copiée hors de lui. Et l'identité de l'objet change : le proxy et l'objet réel sont deux objets distincts, avec parfois deux classes différentes.
Mon avis sur l'ordre d'apprentissage : pour un junior qui arrive sur un projet Spring ou Vue, le Proxy est le pattern à reconnaître en premier, avant le Singleton ou l'Observateur. Les autres se lisent dans le code. Celui-ci agit hors du fichier, et c'est précisément pour ça qu'il coûte des heures.
Les quatre cas, code à l'appui
Un proxy en vingt lignes de JavaScript
Avant les frameworks, voici le mécanisme à nu, avec l'objet Proxy natif de JavaScript. Ce proxy journalise chaque appel de méthode, puis délègue à l'objet réel, exactement comme le fait Spring. Le code a été exécuté avec Node.js 22.
delegation.mjs, Node.js 22
class ServiceImport {
importerTout(commandes) {
for (const c of commandes) this.importerUne(c);
}
importerUne(commande) {
console.log(` import de ${commande}`);
}
}
function avecJournal(cible) {
return new Proxy(cible, {
get(objet, nom) {
const valeur = objet[nom];
if (typeof valeur !== 'function') return valeur;
return (...args) => {
console.log(`[proxy] ${nom}`);
return valeur.apply(objet, args); // délègue à l'objet réel
};
},
});
}
const service = avecJournal(new ServiceImport());
service.importerTout(['A', 'B']);
service.importerUne('C');
Sortie
[proxy] importerTout
import de A
import de B
[proxy] importerUne
import de C
Les deux imports lancés par importerTout() n'apparaissent pas dans le journal du proxy. La ligne valeur.apply(objet, args) exécute la méthode sur l'objet réel, donc à l'intérieur, this désigne l'objet réel et non le proxy. Remplace la journalisation par l'ouverture d'une transaction, et tu as le bug Spring.
Spring : l'appel interne qui contourne @Transactional
Les exemples Java visent Spring Framework 7.0, la génération embarquée par Spring Boot 4, avec Java 21. La documentation de référence est explicite : en mode proxy, le mode par défaut, seuls les appels externes qui arrivent par le proxy sont interceptés, et une méthode qui en appelle une autre de la même classe ne déclenche aucune transaction, même si la méthode appelée porte @Transactional. La même limite s'applique aux annotations de cache comme @Cacheable.
Avant : ImportCommandes.java, Spring Framework 7.0
@Service
public class ImportCommandes {
private final CommandeRepository commandes;
private final LigneRepository lignes;
public ImportCommandes(CommandeRepository commandes, LigneRepository lignes) {
this.commandes = commandes;
this.lignes = lignes;
}
public void importerTout(List<CommandeCsv> fichier) {
for (CommandeCsv csv : fichier) {
try {
importerUne(csv); // appel interne : le proxy n'est pas traversé
} catch (CommandeInvalide e) {
// on passe à la commande suivante
}
}
}
@Transactional
public void importerUne(CommandeCsv csv) {
Commande commande = commandes.save(new Commande(csv.reference()));
for (ArticleCsv article : csv.articles()) {
if (article.quantite() <= 0) {
throw new CommandeInvalide(csv.reference()); // RuntimeException
}
lignes.save(new LigneCommande(commande, article));
}
}
}
Le scénario du début se reproduit ligne pour ligne. Sans transaction englobante, chaque appel à save() s'exécute et se valide seul, puisque les méthodes d'un repository Spring Data portent leur propre transaction. Quand l'exception arrive, la commande et les premières lignes sont déjà en base, et il n'y a plus rien à annuler.
La documentation de Spring classe les corrections par ordre de préférence. La meilleure consiste à supprimer l'appel interne en déplaçant la méthode transactionnelle dans un autre bean :
Après : deux beans, Spring Framework 7.0
@Service
public class ImportCommandes {
private final ImportUneCommande importUne;
public ImportCommandes(ImportUneCommande importUne) {
this.importUne = importUne;
}
public void importerTout(List<CommandeCsv> fichier) {
for (CommandeCsv csv : fichier) {
try {
importUne.importer(csv); // traverse le proxy du second bean
} catch (CommandeInvalide e) {
// la commande invalide a été annulée en entier
}
}
}
}
@Service
public class ImportUneCommande {
private final CommandeRepository commandes;
private final LigneRepository lignes;
public ImportUneCommande(CommandeRepository commandes, LigneRepository lignes) {
this.commandes = commandes;
this.lignes = lignes;
}
@Transactional
public void importer(CommandeCsv csv) {
// même corps que importerUne() plus haut
}
}
Les deux autres options citées par la documentation arrivent loin derrière : l'auto-injection, où le bean reçoit une référence vers son propre proxy, et AopContext.currentProxy(), que l'équipe Spring déconseille fortement parce qu'il couple le code métier à la mécanique AOP. Le mode aspectj, qui modifie le bytecode au lieu de créer un proxy, supprime le problème à la racine, au prix d'une configuration de tissage que peu de projets mettent en place.
Ces exemples Java n'ont pas été exécutés pour cet article : ils ont été relus ligne à ligne contre la documentation de Spring Framework 7.0. Le comportement décrit est celui qu'elle documente, et le test d'échec décrit dans la section des pièges permet de le constater sur ton propre projet. Ces mécanismes de transaction et de beans sont travaillés en pratique dans la formation Spring Boot : développer des API REST en Java.
Vue : l'objet d'origine et la valeur copiée
La documentation de Vue le dit sans détour : ce que renvoie reactive() est un Proxy JavaScript de l'objet d'origine, distinct de lui, et seul le proxy est réactif. Le script suivant a été exécuté avec Vue 3.5.44, la version publiée sur npm au moment où ces lignes sont écrites.
reactif.mjs, Vue 3.5.44, Node.js 22
import { reactive, toRefs, watchEffect, nextTick } from 'vue';
const brut = { articles: 2, total: 40 };
const panier = reactive(brut);
console.log(panier === brut); // false
let declenchements = 0;
watchEffect(() => {
declenchements++;
void panier.total;
});
let { total } = panier; // copie de la valeur
const { total: totalSuivi } = toRefs(panier); // lien conservé
panier.total = 55; // passe par le proxy
await nextTick();
brut.total = 70; // contourne le proxy
await nextTick();
console.log(total, totalSuivi.value, declenchements); // 40 70 2
Les trois nombres racontent les trois façons de contourner un proxy. La déstructuration a copié 40 au moment de la lecture, et cette copie ne bougera plus. La référence créée par toRefs() lit 70, puisqu'elle passe toujours par le proxy. Et l'effet ne s'est déclenché que deux fois, au démarrage puis pour 55 : la modification faite sur l'objet d'origine n'a prévenu personne, alors que la valeur a bien changé. C'est pour ces raisons que la documentation recommande ref() comme API principale pour déclarer un état réactif.
Doctrine : le proxy de chargement paresseux
Quand tu charges une commande, Doctrine ne charge pas son client tout de suite. Il place à la place un objet qui remplace le client et n'interroge la base qu'au premier accès à ses données. Avant PHP 8.4, la documentation de Doctrine le décrit ainsi : l'ORM génère des classes qui étendent tes entités. Le client reçu par la relation était donc une instance d'une sous-classe générée, d'où le nom de classe inattendu.
PHP 8.4 a intégré les objets paresseux au langage, et Doctrine ORM 3.4 s'en sert. Depuis la 3.5, ne pas les activer sous PHP 8.4 est déprécié, et la documentation annonce que Doctrine 4 n'utilisera plus que ce mécanisme. L'objet paresseux est alors créé à partir de la classe de ton entité elle-même. Le symptôme du nom de classe disparaît sur un projet à jour, mais il reste présent sur tous ceux qui tournent encore sur une version antérieure, et l'ORM 2 en fait partie. L'écriture robuste fonctionne dans les deux cas :
PHP 8.4, Doctrine ORM 3
$client = $commande->getClient(); // objet paresseux, aucune requête pour l'instant
// Fragile : dépend de la façon dont Doctrine a créé l'objet
if (get_class($client) === Client::class) {
// ...
}
// Robuste : vrai pour l'entité comme pour une sous-classe générée
if ($client instanceof Client) {
// ...
}
Pour comparer deux entités, l'identifiant reste le critère le plus sûr. Le lien entre ces proxys et le fameux problème des requêtes N+1 est développé dans l'article ORM ou SQL écrit à la main, et Doctrine occupe une bonne part de la formation Symfony 7.
Quand une IA écrit le code
Un assistant de code lit les fichiers de ton projet. Le proxy, lui, n'existe dans aucun fichier : Spring le fabrique au démarrage de l'application, Vue à chaque appel de reactive(). Rien dans le code source ne rappelle à l'assistant que la méthode qu'il appelle depuis la même classe perdra son annotation. Cet article ne prétend pas mesurer la fréquence de cette erreur chez les assistants, mais le risque est structurel, et il se traite avec une consigne écrite.
Demander à l'assistant d'« utiliser des design patterns » serait contre-productif : la consigne pousse à ajouter des interfaces et des fabriques là où il n'en faut pas. Ce qui aide, c'est de décrire les proxys déjà présents dans le projet et les règles qui en découlent.
Le fichier AGENTS.md est le format partagé. Codex le lit avant tout travail, de la racine du dépôt jusqu'au dossier courant, et le fichier le plus proche l'emporte. Claude Code lit CLAUDE.md, et sa documentation indique qu'il lit aussi AGENTS.md quand aucun CLAUDE.md n'est présent, ou quand un CLAUDE.md l'importe. Le guide CLAUDE.md avec trois exemples détaille l'organisation de ces fichiers, et l'article sur les principes SOLID propose d'autres règles du même type.
AGENTS.md, section à adapter à ta pile
## Proxys créés par les frameworks
- Spring : @Transactional et @Cacheable passent par un proxy.
Un appel this.methode() depuis la même classe contourne le proxy
et l'annotation n'a aucun effet. Une méthode qui doit avoir sa
propre transaction va dans un autre bean, injecté par constructeur.
Ne pas utiliser AopContext.currentProxy().
- Vue : ne pas déstructurer un objet issu de reactive(). Utiliser
ref(), ou toRefs() pour exposer ses propriétés. Ne jamais modifier
l'objet d'origine passé à reactive().
- Doctrine : comparer les entités par identifiant ou avec instanceof,
jamais avec get_class() ou ::class.
- Chaque méthode transactionnelle a un test du cas d'échec qui
vérifie qu'aucune donnée partielle ne reste en base.
Une limite à garder en tête : la documentation de Claude Code précise que ces fichiers sont traités comme du contexte, pas comme une configuration imposée. Une consigne courte et vérifiable est mieux suivie qu'un paragraphe, mais elle ne remplace pas la relecture. Pour celle-ci, quatre vérifications suffisent sur du code généré :
- chercher les appels internes vers une méthode annotée
@Transactionalou@Cacheablede la même classe - vérifier qu'un test provoque l'échec et compte les lignes restées en base, au lieu de tester le seul cas qui fonctionne
- repérer les déstructurations d'un objet réactif et les modifications de l'objet d'origine
- refuser toute comparaison de classe sur une entité chargée par une relation
Les pièges
Croire qu'annoter toute la classe règle le problème
Placer @Transactional sur la classe ouvre bien une transaction quand importerTout() est appelée de l'extérieur. Mais c'est alors une seule transaction pour tout le fichier. Comme l'exception est attrapée dans la boucle, cette transaction se valide à la fin avec la commande invalide et ses lignes partielles. Sans le try, une seule commande invalide annulerait le fichier entier. Dans les deux cas, le besoin initial, une transaction par commande, n'est pas couvert, parce que l'appel interne ne crée toujours pas de nouvelle transaction.
Ne tester que le cas qui réussit
Avec un fichier valide, la version fautive et la version corrigée produisent exactement le même résultat en base. Le bug n'apparaît qu'en cas d'échec. Un test utile importe un fichier qui contient une commande invalide, puis vérifie que cette commande et ses lignes sont absentes, et que les commandes valides sont présentes. La documentation de Spring fait la même remarque à propos d'une autre configuration fautive : le code semble fonctionner jusqu'au jour où l'on teste un retour arrière.
Utiliser toRaw() pour contourner un comportement gênant
Vue fournit toRaw() pour récupérer l'objet d'origine derrière un proxy réactif. C'est utile pour transmettre des données à une bibliothèque externe, mais toute modification faite sur cet objet brut se fait en silence, comme le montre le compteur de l'exemple plus haut. Récupérer l'objet brut pour « réparer » une comparaison crée un second bug plus discret que le premier.
Chercher le pattern d'après le nom de la classe
Les Facades de Laravel sont des proxys, et le Décorateur ressemble à un proxy au point qu'on les confond à la lecture : même interface, même enveloppe autour d'un objet. La différence tient à l'intention. Le Décorateur ajoute un comportement que le code appelant a choisi d'assembler, le Proxy contrôle l'accès à un objet, souvent à l'insu de l'appelant. Pour classer un mécanisme, lis sa structure et son intention. Le principe composition plutôt qu'héritage éclaire pourquoi ces deux patterns reposent sur une enveloppe plutôt que sur une sous-classe.
Questions fréquentes
Quelle différence entre un Proxy et un Décorateur ?
Pourquoi Spring n'intercepte-t-il pas les appels internes ?
this désigne cet objet et non le proxy, donc un appel interne ne repasse jamais par le proxy. Le mode aspectj lève cette limite en modifiant le bytecode, mais il demande une configuration de tissage. Déplacer la méthode dans un autre bean reste la solution la plus simple.Le Proxy natif de JavaScript, c'est le même pattern ?
Proxy intercepte les lectures, les écritures et les appels faits sur un objet cible, à travers des fonctions qu'on lui fournit. Vue s'en sert pour sa réactivité. Le pattern décrit l'intention, l'objet natif fournit le mécanisme.Comment savoir si un objet est un proxy ?
isProxy(), Spring la classe utilitaire AopUtils et sa méthode isAopProxy(). qui détecte les proxys JDK comme les proxys de sous-classe. Le réflexe utile reste de connaître la liste des annotations et des fonctions qui en créent dans ton projet.