Skip to content

🦅 NestJS — du débutant au senior

Parcours structuré pour maîtriser NestJS comme un architecte, de l'IoC container aux patterns avancés, avec les différences entre Nest 7 / 8 / 9 / 10 / 11 — et une couche dédiée à servir des agents IA depuis Nest (streaming LLM, boucle agentique, BullMQ, MCP).

Pour qui est ce parcours

Tu viens de PHP/TypeScript avec 7 ans d'XP. Tu connais déjà MVC, l'injection de dépendances « à la Symfony/Laravel », les ORMs, les queues. Ce parcours ne réexplique pas ce qu'est un service — il explique comment Nest le résout, pourquoi, et où ça casse en prod. On vise le niveau où tu peux défendre une décision d'architecture en design review, pas réciter la doc.


🧠 Le modèle mental : Nest n'est pas « Express en TypeScript »

C'est le malentendu n°1 des devs qui arrivent d'Express ou de Symfony. Nest n'est pas un framework HTTP. C'est un framework d'application construit autour de trois idées, dans cet ordre d'importance :

  1. Un IoC container (inversion de contrôle) qui résout un graphe de dépendances au démarrage. C'est le cœur. Express/Fastify ne sont qu'un adaptateur de transport branché dessus (@nestjs/platform-express ou @nestjs/platform-fastify). Le même code métier tourne au-dessus de HTTP, de WebSockets, de gRPC, de Kafka ou d'une queue — parce que le métier ne connaît pas le transport.
  2. Une architecture modulaire où chaque @Module est une frontière d'encapsulation : il déclare ce qu'il consomme (imports), ce qu'il fabrique (providers), ce qu'il expose au monde (exports) et ses points d'entrée HTTP (controllers). C'est le même principe qu'un bounded context DDD, appliqué à la résolution de dépendances.
  3. Une pile de cross-cutting concerns (middleware → guards → interceptors → pipes → filters) avec un ordre d'exécution déterministe. Connaître cet ordre par cœur, c'est la frontière junior/senior sur ce framework.
            ┌─────────────────────────────────────────────┐
            │  IoC container (résolu au bootstrap)         │
 @Module ──▶│  graphe de providers, scopes, lifecycle      │
            └───────────────────┬─────────────────────────┘
                                │ injecte

 HTTP / WS / gRPC / Microservice ─▶ Controller ─▶ Service ─▶ Repository
        (transport = adaptateur)      (I/O)        (métier)    (données)

La conséquence pratique : si tu raisonnes « requête → handler » (mental model Express), tu vas te battre contre Nest. Si tu raisonnes « graphe d'objets résolu une fois, requêtes qui le traversent », tout devient naturel — les scopes, les dynamic modules, le testing, le lifecycle. Garde cette image en tête sur tout le reste du parcours.


Niveau 1 — Foundations

L'architecture, le request lifecycle, les modules et la DI. Tout part de là — et 80 % des bugs d'archi viennent d'une DI mal comprise.

Le piège qui coûte le plus cher : les request-scoped providers

Par défaut un provider est singleton (Scope.DEFAULT) — instancié une fois, partagé partout. Le jour où tu mets un provider en Scope.REQUEST (pour avoir le user courant, un tenant, un trace-id…), toute la chaîne de dépendances qui le consomme devient request-scoped aussi (bubbling). Résultat : Nest réinstancie une partie du graphe à chaque requête → coût CPU/GC mesurable, et tu perds le cache de connexion qui vivait dans le singleton. Un senior préfère AsyncLocalStorage (le ClsModule de nestjs-cls) pour propager le contexte requête sans contaminer les scopes. C'est exactement le genre d'arbitrage qui distingue un mid d'un senior.


Niveau 2 — HTTP layer

Middleware, guards, interceptors, pipes, exception filters, custom decorators — la pile de cross-cutting concerns.

L'ordre d'exécution — à connaître par cœur

