Injection de dépendances et portées
stablemis à jour 2026-09-26
Chaque requête reçoit son propre conteneur de services, posé comme un calque transparent sur celui de l’application : elle y lit tout, n’écrit que sur le sien, et le calque part à la poubelle quand la réponse est partie. Cette page explique ce calque, les trois durées de vie d’un service (
singleton,transient,request) et la façon d’atteindre « le calque de MA requête » depuis n’importe quel code. Ancré sursrc/nodefony/src/Container.ts,src/nodefony/src/kernel/injector/etsrc/nodefony/src/runtime/RequestContext.ts.
📍 Documentation › Injection & portées
🧠 Le modèle mental — la carte et les calques#
Imagine une carte imprimée affichée au mur : c’est le conteneur de l’application, avec le journal, le routeur, le pare-feu, tes services partagés. Chaque requête qui arrive pose dessus un calque transparent — son scope.
- À travers le calque, on lit toute la carte : depuis une requête,
get("router")rend le routeur de l’application. - On dessine sur le calque, jamais sur la carte : ce qu’une requête pose (
set) ne concerne qu’elle. - Deux requêtes, deux calques : même lancées au même instant, elles ne voient jamais le dessin de l’autre.
- En fin de requête, le calque part à la poubelle : tout ce qui était dessiné dessus disparaît, et les services créés pour elle sont nettoyés.
Reste une question : dans un code qui ne reçoit pas la requête en argument, lequel de ces calques
est le mien ? C’est le contexte asynchrone de la requête (RequestContext, bâti sur
l’AsyncLocalStorage de Node) qui répond, par RequestContext.getScope().
Ce modèle n’est pas une image : c’est exactement la mécanique du code. Un scope est un objet dont le prototype est le conteneur de l’application — la lecture remonte la chaîne de prototypes de JavaScript, l’écriture reste sur l’objet du scope (voir Le mécanisme).
❓ Les quatre questions d’une portée#
Une « portée » répond à quatre questions. Les voici pour chaque durée de vie qu’un objet peut avoir.
| Durée de vie | Combien d’instances ? | Qui la voit ? | Quand meurt-elle ? | Comment je l’obtiens ? |
|---|---|---|---|---|
service singleton (défaut) |
une pour toute l’application | toutes les requêtes | à l’arrêt de l’application | @Inject("nom") |
service transient |
une par résolution | celui qui l’a demandée | quand plus rien ne la référence | @Inject("nom") |
service request |
une par requête (créée si besoin) | la requête qui l’a créée | à la fin de la requête (clean() appelé) |
@Inject("nom"), ou RequestContext.getScope()?.get(nom) |
| objet posé à la main sur le scope | une, posée par ton code | la requête qui l’a posée | à la fin de la requête (sans clean()) |
RequestContext.getScope()?.get(nom) |
| contrôleur (défaut, singleton) | un pour toute l’application | toutes les requêtes | à l’arrêt de l’application | construit par le routeur à sa 1re requête, mis en cache |
contrôleur @Scope("request") |
un par requête (par connexion WS) | la requête qu’il sert | à la fin de la requête | construit par le routeur |
❗ Important
En WebSocket, « la requête » est la CONNEXION. Un scope est ouvert au handshake et refermé à la fermeture de la socket : tous ses messages, et toutes ses invocations
api.request— concurrentes comprises —, partagent le même calque et les mêmes servicesrequest. N’y poser que ce qui vaut pour la connexion entière.
« Portée » veut dire cinq choses — les distinguer d’abord#
Le mot est surchargé dans Nodefony. Les confondre produit des bugs qui ne plantent pas.
| Ce qu’on écrit | Ce que ça règle | Où c’est implémenté |
|---|---|---|
@injectable({ scope: "request" }) |
la durée de vie d’un service (DIScope) |
DIScope (injector.ts#DIScope) |
RequestContext.getScope() |
le calque de la requête en cours (un Scope) |
RequestContext.getScope() (RequestContext.ts#RequestContext.getScope) |
Injector.getScope("nom") |
la durée de vie déclarée d’un service — pas un calque | Injector.getScope() (injector.ts#Injector.getScope) |
@Scope("request") sur un contrôleur |
un contrôleur par requête au lieu d’une instance partagée | Scope() (routerDecorators.ts#Scope) |
@RequireScope("users:write") |
une permission — rien à voir avec l’injection | autorisation (firewall) |
Les quatre premières parlent de durée de vie ; la cinquième est un faux ami — c’est un droit d’accès.
🚀 Démarrage rapide#
Le besoin. Ton application sert plusieurs clients, reconnus à l’en-tête x-tenant. Plusieurs
morceaux de code ont besoin de savoir « quel client pour CETTE requête ». Une variable globale ne
marche pas : Node sert toutes les requêtes dans un seul processus, et pendant que la requête d’un
client attend sa base de données, celle d’un autre s’exécute et l’écraserait.
La réponse : un service de portée request — un exemplaire par requête, posé sur son calque, lu par
le contrôleur qui l’injecte et par une fonction qui ne reçoit rien.
import { Service, injectable, Inject, RequestContext } from "nodefony";
import type { Scope as DiScope } from "nodefony";
import { Controller, controller, Get, Scope } from "@nodefony/framework";
import type { Context, HttpContext } from "@nodefony/http";
// ── nodefony/services/TenantContext.ts ──────────────────────────────────────
// Un exemplaire PAR REQUÊTE : créé à sa première résolution dans la requête,
// rangé sur son calque, nettoyé quand elle se termine.
@injectable({ name: "tenantContext", scope: "request" })
class TenantContext extends Service {
readonly id: string;
// Un service `request` reçoit le scope de la requête en premier argument.
constructor(scope: DiScope) {
// Le MÊME nom qu'au décorateur : c'est la clé de l'exemplaire sur le calque.
super("tenantContext", scope, false);
const header =
RequestContext.getContext<HttpContext>()?.request.getHeader("x-tenant");
this.id = typeof header === "string" ? header : "public";
}
override clean(syslog = false): void {
// Libérer ici ce que la requête a ouvert : connexion, verrou, fichier.
super.clean(syslog);
}
}
// ── n'importe où, sans recevoir la requête ──────────────────────────────────
// L'ALS dit quel calque est celui de la requête qui exécute ce code.
function currentTenantLabel(): string {
const tenant = RequestContext.getScope()?.get<TenantContext>("tenantContext");
return tenant ? `client ${tenant.id}` : "hors requête";
}
// ── nodefony/controllers/MeController.ts ────────────────────────────────────
// Il injecte un service `request` : il doit être par requête lui aussi. Un
// contrôleur singleton — le défaut — qui le tenterait serait refusé au
// démarrage (dépendance captive).
@Scope("request")
@controller("/api/me")
class MeController extends Controller {
constructor(
context: Context,
@Inject("tenantContext") private tenant: TenantContext,
) {
super("MeController", context);
}
@Get("/")
whoami() {
return this.renderJson({
tenant: this.tenant.id,
label: currentTenantLabel(),
});
}
}
export { TenantContext, MeController, currentTenantLabel };
Rien à déclarer dans le module : importer le contrôleur suffit, et le service n’est construit que par les requêtes qui le réclament. Une requête qui ne le résout pas ne paie rien.
Ce qu’on observe#
curl -s -H "x-tenant: acme" http://localhost:5151/api/me
# {"tenant":"acme","label":"client acme"}
curl -s -H "x-tenant: globex" http://localhost:5151/api/me
# {"tenant":"globex","label":"client globex"}
Lancées en même temps, mille fois, les deux requêtes ne se mélangent jamais : chacune lit son
propre calque. La preuve n’est pas ce curl : c’est le test
requestScopeIsolation.test.ts (src/nodefony/src/tests/), qui fait se chevaucher deux requêtes
autour d’une attente, montre qu’une variable de module est écrasée par l’autre, et que le scope,
lui, rend à chacune la sienne.
💡 Astuce
Un contrôleur est un singleton par défaut (
Controller.scope,Controller.ts#Controller.scope) : il ne peut donc pas injecter un servicerequest— ce serait une dépendance captive, refusée au démarrage. D’où le@Scope("request")deMeController: une instance par requête, qui reçoit le calque de SA requête.
📖 Lexique#
| Terme | Sens |
|---|---|
| DI | Dependency Injection : fournir ses dépendances à un objet au lieu qu’il les construise. |
| Conteneur | Annuaire nommé d’instances de services (Container). Celui du kernel est la « carte ». |
| Scope (calque) | Conteneur propre à une requête (Scope) : hérite de la carte par prototype, jeté en fin de requête. |
| Registre | Annuaire de classes @injectable. Distinct du conteneur, clés distinctes. |
| Clé | Nom sous lequel une instance vit réellement dans un conteneur — celui du super(nom, …). |
singleton |
Durée de vie : une instance, rangée dans le conteneur du kernel à sa première résolution. |
transient |
Durée de vie : une instance neuve à chaque résolution. |
request |
Durée de vie : une instance par requête (par connexion en WebSocket), rangée sur son scope, nettoyée à sa fin. |
| Dépendance captive | Un service qui vit plus longtemps qu’une dépendance qu’il garde — refusée par l’injecteur. |
| ALS | AsyncLocalStorage : le mécanisme de Node qui transporte l’état d’une requête à travers ses await. |
| Prototype | Mécanisme d’héritage de JavaScript — la lecture d’un scope remonte vers la carte par lui. |
| Résolution | L’acte de trouver (ou fabriquer) l’instance qui satisfait une dépendance déclarée. |
Pourquoi — le problème propre à un serveur Node#
Une analogie : sans injection, chaque pièce d’une machine fabrique elle-même ses vis. Avec un conteneur, les vis sont dans un casier commun et chaque pièce déclare celle qu’elle veut. Le code dépend d’un contrat (un nom, un type), plus d’une construction : on remplace une implémentation sans toucher au consommateur, et on teste une classe en lui donnant un faux.
Un serveur Node ajoute une contrainte qu’un script n’a pas : un seul processus sert des centaines
de requêtes entrelacées. À chaque await — une requête SQL, un appel réseau —, Node passe à une
autre requête. Certaines choses sont globales et durables (le journal, le routeur) ; d’autres
appartiennent à UNE requête (le client servi, l’utilisateur, une transaction). La moindre fuite de
l’une vers l’autre est une faille de confidentialité.
| Approche | Ce qui se passe |
|---|---|
une variable de module currentTenant |
la requête B l’écrase pendant que A attend : A lit le client de B — une fuite silencieuse et intermittente |
passer tenant en paramètre partout |
chaque signature change ; ça casse aux rappels de bibliothèques tierces et aux écouteurs d’événements |
un singleton qui lit la requête à l’appel par RequestContext |
juste : le singleton ne garde rien, il lit « la requête courante » au moment où on l’appelle |
un service request |
juste : un objet par requête, avec un cycle de vie — créé si besoin, nettoyé à la fin |
Les deux dernières lignes se combinent : l’ALS porte les données de la requête (son
identifiant, l’utilisateur, le client reconnu), la portée request porte les objets à cycle de
vie (une connexion de base liée au client, un journal d’audit à vider en fin de requête). La
plupart des applications n’ont besoin que de la première ; la seconde sert quand un objet doit
naître et mourir avec la requête.
Le mécanisme — une chaîne de prototypes#
Le conteneur du kernel est créé au démarrage, et le kernel y pose les services partagés. Le
pipeline HTTP déclare au démarrage un type de scope "request" (Container.addScope(),
Container.ts#Container.addScope), puis, à chaque requête, en ouvre une instance (Container.enterScope(),
Container.ts#Container.enterScope).
Le scope adopte le prototype du conteneur parent (Scope, Container.ts#Scope) : son annuaire
de services est un objet dont le prototype est celui de la carte. Trois conséquences, toutes
voulues :
- Lire, c’est remonter la chaîne :
scope.get("syslog")trouve le journal de l’application sans code intermédiaire (Container.get(),Container.ts#Container.get). Un service ajouté à la carte après l’ouverture d’un scope y est aussitôt visible. - Écrire, c’est rester sur le calque :
Scope.set()(Container.ts#Container.set) n’écrit que sur l’objet du scope. Leset()du conteneur racine, lui, écrit sur le prototype partagé (Container.set(),Container.ts#Container.set) — c’est pourquoiScopele redéfinit : sans cette redéfinition, un service posé pour une requête deviendrait visible de toutes les requêtes concurrentes. - Retirer ne touche que le calque :
Scope.remove()(Container.ts#Container.remove) ne retire qu’une clé posée sur le scope, jamais un service hérité.
❗ Important
L’isolation est structurelle, pas une discipline : depuis un scope, aucune méthode du conteneur n’écrit sur la carte. Et la configuration partagée est protégée de la même façon : elle est figée à la fin du démarrage, et une requête qui veut sa variante pose un calque de configuration sur liste blanche (ADR-0012, Configurer une application).
Ce qu’un scope sait encore faire :
- se fermer une fois :
Container.leaveScope()(Container.ts#Container.leaveScope) le retire du registre avant de le nettoyer — unclean()qui lèverait ne l’épinglerait pas. Un scope fermé ne se rouvre pas :closedvauttrue(Container.ts#Container.closed),get()rendnull,set()lève ; - porter un calque sur le calque : un scope ouvert depuis un scope chaîne sur les services de
son parent (
Container.ts#Scope.constructor), donc voit ce que la requête a posé ; - refuser
reset(): sur un scope, il lèverait plutôt que de le détacher en silence de sa carte (Scope.reset(),Container.ts#Container.reset) ; - ne pas confondre un nom de JavaScript avec un service : les prototypes n’héritent pas
d’
Object.prototype(createProto(),Container.ts#createProto), donchas("toString")rendfalse.
⚙️ Les portées — mises en situation#
Choisir en cinq secondes, puis lire la section correspondante.
| Ce que tu veux | Ce que tu écris | Nombre d’instances |
|---|---|---|
| un service partagé par toute l’application (cache, pool) | @injectable("nom") (défaut singleton) |
1 pour la vie de l’application |
| un objet jetable, sans état partagé | @injectable({ scope: "transient" }) |
1 par résolution |
| un objet qui naît et meurt avec la requête | @injectable({ scope: "request" }) |
1 par requête, puis nettoyé |
| une donnée de la requête, lue partout | RequestContext (voir ci-dessous) |
aucune instance : une donnée |
| un contrôleur (le cas courant, sans état) | rien : le défaut est singleton |
1 pour la vie de l’application |
un contrôleur qui injecte un service request |
@Scope("request") sur la classe |
1 par requête (par connexion) |
singletonune instance, partagée par toute l’application#La portée par défaut. C’est ce que tu veux dès qu’un service porte un état qui n’a de sens qu’unique : un cache, un compteur, une connexion, un pool.
La résolution suit un ordre précis (Injector._resolveWithStack(), injector.ts#Injector._resolveWithStack) :
- le nom écrit dans
@Injectretrouve la classe au registre ; - la classe dit où son instance vit — clé apprise à la pose
(
Injector.containerKeyOf(),injector.ts#Injector.containerKeyOf) ; - si le conteneur du kernel la porte, elle est rendue telle quelle ;
- sinon elle est construite puis mémorisée sous sa clé canonique.
⚠️ Attention
La mémorisation range dans le conteneur du kernel — jamais dans un cache statique, qui fuirait d’un kernel à l’autre, tests compris. Corollaire assumé : sans kernel, pas de mémorisation → deux résolutions donnent deux instances. En test unitaire isolé, c’est le comportement attendu.
transientune instance neuve à chaque résolution#Chaque résolution reconstruit ; aucun conteneur n’est consulté. À réserver aux objets sans état partagé, dont la duplication ne coûte rien et ne perd rien.
@injectable({ scope: "transient" })
class RequestSigner extends Service {}
Une dépendance transient vit aussi longtemps que celui qui la détient : un singleton qui la
reçoit la garde pour toujours. Et transient sur un service qui ouvre une connexion ou attache un
écouteur, c’est une fuite par résolution — la portée décrit une intention, elle n’exonère pas du
nettoyage.
requestun exemplaire par requête#@injectable({ scope: "request" }) donne un exemplaire par requête — par connexion en
WebSocket. Le contrat, tel que l’injecteur l’applique (Injector._resolveRequestScoped(),
injector.ts#Injector._resolveRequestScoped) :
- créé à la première résolution dans la requête, puis rendu à chaque résolution suivante de la même requête ; une requête qui ne le résout pas ne paie rien ;
- rangé sur le scope de la requête que l’ALS désigne, sous la clé de son
super(nom, …); - son constructeur reçoit le scope en premier argument — c’est le conteneur à passer à
super(); - nettoyé à la fermeture : son
clean()est appelé une fois, dans l’ordre inverse des créations (un service créé après ceux dont il dépend est nettoyé avant eux), parScope.clean()(Container.ts#Scope.clean). Unclean()qui lève est journalisé et n’empêche pas les suivants ; - refusé hors d’une requête : résolu au démarrage, dans une commande, ou dans une promesse qui continue après la réponse, il lève en nommant la cause.
⚠️ Attention
Un singleton ne peut pas dépendre d’un service
request: mémorisé à sa première construction, il garderait l’exemplaire de la première requête et le servirait à toutes les suivantes. L’injecteur refuse cette dépendance captive au démarrage, par analyse des déclarations (Injector.assertNoCaptiveDependency(),injector.ts#Injector.assertNoCaptiveDependency), et à la résolution pour le reste. Le message propose trois remèdes : passer le détenteur enrequest, entransient, ou lire le service à l’appel parRequestContext.getScope().La garde voit le graphe déclaré — les
@Injectet la pile de résolution. UnInjector.instantiate()écrit à la main repart d’une pile vide (injector.ts#Injector.instantiate) : appelé depuis le constructeur d’un singleton pendant une requête, il rend l’exemplaire de CETTE requête sans que la garde connaisse le détenteur, et le singleton qui le garde l’a capturé. Passer par l’injection déclarée, jamais par une résolution impérative gardée dans un champ.
Quatre règles pratiques :
- Donne le même nom au décorateur et au
super()(@injectable({ name: "tenantContext", … })etsuper("tenantContext", …)). La clé cherchée sur le scope est apprise à la première création : avec deux noms différents, un exemplaire posé à la main n’est trouvé que sous l’un ou l’autre selon l’histoire du processus. - Ne réutilise pas le nom d’un objet du pipeline ou d’un service du kernel (
context,controller,router…) : l’injecteur refuse de le masquer pour toute la requête, et le dit. clean()n’est pas attendu : la fermeture reste synchrone. Unasync clean()part en arrière- plan et son rejet est journalisé. Ce qui doit être validé (unCOMMIT) appartient à l’action ;clean()est le filet qui annule et libère.clean()s’exécute hors du contexte asynchrone de la requête (en HTTP) : y lire le scope parthis.container, pas parRequestContext.
Un service request peut être listé dans @services([...]) : il y est déclaré (sa chaîne de
dépendances est vérifiée au démarrage) sans être instancié. module.addService(), qui construit
tout de suite, le refuse.
Le scope de la requête — ce que le pipeline y pose#
Tu n’ouvres jamais un scope toi-même dans une application : le pipeline le fait.
- HTTP : ouvert à l’entrée de chaque requête par
HttpKernel.handle()(enterScope,http-kernel.ts#HttpKernel.handle), refermé au démontage après les hooksonAfterResponseetonFinishdansHttpKernel.releaseHttpContext()(leaveScope,http-kernel.ts#HttpKernel.finishTeardownHttp) — y compris quand un hook lève (failTeardownHttp(),http-kernel.ts#HttpKernel.failTeardownHttp). - WebSocket : ouvert au handshake par
HttpKernel.onWebsocketRequest()(enterScope,http-kernel.ts#HttpKernel.onWebsocketUpgrade), refermé à la fermeture de la socket. - Le pipeline y pose
context(set("context"),Context.ts#Context.constructor) puiscontroller(Resolver.ts#Resolver.controller). Leresolvern’y est pas : c’est un champ du contexte (context.resolver).
Le contrôleur singleton — le défaut, sous contrat strict#
Un contrôleur est un singleton par défaut : construit à sa première requête, mis en cache par
le routeur (Router.getSingletonController(), router.ts#Router.getSingletonController), puis partagé par toutes les
requêtes, concurrentes comprises. Son initialize() tourne une fois, à la création.
Le contrat est strict : l’action ne lit ni n’écrit aucun état de requête sur this. Tout
passe par les arguments décorés et les helpers, qui retrouvent la requête courante par l’ALS — y
compris la route et la query d’un appel du pont api.request, qui a son propre résolveur. Et il ne
peut pas injecter de service request : c’est une dépendance captive, refusée au démarrage.
Deux filets rendent la faute visible au lieu de la laisser fuir :
- les accesseurs d’état (
this.query = …,this.context = …,setRoute()…) lèvent sur un singleton, dans tous les environnements ; - en développement, toute écriture sur un champ de l’instance lève aussi, en nommant le
contrôleur, le champ et le remède (
guardSingletonState(),singletonGuard.ts#guardSingletonState).
🛑 Prudence
Ce que les filets ne voient pas : un champ
#privé, et la mutation d’un objet tenu par un champ (this.cache.set(…)). Là, rien ne plante et deux utilisateurs se marchent dessus. Même prudence pour un callback qui s’exécute HORS de la bulle de la requête — écouteur d’un émetteur, minuteur armé ailleurs : il n’y retrouve pasthis.context. Lire le contexte AVANT, dans une variable.
@Scope("request")une instance par requête#Le décorateur Scope() (routerDecorators.ts#Scope) rend à une classe une instance par requête —
par CONNEXION en WebSocket. À déclarer quand le contrôleur injecte un service request à son
constructeur, prépare chaque requête dans initialize(), ou garde un état sur this.
RealtimeController l’est par construction (RealtimeController.ts#RealtimeController), et refuse le singleton au
démarrage : son pair JSON-RPC et notifyClient/requestClient désignent la connexion par
l’instance.
Portées et concurrence — le tableau qui rassure#
| Élément | Où il vit | Partagé entre requêtes ? | Nettoyé quand ? |
|---|---|---|---|
syslog, router, firewall |
conteneur du kernel | oui — lus à travers le calque | à l’arrêt (clean() du kernel) |
service singleton |
conteneur du kernel (mémorisé) | oui | à l’arrêt |
service transient |
nulle part — neuf à chaque fois | non | quand plus rien ne le référence |
service request |
scope de la requête | non — sauf messages d’une même socket | leaveScope → son clean() |
| contrôleur (défaut, singleton) | cache du routeur | oui — d’où le contrat sans état | à l’arrêt |
contrôleur @Scope("request") |
scope de la requête | non | leaveScope en fin de requête |
context |
scope de la requête | non | leaveScope en fin de requête |
Situation 1 — « je veux un service partagé par toute l’application »#
Rien à déclarer : le défaut est singleton.
import { Service, Module, injectable, services } from "nodefony";
import type { Container, Event } from "nodefony";
// Le nom du décorateur indexe la CLASSE au registre ;
// celui du `super()` indexe l'INSTANCE au conteneur.
@injectable("catalog")
class CatalogService extends Service {
private items: string[] | null = null; // n'a de sens qu'avec UNE instance
constructor(module: Module) {
super(
"catalog",
module.container as Container,
module.notificationsCenter as Event,
);
}
list(): string[] {
this.items ??= ["clavier", "souris", "écran"]; // chargé une fois
return this.items;
}
}
// Le module de l'application le construit au démarrage et le pose au conteneur.
@services([CatalogService])
class AppModule extends Module {}
| Deux contrôleurs l’injectent… | Ce qu’ils reçoivent |
|---|---|
| dans la même requête | la même instance |
| dans deux requêtes parallèles | la même instance (le cache est réellement partagé) |
| après la fin d’une requête | la même instance — elle vit dans le conteneur du kernel |
⚠️ Attention
Le nom du
@Injectdoit être celui du registre (@injectable("catalog")), pas celui de la classe. Le type du paramètre ne résout rien : sans@Inject("catalog"), le constructeur reçoit l’argument positionnel suivant, ouundefined.
Situation 2 — « je veux un état propre à CHAQUE requête »#
Trois outils, du plus simple au plus outillé :
- Dans un contrôleur
@Scope("request"),thisEST l’état de la requête : il est neuf à chaque requête. Un contrôleur singleton — le défaut — ne l’est PAS : son état de requête passe par les arguments décorés (@Param,@Query,@Body…). - Une donnée lue partout (le client reconnu, un identifiant de corrélation) : la poser dans le
contexte asynchrone (
RequestContext.set("tenantId", id)) et la lire à l’appel (RequestContext.get()?.tenantId), depuis un singleton compris. Le détail : RequestContext. - Un objet qui naît et meurt avec la requête (une connexion liée au client, un tampon à vider) :
un service
@injectable({ scope: "request" }), comme dans le Démarrage rapide.
Situation 3 — le contre-exemple : de la requête capturée dans un singleton#
C’est l’erreur qui ne se voit pas en développement, à un seul utilisateur.
// ❌ PIÈGE — le contexte de la PREMIÈRE requête est gardé pour toujours
@injectable("audit")
class AuditService extends Service {
private context: Context; // ← figé à la première résolution
constructor(module: Module, context: Context) {
super("audit", module.container as Container);
this.context = context;
}
}
// ✅ Le service ne garde rien de la requête : il la lit au moment de l'appel
@injectable("audit")
class AuditService extends Service {
trace(action: string): void {
const requestId = RequestContext.getRequestId();
/* … */
}
}
Le moteur rend la faute moins probable : une dépendance se résout sans argument — elle n’hérite
jamais des arguments de son parent (Injector._instantiateWithStack(), injector.ts#Injector._instantiateWithStack) —, et un
singleton qui dépendrait d’un service request est refusé. Mais un contexte passé à la main reste
possible : c’est à toi de ne pas le garder.
Situation 4 — un faux service pour UNE requête#
En test, ou pour une requête de démonstration, on remplace un service request pour cette
requête seulement : poser sur son scope, avant sa première résolution, une instance d’une
sous-classe du service, sous sa clé.
class FakeTenantContext extends TenantContext {
override readonly id = "test";
}
const scope = RequestContext.requireScope();
scope.set("tenantContext", new FakeTenantContext(scope));
// Toute résolution de "tenantContext" dans CETTE requête rend le faux ;
// la requête suivante retrouve le vrai service.
L’injecteur ne rend jamais qu’une instance de la classe déclarée : un objet quelconque posé sous
la même clé est refusé, en le disant. Et un faux posé ainsi n’est pas rattaché au scope : si son
clean() compte, l’y rattacher par scope.own(fake). Prouvé par requestScopeIsolation.test.ts.
ℹ️ Note
Poser un objet sur le scope sous le nom d’un service singleton ne change pas ce que reçoit un
@Inject: un singleton se résout dans le conteneur du kernel. Seul le code qui lit le scope (RequestContext.getScope()?.get(nom)) voit la surcharge.
🧭 Atteindre le calque de sa requête — RequestContext.getScope()#
Le scope est le même objet que context.container ; RequestContext le rend atteignable sans
connaître le transport ni recevoir le contexte, depuis n’importe quel code qui s’exécute pour la
requête — un service, un écouteur, une fonction utilitaire.
| Appel | Rend | Quand l’utiliser |
|---|---|---|
RequestContext.getScope() |
le scope ouvert, ou undefined |
le code qui peut aussi tourner hors requête |
RequestContext.requireScope() |
le scope ouvert, ou lève en nommant la cause | le code qui n’a pas de sens hors d’une requête |
getScope() (RequestContext.ts#RequestContext.getScope) rend undefined dans trois cas, et requireScope()
(RequestContext.ts#RequestContext.requireScope) les distingue dans son message, parce que chacun appelle un geste
différent :
- aucune requête en cours — démarrage, commande en ligne, minuterie armée hors requête ;
- une bulle ouverte sans scope — un
RequestContext.run()maison qui ne pose passcope; - un scope déjà refermé — le code continue après la fin de la requête.
Le moment compte : les hooks onAfterResponse voient encore le scope ouvert ; une promesse non
attendue qui continue après la réponse, non — elle tombe dans le troisième cas. Lire ce dont on a
besoin avant que la réponse parte.
Le pont api.request du temps réel pose lui aussi le scope dans sa bulle
(RealtimeController.ts#RealtimeController.actionParamsWrapper) : c’est celui de la connexion, partagé par les invocations
concurrentes de la même socket.
ℹ️ Note
Ne pas confondre avec
Injector.getScope("nom"), qui rend la durée de vie déclarée d’un service ("singleton","transient","request"), pas un conteneur.
🏗️ Architecture interne — comment une dépendance est résolue#
Le nœud de l’injection : registre de CLASSES, conteneur d’INSTANCES#
Nodefony tient deux annuaires différents, et ils ne portent pas les mêmes clés.
- Le registre indexe des classes décorées
@injectable(injectables,injector.ts#injectables) — un objet sans prototype, pour qu’aucuntoStringou__proto__ne réponde comme un faux service. - Le conteneur range des instances sous le nom passé à
super(nom, …).
Le décorateur ne peut pas connaître le second nom : il s’exécute au chargement de la classe,
super() seulement à la construction. La solution est un apprentissage : au moment où le
service est posé, le couple (classe, clé) est mémorisé (Injector.rememberContainerKey(),
injector.ts#Injector.rememberContainerKey), et toute résolution ultérieure passe par la classe. Sans ce relais,
@Inject("Router") interrogeait le conteneur avec "Router" là où l’instance est rangée sous
"router" : réponse null, service reconstruit en silence avec un cache vide.
Une dépendance se nomme#
L’injecteur ne lit qu’une métadonnée (Injector._instantiateWithStack(), injector.ts#Injector._instantiateWithStack) :
inject:services, posée par @Inject("nom") et indexée par position. Un paramètre sans
@Inject reçoit l’argument positionnel passé à la construction, ou undefined. C’est ce qui
permet à un contrôleur de recevoir son context en premier argument, et ses @Inject ensuite.
Le type d’un paramètre ne résout jamais rien, même quand il désigne un service inscrit
(ADR-0018). Les métadonnées émises par le
compilateur (emitDecoratorMetadata, clés design:*) ne sont plus produites ni lues : un type
importé par import type, ou dont le bundler avait élidé l’import, y devenait Object, et le
service recevait un argument faux sans un mot. Une garde du dépôt
(src/nodefony/src/tests/noTypeMetadata.test.ts) refuse le retour d’une telle lecture.
L’injection par propriété est retirée elle aussi : interne, jamais exportée par nodefony, sans
usage, elle lisait design:type quand on omettait le nom. L’injection par constructeur, nommée,
est la seule voie — explicite, couverte, et c’est elle que le tri des services sait ordonner.
Garde-fous du moteur#
- Dépendances circulaires : la pile de résolution est propre à chaque arbre d’appel ; un nom
déjà présent lève avec le chemin complet
A → B → A(injector.ts#Injector._instantiateWithStack). - Message actionnable : quand un service
@injectableabsent du conteneur est construit sans argument et que son constructeur casse, l’erreur nomme le demandeur et la cause probable — un ordre de déclaration. - Dépendance captive : refusée au démarrage et à la résolution, avec le chemin complet et trois
remèdes (
Injector._captiveError(),injector.ts#Injector._captiveError).
Le cycle de vie, selon la portée#
🧰 API du conteneur#
| Méthode | Rôle |
|---|---|
set(name, instance) |
poser un service — racine : sur le prototype partagé ; scope : sur le scope seul (Container.ts#Container.set, Container.ts#Scope.set) |
get<T>(name) |
lire un service — null si absent ou si le conteneur est fermé (Container.ts#Container.get) |
has(name) |
le nom est-il lisible, hérité compris ? (Container.ts#Container.has) |
remove(name) |
retirer — racine : les scopes cessent d’en hériter, leurs surcharges propres restent ; scope : sa clé propre seulement (Container.ts#Container.remove) |
addScope(name) |
déclarer un type de scope, au démarrage (Container.ts#Container.addScope) |
enterScope(name) |
ouvrir une instance de scope — lève si le type n’est pas déclaré (Container.ts#Container.enterScope) |
leaveScope(scope) |
fermer et nettoyer une instance ; un second appel ne fait rien (Container.ts#Container.leaveScope) |
scopeCount(name) |
nombre de scopes ouverts — sonde de fuite bon marché (Container.ts#Container.scopeCount) |
clean() / reset() |
démontage / remise à zéro de la racine ; reset() lève sur un scope (Container.clean(), Container.ts#Container.clean ; Scope.reset(), Container.ts#Container.reset) |
scope.hasOwn(name) |
le nom est-il posé sur ce scope ? (Container.ts#Scope.hasOwn) |
scope.own(instance) |
rattacher un objet pour que son clean() soit appelé à la fermeture (Scope.own(), Container.ts#Scope.own) |
scope.closed |
true une fois le scope refermé (Container.ts#Container.closed) |
Signatures complètes : générées depuis les TSDoc, jamais recopiées ici.
🧩 L’ordre des @services([...]) se calcule, il ne se subit pas#
@services instancie séquentiellement et pose chaque instance au conteneur. Un service réclamé
par un autre doit donc exister avant lui — sinon l’injecteur le reconstruit sans argument et son
constructeur casse. Faire reposer cet ordre sur une liste écrite à la main est un piège : déplacer
HttpKernel de trois lignes dans @nodefony/http suffisait à renvoyer un 499 sur chaque
requête.
Comme les dépendances sont déjà déclarées, l’ordre se calcule
(orderServicesByDependencies(), serviceOrder.ts#orderServicesByDependencies) :
- tri topologique stable — à contrainte égale, l’ordre d’écriture est conservé, donc une liste déjà correcte sort inchangée ;
- les entrées
string(chemins à charger) et les classes hors registre gardent leur position ; - un cycle lève une erreur nommant le cycle.
ℹ️ Note
Le tri couvre les dépendances déclarées dans la même liste. Le message d’erreur de l’injecteur reste le filet pour ce que le tri ne voit pas : une dépendance vers un autre module, ou une résolution à l’exécution.
⚡ Performance & mémoire#
Ce chemin s’exécute à chaque requête. Les choix de Container.ts suivent tous ce fait.
- Héritage par prototype plutôt que remontée logicielle : lire un service de la carte depuis un scope est résolu par le moteur JavaScript, sans code intermédiaire.
- Adoption du prototype parent : ouvrir un scope ne fabrique ni closure ni prototype jetés.
- Registre des scopes ouverts = un
Setpar nom (Scopes,Container.ts#Scopes) : il sert à les compter et à tout refermer à l’arrêt, sans fabriquer ni hacher de clé à chaque requête. - Identifiant fabriqué à la première lecture (
Container.id,Container.ts#Container.id) : une requête ordinaire ne le lit jamais, donc ne le paie jamais. - Rien d’alloué pour les services
requesttant qu’aucun n’est résolu : le champ qui les rattache restenull.
Les ordres de grandeur, avec leur décor :
| Opération | Coût mesuré | Décor de la mesure |
|---|---|---|
| ouvrir puis fermer un scope | ≈ 1,43 µs | sonde dans le serveur, un processus en production, paires alternées |
résoudre un service request dans une requête |
≈ 1 µs | micro-banc micro-request-scope.mjs |
RequestContext.getScope() |
≈ 17 ns | médiane de 7 séries, code bâti, Node 26 |
lire un singleton depuis un scope |
une lecture de propriété héritée | — |
Toute modification de Container.ts ou du pipeline de requête passe par le gate mémoire
(npm run test:memory dans @nodefony/http) : il compte les scopes restés ouverts et les services
request jamais libérés après chaque boucle. La sonde scopeCount() (Container.ts#Container.scopeCount) existe
pour voir une fuite venir.
⚠️ Pièges (symptôme → cause → correction)#
| Symptôme | Cause | Correction |
|---|---|---|
| service reconstruit, cache vide, aucun plantage | nom @Inject ≠ clé du conteneur, résolution avant apprentissage |
vérifier le super(nom) réel ; déclarer le service par @services |
deux instances d’un singleton en test |
pas de kernel, donc nulle part où mémoriser | attendu hors kernel ; monter un kernel de test si l’unicité est testée |
| état d’une requête visible dans une autre | écriture sur le conteneur du kernel au lieu du scope, ou variable de module | RequestContext.requireScope().set(…), ou un service request |
| « dépendance captive » au démarrage | un singleton — service, ou contrôleur sans @Scope("request") — dépend d’un service request |
passer le détenteur en request ou transient, ou lire à l’appel par getScope() |
| « Contrôleur … : écriture de « this.x » refusée » | un état de requête écrit sur un contrôleur singleton (le défaut) | argument décoré, service request, ou @Scope("request") sur la classe |
| « Contrôleur temps réel … déclaré @Scope(“singleton”) » | un RealtimeController vit par connexion, par construction |
retirer @Scope("singleton") |
| « résolu hors d’une requête ouverte » | service request résolu au démarrage, en commande, ou après la réponse |
le résoudre dans la requête ; lire ce qu’il faut avant que la réponse parte |
| « la clé … est déjà occupée » | service request nommé comme un objet du pipeline ou un service du kernel, ou objet quelconque posé sous sa clé |
renommer le service ; poser une instance de sa classe |
| faux posé sur le scope ignoré | nom du @injectable ≠ nom du super() |
donner le même nom aux deux |
clean() d’un service request ne voit pas RequestContext |
la fermeture HTTP s’exécute hors du contexte asynchrone de la requête | lire le scope par this.container |
| deux messages WebSocket voient le même exemplaire | en WebSocket, la requête est la connexion | attendu ; n’y ranger que ce qui vaut pour la connexion |
Scope "X" not declared |
enterScope("X") sans addScope("X") au démarrage |
déclarer le scope au démarrage |
fuite mémoire, scopeCount qui monte |
leaveScope jamais appelé (pipeline court-circuité) |
le kernel le fait à toutes les sorties — ne pas contourner le démontage |
Circular dependency detected: A → B → A |
cycle de résolution | casser le cycle, ou résoudre à l’appel |
| deux utilisateurs voient les données de l’autre | état muté sur un contrôleur singleton que les filets ne voient pas (#privé, objet muté) |
retirer l’état de this, ou déclarer @Scope("request") |
un paramètre de constructeur reçoit undefined |
pas de @Inject("nom") : le type d’un paramètre ne résout rien |
nommer la dépendance : @Inject("catalog") |
📡 Observabilité#
L’identité réelle des services se vérifie depuis l’intérieur du serveur — un test HTTP ordinaire ne la voit pas, parce qu’un doublon ne casse rien de visible : chaque copie « marche », seul son état est dupliqué et perdu.
Le module de test expose une sonde (DiController.probe(), DiController.ts#DiController.probe) qui répond à la
seule question qui compte : les consommateurs d’un service partagent-ils la même instance ? Elle
ne renvoie que des booléens d’identité, jamais un objet de service. Pour la portée request, la
route /nodefony/test/request-scope/probe dit si l’exemplaire est le même dans toute la requête et
rangé sur son scope.
🧪 Tests & couverture#
Les compteurs exacts vivent dans la carte de l’aperçu, régénérée depuis vitest, jamais figés ici.
- unitaires (
src/nodefony/src/tests/) —Container.test.tsetInjector.test.ts: le conteneur, les scopes, la résolution, la détection de cycle ;injectorRequestScope.test.ts: la portéerequest(un exemplaire par requête, nettoyage dans l’ordre inverse, dépendance captive refusée au démarrage et à la résolution, clé occupée) ;RequestContext.test.ts:getScope()et les trois causes derequireScope();requestScopeIsolation.test.ts: la fuite d’une variable de module entre deux requêtes concurrentes, et le faux par requête. - attaque —
injector.attack.test.tsetservices.attack.test.ts: registre sans prototype, non-propagation des arguments aux dépendances, résolution circulaire. - intégration (serveur réel) (
src/packages/@nodefony/http/nodefony/tests/integration/) —di-singleton.test.ts: chaque consommateur partage l’instance ;request-scope.test.ts: le scope est dans l’ALS, et un hookonAfterResponsele voit encore ouvert ;request-service.test.ts: la portéerequesten HTTP et en WebSocket (un exemplaire pour toute la connexion, nettoyé une fois). - mémoire —
memory.test.ts(@nodefony/http) : scopes drainés et servicesrequestréclamés après chaque boucle, comptés exactement.
Couverture : npm run coverage dans src/nodefony.
🔗 Pour aller plus loin#
-
⬆️ Retour au hub : Toute la documentation
-
🧭 Pages sœurs : Cycle de boot du Kernel · Pipeline d’une requête
-
Pourquoi un scope par copie prototypale → ADR-0011
-
La configuration figée et son calque par requête → ADR-0012
-
Le contexte asynchrone de la requête → RequestContext
-
Où le scope de requête s’ouvre et se ferme → pipeline-requete
-
La brique de base injectée → service
-
Vue d’ensemble de l’architecture → vue-ensemble