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

Service — la brique de base de tout composant

stablenodefony (cœur)mis à jour 2026-07-19

Service est la classe dont presque tout hérite dans Nodefony : le Kernel, chaque Module, chaque controller, chaque adapter ORM. Elle branche trois câbles d’un coup — les dépendances (container DI), le journal (log) et le bus d’événements (notificationsCenter) — puis garantit qu’ils se débranchent proprement (clean()). Ancré sur src/nodefony/src/Service.ts, Event.ts et Container.ts.

📍 Documentation › Cœur — @nodefony/core › Service

🧠 Le modèle mental — une prise à trois broches#

Un composant serveur a toujours besoin des trois mêmes choses : trouver ses voisins, dire ce qu’il fait, réagir à ce qui arrive. Service est la prise murale qui apporte les trois d’un seul geste — au lieu que chaque brique se recâble à la main.

Trois idées portent toute la page :

  1. On hérite, on ne recâble pas. class MonService extends Service et les trois broches sont là.
  2. Le bus peut être partagé. Deux services du même module parlent sur le même Event — c’est ce qui les découple sans les faire se connaître.
  3. Ce qui se branche doit se débrancher. Service trace les écouteurs qu’il pose pour que clean() les retire du bus partagé : sans ça, chaque instance fuirait.

📖 Lexique#

Terme Sens
Service La classe de base : identité (name) + DI + journal + événements.
Container Annuaire d’injection de dépendances (DI) : un nom → une instance. Voir injection-portees.
DI Dependency Injection — on reçoit ses dépendances au lieu de les construire soi-même.
notificationsCenter Le bus d’événements d’un service (une instance d’Event).
Event Bus maison qui étend l’EventEmitter de Node avec emitAsync et emitAsyncGuarded.
Bus partagé Un même Event passé à plusieurs services (typiquement celui du module ou du kernel).
Bus dédié Un Event créé pour un seul service — personne d’autre ne l’écoute.
Écouteur tracké Écouteur posé via l’API du service, donc retiré automatiquement par clean().
PDU Process Data Unit — une entrée de journal structurée (RFC 5424). Voir syslog.
Hot path Le chemin parcouru à chaque requête HTTP/WS — la moindre allocation y coûte cher.
Microtask Unité d’ordonnancement d’une Promise : un await inutile en alloue une pour rien.
Émission gardée emitAsyncGuarded — chaque écouteur est isolé (try/catch + délai maximal), les échecs sont collectés.
Scope DI singleton (une instance mémoïsée), transient (une neuve à chaque résolution) ou request (une par requête, par connexion en WebSocket).
Tri topologique Calcul de l’ordre d’instanciation depuis les dépendances déclarées, au lieu de le lire dans une liste.
BootReport Bilan de démarrage : ce qui a été chargé, ce qui a été ignoré, pourquoi. Fait dire « boot DÉGRADÉ ».
Fail-soft / fail-loud Continuer malgré la panne / refuser de continuer en silence. Nodefony fait les deux, jamais en cachette.

Qu’est-ce qu’un Service — et pourquoi un bus d’événements#

Un serveur est un assemblage de composants qui doivent se parler sans se connaître. Le kernel signale « je démarre », un module réagit. Une requête signale « terminée », le profileur collecte. Un service métier signale « catalogue modifié », un cache s’invalide.

Câbler ces composants en dur (A appelle B qui appelle C) donne une pelote : ajouter un consommateur oblige à modifier le producteur. Le bus d’événements inverse la charge — on émet un signal, et qui veut réagir s’abonne. Le producteur ne connaît plus ses consommateurs.

Nodefony ne se contente pas de fournir un bus quelque part : il le met dans la classe de base, avec l’accès aux dépendances et le journal. Résultat : n’importe quelle brique du framework part avec le même socle, et tout le monde parle la même langue.

💡 Astuce

Si tu viens de Symfony : Service n’est pas l’équivalent d’un service services.yaml. C’est plutôt « la classe de base que Symfony n’a pas » — un composant y est un POPO, ici il hérite d’un socle qui lui donne DI + log + events. Le rapprochement s’arrête là.

La vision Nodefony#