C'est la question de design review et d'entretien. Voici la séquence exacte d'une requête qui réussit :

Requête entrante


1. Middleware            (niveau Express/Fastify — pas d'accès au contexte Nest typé)

2. Guards               (CanActivate — auth / RBAC ; lèvent 403 ici, AVANT toute logique)

3. Interceptors (avant) (pre-controller : start timer, bind context)

4. Pipes                (validation + transformation des params/body — ValidationPipe)

5. ►► CONTROLLER HANDLER ◄◄   (ta logique)

6. Interceptors (après) (post-controller : map réponse, cache, logging de durée)

7. Exception filters    (UNIQUEMENT si une exception a été levée n'importe où au-dessus)

Réponse sortante
CoucheSait quoi ?Rôle légitimeAnti-pattern fréquent
Middlewarereq/res brutsCORS, body parsing, helmet, request-idY mettre de l'auth métier (pas d'accès au ExecutionContext)
GuardExecutionContext + métadonnéesAutorisation (oui/non)Y faire de la validation de payload
Interceptorcontexte + flux RxJS (Observable)Transformer la réponse, timing, cache, retryDécider de l'auth (trop tard, après le guard)
Pipela valeur d'un argumentValider/transformer un inputEffets de bord (appel DB, mutation d'état)
Filterl'exception levéeMapper erreur → réponse HTTP propreMasquer une erreur (avaler sans logger)

