NestJS — Modules
TL;DR — Un module est une frontière : il déclare ses providers, expose ce qu'il veut via
exports, et consomme d'autres modules viaimports. Trois variantes à maîtriser : module statique, module dynamique (forRoot), module global (@Global).ModuleRefpermet la résolution runtime,LazyModuleLoaderle chargement à la demande. Structure ton app par bounded context, pas par couche technique.
🧠 Mental model
Pense module = package privé. Tout ce qui n'est pas dans exports est invisible au reste de l'app. Tout ce qui n'est pas dans imports ne peut pas être injecté.
┌─────────── AppModule ───────────┐
│ imports: [UsersModule, AuthModule, BillingModule]
│ │
│ ┌─── UsersModule ────────────┐ │
│ │ providers: UsersService, │ │
│ │ UsersRepo (priv)│ │
│ │ exports: UsersService │ │ ← seul UsersService est visible dehors
│ └────────────────────────────┘ │
│ │
│ ┌─── AuthModule ─────────────┐ │
│ │ imports: [UsersModule] │ │ ← peut injecter UsersService
│ │ providers: AuthService │ │ ← UsersRepo reste invisible ici
│ └────────────────────────────┘ │
└─────────────────────────────────┘Analogie : module = classe avec des champs privés/publics. Le exports est la liste des public, le reste est private. Et imports est la déclaration import { X } from 'pkg' au niveau du graphe.
Deux pièges mentaux : (1) imports n'importe pas des fichiers — ça branche un autre module Nest dans le container. (2) Re-exporter (mettre un module dans exports) crée une dépendance transitive : A re-exporte B, alors quiconque importe A reçoit aussi B. Utile mais dangereux.
🛠️ Code minimal
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
import { UsersController } from './users.controller';
@Module({
controllers: [UsersController],
providers: [UsersService, UsersRepository], // repo privé au module
exports: [UsersService], // seul le service est public
})
export class UsersModule {}
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
@Module({
imports: [UsersModule], // ← peut injecter UsersService ici
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
// app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from './config/config.module';
@Module({
imports: [
ConfigModule.forRoot({ envFile: '.env' }),
UsersModule,
AuthModule,
],
})
export class AppModule {}🎯 Patterns courants
1. Feature module vs Global module. Par défaut, un module est local — il faut l'importer là où on veut l'utiliser. @Global() le rend disponible partout (les exports sont visibles globalement).
@Global()
@Module({
providers: [Logger, ConfigService],
exports: [Logger, ConfigService],
})
export class CoreModule {}Règle : un seul @Global() par app (idéalement CoreModule). Plus, et tu perds la lisibilité du graphe.
2. Dynamic module (config-driven). Le pattern signature de Nest pour les libs réutilisables.
export interface MailerOptions { from: string; smtpUrl: string; }
@Module({})
export class MailerModule {
static forRoot(opts: MailerOptions): DynamicModule {
return {
module: MailerModule,
providers: [
{ provide: 'MAILER_OPTS', useValue: opts },
MailerService,
],
exports: [MailerService],
};
}
static forRootAsync(opts: {
imports?: any[];
useFactory: (...args: any[]) => Promise<MailerOptions> | MailerOptions;
inject?: any[];
}): DynamicModule {
return {
module: MailerModule,
imports: opts.imports ?? [],
providers: [
{ provide: 'MAILER_OPTS', useFactory: opts.useFactory, inject: opts.inject ?? [] },
MailerService,
],
exports: [MailerService],
};
}
}
// Usage
@Module({
imports: [
MailerModule.forRootAsync({
imports: [ConfigModule],
useFactory: (cfg: ConfigService) => ({ from: cfg.get('FROM'), smtpUrl: cfg.get('SMTP') }),
inject: [ConfigService],
}),
],
})
export class AppModule {}forRoot = config sync, forRootAsync = config qui dépend d'autres providers. C'est l'équivalent Spring @ConfigurationProperties + @Bean.
3. Re-exporting. Pour exposer un module importé à travers ton propre module.
@Module({
imports: [SharedDbModule],
exports: [SharedDbModule], // ← tout consumer reçoit aussi SharedDbModule
})
export class UsersModule {}Utile pour les "facades" : un module orchestrateur qui expose plusieurs sous-modules.
4. Lazy loading via LazyModuleLoader. Charge un module à la demande au runtime — utile pour les commandes CLI rares, plugins, microservices conditionnels.
@Injectable()
export class JobRunner {
constructor(private readonly lazy: LazyModuleLoader) {}
async runReport() {
const { ReportingModule } = await import('./reporting/reporting.module');
const moduleRef = await this.lazy.load(() => ReportingModule);
const svc = moduleRef.get(ReportingService);
return svc.generate();
}
}Réduit le temps de boot et la RAM si tu as 50 modules dont 5 utilisés sur 95% des requêtes.
5. ModuleRef pour la résolution runtime. Injection dynamique sans connaître la classe au compile-time.
@Injectable()
export class StrategyResolver {
constructor(private readonly moduleRef: ModuleRef) {}
pickStrategy(name: string) {
const token = name === 'fast' ? FastStrategy : SlowStrategy;
return this.moduleRef.get(token, { strict: false });
}
async pickRequestScoped(name: string) {
return this.moduleRef.resolve(SomeRequestScopedService); // async, transient/request
}
}strict: false permet de chercher dans tout le graphe, pas seulement le module courant.
get vs resolve vs create — la confusion qui sépare le junior du senior :
| Méthode | Retourne | Pour quel scope | Async ? | Piège |
|---|---|---|---|---|
moduleRef.get(Token) | l'instance singleton déjà dans le container | Scope.DEFAULT uniquement | non | Lève sur un provider request/transient — ils n'existent pas « globalement » |
moduleRef.resolve(Token) | une instance fraîche par appel (transient) ou liée au contexte (request, si tu passes le contextId) | TRANSIENT / REQUEST | oui | Sans contextId partagé, deux resolve() d'un provider request renvoient deux instances |
moduleRef.create(Class) | une instance hors graphe, jamais enregistrée, jamais réutilisée | classe arbitraire (même non-@Injectable) | oui | Toi seul tiens la référence → pas de cycle de vie géré, pas de onModuleDestroy |
// Résoudre un provider request-scoped DANS le bon contexte de requête
import { ContextIdFactory } from '@nestjs/core';
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId(req, contextId);
const handler = await this.moduleRef.resolve(RequestScopedHandler, contextId);Mémo : get = « donne-moi le singleton », resolve = « fabrique/retrouve dans un contexte », create = « instancie ça pour moi, je gère sa vie ». 90% des bugs ModuleRef viennent d'un get() sur un provider non-singleton.
6. Module structure DDD-friendly. Pour une app à plusieurs domaines :
src/
app.module.ts
core/ ← Global : Logger, Config, DB
shared/ ← Pipes/Guards/Filters communs, exporté à la demande
users/
users.module.ts
application/ ← use-cases (services)
domain/ ← entities, value objects, ports (interfaces)
infrastructure/ ← repositories, adapters
presentation/ ← controllers, DTOs
billing/
billing.module.ts
application/ domain/ infrastructure/ presentation/Chaque feature module est autonome. Les modules ne se connaissent qu'à travers leurs exports.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- Nest 7 :
forRoot/forRootAsyncdéjà standards mais pas de helper officiel — chacun les écrit à la main. Pas de lazy loading. - Nest 8 : Introduction de
ConfigurableModuleBuilder(preview) pour générer lesforRoot/forRootAsyncboilerplate-free.ModuleRef.createpermet d'instancier des classes hors graphe. - Nest 9 :
ConfigurableModuleBuilderstabilisé — fortement recommandé pour les nouvelles libs.LazyModuleLoaderapparaît en API. - Nest 10 :
LazyModuleLoaderstable.RouterModule.register([])remplace l'ancienRouterModule.forRoutes()(déplacement du package). Améliorations DX sur les messages d'erreur de dep manquante (pointe le module fautif). - Nest 11 :
ConfigurableModuleBuildersupporte mieux les options async typées strict.@Global()propage les exports plus prévisiblement quand mélangé avec dynamic modules.
ConfigurableModuleBuilder (Nest 9+)
import { ConfigurableModuleBuilder } from '@nestjs/common';
export interface MailerOptions { from: string; smtpUrl: string; }
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<MailerOptions>().build();
@Module({ providers: [MailerService], exports: [MailerService] })
export class MailerModule extends ConfigurableModuleClass {}
// Usage : MailerModule.register({ from: ..., smtpUrl: ... })
// MailerModule.registerAsync({ useFactory: ... })Tu obtiens gratuitement register/registerAsync (renommable via .setClassMethodName('forRoot')), le token, et la gestion des options. Plus de boilerplate.
Trois leviers de personnalisation que peu connaissent :
const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } =
new ConfigurableModuleBuilder<MailerOptions>()
.setClassMethodName('forRoot') // 1. nom de la méthode statique
.setExtras(
{ isGlobal: false }, // 2. options "extra" hors du token (ex: rendre global)
(definition, extras) => ({ ...definition, global: extras.isGlobal }),
)
.setFactoryMethodName('createMailerOptions') // 3. nom de la méthode useFactory côté consumer
.build();
// OPTIONS_TYPE / ASYNC_OPTIONS_TYPE : types exportables pour typer fort les signatures côté lib
export type MailerModuleOptions = typeof OPTIONS_TYPE;forRoot vs forFeature — ce ne sont pas des synonymes. forRoot configure le module une fois (la connexion, le client, les options globales) ; on l'appelle dans AppModule. forFeature enregistre des ressources scopées par usage par-dessus cette config (les repositories d'une entité avec TypeORM, une queue nommée avec BullMQ) et peut être appelé N fois sans écraser — chaque feature module appelle forFeature pour ses entités. ConfigurableModuleBuilder génère nativement le couple : laisse le builder produire forRoot, et écris forFeature à la main comme méthode statique qui retourne un DynamicModule ne fournissant que les providers feature-locaux (sans re-configurer les options globales).
⚠️ Pitfalls
- Importer deux fois le même dynamic module avec configs différentes. Le second
forRootécrase. Solution : créer un module séparé par usage, ou utiliser des feature tokens (forFeature). @Global()partout. Tu casses l'encapsulation. Si tu en mets 5, tu n'as plus de graphe — tout est globalement visible.- Cycle entre modules. Si A importe B et B importe A, Nest crash sauf à utiliser
forwardRef(() => OtherModule)dansimports. C'est un signal d'architecture cassée — extrais une lib commune. - Module sans
exports. Tu déclares un provider mais oubliesexports. Au moment d'injecter ailleurs : "Nest can't resolve dependencies". Vérifie toujours. - Mettre un controller dans
exports. Inutile : les controllers ne s'injectent jamais. Seuls providers (et modules) s'exportent. - Mélanger module statique et dynamique. Si
MailerModulea un constructeur statique standard (sansforRoot) et qu'on essaieMailerModule.forRoot({...}), ça plante silencieusement (la méthode n'existe pas). Toujours documenter. LazyModuleLoaderdans un controller standard. Charger un module en plein milieu d'un handler HTTP ajoute de la latence imprévisible. Préfère pré-charger enOnApplicationBootstrapsi possible.- Importer un module juste pour un type. L'import TS suffit pour les types. Le
importsNest n'est utile que pour l'injection.
🧪 Testing
import { Test } from '@nestjs/testing';
// Tester un module isolé en mockant ses deps
const moduleRef = await Test.createTestingModule({
imports: [UsersModule],
})
.overrideProvider(UsersRepository)
.useValue({ findById: jest.fn() })
.compile();
const svc = moduleRef.get(UsersService);Pour tester un dynamic module :
const moduleRef = await Test.createTestingModule({
imports: [
MailerModule.forRoot({ from: 'test@x', smtpUrl: 'smtp://test' }),
],
}).compile();
const mailer = moduleRef.get(MailerService);
expect(mailer).toBeDefined();Pour tester LazyModuleLoader : injecte-le, mock load() avec un TestingModule factice.
🧭 Comment un staff raisonne sur le découpage en modules
Un module n'est pas une unité de code, c'est une unité de décision. La question n'est jamais « où je mets ce fichier » mais « qui a le droit de dépendre de quoi ». Voici la grille mentale d'un architecte.
Le module comme frontière de confiance (3 axes)
| Axe | Question | Mauvais signal |
|---|---|---|
| Cohésion | Ces providers changent-ils pour la même raison (même acteur métier) ? | Un module qui bouge à chaque sprint pour des raisons décorrélées |
| Couplage | Combien de modules dois-je importer pour faire vivre celui-ci ? | imports à 8+ entrées, ou des forwardRef |
| Surface | Combien de providers j'exports ? | Tu exportes 90% de ce que tu déclares → le module n'encapsule rien |
Règle de pouce : le ratio exports / providers doit rester bas. Un module qui exporte presque tout est un namespace déguisé, pas une frontière. À l'inverse, un module qui n'exporte rien et n'a pas de controller ne sert à rien (sauf module purement « side-effect » comme un ScheduleModule).
Le graphe de modules EST l'architecture
Le imports de chaque module dessine un DAG (graphe orienté acyclique). Un staff lit ce DAG comme un plan : si tu vois une flèche qui remonte (un module « bas niveau » qui importe un module « haut niveau »), c'est une inversion de dépendance ratée. La solution canonique n'est jamais forwardRef — c'est l'extraction d'une frontière : tu poses une interface (port) dans le module bas, et l'implémentation (adapter) vit dans le module haut, branchée par token.
// domain/ports/notifier.port.ts — dans le module BAS (leases)
export const NOTIFIER = Symbol('NOTIFIER');
export interface Notifier { leaseCreated(leaseId: string): Promise<void>; }
// leases.service.ts — dépend de l'abstraction, pas du module Comms
constructor(@Inject(NOTIFIER) private readonly notifier: Notifier) {}
// comms.module.ts — le module HAUT fournit l'implémentation
@Module({
providers: [{ provide: NOTIFIER, useClass: EmailNotifier }],
exports: [NOTIFIER],
})
export class CommsModule {}forwardRef traite le symptôme (le cycle), le port traite la maladie (le couplage bidirectionnel). En entretien, proposer forwardRef en premier réflexe est un red flag ; proposer un port, c'est un signal senior.
Modes de défaillance d'un graphe de modules
- Le « god SharedModule » — un
SharedModulequi ré-exporte 20 choses devient un point de couplage global. Tout le monde l'importe, donc tout est transitivement lié. Découpe-le par capacité (PaginationModule,AuthGuardsModule). - Le
@Global()qui ment — un provider global change de comportement selon l'ordre d'enregistrement des modules dynamiques. L'ordre dansimportsdu root compte pour les globals : le dernieruseValuesur un même token gagne. - Le dynamic module importé 2× avec configs différentes — sans
forFeature, le second écrase. Symptôme : des connexions DB ou des clients HTTP qui pointent silencieusement au mauvais endroit en prod. - Le request-scoped contagieux — si un provider
Scope.REQUESTest exporté et consommé par un module qui se croit singleton, tout le sous-graphe consommateur devient request-scoped (recréé à chaque requête). Perf qui s'effondre sans erreur. (Voir le fichier Scopes & injection.) - Lazy module qui fuit —
LazyModuleLoader.load()met en cache le module après le 1er chargement. Si tu comptes sur un rechargement « frais » à chaque appel, tu te trompes : c'est un singleton paresseux, pas un module éphémère.
Observabilité du graphe
En prod, un staff veut voir le graphe. Trois leviers :
app.get(token)+ introspection deModulesContainer(injectable) pour auditer ce qui est réellement enregistré.@nestjs/devtools-integration(Nest 9+) : graphe visuel des modules/providers, détection des cycles, des providers non résolus, et du « scope leak ».- En CI :
nest buildéchoue déjà sur une dep non résolue, mais ajoute un test d'architecture (Test.createTestingModule({ imports: [AppModule] }).compile()) qui boote le graphe complet — ça attrape 80% des erreurs de wiring avant le déploiement.
🤖 Architecturer un feature d'agent IA en modules (intégration stack)
Tu vas servir et orchestrer des agents IA depuis NestJS. Le découpage en modules est exactement l'outil pour ça : un AiModule qui encapsule le client LLM, un AgentModule qui orchestre la boucle tool-use, et des feature modules métier consommés par l'agent comme outils. La règle d'or : jamais new Anthropic() dans un champ — le client LLM est un provider DI, configuré par forRootAsync, mockable en test, et partagé (pooling, retries SDK, timeout).
1. AiModule — le client LLM comme provider configurable
// ai/ai.module.ts
import { ConfigurableModuleBuilder, Module } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
export interface AiOptions {
apiKey: string;
defaultModel?: string; // ex: 'claude-sonnet-4-6'
maxRetries?: number;
}
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<AiOptions>()
.setClassMethodName('forRoot') // → AiModule.forRoot / forRootAsync
.setExtras({ isGlobal: false }, (def, extras) => ({ ...def, global: extras.isGlobal }))
.build();
export const ANTHROPIC = Symbol('ANTHROPIC_CLIENT');
@Module({
providers: [
{
provide: ANTHROPIC,
inject: [MODULE_OPTIONS_TOKEN],
// SDK gère le retry exponentiel + le streaming ; ne pas le réimplémenter.
useFactory: (opts: AiOptions) =>
new Anthropic({ apiKey: opts.apiKey, maxRetries: opts.maxRetries ?? 2 }),
},
],
exports: [ANTHROPIC, MODULE_OPTIONS_TOKEN],
})
export class AiModule extends ConfigurableModuleClass {}// usage dans le root — la clé vient du Config, pas du code
@Module({
imports: [
AiModule.forRootAsync({
imports: [ConfigModule],
useFactory: (cfg: ConfigService) => ({
apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
defaultModel: 'claude-sonnet-4-6',
maxRetries: 3,
}),
inject: [ConfigService],
}),
AgentModule,
],
})
export class AppModule {}Modèles actuels à connaître (mi-2026) : claude-opus-4-8 (flagship, raisonnement long / agentique), claude-sonnet-4-6 (défaut équilibré coût/latence), claude-haiku-4-5 (classif/extraction rapide et bon marché). Le choix du modèle est une décision de runtime (par requête), donc passe-le en paramètre — ne le fige pas dans le module.
2. AgentModule — la boucle tool-use, et les outils sont des modules
L'insight architectural : un « outil » d'agent = un service exporté par un feature module. L'AgentModule importe les modules métier et expose leurs services à la boucle agentique. Le découpage en modules te donne gratuitement la frontière outil/métier.
// agent/agent.module.ts
@Module({
imports: [AiModule, LeasesModule, LotsModule], // les outils = services métier
providers: [AgentService, ToolRegistry],
controllers: [AgentController],
})
export class AgentModule {}// agent/agent.service.ts — boucle tool-use server-side, streamée
import { Inject, Injectable } from '@nestjs/common';
import type Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../ai/ai.module';
@Injectable()
export class AgentService {
constructor(
@Inject(ANTHROPIC) private readonly llm: Anthropic,
private readonly tools: ToolRegistry, // mappe name → service métier
) {}
// signal = AbortSignal lié à la déconnexion client (voir §3)
async *run(prompt: string, signal: AbortSignal): AsyncGenerator<AgentEvent> {
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: prompt }];
while (true) {
const stream = this.llm.messages.stream(
{ model: 'claude-sonnet-4-6', max_tokens: 1024, tools: this.tools.schemas(), messages },
{ signal }, // annulation propagée jusqu'au réseau
);
for await (const ev of stream) {
if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
yield { kind: 'token', text: ev.delta.text };
}
}
const final = await stream.finalMessage();
messages.push({ role: 'assistant', content: final.content });
const toolUses = final.content.filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use');
if (toolUses.length === 0) return; // pas d'outil → réponse finale
const results: Anthropic.ToolResultBlockParam[] = [];
for (const t of toolUses) {
yield { kind: 'tool_call', name: t.name, id: t.id }; // trace pour l'UI (timeline)
const out = await this.tools.invoke(t.name, t.input, signal); // délègue au service métier
results.push({ type: 'tool_result', tool_use_id: t.id, content: JSON.stringify(out) });
}
messages.push({ role: 'user', content: results });
}
}
}Le point de design : ToolRegistry est injecté, et chaque outil délègue à un service exporté par un feature module. Ajouter un outil = importer un module de plus dans AgentModule et l'enregistrer dans le registry. Aucune logique métier ne vit dans l'agent ; il orchestre.
3. Endpoint SSE + annulation (AbortController jusqu'au LLM)
// agent/agent.controller.ts
import { Controller, Post, Body, Res, Req } from '@nestjs/common';
import type { Response, Request } from 'express';
@Controller({ path: 'agent', version: '1' })
export class AgentController {
constructor(private readonly agent: AgentService) {}
@Post('stream')
async stream(@Body() dto: { prompt: string }, @Req() req: Request, @Res() res: Response) {
res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
const ac = new AbortController();
req.on('close', () => ac.abort()); // déconnexion client → on annule le LLM (coût + ressources)
try {
for await (const ev of this.agent.run(dto.prompt, ac.signal)) {
res.write(`data: ${JSON.stringify(ev)}\n\n`);
}
res.write('event: done\ndata: {}\n\n');
} catch (e) {
if (!ac.signal.aborted) res.write(`event: error\ndata: ${JSON.stringify({ message: 'agent_failed' })}\n\n`);
} finally {
res.end();
}
}
}4. Jobs IA lourds : BullMQ dans un module dédié
Pour les générations longues (rapport multi-pages, batch d'extraction), tu ne streames pas dans la requête HTTP : tu enfiles un job. Un AiJobsModule encapsule la queue.
- Idempotence : la clé du job = un
generationId(UUID côté client). Réessayer = même clé → pas de double facturation tokens. - Retry coût-conscient :
attempts: 3mais backoff exponentiel et un cost guard qui coupe si le cumul tokens dépasse un budget par tenant (mesuré surusage.input_tokens/output_tokensrenvoyés par le SDK). - Sortie partielle : persiste les tokens au fil de l'eau (Redis stream / table append-only) pour qu'un job tué à 80% ne reparte pas de zéro — reprends depuis le dernier checkpoint.
@Module({
imports: [BullModule.registerQueue({ name: 'ai-generations' }), AiModule],
providers: [AiGenerationProcessor], // @Processor('ai-generations')
exports: [BullModule],
})
export class AiJobsModule {}5. Edge concerns (idempotence, rate-limit, cost-guard)
Ces préoccupations ne vivent pas dans l'AgentService — ce sont des cross-cutting concerns qu'on branche en guards/interceptors exportés par un module dédié (AiEdgeModule) : @Throttle (rate-limit par tenant), un IdempotencyGuard (rejoue la réponse cachée si même Idempotency-Key), un CostGuardInterceptor (refuse si quota tokens dépassé). On les active globalement via APP_GUARD/APP_INTERCEPTOR ou par feature. Le découpage en modules garde ces protections séparées de la logique d'orchestration — testables et réutilisables sur tout endpoint IA.
Exposer un endpoint MCP (Model Context Protocol) suit la même logique : un
McpModulequi mappe tes services métier exportés en MCP tools. Le module est, là encore, la frontière entre « ce que l'agent peut appeler » et le reste de l'app.
🎬 Cas d'usage concrets
Scénario 1 — Monorepo LegalTech multi-cabinet
Qui — Un éditeur LegalTech FR (28 ETP) qui sert 3 produits distincts : (1) une plateforme de gestion de dossiers pour cabinets d'avocats, (2) un assistant rédactionnel à destination de juristes d'entreprise, (3) un portail self-service pour particuliers (litiges conso). Ces 3 produits partagent 70% du domaine (entités juridiques, documents, signature).
Problème métier — Au début, 3 repos Nest distincts, chacun copiant entities/, documents/, signature/. Quand la signature électronique change de provider, c'est 3 PRs en parallèle, 3 reviews, 3 déploiements.
Comment ce concept aide — Nest monorepo (nest g app, nest g library) avec un dossier libs/ pour le domaine commun (legal-core, documents, signature) et apps/ pour les 3 produits. Chaque produit n'importe que les modules dont il a besoin. Le découpage par bounded context est respecté à la fois côté lib et côté app.
// libs/signature/src/signature.module.ts
@Module({})
export class SignatureModule {
static forRootAsync(opts: AsyncSignatureOptions): DynamicModule {
return {
module: SignatureModule,
imports: opts.imports,
providers: [
{ provide: 'SIGNATURE_OPTS', useFactory: opts.useFactory, inject: opts.inject },
{ provide: SignatureService, useClass: ConfigurableSignatureService },
],
exports: [SignatureService],
};
}
}
// apps/cabinet-portal/src/app.module.ts
@Module({
imports: [
SignatureModule.forRootAsync({
imports: [ConfigModule],
useFactory: (cfg) => ({ provider: 'yousign', apiKey: cfg.get('YOUSIGN_KEY') }),
inject: [ConfigService],
}),
MattersModule,
],
})
export class AppModule {}Gains chiffrés — Changement de provider de signature déployé en 1 PR sur 3 apps (vs 3 PRs avant), couverture de tests sur la lib signature à 95% (vs ~40% éparpillé), build CI passé de 14 min cumulés à 6 min (cache lib partagé).
Scénario 2 — SaaS immobilier (gestion locative)
Qui — Une PropTech FR (45 ETP) éditant un SaaS pour syndics et gestionnaires locatifs (≈ 90 000 lots gérés). 12 features modules : Lots, Tenants, Leases, Payments, Maintenance, Communications, Documents, Accounting, Reporting, Notifications, Auth, Admin.
Problème métier — L'app a grossi en lasagne (tous les services dans un même src/services/), avec des cycles de deps cachés. Le moindre changement déclenche 200 tests inutiles. L'équipe veut isoler chaque feature, mais sans tout casser.
Comment ce concept aide — Refacto vers une structure par feature module. Chaque feature expose son *.service.ts mais garde son repository et ses entités privés. CoreModule centralise Logger, Config, Database. Un SharedModule non-global expose pipes/guards/filters communs (@ApiPagination, OwnershipGuard).
// src/leases/leases.module.ts
@Module({
imports: [LotsModule, TenantsModule],
controllers: [LeasesController],
providers: [LeasesService, LeasesRepository, LeaseDomainService],
exports: [LeasesService],
})
export class LeasesModule {}LeasesRepository n'est pas exporté — il reste interne. Toute manipulation passe par LeasesService, ce qui rend le refacto DB possible sans toucher aux consumers.
Gains chiffrés — Tests par feature en parallèle, CI tombée de 22 à 8 min, 0 cycle de deps depuis l'introduction d'une règle eslint boundaries/no-circular-deps. 4 features ont pu être migrées vers une nouvelle DB sans toucher aux autres.
Scénario 3 — Industrie : superviseur de production (MES)
Qui — Une ETI FR (250 personnes) dans l'agroalimentaire pilotant 7 sites de production. Le MES (Manufacturing Execution System) ingère des données capteurs (OPC-UA), pilote des recettes, génère des rapports qualité, communique avec l'ERP SAP.
Problème métier — Chaque site a une configuration différente (capteurs, recettes, conformités). On ne peut pas embarquer tout en RAM partout : un MES de site avec uniquement les modules utiles. Le module ReportingModule (gros, lourd, utilisé 30 min par jour pour les exports SAP) ne doit pas bloquer le boot des sites en 24/7.
Comment ce concept aide — LazyModuleLoader pour charger ReportingModule à la demande lors de l'appel au cron de fin de quart. Configuration par site via forRootAsync lisant un fichier YAML local. ModuleRef pour résoudre dynamiquement la stratégie d'ingestion selon le type de capteur.
@Injectable()
export class ShiftEndJob {
constructor(private readonly lazy: LazyModuleLoader) {}
@Cron('0 5,13,21 * * *')
async runShiftEnd() {
const { ReportingModule } = await import('./reporting/reporting.module');
const moduleRef = await this.lazy.load(() => ReportingModule);
const svc = moduleRef.get(ReportingService);
await svc.exportShiftToSAP();
}
}Gains chiffrés — RAM au boot passée de 480 MB à 180 MB sur les sites (le module reporting et ses 12 deps Excel/PDF ne sont chargés que 3 fois/jour), temps de boot tombé de 14s à 4s (critique en cas de redémarrage après coupure), incidents de mémoire pleine sur sites distants éliminés.
🛠️ Exemple end-to-end
Use case — Plateforme SaaS de gestion locative immobilière. On expose un endpoint POST /v1/leases qui crée un bail. Le module LeasesModule orchestre LotsModule (vérif que le lot est libre), TenantsModule (vérif que le locataire existe), DocumentsModule (génération PDF du bail) — DocumentsModule est chargé en lazy car la lib PDF pèse 30 MB et n'est utilisée que 5% du temps.
// src/core/core.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { DatabaseModule } from './database/database.module';
@Global()
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: ['.env.local', '.env'] }),
LoggerModule.forRoot({ pinoHttp: { level: process.env.LOG_LEVEL ?? 'info' } }),
DatabaseModule,
],
exports: [ConfigModule, LoggerModule, DatabaseModule],
})
export class CoreModule {}// src/lots/lots.module.ts
import { Module } from '@nestjs/common';
import { LotsService } from './lots.service';
import { LotsRepository } from './lots.repository';
@Module({
providers: [LotsService, LotsRepository],
exports: [LotsService],
})
export class LotsModule {}
// src/tenants/tenants.module.ts
@Module({
providers: [TenantsService, TenantsRepository],
exports: [TenantsService],
})
export class TenantsModule {}Chaque feature module expose son service, garde son repo privé.
// src/documents/documents.module.ts (lazy-loaded)
import { Module } from '@nestjs/common';
import { PdfGeneratorService } from './pdf-generator.service';
import { S3UploaderService } from './s3-uploader.service';
import { DocumentsService } from './documents.service';
@Module({
providers: [PdfGeneratorService, S3UploaderService, DocumentsService],
exports: [DocumentsService],
})
export class DocumentsModule {}Ce module n'est volontairement pas dans imports de AppModule. Il sera chargé via LazyModuleLoader à la demande.
// src/leases/dto/create-lease.dto.ts
import { IsISO8601, IsInt, IsUUID, Min } from 'class-validator';
export class CreateLeaseDto {
@IsUUID() lotId!: string;
@IsUUID() tenantId!: string;
@IsISO8601() startDate!: string;
@IsInt() @Min(1) durationMonths!: number;
@IsInt() @Min(0) monthlyRent!: number;
}// src/leases/leases.service.ts
import { Injectable, BadRequestException, ConflictException } from '@nestjs/common';
import { LazyModuleLoader } from '@nestjs/core';
import { LotsService } from '../lots/lots.service';
import { TenantsService } from '../tenants/tenants.service';
import { LeasesRepository } from './leases.repository';
import { CreateLeaseDto } from './dto/create-lease.dto';
@Injectable()
export class LeasesService {
constructor(
private readonly lots: LotsService,
private readonly tenants: TenantsService,
private readonly repo: LeasesRepository,
private readonly lazy: LazyModuleLoader,
) {}
async create(dto: CreateLeaseDto) {
const lot = await this.lots.findById(dto.lotId);
if (!lot) throw new BadRequestException('lot_not_found');
if (lot.occupied) throw new ConflictException('lot_already_occupied');
const tenant = await this.tenants.findById(dto.tenantId);
if (!tenant) throw new BadRequestException('tenant_not_found');
const lease = await this.repo.create({
lotId: lot.id,
tenantId: tenant.id,
startDate: new Date(dto.startDate),
durationMonths: dto.durationMonths,
monthlyRent: dto.monthlyRent,
});
await this.lots.markOccupied(lot.id);
const pdfUrl = await this.generatePdfLazy(lease);
await this.repo.attachPdfUrl(lease.id, pdfUrl);
return { ...lease, pdfUrl };
}
private async generatePdfLazy(lease: any): Promise<string> {
const { DocumentsModule } = await import('../documents/documents.module');
const moduleRef = await this.lazy.load(() => DocumentsModule);
const docs = moduleRef.get((await import('../documents/documents.service')).DocumentsService);
return docs.generateLeasePdf(lease);
}
}Le service injecte LazyModuleLoader pour ne charger DocumentsModule (et la lib PDF de 30 MB) qu'au moment de la création du bail. Sur un endpoint de listing des baux, ce module n'est jamais chargé.
Précision senior sur le code ci-dessus — le double await import(...) (un pour le module, un pour récupérer le token DocumentsService) est volontaire : moduleRef.get(token) exige une référence de classe, et importer cette classe en haut du fichier réintroduirait le bundle eager qu'on cherche à éviter. Trois gardes-fous à connaître :
load()met en cache — le 2ᵉ appel ne recharge rien, il renvoie le mêmemoduleRef. C'est un singleton paresseux, pas un module éphémère. Si tu veux mesurer le gain (Exercice 4), le premier appel paie le coût d'import ; les suivants sont gratuits.- Latence du cold start — sur un endpoint chaud, ce premier
import()ajoute des dizaines/centaines de ms imprévisibles. Pour un module lourd sur un chemin critique, pré-charge-le enOnApplicationBootstrap(await this.lazy.load(...)au démarrage) : tu gardes le bénéfice RAM/tree-shaking du chunk séparé, mais tu sors le coût d'I/O du hot-path. - Pas de controllers en lazy — un
LazyModuleLoadern'enregistre pas les routes du module chargé (le routing est figé au boot). Le lazy loading sert aux providers (services, processors), jamais à exposer un controller HTTP à la demande.
// src/leases/leases.module.ts
import { Module } from '@nestjs/common';
import { LotsModule } from '../lots/lots.module';
import { TenantsModule } from '../tenants/tenants.module';
import { LeasesController } from './leases.controller';
import { LeasesService } from './leases.service';
import { LeasesRepository } from './leases.repository';
@Module({
imports: [LotsModule, TenantsModule],
controllers: [LeasesController],
providers: [LeasesService, LeasesRepository],
exports: [LeasesService],
})
export class LeasesModule {}LeasesModule consomme LotsModule et TenantsModule via imports. DocumentsModule est volontairement absent — il est chargé en lazy.
// src/leases/leases.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { LeasesService } from './leases.service';
import { CreateLeaseDto } from './dto/create-lease.dto';
@Controller({ path: 'leases', version: '1' })
export class LeasesController {
constructor(private readonly leases: LeasesService) {}
@Post()
create(@Body() dto: CreateLeaseDto) {
return this.leases.create(dto);
}
}// src/app.module.ts
import { Module } from '@nestjs/common';
import { CoreModule } from './core/core.module';
import { LotsModule } from './lots/lots.module';
import { TenantsModule } from './tenants/tenants.module';
import { LeasesModule } from './leases/leases.module';
@Module({
imports: [CoreModule, LotsModule, TenantsModule, LeasesModule],
})
export class AppModule {}Cet exemple démontre : (a) @Global() core, (b) feature modules isolés avec exports ciblés, (c) consommation inter-modules (LeasesModule importe LotsModule + TenantsModule), (d) LazyModuleLoader pour différer le chargement d'un module lourd. La RAM au boot reste minimale, l'app charge le PDF generator seulement quand on crée un bail.
🎤 En entretien
Q1 — « A importe B, B importe A : Nest crashe. Que fais-tu ? »
Réflexe junior :
forwardRef(() => B). Réponse senior : un cycle est un smell d'architecture, pas un problème de wiring. J'inverse la dépendance via un port (interface + token@Inject) posé dans le module bas, l'implémentation dans le module haut.forwardRefne reste qu'en dernier recours pour des cycles intra-module légitimes (ex. deux services qui se référencent), jamais comme solution d'archi.
Q2 — « Quelle différence entre @Global() et re-exporter un module ? »
@Global()rend lesexportsvisibles partout sans import, au prix de l'encapsulation — réservé àCoreModule(Logger/Config/DB), un seul par app. Re-exporter (exports: [SharedDbModule]) crée une dépendance transitive ciblée : seuls ceux qui importent mon module héritent du re-export. Le re-export est explicite et traçable dans le DAG ; le global est implicite et global. On préfère toujours l'explicite.
Q3 — « forRoot vs forFeature vs ConfigurableModuleBuilder ? »
forRootconfigure le module une fois (connexion, client global).forFeatureenregistre des ressources scopées par usage (repositories d'entités, queues nommées) et peut être appelé plusieurs fois sans écraser.ConfigurableModuleBuilder(Nest 9+) génère le boilerplateregister/registerAsync+ le token d'options — c'est ce que j'utilise pour toute nouvelle lib réutilisable plutôt que d'écrire les deux signatures à la main.
Q4 — « Tu exposes un agent IA en HTTP. Où vit le client Anthropic dans le graphe de modules, et pourquoi ? »
Dans un
AiModuledédié, en provider DI créé parforRootAsync(clé lue duConfigService), exporté via un token — jamaisnew Anthropic()dans un champ. Raisons : config centralisée et typée, mock trivial en test (overrideProvider), retries/timeouts du SDK partagés, et une seule frontière à auditer pour le coût et les secrets. Le modèle (claude-opus-4-8vsclaude-sonnet-4-6vsclaude-haiku-4-5) reste un paramètre runtime, pas une constante de module, parce que le choix coût/latence se fait par requête.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans l'ordre ; chacun s'appuie sur le précédent.
Exercice 1 — Dynamic module from scratch (implémenter)
Objectif : écrire un RateLimitModule.forRootAsync() qui injecte une config { windowMs, max } lue du ConfigService et expose un RateLimitService. Indice/Solution : @Module({}) vide + méthode statique retournant un DynamicModule avec un provider { provide: RL_OPTS, useFactory, inject } et RateLimitService qui lit RL_OPTS. Vérifie en test que moduleRef.get(RateLimitService) reçoit bien la config. Puis réécris-le avec ConfigurableModuleBuilder et compare le boilerplate supprimé.
Exercice 2 — Encapsulation stricte (implémenter)
Objectif : un UsersModule qui exporte UsersService mais garde UsersRepository privé ; un AuthModule qui injecte UsersService et doit échouer à la compilation Nest s'il tente d'injecter UsersRepository. Indice/Solution : ne mets pas UsersRepository dans exports. Écris un test qui importe seulement AuthModule et asserte l'erreur « Nest can't resolve dependencies » quand AuthService dépend du repo. C'est la frontière de confiance rendue exécutable.
Exercice 3 — Casser un cycle avec un port (casser puis réparer)
Objectif : crée volontairement LeasesModule ⇄ CommsModule (leases notifie comms, comms lit un lease), constate le crash, puis répare sans forwardRef. Indice/Solution : pose NOTIFIER (token + interface) dans leases/domain/ports, LeasesService dépend de l'abstraction, CommsModule fournit { provide: NOTIFIER, useClass: EmailNotifier } et l'exporte. Le DAG redevient acyclique. Documente pourquoi le port > forwardRef.
Exercice 4 — Lazy loading mesuré (prod-grade)
Objectif : mettre un ReportingModule (simule une dep lourde de 30 MB avec un gros import) en lazy via LazyModuleLoader, et prouver le gain RAM/boot avec process.memoryUsage() avant/après. Indice/Solution : compare le RSS au boot avec le module en imports (eager) vs chargé à la demande. Mesure aussi la latence du premier appel (cache froid) vs les suivants — démontre que load() met en cache. Conclusion à écrire : lazy hors hot-path uniquement.
Exercice 5 — AiModule + boucle agentique (prod-grade, stack)
Objectif : implémenter AiModule.forRootAsync() (client Anthropic DI'd) + un AgentModule qui orchestre une boucle tool-use avec un outil métier (getLeaseById exposé par LeasesModule), streamé en SSE, annulable via AbortController sur req.on('close'). Indice/Solution : réutilise les §1–3 de la section IA. Le test clé : déclenche le stream, ferme la connexion à mi-parcours, et asserte que signal.aborted === true et qu'aucun appel LLM supplémentaire n'est émis. Mock le client via overrideProvider(ANTHROPIC).
Exercice 6 — Casser un graphe avec un scope contagieux (casser puis réparer)
Objectif : rendre un provider exporté Scope.REQUEST, l'injecter dans un service supposé singleton, observer que tout le sous-graphe devient request-scoped (instancie un compteur dans le constructeur pour le prouver), puis réparer. Indice/Solution : le compteur s'incrémente à chaque requête → contagion. Répare en isolant le request-scoped derrière ModuleRef.resolve() (résolution à la demande) au lieu de l'injecter en constructeur, ou en remontant l'état request dans un CLS/AsyncLocalStorage. Écris pourquoi exporter un provider request-scoped est un piège silencieux.
🔁 Quand utiliser / éviter
| Pattern | Utiliser | Éviter |
|---|---|---|
| Feature module | Toujours (par défaut) | Jamais |
@Global() | Core (Logger, Config, DB) | Modules métier |
Dynamic forRoot | Lib réutilisable avec options | Module purement interne |
| Re-export | Module facade orchestrateur | Par paresse pour éviter un import explicite |
LazyModuleLoader | CLI rare, plugin, gros module peu utilisé | Hot path HTTP |
ModuleRef | Strategy pattern runtime | Cas où DI statique suffit |
🔗 Liens
- Modules : https://docs.nestjs.com/modules
- Dynamic modules : https://docs.nestjs.com/fundamentals/dynamic-modules
- ConfigurableModuleBuilder : https://docs.nestjs.com/fundamentals/dynamic-modules#configurable-module-builder
- Lazy modules : https://docs.nestjs.com/fundamentals/lazy-loading-modules
- ModuleRef : https://docs.nestjs.com/fundamentals/module-ref