La vie intérieure d'un module
Un service sans état peut être partagé. Un service avec état doit être seul.
L'épisode précédent a posé la question en la laissant ouverte : combien de temps vit chaque service ? La structure des modules ne suffit pas à y répondre — elle dit quoi, pas quand. Pour savoir quand, j'ai dû entrer à l'intérieur d'AgentModule et regarder chaque service en face.
Ce que le scope déclare
NestJS propose trois scopes pour ses providers. DEFAULT — ce que le framework appelle Singleton — crée une instance au démarrage et la partage entre tous les consommateurs jusqu'à l'extinction du processus. TRANSIENT crée une nouvelle instance à chaque injection. REQUEST crée une instance par requête HTTP — un scope conçu pour les API web, sans pertinence pour un service de messagerie comme AgenticLayer.
Deux scopes utiles, donc. Or la question n'est pas "lequel est plus rapide" — elle est "lequel est vrai".
ToolsRegistry est un registre. À l'initialisation du module, il reçoit les six tools enregistrés manuellement dans ToolsModule : search_code, get_file, get_files, list_files, analyze_ast, find_tests. Aucun tool n'est ajouté ni retiré pendant l'exécution. Aucune règle évaluée ne modifie cet état. Deux évaluations qui tournent en parallèle lisent le même registre sans interférence — non pas parce qu'elles sont bien synchronisées, mais parce qu'il n'y a rien à synchroniser. ToolsRegistry est Singleton parce qu'il n'a pas d'état par exécution.
LoopDetector est l'inverse. Sa raison d'être est de se souvenir — non pas de tout, mais de ce qui vient de se passer dans cette exécution précise : les derniers tool calls, leurs paramètres, leur fréquence. Si deux règles partagent la même instance, la mémoire de l'une contamine l'autre. LoopDetector doit mourir à la fin de chaque règle — non comme une précaution, mais comme une nécessité structurelle. Il est Transient parce qu'il est son état.
SchemaBuilder échappe à la question. Il génère à la volée le schéma Zod de validation depuis la configuration YAML d'une règle — une transformation pure, sans entrée mutable, sans mémoire entre les appels. Ses méthodes sont statiques. On pourrait lui attribuer un scope ; ce serait une fiction.
LLMFactory et PromptCompiler sont Singleton pour une raison différente. Ils portent un état — LLMFactory résout les clés API via ConfigService, PromptCompiler maintient un cache in-memory des templates déjà lus depuis le système de fichiers — mais cet état est initialisé une fois et ne mute plus. Deux évaluations parallèles lisent le même cache de templates sans risque : le cache est une optimisation, pas un canal de communication.
Pour un développeur junior : en NestJS, un
@Injectable()est Singleton par défaut — une seule instance partagée entre tous les modules qui l'injectent. Pour déclarer un scope Transient, on écrit@Injectable({ scope: Scope.TRANSIENT }). La conséquence est subtile : si un provider Singleton injecte un provider Transient, le Transient est instancié une seule fois au démarrage — il perd son caractère transient. NestJS appelle cela la scope chain : le scope d'un provider est déterminé par le scope le plus long de ses consommateurs.AgentServiceest Transient — ce qui forceLoopDetectoretStructuredOutputHandler, qu'il injecte, à être effectivement recréés à chaque instanciation d'AgentService. DéclarerLoopDetectorTransient sans que son consommateur le soit aussi n'aurait servi à rien.