Pourquoi cet ordre est non-négociable : un guard rejette une requête non autorisée avant que le pipe ne dépense du CPU à valider un gros body, et avant que le handler ne touche la DB. Inverser guard et pipe (en mettant l'auth dans un pipe) ouvre une porte à du DoS sur la validation. C'est un raisonnement de défense en profondeur, pas juste de « propreté ».


Niveau 3 — Data layer

ORM, transactions, repository patterns, le piège classique d'unit of work — là où les fuites de connexions et les transactions fantômes tuent la prod.

Choisir son ORM en senior — la grille de décision
CritèrePrismaTypeORMDrizzle (mention)
Type-safetyExcellente (client généré)Moyenne (decorators, any qui fuit)Excellente (SQL-first, inféré)
MigrationsDéclaratives, robustesImpératives, pièges en prodSQL généré, contrôle fin
Requêtes complexes$queryRaw quand ça coinceQueryBuilder puissantSQL natif, zéro magie
Transactions imbriquéesInteractive transactionsQueryRunner manuelCallback-based
Maturité écosystème Nest@nestjs/prisma-friendlyIntégration historiqueCommunautaire
Coût runtimeEngine binaire séparéPur JSPur JS, léger

Raisonnement staff : Prisma pour la vélocité produit et la sûreté de typage ; TypeORM si tu hérites d'un legacy ou que tu as besoin du QueryBuilder ; Drizzle quand tu veux du SQL sans abstraction qui fuit. Le critère décisif n'est jamais « lequel est le plus populaire » — c'est « lequel rend mes transactions et mes migrations prévisibles sous charge ».

Le piège transactionnel à graver : une transaction doit englober une unité de travail métier complète, pas une requête. Si ton OrderService.create() insère une commande puis appelle InventoryService.decrement() dans deux transactions séparées, un crash entre les deux laisse un état incohérent (commande créée, stock pas décrémenté). La solution n'est pas « tout mettre dans un try/catch » — c'est de propager un EntityManager/Prisma.TransactionClient transactionnel à travers la couche métier (souvent via AsyncLocalStorage), pour que toutes les écritures partagent la même transaction. C'est l'Unit of Work, et c'est un sujet d'entretien senior garanti.


Niveau 4 — Advanced patterns

CQRS, microservices, GraphQL, WebSockets, queues, cron — les outils pour scaler au-delà du CRUD.


Niveau 5 — Quality

Testing, OpenAPI, config, logging, error handling — ce qui sépare un POC d'un service qu'on peut faire évoluer à 5 personnes pendant 3 ans.


Niveau 6 — Production

Perf, sécurité, déploiement, monorepo, versions, scaling — le monde réel, avec ses 3h du matin et ses incidents.

Repères de versions (Nest 7 → 11)

VersionNode minimumCe qui change et pourquoi ça t'impacte
Nest 7Node 10/12Base historique. @nestjs/platform-fastify se stabilise.
Nest 8Node 12+RxJS 7 ; les microservices gagnent en robustesse. Migration RxJS = le vrai coût.
Nest 9Node 12/14+Refonte du logging, lifecycle hooks plus prévisibles.
Nest 10Node 16+Passage à des deps modernes ; CacheModule sort vers @nestjs/cache-manager. SWC supporté → builds ultra-rapides.
Nest 11Node 18+ (idéalement 20/22)Express 5 par défaut (⚠️ breaking sur le routing wildcard), Fastify 5, logger amélioré. Vérifie tes routes * avant d'upgrader.

Raisonnement de migration : on ne saute jamais deux majeures d'un coup en prod. On lit le migration guide officiel, on isole les breaking changes (souvent : RxJS, le routing Express 5, les peer deps), on les couvre par des tests e2e avant de bumper, et on déploie derrière un flag/canary. La version de Node minimum est le premier filtre : Nest 11 sur du Node 16 ne démarre pas.


🤖 Servir & orchestrer des agents IA depuis NestJS

C'est ta stack (Python + NestJS + Angular, agents IA de bout en bout). Voici comment un staff engineer expose un LLM derrière Nest proprement — pas un new Anthropic() planqué dans un champ de service.

1. Le client LLM est un provider injecté, configuré via forRootAsync

L'anti-pattern : instancier le SDK dans chaque service. Le bon pattern : un module dynamique qui fournit un client unique, configuré depuis l'env, mockable en test.

ts
// llm.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ConfigModule, ConfigService } from '@nestjs/config';

export const LLM_CLIENT = Symbol('LLM_CLIENT');

@Module({})
export class LlmModule {
  static forRootAsync(): DynamicModule {
    return {
      module: LlmModule,
      imports: [ConfigModule],
      providers: [
        {
          provide: LLM_CLIENT,
          inject: [ConfigService],
          // Le SDK gère retries (429/5xx) + timeouts tout seul : on lui fait confiance.
          useFactory: (config: ConfigService) =>
            new Anthropic({
              apiKey: config.getOrThrow<string>('ANTHROPIC_API_KEY'),
              maxRetries: 3,
            }),
        },
      ],
      exports: [LLM_CLIENT],
      global: true,
    };
  }
}
ts
// chat.service.ts — on injecte le client, on ne le construit jamais ici
import { Inject, Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { LLM_CLIENT } from './llm.module';

@Injectable()
export class ChatService {
  constructor(@Inject(LLM_CLIENT) private readonly llm: Anthropic) {}

  async answer(prompt: string): Promise<string> {
    const res = await this.llm.messages.create({
      model: 'claude-opus-4-8', // flagship ; sonnet-4-6 pour le volume, haiku-4-5 pour le low-cost
      max_tokens: 1024,
      thinking: { type: 'adaptive' }, // adaptive = le modèle dose son raisonnement seul
      messages: [{ role: 'user', content: prompt }],
    });
    const block = res.content.find((b) => b.type === 'text');
    return block?.type === 'text' ? block.text : '';
  }
}

Pourquoi ça compte : le client injecté se mocke en une ligne dans Test.createTestingModule({...}).overrideProvider(LLM_CLIENT). Le new Anthropic() en dur, lui, t'oblige à monkey-patcher le module ou à taper le réseau en test. C'est de la pure mécanique DI — exactement le Niveau 1 appliqué.

Modèles Anthropic (repères 2026)

claude-opus-4-8 (flagship, raisonnement long-horizon), claude-sonnet-4-6 (meilleur rapport vitesse/intelligence), claude-haiku-4-5 (le plus rapide/économique). Toujours streamer dès que l'output peut être long (évite les timeouts HTTP du SDK), et laisser le SDK gérer les retries plutôt que de réimplémenter un backoff.

2. Streamer les tokens en SSE (avec annulation propre)

Un agent qui répond en 8 secondes sans rien afficher = UX morte. On stream les tokens. En Nest, SSE se fait avec @Sse() + un Observable, ou — pour garder le contrôle de l'AbortController côté serveur — en écrivant directement dans la Response.

ts
// chat.controller.ts
import { Controller, Post, Body, Res, Req } from '@nestjs/common';
import type { Response, Request } from 'express';
import { Inject } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { LLM_CLIENT } from './llm.module';

@Controller('chat')
export class ChatController {
  constructor(@Inject(LLM_CLIENT) private readonly llm: Anthropic) {}

  @Post('stream')
  async stream(@Body('prompt') prompt: string, @Req() req: Request, @Res() res: Response) {
    res.set({
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive',
    });

    // Annulation : si le client coupe la connexion, on abort l'appel LLM
    // (sinon on continue à payer des tokens pour une réponse que personne ne lit).
    const controller = new AbortController();
    req.on('close', () => controller.abort());

    const stream = this.llm.messages.stream(
      {
        model: 'claude-opus-4-8',
        max_tokens: 4096,
        messages: [{ role: 'user', content: prompt }],
      },
      { signal: controller.signal },
    );

    stream.on('text', (delta) => {
      res.write(`data: ${JSON.stringify({ delta })}\n\n`);
    });

    await stream.finalMessage();
    res.write('data: [DONE]\n\n');
    res.end();
  }
}

Le détail qui fait le senior : le req.on('close')controller.abort(). Sans lui, un user qui ferme son onglet laisse une requête LLM tourner jusqu'au bout — du coût pur, multiplié par tout ton trafic. L'annulation côté client doit se propager en annulation côté serveur. C'est la même discipline que pour fermer une connexion DB.

3. La boucle agentique (tool use) côté serveur

Un « agent » au sens fort = une boucle : le modèle demande un outil → tu l'exécutes → tu renvoies le résultat → il continue, jusqu'à stop_reason: 'end_turn'. Cette boucle vit côté serveur, dans un service Nest, où chaque tool est une méthode que tu contrôles (et que tu peux gater).

ts
// agent.service.ts — squelette de la boucle (vision d'ensemble)
async run(userInput: string) {
  const messages: Anthropic.MessageParam[] = [{ role: 'user', content: userInput }];
  for (let step = 0; step < 8; step++) {           // garde-fou anti-boucle-infinie
    const res = await this.llm.messages.create({
      model: 'claude-opus-4-8',
      max_tokens: 2048,
      tools: this.toolSchemas,                       // schémas JSON de tes outils
      messages,
    });
    messages.push({ role: 'assistant', content: res.content });
    if (res.stop_reason !== 'tool_use') break;       // terminé

    const results = [];
    for (const block of res.content) {
      if (block.type === 'tool_use') {
        const out = await this.execTool(block.name, block.input); // TES méthodes, gatées
        results.push({ type: 'tool_result', tool_use_id: block.id, content: out });
      }
    }
    messages.push({ role: 'user', content: results });
  }
  return messages;
}

Décisions d'architecte à prendre ici : (a) un garde-fou d'itérations (step < 8) pour qu'un modèle qui boucle ne ruine pas ta facture ; (b) valider les inputs de chaque tool avant exécution (un tool_use est un input non-fiable, comme un body HTTP — passe-le par une ValidationPipe mentale) ; (c) gater les tools à effet de bord (envoi d'email, écriture DB) derrière une confirmation ou un check de permission, exactement comme un Guard sur une route.

4. Les jobs IA longs vont dans BullMQ — pas dans le handler HTTP

Une génération de 90 secondes ne tient pas dans une requête HTTP synchrone. On enqueue : le handler retourne un generationId immédiatement, un worker BullMQ fait le travail, le client suit via SSE/WebSocket. Les invariants d'un job IA en prod :

  • Idempotence keyée sur le generationId : si BullMQ rejoue le job (retry), il ne doit pas régénérer/refacturer deux fois. On vérifie en début de job « est-ce déjà fait ? ».
  • Retry conscient du coût : un échec réseau → on retry ; une réponse partielle déjà facturée → on ne rejoue pas l'appel LLM, on reprend ce qui existe. Tous les échecs ne sont pas égaux.
  • Gestion de l'output partiel : si le stream a produit 80 % puis a coupé, on persiste le partiel et on reprend, plutôt que de jeter le travail (et l'argent) déjà fait.

