🦅 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 :
- 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-expressou@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. - Une architecture modulaire où chaque
@Moduleest 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. - 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.
- 01 — Architecture overview
- 02 — Dependency Injection deep dive
- 03 — Modules (feature, dynamic, global)
- 04 — Controllers & routing
- 05 — Providers & services
- 06 — Request lifecycle (l'ordre exact)
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.
- 01 — Middleware
- 02 — Guards (auth, RBAC)
- 03 — Interceptors
- 04 — Pipes & validation
- 05 — Exception filters
- 06 — Custom decorators
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| Couche | Sait quoi ? | Rôle légitime | Anti-pattern fréquent |
|---|---|---|---|
| Middleware | req/res bruts | CORS, body parsing, helmet, request-id | Y mettre de l'auth métier (pas d'accès au ExecutionContext) |
| Guard | ExecutionContext + métadonnées | Autorisation (oui/non) | Y faire de la validation de payload |
| Interceptor | contexte + flux RxJS (Observable) | Transformer la réponse, timing, cache, retry | Décider de l'auth (trop tard, après le guard) |
| Pipe | la valeur d'un argument | Valider/transformer un input | Effets de bord (appel DB, mutation d'état) |
| Filter | l'exception levée | Mapper erreur → réponse HTTP propre | Masquer 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ère | Prisma | TypeORM | Drizzle (mention) |
|---|---|---|---|
| Type-safety | Excellente (client généré) | Moyenne (decorators, any qui fuit) | Excellente (SQL-first, inféré) |
| Migrations | Déclaratives, robustes | Impératives, pièges en prod | SQL généré, contrôle fin |
| Requêtes complexes | $queryRaw quand ça coince | QueryBuilder puissant | SQL natif, zéro magie |
| Transactions imbriquées | Interactive transactions | QueryRunner manuel | Callback-based |
| Maturité écosystème Nest | @nestjs/prisma-friendly | Intégration historique | Communautaire |
| Coût runtime | Engine binaire séparé | Pur JS | Pur 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.
- 01 — CQRS
- 02 — Microservices
- 03 — GraphQL
- 04 — WebSockets & SSE
- 05 — Queues (BullMQ)
- 06 — Scheduling & cron
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.
- 01 — Testing (unit, e2e)
- 02 — OpenAPI / Swagger
- 03 — Config & env
- 04 — Logging & observability
- 05 — Error handling
Niveau 6 — Production
Perf, sécurité, déploiement, monorepo, versions, scaling — le monde réel, avec ses 3h du matin et ses incidents.
- 01 — Performance
- 02 — Security
- 03 — Deployment (Docker)
- 04 — Monorepo (Nx)
- 05 — Versions 7 → 11
- 06 — Scaling patterns
Repères de versions (Nest 7 → 11)
| Version | Node minimum | Ce qui change et pourquoi ça t'impacte |
|---|---|---|
| Nest 7 | Node 10/12 | Base historique. @nestjs/platform-fastify se stabilise. |
| Nest 8 | Node 12+ | RxJS 7 ; les microservices gagnent en robustesse. Migration RxJS = le vrai coût. |
| Nest 9 | Node 12/14+ | Refonte du logging, lifecycle hooks plus prévisibles. |
| Nest 10 | Node 16+ | Passage à des deps modernes ; CacheModule sort vers @nestjs/cache-manager. SWC supporté → builds ultra-rapides. |
| Nest 11 | Node 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.
// 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,
};
}
}// 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.
// 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).
// 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
429si 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.