Skip to content

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 via imports. Trois variantes à maîtriser : module statique, module dynamique (forRoot), module global (@Global). ModuleRef permet la résolution runtime, LazyModuleLoader le 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

ts
// 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).

ts
@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.

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

ts
@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.

ts
@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.

ts
@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éthodeRetournePour quel scopeAsync ?Piège
moduleRef.get(Token)l'instance singleton déjà dans le containerScope.DEFAULT uniquementnonLè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 / REQUESTouiSans contextId partagé, deux resolve() d'un provider request renvoient deux instances
moduleRef.create(Class)une instance hors graphe, jamais enregistrée, jamais réutiliséeclasse arbitraire (même non-@Injectable)ouiToi seul tiens la référence → pas de cycle de vie géré, pas de onModuleDestroy
ts
// 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/forRootAsync dé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 les forRoot/forRootAsync boilerplate-free. ModuleRef.create permet d'instancier des classes hors graphe.
  • Nest 9 : ConfigurableModuleBuilder stabilisé — fortement recommandé pour les nouvelles libs. LazyModuleLoader apparaît en API.
  • Nest 10 : LazyModuleLoader stable. RouterModule.register([]) remplace l'ancien RouterModule.forRoutes() (déplacement du package). Améliorations DX sur les messages d'erreur de dep manquante (pointe le module fautif).
  • Nest 11 : ConfigurableModuleBuilder supporte mieux les options async typées strict. @Global() propage les exports plus prévisiblement quand mélangé avec dynamic modules.

ConfigurableModuleBuilder (Nest 9+)

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

ts
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

  1. 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).
  2. @Global() partout. Tu casses l'encapsulation. Si tu en mets 5, tu n'as plus de graphe — tout est globalement visible.
  3. Cycle entre modules. Si A importe B et B importe A, Nest crash sauf à utiliser forwardRef(() => OtherModule) dans imports. C'est un signal d'architecture cassée — extrais une lib commune.
  4. Module sans exports. Tu déclares un provider mais oublies exports. Au moment d'injecter ailleurs : "Nest can't resolve dependencies". Vérifie toujours.
  5. Mettre un controller dans exports. Inutile : les controllers ne s'injectent jamais. Seuls providers (et modules) s'exportent.
  6. Mélanger module statique et dynamique. Si MailerModule a un constructeur statique standard (sans forRoot) et qu'on essaie MailerModule.forRoot({...}), ça plante silencieusement (la méthode n'existe pas). Toujours documenter.
  7. LazyModuleLoader dans un controller standard. Charger un module en plein milieu d'un handler HTTP ajoute de la latence imprévisible. Préfère pré-charger en OnApplicationBootstrap si possible.
  8. Importer un module juste pour un type. L'import TS suffit pour les types. Le imports Nest n'est utile que pour l'injection.

🧪 Testing

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

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

AxeQuestionMauvais signal
CohésionCes 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
CouplageCombien de modules dois-je importer pour faire vivre celui-ci ?imports à 8+ entrées, ou des forwardRef
SurfaceCombien 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.

ts
// 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

  1. Le « god SharedModule » — un SharedModule qui 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).
  2. Le @Global() qui ment — un provider global change de comportement selon l'ordre d'enregistrement des modules dynamiques. L'ordre dans imports du root compte pour les globals : le dernier useValue sur un même token gagne.
  3. 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.
  4. Le request-scoped contagieux — si un provider Scope.REQUEST est 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.)
  5. Lazy module qui fuitLazyModuleLoader.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 de ModulesContainer (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

ts
// 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 {}
ts
// 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.

ts
// agent/agent.module.ts
@Module({
  imports: [AiModule, LeasesModule, LotsModule],   // les outils = services métier
  providers: [AgentService, ToolRegistry],
  controllers: [AgentController],
})
export class AgentModule {}
ts
// 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)

ts
// 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: 3 mais backoff exponentiel et un cost guard qui coupe si le cumul tokens dépasse un budget par tenant (mesuré sur usage.input_tokens/output_tokens renvoyé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.
ts
@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 McpModule qui 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.

ts
// 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).

ts
// 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 aideLazyModuleLoader 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.

ts
@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.

ts
// 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 {}
ts
// 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é.

ts
// 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.

ts
// 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;
}
ts
// 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 :

  1. load() met en cache — le 2ᵉ appel ne recharge rien, il renvoie le même moduleRef. 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.
  2. 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 en OnApplicationBootstrap (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.
  3. Pas de controllers en lazy — un LazyModuleLoader n'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.
ts
// 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.

ts
// 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);
  }
}
ts
// 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. forwardRef ne 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 les exports visibles 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 ? »

forRoot configure le module une fois (connexion, client global). forFeature enregistre 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 boilerplate register/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 AiModule dédié, en provider DI créé par forRootAsync (clé lue du ConfigService), exporté via un token — jamais new 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-8 vs claude-sonnet-4-6 vs claude-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

PatternUtiliserÉviter
Feature moduleToujours (par défaut)Jamais
@Global()Core (Logger, Config, DB)Modules métier
Dynamic forRootLib réutilisable avec optionsModule purement interne
Re-exportModule facade orchestrateurPar paresse pour éviter un import explicite
LazyModuleLoaderCLI rare, plugin, gros module peu utiliséHot path HTTP
ModuleRefStrategy pattern runtimeCas où DI statique suffit

🔗 Liens

Bibliothèque tech perso — Achref