Remplacer les variables globales mutables partagées par un état explicite en toute sécurité
Apprenez à remplacer les variables globales mutables partagées en repérant les singletons cachés et en déplaçant l'état dans des dépendances à portée de requête pour éviter les conditions de concurrence.

Pourquoi les variables globales mutables provoquent des bugs étranges et aléatoires
« Globales mutables » paraît sophistiqué, mais c'est simple : une valeur vit à un endroit unique (partagée) et votre code peut la modifier (mutable). Quand plusieurs parties de l'application lisent et écrivent cette même valeur, le comportement commence à dépendre du temps d'exécution, pas de l'intention.
C'est pourquoi ces bugs semblent aléatoires. Avec un seul utilisateur qui clique, l'app peut sembler correcte. Sous requêtes parallèles, travaux en arrière-plan ou retries automatiques, deux opérations peuvent toucher la même globale en même temps. Une requête met à jour la valeur, et une autre l'utilise par accident, alors même qu'il s'agit de deux utilisateurs ou tâches différentes.
Un exemple courant est une variable currentUser stockée dans un module, un « cache global » qui contient aussi des données par requête, ou un client singleton qui conserve silencieusement de l'état (headers, tokens, un tenant sélectionné). Ce sont précisément les cas où il faut remplacer les globals mutables partagées par un état explicite.
Les symptômes typiques ressemblent à ceci :
- Des utilisateurs voient les données d'un autre ou sont connectés en tant que la mauvaise personne
- Des échecs « ça marche sur ma machine » qui n'apparaissent qu'en charge
- Des tests instables qui passent ou échouent selon l'ordre
- Des 401/403 aléatoires parce que le mauvais token a été réutilisé
- Des jobs en arrière-plan qui utilisent la mauvaise configuration
Le but n'est pas « zéro choses partagées ». Bases de données et pools de connexions sont partagés volontairement. L'objectif : que les ressources partagées restent partagées, tandis que l'état spécifique à la requête est passé en paramètre (ou créé par requête) pour que rien ne soit réutilisé silencieusement.
Cela arrive souvent dans des prototypes générés par l'IA. FixMyMess tombe fréquemment sur des singletons cachés qui semblent inoffensifs jusqu'à ce que le trafic réel arrive.
Ce qui compte comme global ou singleton dans de vrais projets
Une « globale » est toute donnée qui vit en dehors d'une requête, d'un job ou d'une action utilisateur spécifique, mais qui est lue et écrite pendant l'exécution. Un « singleton » est la même idée sous un autre nom : « une seule instance », partagée par tous.
Dans le code réel, ils ne sont pas toujours évidents. Ils se cachent dans des variables au niveau du module, des « services » du framework, des champs statiques de classes, ou des helpers qui stockent des choses en mémoire. Si vous chassez les globals mutables partagées, voici ce que vous recherchez.
Tout ce qui est global n'est pas forcément mauvais. Les constantes en lecture seule sont généralement sûres : chaînes de version, limites fixes, réglages par défaut. Le risque commence quand la valeur peut changer pendant que des requêtes sont en vol. État mutable + concurrence = bugs qui disparaissent quand vous ajoutez des logs.
L'état partagé apparaît souvent comme :
- Caches au niveau du module ou valeurs mémoïsées qui stockent des résultats spécifiques à un utilisateur
- Un objet de config partagé qui est muté (par exemple « current tenant »)
- Un wrapper client DB partagé qui stocke aussi des données par requête comme « current user »
- Des helpers auth/session qui gardent des tokens en mémoire au lieu de les conserver par requête
- Des files en mémoire ou des tableaux de « jobs en attente » utilisés par plusieurs utilisateurs
Le danger augmente avec l'échelle. Un serveur dev mono-utilisateur peut sembler correct, mais plusieurs workers ou instances rendent le comportement moins prévisible. Certains échecs n'apparaissent que lorsque deux requêtes se chevauchent.
Si vous avez hérité d'un prototype généré par l'IA, ces raccourcis « une instance » sont courants. Lors d'un audit, FixMyMess les trouve souvent autour de l'auth, du caching et des travaux en arrière-plan.
Signes rapides que vous avez de l'état partagé caché
L'état partagé caché se manifeste généralement par des problèmes qui paraissent aléatoires. L'app « marche la plupart du temps », puis échoue de façons que vous ne pouvez pas reproduire à la demande.
Le symptôme le plus clair est la contamination des données entre utilisateurs. Une requête met à jour quelque chose, et la requête suivante (d'un autre utilisateur ou tenant) le voit. Vous pouvez remarquer un utilisateur connecté comme quelqu'un d'autre, le mauvais nom d'organisation dans l'en-tête, ou un tableau de bord chargeant brièvement les données d'un autre client.
Le comportement des tests est un autre indice. Un test passe quand vous l'exécutez seul, mais échoue quand vous lancez toute la suite. Cela signifie souvent qu'un test laisse de l'état derrière (comme un currentUser en cache, ou un wrapper DB global avec des settings mutables) qui affecte le test suivant.
Le trafic aggrave le problème. Lorsque les requêtes se chevauchent lors d'un pic, vous obtenez des 500 occasionnels qui disparaissent au retry. Ce schéma pointe souvent vers des objets partagés mutés en plein milieu d'une requête, comme un objet de config global, un client singleton avec des headers par requête, ou une variable « requête courante » au niveau du module.
Un dernier indice : « ça marchait en local ». Beaucoup de serveurs de dev traitent les requêtes une par une, donc les bugs d'état partagé restent cachés jusqu'à ce que la production traite plusieurs requêtes en parallèle.
Signes rapides à scanner :
- Une variable au niveau du module qui change pendant une requête (
token,tenantId,currentUser) - Un client singleton qui stocke des données par requête (headers, auth, locale)
- Des caches en mémoire utilisés comme source de vérité (pas seulement pour l'accélération)
- Des logs montrant des IDs de requête ou d'utilisateur mélangés dans le même flux
- Des bugs qui disparaissent quand vous ajoutez des print statements (changement de timing)
Les équipes amènent parfois à FixMyMess un prototype où deux personnes se connectent en même temps et les sessions se croisent. C'est presque toujours de l'état partagé, pas une « auth mystérieuse ».
Comment traquer les singletons cachés, pas à pas
Les singletons cachés sont une raison courante des comportements « aléatoires » sous charge : une requête change quelque chose et la suivante en hérite.
Recherche pas à pas
Parcourez la base de code dans cet ordre (c'est plus rapide et ça attrape les coupables majeurs en premier) :
- Cherchez les variables au niveau du module qui sont réassignées (pas seulement des constantes). Repérez des noms comme
currentUser,token,client,config, ousession. - Recherchez les patterns singleton :
getInstance(), commentaires « only create once », ou une initialisation paresseuse du type « if not created, create it now ». - Inspectez les caches et la mémoïsation. Un cache peut être acceptable, mais il devient dangereux si la clé est absente, trop large (par ex. seulement
userIdsanstenantId), ou par défaut sur une seule clé pour toutes les requêtes. - Passez en revue les handlers et middlewares pour les objets stockés en dehors du handler. Un piège courant est de créer un objet au démarrage puis de le muter par requête.
- Vérifiez les “locals” du framework et les stores applicatifs. Confondre les locals de requête et les locals d'app transforme des données par requête en stockage inter-requêtes.
Vérification rapide
Pour confirmer que vous avez trouvé le coupable : ouvrez deux sessions navigateur (normale et incognito), connectez-vous comme deux utilisateurs différents, et appelez le même endpoint plusieurs fois. Si les identités, permissions ou réglages fuient entre sessions, vous avez probablement encore de l'état mutable partagé.
Un modèle simple : état par requête vs ressources partagées
Une façon fiable d'éliminer les globals mutables partagées est de séparer tout en deux bacs : ce qui appartient à une requête, et ce qui peut être partagé en toute sécurité entre requêtes.
L'état par requête est tout ce qui change d'un utilisateur à l'autre ou d'un appel à l'autre : l'ID utilisateur courant, les claims d'auth, un ID de corrélation pour les logs, la locale, et la charge utile d'entrée. Ces données ne doivent jamais vivre dans une variable de module, car deux requêtes peuvent se chevaucher et s'écraser mutuellement.
Les ressources partagées sont des briques coûteuses que vous pouvez réutiliser en toute sécurité : un pool de connexions DB, un client HTTP avec des réglages fixes, un template compilé. L'important est que ces objets ne doivent pas contenir de données spécifiques à la requête.
Règle simple : si ce serait faux que la Requête B le voie, ça ne peut pas être global.
Un modèle pratique pour rester honnête :
- Mettez l'état par requête dans les paramètres de fonction ou dans un petit objet
RequestContext. - Construisez les dépendances explicitement via des constructeurs ou une fonction usine.
- Gardez les objets partagés immuables (config) ou sûrs en interne (comme un pool DB).
- Si quelque chose doit être partagé et mutable, protégez-le avec une synchronisation appropriée.
Exemple : au lieu d'un currentUser global, créez ctx = { user, correlationId } quand la requête démarre, puis passez ctx aux handlers et services. Votre pool DB reste partagé, mais vos fonctions de requête prennent ctx pour que le logging et les permissions restent corrects.
Plan de refactor : passer des globals à l'état explicite
Pour remplacer les globals mutables en toute sécurité, commencez petit. Choisissez une zone risquée où le mauvais état fait des dégâts rapides, comme l'auth, la sélection de tenant, ou le caching. Un changement ciblé est plus simple à relire et moins susceptible de casser des fonctionnalités non liées.
D'abord, notez ce que le code prend secrètement depuis « quelque part » : utilisateur courant, tenant ID, feature flags, locale, ID de requête, etc. Créez ensuite un petit objet de contexte de requête qui ne contient que ce dont vous avez vraiment besoin.
Une séquence qui fonctionne dans la plupart des bases de code :
- Choisissez un point d'entrée (route API, handler ou job) et construisez le contexte de requête là.
- Modifiez une fonction à la fois pour accepter le contexte en argument au lieu de lire des globals.
- Quand une fonction a besoin d'un service (db, cache, client auth), passez-le en paramètre ou construisez-le via une usine.
- Gardez les ressources partagées partagées (comme un pool de connexions), mais gardez l'état par requête dans la requête.
- Une fois que ça marche, supprimez l'ancien global ou faites-le échouer bruyamment s'il est accédé.
Une usine peut faire le pont sans réécriture massive. Par exemple, createServices(ctx) peut retourner authService, tenantService, et auditLogger qui lisent tous depuis le contexte passé, pas depuis des variables de module. Cela rend aussi les dépendances visibles au lieu d'implicites.
Enfin, supprimez ou figez l'ancien global. Ne le laissez pas « au cas où ». Quelqu'un le réutilisera lors d'un correctif rapide.
Dépendances à portée de requête sans sur-ingénierie
L'objectif est simple : créez ce dont votre code a besoin une fois par requête, passez-le explicitement, et gardez les parties vraiment partagées (comme les pools) en lecture seule du point de vue du handler. C'est généralement suffisant pour stopper les bugs de concurrence sans construire un énorme système de dépendances.
Gardez le « container de requête » petit. Traitez-le comme un objet simple qui porte seulement ce qui varie par requête : l'utilisateur courant, l'ID de requête, la locale, les feature flags, et une horloge. Tout le reste doit être une ressource partagée réutilisable en toute sécurité.
Pattern pratique dans une webapp :
- Démarrage de l'app : créez les ressources partagées (pool DB, client HTTP, config logger)
- Par requête : créez l'état de requête (user, request ID) et de petits helpers qui en ont besoin
- Handler : acceptez ces dépendances en paramètres, pas via des imports
Par exemple, un handler de login peut construire un RequestContext une fois, puis le passer à des services comme AuthService(ctx, db_pool, logger). Le pool DB est partagé, mais le contexte ne l'est pas. Cela empêche les fuites d'un utilisateur vers une autre requête quand deux requêtes s'exécutent simultanément.
Dépendances partagées généralement sûres : un pool de connexions DB (pas une connexion unique stockée globalement), un client HTTP sans headers par utilisateur intégrés, et une configuration logger (mais pas un global currentUser mutable).
Les jobs en arrière-plan sont des endroits où on retombe souvent sur des globals parce qu'il n'y a pas de requête. Traitez les jobs de la même manière : créez un JobContext avec un job ID et tout ID utilisateur/workspace que le job concerne, et transmettez-le à la fonction du job.
Erreurs communes qui empirent le problème
La façon la plus rapide d'annuler votre refactor est de garder le global et tenter de le « réinitialiser » à chaque requête. Cela paraît sûr en test mono-utilisateur, mais se casse dès que deux requêtes se chevauchent. Si une requête réinitialise la valeur pendant qu'une autre l'utilise, vous obtenez un comportement difficile à reproduire.
Un autre piège classique est de stocker le « current user » dans une variable globale ou un service singleton. C'est pratique parce qu'on y accède partout, mais cela transforme chaque requête en course. Vous verrez des symptômes comme des utilisateurs qui deviennent d'autres utilisateurs, des permissions qui basculent, ou des logs d'audit montrant le mauvais acteur.
Les caches globaux sont aussi risqués quand ils n'incluent pas tenant ou user dans la clé. Un cache qui n'utilise que product ID (ou pire, une unique valeur « latest ») peut faire fuiter des données entre comptes. Le bug ressemble alors à « mon app montre parfois les données de quelqu'un d'autre » plutôt qu'à un bug de cache.
Certaines erreurs commencent comme hacks de performance mais créent des problèmes de fiabilité. Créer une nouvelle connexion DB par requête au lieu d'utiliser un pool peut épuiser les connexions sous charge. Ensuite on « corrige » avec des retries et timeouts, ce qui masque le vrai problème et rend les échecs plus difficiles à raisonner.
Mélanger config mutable et état d'exécution est un autre tueur discret. Si vous changez un objet de config à l'exécution (feature flags, base URLs, valeurs d'environnement) et que cet objet est partagé, chaque requête peut voir une configuration différente selon le timing.
Signes rapides qui apparaissent souvent ensemble :
- Un singleton contient des champs comme
currentUser,token,requestIdoulastResult - Une clé de cache manque
tenantIdouuserId - Des fonctions de « reset » s'exécutent en middleware ou avant les handlers
- Les connexions DB sont ouvertes/fermées dans le handler
- Des objets de config sont modifiés après le démarrage
Si vous héritez d'un code généré par l'IA, ces motifs reviennent souvent dans les prototypes. Ils n'apparaissent que quand de vrais utilisateurs frappent l'app en même temps.
Comment confirmer la correction avec des tests simples
Après avoir remplacé les globals mutables, le code paraît souvent meilleur tout de suite. La vraie preuve : il reste cohérent quand deux choses se passent en même temps.
1) Ajoutez un test de concurrence simple
Vous n'avez pas besoin d'une énorme suite. Commencez par un test qui envoie deux requêtes en parallèle avec des utilisateurs différents (ou des API keys différentes) et affirme que leurs réponses ne se mélangent jamais.
# Pseudocode example
# Send two parallel requests:
# - user A logs in and fetches /me
# - user B logs in and fetches /me
# Assert A never sees B's data, and B never sees A's data.
Si ce test échoue même une fois, il y a encore de l'état partagé caché quelque part.
2) Rendez le cross-talk visible avec des logs
Ajoutez des logs structurés simples qui incluent un ID de requête et l'ID utilisateur dans chaque handler et dans tout objet de service refactoré. Ensuite, cherchez des séquences impossibles, comme l'ID de requête d'A loggant l'ID utilisateur de B.
Quelques vérifications rapides qui font ressortir les couplages cachés :
- Lancez la même suite de tests en mode parallèle.
- Bouclez le test de concurrence 50 à 200 fois pour attraper les échecs nondéterministes.
- Ajoutez une assertion que les objets à portée de requête sont créés par requête (et non réutilisés).
- Surveillez la mémoire pendant la boucle pour confirmer qu'elle reste stable après avoir supprimé des caches accidentels.
- Baissez temporairement les timeouts pour faire apparaître les conditions de course plus vite.
Si vous voyez encore des échecs aléatoires, c'est généralement un singleton restant (comme un client de module stockant current user) ou un cache trop peu granulaire.
Exemple : un prototype qui casse quand deux utilisateurs se connectent
Un bug courant dans les prototypes générés par l'IA semble inoffensif en test mono-utilisateur : l'état d'auth est stocké dans une variable globale. Par exemple, l'app garde currentUser (ou un accessToken) dans une variable de module, et toutes les routes API lisent cette variable.
Voici ce qui se passe en production. L'utilisateur A se connecte, puis l'utilisateur B se connecte un instant après. Le currentUser global est écrasé. Maintenant, quand A clique sur « Mon compte », le serveur répond parfois en tant que B. Ça semble aléatoire car ça dépend du timing, pas du chemin logique du code.
Signes typiques dans les logs et tickets de support :
- « J'ai vu les données de quelqu'un d'autre pendant une seconde »
- Les requêtes affichent le mauvais user ID alors que les cookies semblent corrects
- Le problème n'apparaît qu'en charge ou quand deux personnes testent ensemble
- Actualiser répare parfois le problème
La correction : arrêter de demander à un singleton global qui est l'utilisateur courant. Construisez plutôt un service d'auth par requête, en utilisant un contexte explicite (headers, cookies, session ID). Chaque requête obtient son objet auth, et les handlers le reçoivent en paramètre.
Concrètement : parsez le token de la requête entrante, vérifiez-le, puis passez l'utilisateur vérifié aux fonctions qui en ont besoin. Les ressources partagées (comme un pool DB) peuvent rester partagées, mais l'identité utilisateur doit être à portée de requête.
Après ce refactor, les connexions et appels API concurrents deviennent cohérents : l'utilisateur A voit toujours ses propres données, même si l'utilisateur B est actif en même temps.
Checklist rapide avant livraison
Avant de déployer, faites une dernière passe pour vous assurer de ne pas avoir laissé d'état partagé visible.
Vérifications d'aptitude à la mise en production
- Scannez le code de gestion des requêtes pour des variables au niveau du module qui changent (tout ce qui est écrit pendant une requête).
- Vérifiez caches et mémoïsation. Assurez-vous que les clés incluent ce qui sépare les utilisateurs et tenants (et la locale quand elle change la sortie).
- Confirmez que le contexte de requête est passé en argument, pas lu depuis des singletons cachés. Si une fonction a besoin de l'utilisateur courant, du token d'auth, du tenant ou du fuseau, elle doit le recevoir en paramètre (ou via une dépendance à portée de requête).
- Auditez ce que vous partagez. Pools de connexions, config immuable, et clients lecture-seuls sont généralement OK. Tout objet avec des champs mutables (comme
currentUser,lastQuery,headers) ne l'est pas. - Lancez un test parallèle : deux utilisateurs se connectent et effectuent des actions différentes en même temps. Cherchez des données croisées, des sessions mélangées, ou des erreurs de permission « aléatoires ».
Si vous héritez d'un prototype IA, refaites cette checklist deux fois. Ces apps glissent souvent des globals current user ou des clients partagés avec headers mutables.
Prochaines étapes si votre code généré par l'IA a des bugs de concurrence
Si votre app marche bien avec un seul utilisateur mais casse sous trafic réel, considérez-le comme un problème d'état partagé tant que ce n'est pas démontré autrement. Beaucoup de projets générés par l'IA laissent des données en mémoire qui devraient vivre dans une requête, une session, ou la base.
Commencez par dresser un inventaire rapide de tout ce qui peut être écrit par plus d'une requête. Cherchez des variables de module, des objets mis en cache qui stockent des données utilisateur, des singletons créés au démarrage, et des utilitaires qui conservent un état interne.
Un moyen simple d'avancer : corrigez un parcours utilisateur de bout en bout. Choisissez le chemin qui casse le plus (login, checkout, upload). Refactorez uniquement ce chemin pour que l'état soit transmis via des arguments de fonction ou des dépendances à portée de requête. Une fois qu'un chemin est propre, les patterns sont plus faciles et sûrs à reproduire.
Quand vous ne trouvez pas le singleton caché, réduisez la recherche :
- Ajoutez des logs pour les IDs d'objet et d'utilisateur à des étapes clés (auth, accès DB, caching)
- Greppez pour « global », « singleton », « cache », « memo », « static », et affectations au niveau du module
- Désactivez temporairement le caching en mémoire pour voir si le bug disparaît
Parfois, il est plus rapide de demander une revue d'expert, surtout quand le code mélange frameworks, jobs en arrière-plan et auth custom. FixMyMess (fixmymess.ai) diagnostique et répare le code généré par l'IA, en commençant par un audit de code gratuit. La plupart des projets sont finalisés en 48–72 heures avec des outils assistés par IA et une vérification humaine experte, de sorte que les corrections tiennent sous charge.
Questions Fréquentes
Qu'est-ce qu'une « variable globale mutable partagée » exactement ?
Une variable globale mutable partagée est toute valeur qui vit en dehors d'une requête ou d'un job spécifique et qui peut être modifiée pendant l'exécution de l'application. Elle devient risquée quand plusieurs requêtes peuvent la lire et l'écrire, car le travail d'un utilisateur peut écraser l'état d'un autre utilisateur.
Pourquoi les globals partagées provoquent-elles des bugs qui semblent aléatoires ?
Parce que le résultat dépend du moment d'exécution, pas seulement du flot de code. Sous requêtes parallèles, retries ou travaux en arrière-plan, deux opérations peuvent se chevaucher et réutiliser accidentellement le même état, si bien que le bug apparaît et disparaît selon la charge et l'ordonnancement.
Quels sont des exemples concrets de globals ou singletons cachés ?
Surveillez des éléments comme un currentUser au niveau du module, un client singleton qui stocke des headers ou des tokens, un « cache global » qui contient des résultats par-utilisateur, ou un objet de configuration partagé qui est muté (par exemple « current tenant »). Ces motifs fonctionnent souvent en test mono-utilisateur et échouent quand les requêtes se chevauchent.
Quels sont les signes les plus clairs que l'état fuit entre les requêtes ?
Si des utilisateurs voient parfois le mauvais compte, le mauvais nom de tenant, ou des données d'un autre utilisateur, considérez-le comme un problème d'état partagé tant que ce n'est pas prouvé autrement. Des tests instables dépendant de l'ordre et des erreurs d'auth aléatoires (401/403) sont aussi de bons indices.
Quelle est une façon rapide de reproduire ou confirmer le problème ?
Ouvrez deux sessions (par exemple une fenêtre normale et une fenêtre privée), connectez-vous avec deux utilisateurs différents et appelez plusieurs fois les mêmes endpoints. Si l'identité, les permissions ou les paramètres se croisent, il existe encore des données spécifiques à la requête dans la mémoire partagée.
Qu'est-ce qui peut être partagé en toute sécurité, et qu'est-ce qui ne doit jamais l'être ?
Les ressources partagées sont sûres tant qu'elles ne contiennent pas de données propres à une requête. Un pool de connexions DB, un client HTTP avec des réglages fixes, et une configuration immuable sont généralement sûrs ; le danger commence quand l'objet partagé stocke des champs mutables comme currentUser, token, tenantId ou des headers par requête.
Comment remplacer un `currentUser` global sans tout réécrire ?
Créez un petit contexte de requête au point d'entrée (handler ou middleware) contenant uniquement ce qui change par requête, par exemple l'identité de l'utilisateur et un ID de requête. Passez ensuite ce contexte (ou des services dérivés créés à partir de lui) aux fonctions au lieu d'importer un global qui transporte l'état en douce.
Comment garder le caching sans faire fuir les données entre utilisateurs ?
Le caching est acceptable quand il sert de couche de performance, pas de source de vérité cachée. La clé est le périmètre et les clés correctes : incluez le tenant et l'utilisateur quand les résultats diffèrent par tenant/utilisateur, et évitez une unique valeur « latest » que n'importe quelle requête peut écraser.
Comment gérer les jobs en arrière-plan s'il n'y a pas de « requête » ?
Traitez un job comme une requête : créez un JobContext avec l'ID du job et les identifiants workspace/utilisateur auxquels il appartient, et transmettez-le à la fonction du job. Évitez de lire ou écrire l'état au niveau du module dans les runners de job, car plusieurs jobs peuvent s'exécuter en parallèle.
Que faire si j'hérite d'un prototype généré par l'IA qui casse sous charge ?
Commencez par un audit ciblé autour de l'auth, du caching et des clients singletons, car ce sont des points de défaillance fréquents dans les prototypes générés par l'IA. Si vous voulez aller plus vite, FixMyMess (fixmymess.ai) peut réaliser un audit de code gratuit pour identifier l'état partagé caché puis réparer et durcir le code, avec la plupart des projets terminés en 48–72 heures.