---
title: "Service — la brique de base de tout composant"
source: src/nodefony/docs/service.md
url: https://nodefony.github.io/nodefony-core/docs/modules/core/service/
---


> `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.

```mermaid
flowchart LR
  subgraph S["Service (name)"]
    direction TB
    C["container<br/>DI — get / set / has"]
    L["syslog<br/>log / logger"]
    N["notificationsCenter<br/>on / fire / fireAsync"]
  end
  K["Kernel"] -->|"fournit container + bus"| S
  M["Module"] -->|"fournit container + bus"| S
  S -->|"clean()"| X["débranché<br/>listeners retirés · refs nulles"]
  N -->|"emitAsyncGuarded"| B["cycle de boot<br/>isolé par listener"]
```

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](../../../architecture/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](../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.

> [!TIP]
> 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 :

- `Event.emitAsync()` (`Event.ts#Event.emitAsync`) attend les écouteurs **en séquence**, jamais en `Promise.all` —
  l'ordre des effets de bord est prévisible par construction ;
- `Event.emitAsyncGuarded()` (`Event.ts#Event.emitAsyncGuarded`) isole **chaque** écouteur (try/catch + délai maximal)
  et renvoie `{ results, errors, stopped }` au lieu de laisser le premier rejet faire sauter la suite.

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 :

```typescript
// 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

```bash
npx nodefony development
```

```text
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.

```typescript
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`).

> [!WARNING]
> 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.

```typescript ignore
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 :

```typescript ignore
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`).

```mermaid
sequenceDiagram
  participant S as Service
  participant T as "carte des écouteurs trackés"
  participant B as Event (bus partagé)
  S->>B: on("onTick", fn)
  S->>T: trackListener("onTick", fn)
  Note over S,B: le service vit, émet, écoute
  S->>T: clean() lit la carte
  S->>B: removeListener("onTick", fn)
  S->>T: clear()
  Note over S: syslog / nc / container / kernel = null
```

`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](../../../architecture/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](../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.

### `emitAsyncGuarded` — la 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`                   |

> [!WARNING]
> **`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 :

- **bus partagé** → `Service.attachConfiguredListeners()` (`Service.ts#Service.attachConfiguredListeners`) passe par `this.on`,
  donc l'écouteur est **tracké** et `clean()` le retirera ;
- **bus dédié** → c'est le constructeur d'`Event` qui appelle `Event.settingsToListen()`
  (`Event.ts#Event.settingsToListen`), lequel utilise `Event.listen()` (`Event.ts#Event.listen`) : l'écouteur est **bindé, non
  tracké**. Sans conséquence, puisque le bus meurt avec le service.

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](../../../architecture/injection-portees/).

> [!CAUTION]
> 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.

```typescript ignore
@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 :

- **Court-circuit sans abonné** — `emitAsync` (`Event.ts#Event.emitAsync`) et `emitAsyncGuarded` (`Event.ts#Event.emitAsyncGuarded`)
  sortent sur `listenerCount() === 0` : **0 allocation, 0 microtask**. Un hook optionnel que personne
  n'écoute ne coûte rien. Le test compte les allocations, pas les intentions : `listenerCount` n'alloue
  pas, là où `rawListeners` **copie** le tableau interne à chaque appel.
- **Pas d'`await` gratuit** — `emitAsync` n'attend que si l'écouteur renvoie réellement un thenable
  (`Event.ts#Event.emitAsync`) : un hook synchrone ne paie aucune microtask, et l'ordre séquentiel reste garanti.
- **Timers seulement si demandés** — `emitAsyncGuarded` n'alloue 1 timer + 1 promesse de course par
  écouteur que si `timeoutMs > 0` ; le timer est `unref()` (`Event.ts#Event.emitAsyncGuarded`) et un rejet arrivant après
  la course perdue est neutralisé (`Event.ts#Event.emitAsyncGuarded`) pour ne pas devenir un rejet non géré.
- **Aucune fuite d'écouteurs** — le suivi `#trackedListeners` (`Service.ts#Service.#trackedListeners`) rend `clean()` exact
  sur un bus partagé.
- **Bus optionnel** — `notificationsCenter: false` n'alloue **aucun** `Event`.

> [!WARNING]
> `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 :

- **API** — `GET /nodefony/kernel/api/module/{name}` (`KernelAdminApi.ts#createKernelAdminApi`) renvoie un tableau
  `services: [{ name, class }]`, construit depuis `Module.getServiceNames()` (`Module.ts#Module.getServiceNames`) croisé
  avec le container (`KernelAdminApi.ts#createKernelAdminApi`).
- **Écran** — la page de détail d'un module (`studio/frontend/src/routes/ModuleDetail.tsx`) affiche
  cette liste à côté de la config, des docs et des symboles du module.
- **Sonde de fuite** — `Container.scopeCount(name)` (`Container.ts#Container.scopeCount`) donne le nombre de scopes
  **vivants** : un compteur qui monte sans jamais redescendre signale un `leaveScope` manquant.

## ⚠️ 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 :

- **Unitaires — le socle** : `Service.test.ts` couvre construction (les trois formes de bus,
  réutilisation du syslog, suppression de `options.events`), délégation container, les 18 méthodes
  d'événements, le journal sévérité par sévérité, `initSyslog`, l'héritage et le partage de container
  entre deux services.
- **Unitaires — l'anti-fuite** : le cœur de `clean()` est prouvé point par point — retrait sur bus
  partagé, **non**-retrait des écouteurs des autres services, retrait des écouteurs injectés par
  config (`Service.test.ts`).
- **Unitaires — le bus** : `Event.test.ts` (construction par settings, `listen`, `fire`, `emitAsync`,
  cas limites, charge) et `EventGuarded.test.ts` (isolation, délai maximal, `stopped`, et la
  non-régression du court-circuit `emitAsync`).
- **Unitaires — l'ordre** : `serviceOrder.test.ts` verrouille le tri topologique stable.
- **Tests d'attaque** : `services.attack.test.ts` attaque l'**intégrité du boot** — construction qui
  échoue en production sur module critique (fatal attendu), fail-soft **annoncé** au BootReport,
  absence du service au container, message d'erreur actionnable, ordre d'écriture inversé, et
  divergence entre nom de classe et clé container.

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

- ⬆️ **Retour au hub** : [@nodefony/core — vue d'ensemble](../) · [Toute la documentation](../../../)
- 🧭 **Pages sœurs** : [Syslog (PDU, sévérités, transports)](../syslog/) · [Kernel & modules](../kernel/) · [RequestContext (ALS)](../request-context/) · [Client isomorphe](../client/)

- Portées d'injection et cycle des scopes → [injection-portees](../../../architecture/injection-portees/)
- Qui émet quelle phase, et dans quel ordre → [cycle-boot-kernel](../../../architecture/cycle-boot-kernel/)
- Où le `Service` se retrouve sur une requête → [pipeline-requete](../../../architecture/pipeline-requete/)