5. À la frontière : idempotency, rate-limit, cost-guard, et un endpoint MCP

Avant même d'atteindre le LLM, l'edge de ton API Nest doit porter :

  • Idempotency-Key (un Interceptor qui dédupe les POST rejoués) ;
  • Rate-limit par user/tenant (@nestjs/throttler) — sinon un client peut vider ton budget tokens ;
  • Cost-guard : un Guard qui rejette en 429 si le tenant a dépassé son quota de tokens du mois ;
  • Endpoint MCP/agent : exposer tes capacités métier comme outils consommables par un agent, avec auth et audit.

Chacun de ces points réutilise une couche du Niveau 2 (Interceptor, Guard) — la pile cross-cutting de Nest est exactement l'outil pour mettre ces garde-fous au bon endroit. C'est là que le parcours « foundations » paie : tu ne réinventes rien, tu places les bons décorateurs.


🏋️ Exercices

Progression : implémenter → rendre production-grade → casser puis réparer. Ne saute pas les niveaux — chacun suppose le précédent acquis.

Exercice 1 — Le request lifecycle, prouvé (foundations)

Objectif : rendre l'ordre d'exécution observable, pas théorique. Construis une route protégée par un Guard, validée par un ValidationPipe (DTO class-validator), enveloppée d'un LoggingInterceptor qui mesure la durée, et un ExceptionFilter global. Loggue une ligne à chaque couche. Indice/Solution : envoie (a) une requête non autorisée → tu dois voir le log du guard et rien d'autre ; (b) un body invalide avec auth OK → log guard, puis log pipe (rejet), jamais le handler ; (c) une requête nominale → guard → interceptor(in) → handler → interceptor(out). Si ton pipe loggue avant ton guard, ta config est inversée — c'est le bug à diagnostiquer.