Service (Service.ts#Service) porte cinq membres publics — name, container, kernel, syslog, options — et un bus privé #nc exposé en lecture seule par le getter Service.notificationsCenter (Service.ts#Service.notificationsCenter). Toutes les méthodes d’événements passent par un getter privé gardé Service.nc (Service.ts#Service.#trackedListeners) : c’est lui, et lui seul, qui lève notificationsCenter not initialized — le test est écrit une fois au lieu d’être dupliqué sur les 18 méthodes déléguées.

Le constructeur (Service.ts#describeReceived) est volontairement opportuniste : il réutilise ce que le container lui offre (kernel, syslog) et ne fabrique que ce qui manque. Le bus suit la même logique — partagé si on lui en passe un, dédié sinon, absent si on passe false.

Le bus lui-même, Event (Event.ts#Event), étend l’EventEmitter de Node avec exactement deux idées maison :

Ce dernier porte tout le cycle de vie du kernel via Kernel.fireLifecycle() (Kernel.ts#Kernel.fireLifecycle) : un hook de module qui pend ou qui jette ne gèle plus le démarrage du serveur.

Le compromis assumé : Service délègue massivement (18 méthodes d’événements + 6 méthodes de container) plutôt que d’exposer ses collaborateurs bruts. C’est un peu de code répétitif dans la classe de base, contre deux garanties — le suivi des écouteurs pour clean(), et une façade DI tolérante qui rend null au lieu d’exploser après destruction.

🚀 Démarrage rapide#

Un service d’application, de bout en bout#

Dans une app générée par nodefony create app, un service se déclare sur le module qui le porte. Voici l’ensemble minimal qui tourne — décoration, construction, journal, événement, wiring :

// index.ts — point d'entrée de l'app (`nodefony create app` génère le squelette).
// En vrai, `CatalogService` vivrait dans `nodefony/services/CatalogService.ts` et
// serait importé ici ; les deux sont réunis pour que le bloc soit copiable tel quel.
import { Module, Service, injectable, services } from "nodefony";
import type { Container, Event, Kernel } from "nodefony";

// `@injectable("catalog")` inscrit la CLASSE au registre DI sous le nom "catalog".
@injectable("catalog")
class CatalogService extends Service {
  private items = new Map<string, number>();

  // Le module porteur transmet SES ressources : même container, MÊME bus.
  // C'est ce partage qui permet aux services du module de se parler.
  constructor(module: Module) {
    super(
      "catalog", // clé dans le container — c'est elle qu'on interroge
      module.container as Container,
      module.notificationsCenter as Event,
      module.options.catalog, // sous-section de config du module
    );
  }

  add(ref: string, qty: number): void {
    this.items.set(ref, qty);
    this.log(`ajout ${ref} ×${qty}`, "INFO"); // msgid = "catalog" par défaut
    this.fire("onCatalogChanged", ref, qty); // synchrone, 0 microtask
  }
}

// `@services([...])` déclare les services du module : ils sont instanciés à
// `onPreBoot`, dans l'ordre CALCULÉ depuis leurs dépendances.
@services([CatalogService])
class App extends Module {
  constructor(kernel: Kernel) {
    super("app", kernel, import.meta.url, {});
  }

  override async onKernelReady(): Promise<this> {
    // `this.on(...)` est TRACKÉ → retiré du bus partagé par `clean()`.
    this.on("onCatalogChanged", (ref: string, qty: number) => {
      this.log(`catalogue modifié : ${ref} ×${qty}`, "NOTICE");
    });
    // Le service est au container sous sa clé — jamais un `new` à la main.
    this.get<CatalogService>("catalog")?.add("NF-001", 3);
    return this;
  }
}

export default App;

Ce qu’on observe#

npx nodefony development
DEBUG   app          SERVICE ADD : catalog          # Module.addService a posé l'instance
INFO    catalog      ajout NF-001 ×3                # msgid = le nom du service
NOTICE  app          catalogue modifié : NF-001 ×3  # l'écouteur du module a reçu l'événement

Le msgid de la deuxième ligne est catalog sans qu’on l’ait écrit : Service.log() (Service.ts#Service.log) prend this.name par défaut. La trace SERVICE ADD vient de Module.addService() (Module.ts#Module.addService), qui instancie via l’injecteur puis range l’instance au container sous instance.name.

Sans kernel ni module — un Service tout seul#

Un Service n’exige aucune infrastructure : il se construit dans un test ou un script. C’est ce qui rend la classe testable en isolation.

import { Service, Container, Event } from "nodefony";

const bus = new Event(); // bus PARTAGÉ, fourni par l'appelant
const container = new Container(); // annuaire DI racine

const producer = new Service("worker-a", container, bus);
const consumer = new Service("worker-b", container, bus);

// Tracké : `clean()` saura le retirer du bus partagé.
consumer.on("onTick", (n: number) => {
  consumer.log(`tick ${n}`, "DEBUG");
});

producer.fire("onTick", 1); // worker-b reçoit — même bus

consumer.clean(); // retire SES écouteurs, détache container/syslog/nc
producer.fire("onTick", 2); // plus personne n'écoute : aucune fuite

❗ Important

Après clean(), toute méthode d’événement du service lève (notificationsCenter not initialized) et set() lève aussi (container not initialized) — mais get() rend null en silence. Cette asymétrie est délibérée : lire après destruction est un cas de sortie de route tolérable, écrire ne l’est pas.

🏗️ Architecture interne#

Ce que fait le constructeur, dans l’ordre#

Service.ts#describeReceived exécute cinq étapes, toutes lisibles en 50 lignes :

  1. Identité — name est posé tel quel ; il servira de clé container et de msgid syslog.
  2. Container — celui qu’on fournit, sinon un new Container() frais (Service.ts#describeReceived).
  3. Kernel + syslog — récupérés depuis le container ; si le syslog manque, un Syslog est créé avec moduleName = this.name puis posé au container pour les suivants (Service.ts#Service.name).
  4. Bus — les trois formes ci-dessous (Service.ts#Service.notificationsCenter).
  5. Écartement des options — la clé events n’est jamais recopiée dans this.options : elle est lue puis écartée par rest-destructuring (Service.ts#Service.options).

⚠️ Attention

L’étape 5 écarte events à la construction plutôt que de la supprimer après coup par delete (qui ferait muter la hidden class V8) : des consommateurs parcourent for (… in this.options) et appellent .path sur chaque valeur — ils supposent la clé absente.

Les trois formes de bus — trois situations réelles#

Le troisième argument du constructeur décide de tout. Choisis-le en fonction de qui doit entendre.

Tu passes… Le service obtient… Quand c’est le bon choix
un Event existant ce bus partagé (#sharedNc = true) service d’un module / du kernel : il doit parler aux voisins
undefined ou null un bus dédié, créé pour lui brique autonome, aucun voisin à prévenir
false aucun bus — les méthodes d’événements lèvent pur utilitaire, sans signal à émettre : 0 allocation d’Event

Situation 1 — un service de module. Le module te tend son bus, tu le transmets : ton service et tous ceux du module s’entendent, sans se connaître.

super(
  "catalog",
  module.container as Container,
  module.notificationsCenter as Event,
);

Situation 2 — une brique isolée. Rien à prévenir, personne à écouter : laisse le troisième argument vide. Le bus est créé (Service.ts#Service.notificationsCenter) et, s’il n’y a pas de kernel partageant le même container, il est publié sous la clé notificationsCenter pour que d’autres puissent le retrouver (Service.ts#Service.notificationsCenter).

Situation 3 — un pur utilitaire. Un helper qui ne signale rien : false. Aucun Event n’est alloué, et options n’est pas fusionné avec les défauts (Service.ts#Service.options) — ce que le contre-cas suivant illustre :

new Service("calc", container, false, { foo: "bar" }); // ✅ options.foo conservé, 0 bus
new Service("calc", container, false).fire("x"); // ❌ lève : notificationsCenter not initialized

Écouteurs trackés — la mécanique anti-fuite#

C’est la raison d’être de la délégation. Chaque écouteur posé par l’API du service (Service.on() (Service.ts#Service.on), once, addListener, prependListener…) est enregistré dans la carte privée #trackedListeners (Service.ts#Service.#trackedListeners) via Service.trackListener() (Service.ts#Service.trackListener).

Service.clean() (Service.ts#Service.clean) ne retire les écouteurs que si le bus est partagé : sur un bus dédié, l’objet entier part au ramasse-miettes avec le service, il n’y a rien à décrocher.

Cycle de vie#

Étape Appel Ce qui se passe
Naissance new Service(name, container, nc, options) câblage des trois broches, écouteurs de config attachés
Démarrage init(owner) — optionnel, à toi de l’écrire appelé UNE fois au boot par le module qui porte le service (Module.ts#Module.init), sous garde (délai maximal + criticité du module). Reçoit son propriétaire, donc sa configuration résolue. C’est ici que se fait tout ce qui demande un await : connexion, chargement, préchauffage. ⚠️ init, pas initialize — initialize() est le hook du Controller, appelé à sa création (une fois pour un singleton, à chaque requête sous @Scope("request"))
Journal Service.initSyslog() (Service.ts#Service.initSyslog) démarre la sortie console (environnement + verbosité + filtres)
Vie log / fire / on / get délégation vers syslog, bus et container
Destruction Service.clean() (Service.ts#Service.clean) retire les écouteurs trackés, remet syslog/nc/container/kernel à vide
Destruction+ clean(true) appelle en plus Syslog.reset() — les transports sont fermés

clean() est idempotent : le rappeler ne lève pas.

🧰 API publique#

Les signatures exactes vivent dans le graphe TSDoc (.ai/symbols.json) ; ce qui suit est l’usage.

Dépendances — la façade container#

Appel Ancre Comportement
get<T>(name) Service.ts#Service.fire l’instance typée, ou null si absente ou après clean()
set(name, obj) Service.ts#Service.set enregistre — lève si le container est détaché
remove(name) Service.ts#Service.remove si la cible est un Service, appelle son clean() d’abord
has(name) Service.ts#Service.has false plutôt qu’une erreur quand le container est détaché

Cette façade est tolérante en lecture, stricte en écriture. Le détail du container lui-même (scopes par requête, arbre de paramètres, héritage prototypal) est traité dans injection-portees — Container.enterScope() (Container.ts#Container.enterScope), Container.leaveScope() (Container.ts#Container.leaveScope) et Container.scopeCount() (Container.ts#Container.scopeCount) pour les sondes de fuite.

Journal#

Appel Ancre Usage
log(pci, severity?, msgid?, msg?) Service.ts#Service.log le point d’entrée de tout log Nodefony
logger(pci, …) Service.ts#Service.logger raccourci DEBUG + console.debug formaté
trace(pci, …) Service.ts#Service.trace idem avec console.trace (pile d’appels)

log() est increvable : sans syslog il fabrique un Pdu directement, et toute exception y est attrapée pour retomber sur console (Service.ts#Service.attachConfiguredListeners). Un service qui journalise ne peut pas faire tomber le process à cause du journal. Sévérités et transports : syslog.

Événements#

Appel Ancre Note
fire(name, …) / emit(name, …) Service.ts#Service.emit synchrone, 0 microtask — le défaut sur le hot path
fireAsync(name, …) / emitAsync(…) Service.ts#Service.emitAsync attend les écouteurs asynchrones, en séquence
emitAsyncGuarded(name, options?, …) Service.ts#Service.emitAsyncGuarded isole chaque écouteur — boot / jobs uniquement
on / once / addListener Service.ts#Service.addListener trackés → retirés par clean()
off / removeListener Service.ts#Service.removeListener retirent aussi l’entrée de suivi
listen(name, listener) Service.ts#Service.listen bind sur this, non tracké — renvoie un déclencheur
settingsToListen(settings, ctx) Service.ts#Service.settingsToListen câble les clés onXxx d’un objet de config
removeAllListeners(name?) Service.ts#Service.removeAllListeners ⚠️ sur un bus partagé, vide aussi les écouteurs des voisins

Le contrat EventEmitter complet (listenerCount, eventNames, rawListeners, prependListener, setMaxListeners…) est délégué à l’identique.

emitAsyncGuardedla garde du cycle de vie#

Options purement mécaniques (IGuardedEmitOptions, Event.ts#IGuardedEmitOptions) : la politique reste à l’appelant.

Option Effet
timeoutMs délai maximal par écouteur. 0/absent = aucun timer alloué
warnMs seuil de lenteur. 0/absent = aucune mesure (pas un seul Date.now)
onListenerError appelé sur rejet ou dépassement ; renvoyer true arrête la chaîne (Event.ts#Event.emitAsyncGuarded)
onListenerSlow appelé quand un écouteur réussit mais dépasse warnMs (Event.ts#IGuardedEmitOptions.warnMs)

Le résultat (IGuardedEmitResult, Event.ts#IGuardedEmitResult) porte results, errors et stopped. En cas de dépassement, l’erreur remontée est une Error explicite (Event.ts#IGuardedEmitError.error) : le délai est suivi par un simple drapeau timedOut (Event.ts#Event.emitAsyncGuarded), et aucune valeur qui ne soit pas une Error ne peut remonter.

Côté kernel, Kernel.fireLifecycle() (Kernel.ts#Kernel.fireLifecycle) branche la politique : délai issu de Kernel.bootTimeoutMs() (Kernel.ts#Kernel.bootTimeoutMs) — 20 s en développement, 60 s en production, surchargeable par NF_BOOT_TIMEOUT_MS — et seuil de lenteur Kernel.bootWarnMs() (Kernel.ts#Kernel.bootWarnMs), 5 s par défaut. Un hook lent est signalé (NOTICE), un hook qui pend est coupé.

⚙️ Options du service#

Le quatrième argument du constructeur. Toutes les clés non listées sont conservées telles quelles et restent lisibles via this.options — c’est le canal de configuration d’un service.

Option Type Défaut effectif Effet
events.nbListeners number 20 limite d’écouteurs avant l’avertissement MaxListeners
syslog SyslogDefaultSettings moduleName: name réglages du Syslog créé par ce service (ignoré s’il en hérite un)
onXxx function — écouteur auto-attaché à l’événement Xxx (convention /^on(.+)$/)
(toute autre clé) libre — config propre au service, lisible dans this.options

⚠️ Attention

events remplace le défaut EN ENTIER, il ne s’y fusionne pas. Sans events, le défaut de defaultOptions s’applique (nbListeners: 20, Service.ts#Service.constructor). Mais passer events: { captureRejections: true } sans nbListeners fait retomber la limite à celle de Node — 10 : l’objet fourni remplace tout le défaut. Redonne nbListeners dès que tu passes events.

La convention onXxx — et son piège#

Une clé d’options qui commence par on suivi d’au moins un caractère est câblée comme écouteur. Mais pas par le même chemin selon la forme du bus :

C’est exactement le correctif qui empêche une fuite d’écouteurs par instance sur un bus partagé.

🧩 Extension — déclarer ses services#

Les trois décorateurs#

Décorateur Ancre Rôle
@injectable(nom?) kernelDecorator.ts#injectable inscrit la classe au registre DI (défaut : constructor.name)
@Inject("nom") kernelDecorator.ts#Inject injecte un service par nom sur un paramètre de constructeur
@services([…]) kernelDecorator.ts#services déclare les services d’un module — instanciés à onPreBoot

@injectable accepte aussi un objet { name?, scope? } où scope (DIScope, injector.ts#DIScope) vaut singleton (défaut, une instance pour le processus), transient (une instance neuve à chaque résolution) ou request (une instance par requête — par connexion en WebSocket —, nettoyée à sa fin ; son constructeur reçoit le scope de la requête). Il n’existe ni singleton: true, ni factory : TypeScript refuse ces clés. Choisir la portée, le calque de chaque requête et la façon de l’atteindre depuis n’importe quel code : Injection et portées.

🛑 Prudence

Le décorateur de propriété @Inject existe dans le code (kernelDecorator.ts#Inject) mais n’est pas ré-exporté par le paquet nodefony : une app ne peut pas l’importer. Injecte par constructeur (@Inject), qui est le chemin supporté.

L’ordre d’écriture ne décide plus du boot#

Historiquement, un service devait être listé avant ses consommateurs dans @services([…]) : déplacer une classe de trois lignes suffisait à casser le serveur au runtime. Aujourd’hui, l’ordre se calcule — orderServicesByDependencies() (serviceOrder.ts#orderServicesByDependencies), appelé par le décorateur (kernelDecorator.ts#services), fait un tri topologique stable depuis les dépendances déclarées (les noms @Inject("nom") ; le type d’un paramètre n’en crée aucune). Une liste déjà correcte ressort inchangée.

@services([Consumer, Dependency]) // ✅ l'ordre écrit n'a plus d'importance
class MyModule extends Module {}

Deux annuaires, et le pont entre eux#

C’est le point qui surprend le plus. @injectable("Router") indexe une classe ; super("router", …) indexe une instance au container. Les deux chaînes n’ont aucune raison d’être égales, et le décorateur ne peut pas connaître la seconde : il s’exécute au chargement de la classe, super() seulement à la construction.

Le pont est appris au seul instant où le couple est connu — quand l’instance est posée au container : Injector.rememberContainerKey() (injector.ts#Injector.rememberContainerKey), appelé depuis Module.addService() (Module.ts#Module.addService). Toute résolution ultérieure passe alors par la classe et retrouve cette instance, au lieu d’en fabriquer une seconde au cache vide.

Les cycles sont détectés à l’instanciation, avec le chemin complet dans le message (injector.ts#Injector._resolveWithStack).

Charger un service depuis un chemin#

Module.loadService() (Module.ts#Module.loadService) accepte un spécificateur de module (import() dynamique) et délègue à addService. Utile pour un service optionnel dont la présence dépend de la configuration.

🔐 Intégrité du boot — jamais de dégradation silencieuse#

Un service qu’on ne peut pas construire suit exactement la même politique qu’un service qu’on ne peut pas initialiser : Module.handleServiceBootError() (Module.ts#Module.handleServiceBootError) délègue au verdict du kernel, qui tranche selon deux axes.

Contexte Verdict
Production + module critique fatal — l’erreur remonte, le boot s’interrompt
Développement + module critique fail-soft ANNONCÉ : agrégé au BootReport → « boot DÉGRADÉ »
static override critical = false (Module.ts#Module.critical) fail-soft annoncé même en production
Erreur de configuration fatal quel que soit l’environnement

Deux garanties complémentaires, vérifiées par les tests d’attaque : un service dont la construction échoue n’est pas au container (pas de demi-instance), et le message d’erreur est actionnable — il nomme le service manquant, celui qui le réclame, et ce qu’il faut faire (injector.ts#Injector._lifetimeOf).

❗ Important

Le scénario que cette politique interdit est vécu : un serveur démarrant « UP », ports à l’écoute, et répondant en erreur sur chaque requête parce qu’un service manquait. Un boot amputé doit se déclarer amputé.

⚡ Performance & mémoire#

Service est dans le chemin de chaque requête (le contexte HTTP/WS en hérite). Les choix mesurables :

⚠️ Attention

emitAsyncGuarded n’a rien à faire dans le hot path. Il est dimensionné pour le boot, le cycle de vie et les jobs — appelés une poignée de fois par process. Le pipeline HTTP/WS garde emitAsync nu. Toute modification de Service.ts / Event.ts passe par le gate mémoire (skill nodefony-check-memory-health) avant commit.

📡 Observabilité — Studio#

Les services d’un module sont introspectables sans lire le code :

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause (dans le code) Correction
Fuite d’écouteurs, une de plus par instance écouteur posé directement sur le bus partagé, hors de l’API du service passer par Service.on(), qui appelle trackListener (Service.ts#Service.trackListener)
off() ne retire rien Service.listen() (Service.ts#Service.listen) bind — la référence posée diffère retirer via le déclencheur renvoyé, jamais l’original
Les écouteurs des voisins disparaissent removeAllListeners() (Service.ts#Service.removeAllListeners) agit sur le bus partagé en entier cibler l’événement, ou retirer écouteur par écouteur
notificationsCenter not initialized bus à false, ou appel après clean() (Service.ts#Service.clean) ne pas émettre après destruction ; vérifier le 3ᵉ argument du constructeur
container not initialized sur un set() écriture après clean() (Service.ts#Service.clean) revoir l’ordre du cycle de vie ; get(), lui, rend null
Avertissement MaxListeners à 11 abonnés events fourni sans nbListeners : l’objet remplace tout le défaut (20) redonner nbListeners dans events
Le déclencheur de listen() passe un argument en trop Event.listen() (Event.ts#Event.listen) préfixe les arguments par le nom de l’événement lire le 1ᵉʳ argument comme le nom, ou émettre via fire()
Écouteurs asynchrones exécutés l’un après l’autre emitAsync est séquentiel par design (Event.ts#Event.emitAsync) comportement attendu ; paralléliser dans l’écouteur si besoin
Le service est reconstruit, son cache vide clé container ≠ nom @injectable, pont non appris (injector.ts#Injector.constructor) passer par Module.addService() — jamais un new manuel
Un service déclaré n’est pas au container après le boot sa construction a échoué, fail-soft annoncé (Module.ts#Module.#warnOverwrittenAppConfig) lire le BootReport / les ERROR de démarrage ; en prod le boot aurait échoué
options.events introuvable après construction la clé est écartée volontairement (Service.ts#Service.constructor) lire la valeur avant, ou la conserver sous une autre clé

🧪 Tests & couverture#

Quatre angles couvrent la brique — les chiffres exacts vivent dans la carte de l’aperçu, régénérée depuis vitest, jamais figés ici :

Ce qui manque : aucun banc de charge dédié à Service/Event. La pression réelle est mesurée en bout de chaîne, par le gate mémoire du pipeline HTTP/WS (memory.test.ts, skill nodefony-check-memory-health) — c’est là qu’une fuite d’écouteurs se voit.

Couverture : npm run coverage dans src/nodefony.

🔗 Pour aller plus loin#