Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

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é sur src/nodefony/src/Container.ts, src/nodefony/src/kernel/injector/ et src/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.

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 services request. 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 service request — ce serait une dépendance captive, refusée au démarrage. D’où le @Scope("request") de MeController : 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 :

❗ 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 :

⚙️ 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) :

  1. le nom écrit dans @Inject retrouve la classe au registre ;
  2. la classe dit où son instance vit — clé apprise à la pose (Injector.containerKeyOf(), injector.ts#Injector.containerKeyOf) ;
  3. si le conteneur du kernel la porte, elle est rendue telle quelle ;
  4. 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), par Scope.clean() (Container.ts#Scope.clean). Un clean() 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 en request, en transient, ou lire le service à l’appel par RequestContext.getScope().

La garde voit le graphe déclaré — les @Inject et la pile de résolution. Un Injector.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", … }) et super("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. Un async clean() part en arrière- plan et son rejet est journalisé. Ce qui doit être validé (un COMMIT) 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 par this.container, pas par RequestContext.

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.

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 :

🛑 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 pas this.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 @Inject doit ê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, ou undefined.

Situation 2 — « je veux un état propre à CHAQUE requête »#

Trois outils, du plus simple au plus outillé :

  1. Dans un contrôleur @Scope("request"), this EST 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…).
  2. 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.
  3. 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 :

  1. aucune requête en cours — démarrage, commande en ligne, minuterie armée hors requête ;
  2. une bulle ouverte sans scope — un RequestContext.run() maison qui ne pose pas scope ;
  3. 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.

  1. Le registre indexe des classes décorées @injectable (injectables, injector.ts#injectables) — un objet sans prototype, pour qu’aucun toString ou __proto__ ne réponde comme un faux service.
  2. 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#

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) :

ℹ️ 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.

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.

Couverture : npm run coverage dans src/nodefony.

🔗 Pour aller plus loin#