Exercice 2 — Tuer un request-scoped provider qui contamine le graphe

Objectif : mesurer le coût réel du scope REQUEST et le supprimer sans perdre le contexte. Crée un CurrentUserProvider en Scope.REQUEST injecté dans 4 services en cascade. Mets un console.count('instanciation') dans chacun. Tape la route 1000 fois et mesure le nombre d'instanciations + la latence p95. Indice/Solution : tu verras le graphe se réinstancier à chaque requête. Refactore avec nestjs-cls (AsyncLocalStorage) : les services redeviennent singletons, le contexte requête se propage quand même. Compare les deux p95 — c'est ton argument de design review.

Exercice 3 — Streamer un LLM en SSE avec annulation serveur (IA, hard)

Objectif : un endpoint de chat qui stream, et qui arrête de facturer dès que le client part. Implémente le @Post('stream') de la section IA. Puis ouvre la connexion avec curl -N, et coupe-la (Ctrl-C) au milieu de la réponse. Indice/Solution : sans le req.on('close') → controller.abort(), tu verras (via les logs ou usage) que les tokens continuent d'être consommés après la coupure. Ajoute l'abort, refais le test : l'appel LLM doit s'arrêter immédiatement. Bonus : mesure les tokens économisés sur 100 coupures simulées.

Exercice 4 — La boucle agentique avec garde-fous (IA, hard)

Objectif : une boucle tool-use qui ne peut ni boucler à l'infini, ni exécuter un tool non validé. Construis un agent avec deux tools : get_weather(city) (lecture, libre) et send_email(to, body) (effet de bord, gaté). Mets un garde-fou d'itérations. Indice/Solution : valide block.input contre un schéma avant execTool (un tool_use est un input non-fiable). Gate send_email derrière un check de permission — refuse-le et renvoie un tool_result avec is_error: true expliquant pourquoi ; le modèle doit s'adapter. Teste avec un prompt qui pousse l'agent à spammer des emails : ton gate doit tenir.

Exercice 5 — Job IA idempotent et cost-aware dans BullMQ (production-grade)

Objectif : un job de génération qui survit aux retries sans double facturation. Endpoint POST /generate → retourne un generationId, enqueue un job BullMQ. Le worker appelle le LLM et persiste le résultat indexé par generationId. Indice/Solution : en tête de job, vérifie « generationId déjà complété ? » → si oui, return sans appeler le LLM. Configure un retry BullMQ, puis force un crash après l'appel LLM mais avant la persistance : au replay, le job ne doit pas re-générer (donc : persiste un statut in_progress/done atomiquement, ou rends l'appel reprenable). Distingue dans ton code un échec réseau (rejouable) d'une réponse partielle déjà facturée (à reprendre, pas à rejouer).

Exercice 6 — Casser puis réparer : la transaction fantôme (data layer, hard)

Objectif : reproduire une incohérence transactionnelle, puis la corriger avec un vrai Unit of Work. Écris OrderService.create() qui insère une commande puis décrémente le stock dans deux transactions séparées. Injecte un crash entre les deux. Indice/Solution : observe l'état corrompu (commande sans décrément de stock). Répare en propageant un EntityManager/Prisma.TransactionClient transactionnel à travers les deux écritures (via AsyncLocalStorage/nestjs-cls ou un décorateur @Transactional). Reproduis le crash : cette fois la transaction roll-back tout, l'état reste cohérent. C'est l'exo qui fait comprendre pourquoi « une transaction = une unité de travail métier ».


🎤 En entretien

Questions réellement posées en design review / entretien senior Nest, avec la réponse d'une ligne attendue.

« Pourquoi un provider en Scope.REQUEST peut dégrader les perfs de tout un module ? » Parce que le scope remonte (bubbling) : tout provider qui en dépend devient request-scoped et se réinstancie à chaque requête, ce qui invalide les singletons (pools, caches) et ajoute du coût CPU/GC — préférer AsyncLocalStorage pour le contexte requête.

« Donne l'ordre exact : middleware, guard, interceptor, pipe, filter — et justifie. » Middleware → guard → interceptor(pre) → pipe → handler → interceptor(post) → filter ; l'auth (guard) passe avant la validation (pipe) pour rejeter les non-autorisés avant de dépenser du CPU à valider, et le filter ne s'exécute que sur exception.

« Comment tu sers un LLM qui répond en 60 s sans bloquer ton API ni exploser ta facture ? » Job asynchrone (BullMQ) keyé sur un generationId idempotent, streaming des tokens vers le client en SSE/WebSocket, AbortController propagé du client au serveur pour stopper la génération si le client part, et un cost-guard au niveau de l'edge.

« forRootAsync vs un simple useValue pour ton client LLM — quand et pourquoi ? »forRootAsync quand la config dépend d'autres providers (ex. ConfigService qui lit l'env de façon validée) et qu'on veut un module global, mockable et réutilisable ; useValue ne suffit que pour une valeur statique connue au build, ce qui n'est jamais le cas d'un client qui a besoin d'une clé d'API résolue à l'exécution.

Bibliothèque tech perso — Achref