NestJS — Architecture overview
TL;DR — NestJS est un framework Node opiniâtre qui empile une couche IoC/DI au-dessus d'Express (par défaut) ou Fastify. Trois primitives suffisent :
Module(graphe de dépendances),Provider(unité injectable),Controller(adaptateur HTTP). Le tout s'inspire d'Angular côté front et de Spring Boot côté back. Il "aide" sur les apps de moyenne/grosse taille avec plusieurs domaines ; il "fait mal" sur les micro-scripts CRUD jetables.
🧠 Mental model
Pense Nest comme un conteneur Spring pour Node qui parle HTTP via Express. Ton code décrit un graphe (modules importent des modules, modules déclarent des providers, providers s'injectent entre eux) et Nest se charge du câblage au démarrage.
┌──────────────────────────────────────────────┐
│ NestApplication │
│ ┌────────────────────────────────────────┐ │
│ │ IoC container │ │
│ │ (resolves provider graph at boot) │ │
│ └────────────────────────────────────────┘ │
│ │
HTTP req ──► │ HttpAdapter (Express | Fastify) │
│ │ │
│ ▼ │
│ Router ──► Middleware ──► Guards ──► │
│ Interceptors(pre) ──► Pipes ──► Handler ──► │
│ Interceptors(post) ──► Filters ──► Response │
└──────────────────────────────────────────────┘Analogie : un controller est un adaptateur (port HTTP), un service est de la logique métier pure, un module est un package logique qui choisit ce qui est exposé et ce qui reste privé. Le RootModule est la racine du graphe ; tout le reste est atteint par imports.
Nest n'invente rien sur le réseau : il délègue à @nestjs/platform-express (par défaut) ou @nestjs/platform-fastify. La valeur ajoutée est ailleurs : structure, DI, métadonnées via décorateurs, et un noyau pour brancher pipes/guards/interceptors/filters de façon transversale.
Le modèle mental qui compte vraiment : les 3 scopes de provider
Si tu ne retiens qu'une chose de l'IoC de Nest, c'est ceci : un provider a un cycle de vie (scope), et 90% des bugs subtils en prod viennent d'une mauvaise compréhension de ce cycle. Trois scopes :
| Scope | Instanciation | Coût | Quand l'utiliser |
|---|---|---|---|
DEFAULT (singleton) | 1 instance pour toute l'app, créée au boot | Zéro à chaud | Par défaut. 99% des providers : services métier, repos, clients HTTP/DB. Doit être stateless par requête. |
REQUEST | 1 instance par requête HTTP entrante | Re-instanciation + parcours du sous-graphe à chaque requête | Quand tu as vraiment besoin d'état lié à la requête injecté par DI (rare). Contamine : tout provider qui injecte un REQUEST devient REQUEST lui aussi (propagation virale vers le haut). |
TRANSIENT | 1 instance par injection (chaque consommateur a la sienne) | Multiplication des instances | Providers à état local non partagé (ex. un builder, un logger préfixé par classe). |
Le piège canonique : un @Injectable() singleton avec un champ private currentUser qu'on set au début du handler. En mono-utilisateur ça marche ; sous concurrence, la requête B écrase le currentUser de la requête A entre deux await. Le réflexe junior est de passer le provider en Scope.REQUEST. Le réflexe senior est AsyncLocalStorage (cf. pitfalls + entretien) : tu gardes le singleton, zéro coût de re-câblage, et l'état vit dans un store par requête porté par le runtime, pas dans un champ d'instance. En Nest 11, injecter un REQUEST dans un DEFAULT lève désormais un warning explicite — c'est le framework qui te dit « tu es en train de rendre viral un scope coûteux ».
Comment résout-il le graphe ? Au boot, Nest fait un tri topologique des dépendances : il instancie d'abord les feuilles (providers sans dépendances), puis remonte. Une dépendance circulaire (A injecte B, B injecte A) casse ce tri — d'où forwardRef() (un band-aid, pas une solution : une dépendance circulaire est presque toujours le signe d'une frontière de module mal placée).
🛠️ Code minimal
// src/cats/cats.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class CatsService {
private cats: string[] = [];
findAll() { return this.cats; }
add(name: string) { this.cats.push(name); return name; }
}
// src/cats/cats.controller.ts
import { Body, Controller, Get, Post } from '@nestjs/common';
import { CatsService } from './cats.service';
@Controller('cats')
export class CatsController {
constructor(private readonly cats: CatsService) {}
@Get()
list() { return this.cats.findAll(); }
@Post()
create(@Body('name') name: string) { return this.cats.add(name); }
}
// src/cats/cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService], // visible par les modules qui importent CatsModule
})
export class CatsModule {}
// src/app.module.ts
import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';
@Module({ imports: [CatsModule] })
export class AppModule {}
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();⚖️ Express / Fastify / Spring Boot — comparaison directe
vs Express vanilla : Express est une lib non opinionée — tu choisis ta structure, ton DI (souvent none), ta validation. Sur 5 endpoints c'est libérateur ; sur 50 c'est chaotique. Nest fournit la structure mais te laisse l'HttpAdapter Express en dessous — tu peux toujours app.use(...) un middleware Express, accéder à req/res natifs, ou utiliser n'importe quel package npm Express. Coût : ~5-10% d'overhead par requête (decorators + DI resolution per request si REQUEST scope).
vs Fastify : Plus rapide (~2x sur JSON pur) grâce au schema-based serialization et au routing optimisé. Nest le supporte via @nestjs/platform-fastify. Trade-offs : (a) certains middlewares Express ne marchent pas, (b) l'écosystème de plugins Fastify est plus restreint, (c) la sémantique des hooks diffère. Choisis Fastify si tu as une charge prouvée > 5k RPS sur un service simple ; sinon Express suffit.
vs Spring Boot : Spring Boot est l'inspiration directe. Équivalences :
@Module↔@Configuration+@ComponentScan@Injectable↔@Component/@Service@Controller↔@RestControllerforRoot/forRootAsync↔@EnableXxx+@ConfigurationProperties- Interceptors ↔ Spring AOP
@Around - Guards ↔ Spring Security
Filter/AccessDecisionManager - Pipes ↔ Spring
@Valid+Converter - Exception filters ↔
@ControllerAdvice+@ExceptionHandler
Différences clés : Nest n'a pas l'équivalent d'@Transactional natif (il faut un interceptor + ALS), pas de classpath scanning (tout est explicite dans imports/providers), et pas de typage runtime des génériques (Java a Class<T>, TS perd les types à l'exécution). En contrepartie : démarrage en ~500ms vs 3-5s, hot-reload instantané, et écosystème npm complet.
🎯 Patterns courants
1. Layered architecture (Controller → Service → Repository). Le controller ne fait que parser/valider l'entrée, déléguer au service, et formater la sortie. Le service contient la règle métier. Le repository encapsule l'accès aux données. Cette séparation rend chaque couche testable isolément.
2. Module par bounded context. Un module ≠ un fichier ; c'est une frontière. BillingModule, AuthModule, CatalogModule. Tout ce qui n'est pas dans exports reste privé au module. C'est l'équivalent du package-private Java.
3. CoreModule + SharedModule. CoreModule (singleton, importé une seule fois dans AppModule) pour Logger, ConfigService, DatabaseModule. SharedModule pour les pipes/filters/utilities communs, importable partout. Évite de répandre des forRoot() dans la base de code.
@Global()
@Module({
providers: [Logger, ConfigService],
exports: [Logger, ConfigService],
})
export class CoreModule {}4. Adapter pattern via providers. Tu peux fournir une interface (token) et choisir l'implémentation au démarrage. C'est la clé pour le mock de tests et le swap d'infra (Redis vs in-memory).
5. Cross-cutting via decorators. Logging, auth, transactions : on les place en interceptor/guard plutôt que de polluer chaque controller. C'est l'AOP du pauvre, mais ça marche bien.
6. Hexagonal-friendly. Nest n'impose pas l'architecture hexagonale mais s'y prête : tu mets tes ports/use-cases dans un module domain, tes adapters dans des modules d'infra, et tu wires via DI.
🧩 Ce que Nest ajoute concrètement par-dessus Node
- IoC container avec résolution topologique au boot — graphe vérifié, erreurs claires quand une dep manque.
- Métadonnées via décorateurs —
Reflect.metadatapour brancher pipes/guards/interceptors sur méthodes sans toucher au code. - Pipeline standardisé (cf. fichier 06) — chaque requête traverse la même chaîne, ce qui rend les features cross-cutting (logging, auth, transactions) déclaratives.
- Lifecycle hooks (
OnModuleInit,OnApplicationShutdown) avec graceful shutdown intégré (app.enableShutdownHooks()). - Multi-transport unifié : un même service peut être exposé en HTTP REST, GraphQL, gRPC, WebSocket, microservice (Kafka/RMQ/Redis pub-sub) sans changer la logique métier. Tu changes juste le module / l'adapter.
- Testing module — un mini-container dédié aux tests, avec override fluent (
overrideProvider,overrideGuard). - CLI (
@nestjs/cli) — scaffolding cohérent, monorepo natif (nest g app,nest g library), build optimisé. - Compatible CommonJS et ESM, mais préfère CJS pour la simplicité des décorateurs et de
tsconfig(Nest 11 supporte mieux ESM mais des libs vendor restent CJS-only).
🚦 Quand Nest aide / quand il fait mal
Aide quand :
- Plusieurs domaines métier, ≥ 5 modules logiques.
- Équipe avec rotation/onboarding fréquent — la convention rend le code prévisible.
- Mix de transports (HTTP + queue + WS) dans la même app.
- Besoin de tests d'intégration solides avec mocks par couche.
- Migration progressive d'un Express historique (tu peux monter Nest en sous-route).
Fait mal quand :
- Lambda/edge function unique (< 100 lignes utiles) — le coût boot (~300-500ms cold) tue la latence.
- Prototype 48h jetable — le boilerplate (
module/service/controller) ralentit. - App temps-réel ultra-low-latency où chaque ms compte (préfère uWebSockets.js direct).
- Tu veux du Node idiomatique (callbacks, streams, fonctions pures) — Nest pousse vers l'OOP/decorators, ce qui peut grincer.
- Tu vises une grosse compatibilité runtime exotique (Bun, Deno, Cloudflare Workers) — Nest cible Node, marche partiellement ailleurs.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- Nest 7 (2020) : RxJS 6, Node 10+. Premier vrai support stable de Fastify v2.
@nestjs/platform-fastifyencore en rattrapage côté hooks. - Nest 8 (2021) : RxJS 7, Node 10+ toujours.
Loggerrendu injectable proprement,LoggerServiceinterface stabilisée. Apparition deModule.forRootAsyncmieux typé. - Nest 9 (2022) : Node 12 minimum. Refonte du
Reflector(reflector.getAllAndOverride),versioningintégré dans le core. Drop du support legacyexpress@4ancien. - Nest 10 (2023) : Node 16+. Lazy loading modules stabilisé via
LazyModuleLoader.RouterModule(sous@nestjs/core) remplace@nestjs/core/router. Suppression deHttpModuledu core (déplacé dans@nestjs/axios). Améliorations gros breaking côtéConfigModule(typed config). - Nest 11 (2024+) : Node 20+ requis. Fastify v5 par défaut côté
platform-fastify. Logger amélioré (couleurs, JSON natif viaBufferLogger). DI plus stricte sur les scopes mixés (REQUESTqui contamineDEFAULTlève désormais un warning explicite). Express v5 supporté.
Conséquences pratiques : si tu migres 9→11, surveille (a) les imports HttpModule (@nestjs/axios), (b) RouterModule (forme register([]) vs ancienne), (c) Fastify v5 qui change la signature de plusieurs hooks.
⚠️ Pitfalls
- Confondre singleton et stateless. Par défaut, providers = singletons. Une variable d'instance partagée devient bug multi-tenant. Solution : push state au request scope (avec parcimonie) ou utilise
AsyncLocalStorage. - Importer le même module partout. Préfère
@Global()pourConfig/Logger, sinon tu finis avec desimports: [...]énormes. Mais n'abuse pas du@Global: ça casse la frontière modulaire. - Logique métier dans le controller. Le controller est jetable (peut être remplacé par un GraphQL resolver, un message handler RMQ, etc.). Si la logique y vit, tu ne peux plus la réutiliser.
- Re-exporter sans réfléchir. Re-exporter un module à travers un autre crée des dépendances transitives invisibles. Documente.
- Choisir Fastify sans benchmarker. Fastify est plus rapide, mais beaucoup de middlewares Express ne marchent pas tels quels. Si tu n'as pas un goulot prouvé sur la couche HTTP, reste sur Express.
- Trop d'AOP (interceptors partout). Chaque interceptor global ajoute du coût. 5 interceptors globaux = +5 await par requête. Garde-les ciblés.
- Vouloir absolument du DDD pur. Nest est compatible mais pas conçu pour. Le couple
controller → service → repoest largement suffisant pour 80% des apps. - Confondre
providersetimports.providersdéclare ;importsconsomme. Mettre un service dansimportsne fait rien (et inversement).
🏛️ Anatomie d'une app de prod
Un layout typique pour une app de taille moyenne (10-30 modules) :
src/
main.ts ← bootstrap : pipes globaux, CORS, helmet, swagger
app.module.ts ← racine du graphe : imports tous les feature modules
core/ ← @Global() : Logger, Config, DB connection, Auth
core.module.ts
config/
logger/
database/
shared/ ← réutilisables (non-global), importés à la demande
pipes/
guards/
interceptors/
filters/
modules/
users/
users.module.ts
users.controller.ts
users.service.ts
users.repository.ts
dto/
entities/
billing/
catalog/
main-microservice.ts ← bootstrap alternatif (worker Kafka, par ex.)Le main.ts typique :
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
app.enableCors({ origin: process.env.CORS_ORIGIN });
app.use(helmet());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.enableShutdownHooks(); // graceful shutdown
app.enableVersioning({ type: VersioningType.URI });
await app.listen(process.env.PORT ?? 3000);
}🧪 Testing
import { Test, TestingModule } from '@nestjs/testing';
import { CatsService } from './cats.service';
import { CatsController } from './cats.controller';
describe('CatsController', () => {
let controller: CatsController;
let service: CatsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CatsController],
providers: [
{
provide: CatsService,
useValue: { findAll: jest.fn().mockReturnValue(['Mia']), add: jest.fn() },
},
],
}).compile();
controller = module.get(CatsController);
service = module.get(CatsService);
});
it('lists cats', () => {
expect(controller.list()).toEqual(['Mia']);
expect(service.findAll).toHaveBeenCalled();
});
});Le TestingModule est un mini-IoC : tu peux overrideProvider, overrideGuard, overrideInterceptor, overrideFilter. C'est la fondation de tous les tests d'intégration Nest.
🎬 Cas d'usage concrets
Scénario 1 — SaaS B2B LegalTech (gestion de dossiers d'avocats)
Qui — Une scale-up FR de 35 personnes éditant une plateforme de gestion de dossiers pour cabinets d'avocats (≈ 800 cabinets clients, 12k utilisateurs actifs).
Problème métier — L'app historique en Express monolithique a dérivé : 4 modules métier mélangés (dossiers, facturation, time-tracking, signature électronique), pas d'isolation, chaque équipe casse celle d'à côté. L'onboarding d'un nouveau dev prend 3 semaines avant de pondre une PR.
Comment Nest aide — Structurer chaque bounded context en module (MattersModule, BillingModule, TimeTrackingModule, ESignatureModule), thin controllers, services testables, DI pour swap les adaptateurs (DocuSign vs Yousign selon le client). Le CoreModule global centralise Logger (Pino JSON pour Datadog), ConfigService (typed), DatabaseModule (Prisma).
@Module({
imports: [
CoreModule,
MattersModule,
BillingModule,
TimeTrackingModule,
ESignatureModule.forRootAsync({
imports: [ConfigModule],
useFactory: (cfg: ConfigService) => ({
provider: cfg.get('ESIGN_PROVIDER'), // 'docusign' | 'yousign'
apiKey: cfg.get('ESIGN_API_KEY'),
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}Gains chiffrés — Onboarding tombé à 4 jours, temps de build CI passé de 18 à 6 min (modules indépendamment testables en parallèle), incidents prod -60% (frontières claires entre features).
Scénario 2 — FinTech KYC API (vérification d'identité B2B2C)
Qui — Une fintech parisienne (110 ETP, agrément ACPR EME) qui expose une API KYC à des néobanques et néo-courtiers. ≈ 4 200 RPS en pointe, SLO 99.95%.
Problème métier — L'API doit orchestrer 4 fournisseurs externes (OCR, anti-fraude, AML, biométrie), avec failover, retry contrôlé, idempotency stricte, audit complet pour l'ACPR. Une stack Express + handlers manuels devenait insoutenable : chaque endpoint répétait try/catch + audit + correlation-id + circuit-breaker.
Comment Nest aide — Le pipeline standardisé (Guard auth API key, Interceptors timing + tracing OTel + idempotency Redis, Filter mapping erreurs domain → Problem Details RFC 9457) factorise les concerns. Adapter pattern pour les fournisseurs externes (port IdentityProvider + 4 implémentations) injectables au boot.
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TerminusModule,
ThrottlerModule.forRootAsync({
useFactory: (cfg: ConfigService) => ({
throttlers: [{ ttl: 60_000, limit: cfg.get<number>('RATE_LIMIT') }],
storage: new ThrottlerStorageRedisService(cfg.get('REDIS_URL')),
}),
inject: [ConfigService],
}),
KycModule,
],
providers: [
{ provide: APP_GUARD, useClass: ApiKeyGuard },
{ provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
{ provide: APP_INTERCEPTOR, useClass: TracingInterceptor },
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
],
})
export class AppModule {}Gains chiffrés — Latence p99 stable à 280ms malgré 4 appels externes (grâce aux circuit-breakers + cache), 0 fuite de PII en log (interceptor de redaction), audit ACPR validé en 1 itération (vs 3 sur l'ancienne stack).
Scénario 3 — Orchestrateur e-commerce multi-marketplace
Qui — Une marque DTC FR (mode, 22M€ CA) qui vend sur son site Shopify + Amazon + Cdiscount + Veepee + 8 retailers. Stack interne : un orchestrateur de commandes (sync stock, dispatch fulfillment, retours).
Problème métier — 18 connecteurs, chacun avec son protocole (REST, SFTP CSV, EDI XML, webhook Shopify). Le code monolithique mélange transport et domain ; ajouter un nouveau marketplace prend 3 semaines.
Comment Nest aide — Un OrdersModule (domain pur) + un module par connecteur (ShopifyModule, AmazonModule, …), chacun expose une implémentation du port MarketplaceAdapter. Le multi-transport Nest (HTTP REST + microservice RabbitMQ pour les events internes + cron pour les pulls SFTP) tourne dans la même app.
export const MARKETPLACE_ADAPTERS = Symbol('MARKETPLACE_ADAPTERS');
@Module({
imports: [ShopifyModule, AmazonModule, CdiscountModule, VeepeeModule],
providers: [
{
provide: MARKETPLACE_ADAPTERS,
useFactory: (s, a, c, v) => new Map([['shopify', s], ['amazon', a], ['cdiscount', c], ['veepee', v]]),
inject: [ShopifyAdapter, AmazonAdapter, CdiscountAdapter, VeepeeAdapter],
},
OrderDispatcher,
],
exports: [OrderDispatcher],
})
export class OrchestrationModule {}Gains chiffrés — Ajout d'un nouveau marketplace en 4 jours vs 3 semaines, couverture de tests passée de 22% à 78% (chaque adapter testable isolément), incidents de désynchro stock divisés par 5.
🛠️ Exemple end-to-end
Use case — On bâtit un service KYC pour une néo-courtier FR. Endpoint POST /v1/kyc/verifications qui :
- Authentifie via API key (Guard).
- Garantit l'idempotency via
Idempotency-Key(Interceptor). - Valide le payload (Pipe + DTO).
- Orchestre OCR + anti-fraude + AML.
- Persiste un audit log immuable.
- Mappe les erreurs domain en Problem Details JSON.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { ProblemDetailsFilter } from './shared/filters/problem-details.filter';
async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.use(helmet());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
app.useGlobalFilters(new ProblemDetailsFilter());
app.enableVersioning({ type: VersioningType.URI });
app.enableShutdownHooks();
await app.listen(3000);
}
bootstrap();// src/kyc/kyc.module.ts
import { Module } from '@nestjs/common';
import { KycController } from './kyc.controller';
import { KycService } from './kyc.service';
import { OcrProviderAdapter } from './adapters/ocr.adapter';
import { AmlProviderAdapter } from './adapters/aml.adapter';
import { FraudProviderAdapter } from './adapters/fraud.adapter';
import { KycRepository } from './kyc.repository';
import { AuditModule } from '../audit/audit.module';
export const OCR = Symbol('OCR');
export const AML = Symbol('AML');
export const FRAUD = Symbol('FRAUD');
@Module({
imports: [AuditModule],
controllers: [KycController],
providers: [
KycService,
KycRepository,
{ provide: OCR, useClass: OcrProviderAdapter },
{ provide: AML, useClass: AmlProviderAdapter },
{ provide: FRAUD, useClass: FraudProviderAdapter },
],
})
export class KycModule {}Le module déclare 3 ports (OCR/AML/FRAUD) en Symbol. Les adapters réels appellent Onfido/ComplyAdvantage/Sift ; en test on injecte des fakes via overrideProvider.
// src/kyc/dto/create-verification.dto.ts
import { IsEmail, IsISO31661Alpha2, IsString, Length } from 'class-validator';
export class CreateVerificationDto {
@IsString() @Length(2, 80)
firstName!: string;
@IsString() @Length(2, 80)
lastName!: string;
@IsEmail()
email!: string;
@IsISO31661Alpha2()
countryCode!: string;
@IsString()
documentBase64!: string;
}// src/kyc/kyc.controller.ts
import { Body, Controller, Post, Headers, HttpCode } from '@nestjs/common';
import { CreateVerificationDto } from './dto/create-verification.dto';
import { KycService } from './kyc.service';
import { ApiKey } from '../shared/decorators/api-key.decorator';
@Controller({ path: 'kyc/verifications', version: '1' })
export class KycController {
constructor(private readonly kyc: KycService) {}
@Post()
@HttpCode(202)
async create(
@Body() dto: CreateVerificationDto,
@Headers('idempotency-key') idemKey: string,
@ApiKey() apiKey: string,
) {
const result = await this.kyc.verify(dto, { idemKey, apiKey });
return { id: result.id, status: result.status, riskScore: result.riskScore };
}
}Le controller reste maigre. Il délègue au service, retourne un 202 (traitement async possible) et un DTO de réponse minimaliste — pas d'exposition des champs internes (auditTrailId, providers utilisés…).
// src/kyc/kyc.service.ts
import { Inject, Injectable, Logger } from '@nestjs/common';
import { OCR, AML, FRAUD } from './kyc.module';
import { OcrProvider, AmlProvider, FraudProvider } from './ports';
import { KycRepository } from './kyc.repository';
import { AuditService } from '../audit/audit.service';
import { KycRejectedError } from './errors';
@Injectable()
export class KycService {
private readonly log = new Logger(KycService.name);
constructor(
@Inject(OCR) private readonly ocr: OcrProvider,
@Inject(AML) private readonly aml: AmlProvider,
@Inject(FRAUD) private readonly fraud: FraudProvider,
private readonly repo: KycRepository,
private readonly audit: AuditService,
) {}
async verify(input: CreateVerificationDto, ctx: { idemKey: string; apiKey: string }) {
const existing = await this.repo.findByIdempotencyKey(ctx.idemKey);
if (existing) return existing;
const ocrResult = await this.ocr.extract(input.documentBase64);
if (ocrResult.confidence < 0.85) {
throw new KycRejectedError('document_unreadable', { confidence: ocrResult.confidence });
}
const [amlResult, fraudResult] = await Promise.all([
this.aml.screen({ firstName: input.firstName, lastName: input.lastName, country: input.countryCode }),
this.fraud.score({ email: input.email, ip: ctx.apiKey /* placeholder */ }),
]);
const riskScore = (amlResult.score + fraudResult.score) / 2;
const status: 'approved' | 'review' | 'rejected' =
riskScore < 0.3 ? 'approved' : riskScore < 0.7 ? 'review' : 'rejected';
const saved = await this.repo.save({
idempotencyKey: ctx.idemKey,
apiKey: ctx.apiKey,
input,
ocrResult,
amlResult,
fraudResult,
riskScore,
status,
});
await this.audit.record({
actor: ctx.apiKey,
action: 'kyc.verification.created',
resourceId: saved.id,
payload: { status, riskScore },
});
return saved;
}
}Le service orchestre — il n'a aucune connaissance HTTP. Il est testable unitairement en mockant les 3 ports + repo + audit. Les erreurs domain (KycRejectedError) sont mappées en HTTP par le filter global, sans coupler le service à BadRequestException.
// src/shared/filters/problem-details.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { KycRejectedError } from '../../kyc/errors';
@Catch()
export class ProblemDetailsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
const req = host.switchToHttp().getRequest();
let status = 500;
let body: any = { type: 'about:blank', title: 'Internal Server Error', status };
if (exception instanceof HttpException) {
status = exception.getStatus();
body = { type: 'about:blank', title: exception.message, status };
} else if (exception instanceof KycRejectedError) {
status = 422;
body = {
type: 'https://errors.api/kyc/' + exception.code,
title: 'KYC verification rejected',
status,
detail: exception.message,
code: exception.code,
meta: exception.meta,
};
}
res.status(status).type('application/problem+json').send({ ...body, instance: req.originalUrl });
}
}Le filter centralise le mapping. Ajouter un nouveau type d'erreur domain = 4 lignes dans le filter, zéro changement dans le service. Cette séparation est ce qui rend Nest viable sur 50+ endpoints.
🤖 Architecture pour servir un agent IA depuis Nest
Servir un LLM/agent (Claude) depuis Nest ne change pas les primitives — ça les stresse. Un endpoint IA est : long (10–120 s), streamé (token par token), annulable (l'utilisateur ferme l'onglet), coûteux (chaque retry = $), et stateful sur la durée d'un tour d'agent (boucle tool-use). Les trois primitives s'appliquent telles quelles, mais quelques règles d'architecture deviennent non négociables.
Mental model — Le Controller redevient un pur adaptateur de transport (HTTP/SSE/WS). La boucle agentique (call LLM → exécute les tools → re-call) vit dans un Service, jamais dans le controller. Le client LLM est un provider injecté (forRootAsync), pas un new Anthropic() dans un champ : sinon tu ne peux ni le mocker en test, ni partager le pool de connexions, ni centraliser retries/timeouts/budget.
1. Client LLM injecté via forRootAsync (jamais new en dur)
// src/llm/llm.module.ts
import { Module, Global, DynamicModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC');
@Global()
@Module({})
export class LlmModule {
static forRootAsync(): DynamicModule {
return {
module: LlmModule,
imports: [ConfigModule],
providers: [
{
provide: ANTHROPIC,
inject: [ConfigService],
useFactory: (cfg: ConfigService) =>
new Anthropic({
apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 4, // backoff exponentiel + respect du header `retry-after` (429/5xx/529), intégré au SDK
timeout: 60_000, // par requête HTTP, PAS par tour d'agent
}),
},
],
exports: [ANTHROPIC],
};
}
}Un seul client, partagé, singleton. Pourquoi c'est non négociable : le SDK Anthropic maintient un pool de connexions keep-alive et une logique de retry/backoff centralisée ; un new Anthropic() par requête réinitialise ce pool, multiplie les handshakes TLS, et t'empêche de mocker le client en test (overrideProvider(ANTHROPIC)).
Modèles courants côté Anthropic (juin 2026) : claude-opus-4-8 (flagship, raisonnement/agents long-horizon, le défaut quand la qualité prime), claude-sonnet-4-6 (équilibre coût/qualité), claude-haiku-4-5 (rapide/bon marché, classification/routing/garde-fous). Le choix de modèle est une config par route, pas un littéral codé en dur dans le service — un endpoint de classification route vers Haiku, un agent de refacto vers Opus, sans toucher au code.
Note senior sur le thinking. Sur la génération Opus 4.7/4.8 et Sonnet 4.6, le budget de tokens de réflexion (
thinking: {type: "enabled", budget_tokens: N}) est supprimé — il renvoie un 400. On utilise le thinking adaptatif (thinking: {type: "adaptive"}) : le modèle décide lui-même quand et combien réfléchir, et on règle la profondeur viaoutput_config: {effort: "low" | "medium" | "high" | "xhigh" | "max"}. De même,temperature/top_p/top_ksont retirés sur cette génération. Si tu voisbudget_tokensoutemperaturedans du code agent, c'est qu'il a été écrit pour un modèle antérieur — à migrer.
2. Streaming des tokens en SSE — le controller ne fait que transporter
L'erreur classique est de await la réponse complète puis de la renvoyer : 60 s de page blanche. On streame.
// src/chat/chat.controller.ts
import { Body, Controller, Post, Res, Req } from '@nestjs/common';
import { Response, Request } from 'express';
import { ChatService } from './chat.service';
import { ChatDto } from './dto/chat.dto';
@Controller({ path: 'chat', version: '1' })
export class ChatController {
constructor(private readonly chat: ChatService) {}
@Post('stream')
async stream(@Body() dto: ChatDto, @Res() res: Response, @Req() req: Request) {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.flushHeaders();
// Annulation : le client ferme l'onglet -> on coupe l'appel LLM upstream.
const ac = new AbortController();
req.on('close', () => ac.abort());
try {
for await (const evt of this.chat.run(dto, ac.signal)) {
res.write(`event: ${evt.type}\n`);
res.write(`data: ${JSON.stringify(evt)}\n\n`);
}
} catch (err) {
if (!ac.signal.aborted) {
res.write(`event: error\ndata: ${JSON.stringify({ message: 'stream_failed' })}\n\n`);
}
} finally {
res.end();
}
}
}Note senior : on injecte @Res() natif et on écrit manuellement le SSE. Le req.on('close') → ac.abort() est la pièce maîtresse : sans lui, un utilisateur qui ferme l'onglet continue de brûler des tokens côté serveur jusqu'au bout de la génération. C'est un trou de coût direct.
Trois concerns de prod que la version naïve oublie :
- Heartbeats. Un proxy (nginx, ALB, Cloudflare) coupe une connexion SSE inactive après ~30-60 s. Si le modèle « réfléchit » 40 s avant le premier token (thinking adaptatif à
effortélevé), la connexion meurt avant la réponse. Envoie un commentaire SSE périodique (res.write(': ping\n\n')via unsetIntervalnettoyé dans lefinally) pour garder le tuyau ouvert. - Back-pressure.
res.write()renvoiefalsequand le buffer kernel est plein (client lent). En ignorant ce signal, tu accumules en mémoire et tu fais fuir le heap sous charge. En toute rigueur, sur un client lent on attend l'eventdrainavant de continuer à écrire — ou on s'appuie sur le fait quefor awaitsur le stream Anthropic est déjà naturellement régulé par le réseau upstream. @Res()désactive le pipeline de sortie. Dès que tu prends@Res(), Nest ne touche plus à la réponse : tes interceptors post-handler (sérialisation, enveloppe d'erreur) sont court-circuités. C'est voulu ici (tu veux le contrôle brut du flux), mais c'est un piège classique — si tu veux garder le pipeline, utilise@Res({ passthrough: true })ou renvoie unObservable/SSEvia le décorateur@Sse()de Nest. Le choix de@Res()brut est délibéré pour l'annulation fine.
Pourquoi pas
@Sse()? Le décorateur@Sse()de Nest streame unObservableet gère leContent-Typepour toi — pratique pour un flux simple. Mais il rend l'annulation côté serveur (couper l'appel LLM upstream à la déconnexion) plus indirecte : tu dois câbler leteardownde l'Observable. Pour un agent où chaque token coûte de l'argent, le contrôle explicite de@Res()+AbortControllervaut le surcoût de verbosité.
3. La boucle agentique (tool-use) vit dans le Service
Le controller transporte ; le service raisonne. La boucle « appelle le modèle → s'il demande un outil, exécute-le → renvoie le résultat → recommence » est de la logique métier pure, testable en mockant ANTHROPIC et le registre de tools.
// src/chat/chat.service.ts
import { Inject, Injectable, Logger } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../llm/llm.module';
import { ToolRegistry } from './tool-registry';
type AgentEvent =
| { type: 'text'; delta: string }
| { type: 'tool_call'; id: string; name: string; status: 'running' | 'done' | 'error' }
| { type: 'done'; stopReason: string };
@Injectable()
export class ChatService {
private readonly log = new Logger(ChatService.name);
private readonly MAX_TURNS = 8; // garde-fou : une boucle agentique DOIT être bornée
constructor(
@Inject(ANTHROPIC) private readonly anthropic: Anthropic,
private readonly tools: ToolRegistry,
private readonly config: ConfigService,
) {}
// Choix de modèle par config, jamais codé en dur. Un routeur pourrait
// choisir Haiku pour un tour de routing et Opus pour un tour de raisonnement.
private get model(): string {
return this.config.get('CHAT_MODEL', 'claude-sonnet-4-6');
}
async *run(dto: { messages: Anthropic.MessageParam[] }, signal: AbortSignal): AsyncGenerator<AgentEvent> {
const messages = [...dto.messages];
for (let turn = 0; turn < this.MAX_TURNS; turn++) {
const stream = this.anthropic.messages.stream(
{
model: this.model, // injecté depuis la config, PAS un littéral — cf. note sur le choix de modèle
max_tokens: 4096,
// thinking adaptatif : le modèle décide quand/combien réfléchir ;
// on règle la profondeur par `effort`, pas par un budget de tokens.
thinking: { type: 'adaptive' },
output_config: { effort: 'high' },
tools: this.tools.schemas(),
messages,
},
{ signal }, // l'AbortSignal traverse jusqu'au SDK -> annulation réseau réelle
);
for await (const chunk of stream) {
if (chunk.type === 'content_block_delta' && chunk.delta.type === 'text_delta') {
yield { type: 'text', delta: chunk.delta.text };
}
}
const final = await stream.finalMessage();
messages.push({ role: 'assistant', content: final.content });
if (final.stop_reason !== 'tool_use') {
yield { type: 'done', stopReason: final.stop_reason ?? 'end_turn' };
return;
}
// Exécute les tools demandés, en parallèle, et renvoie les résultats au modèle.
// NB: on `yield` les events `tool_call` depuis le corps du générateur (pas
// depuis le callback de `map`, où `yield` serait une erreur de syntaxe).
const toolUses = final.content.filter(
(b): b is Anthropic.ToolUseBlock => b.type === 'tool_use',
);
for (const tu of toolUses) {
yield { type: 'tool_call', id: tu.id, name: tu.name, status: 'running' };
}
const results = await Promise.all(
toolUses.map(async (tu) => {
try {
const out = await this.tools.execute(tu.name, tu.input, signal);
return { type: 'tool_result' as const, tool_use_id: tu.id, content: JSON.stringify(out) };
} catch {
return { type: 'tool_result' as const, tool_use_id: tu.id, content: 'error', is_error: true };
}
}),
);
messages.push({ role: 'user', content: results });
}
this.log.warn('agent hit MAX_TURNS without natural stop');
yield { type: 'done', stopReason: 'max_turns' };
}
}Points d'architecture qui distinguent un staff d'un junior ici :
- Boucle bornée (
MAX_TURNS). Une boucle tool-use non bornée est une bombe à coût et un DoS auto-infligé. signalpropagé partout — jusqu'au SDK et jusqu'à l'exécution des tools. Annuler à moitié laisse des tools qui tournent dans le vide.- Le
ToolRegistryest un provider : chaque tool est une unité injectable testable, pas unswitchgéant. La frontière modulaire de Nest sert ici de registre d'outils d'agent.
4. Travaux IA longs → BullMQ, pas le request scope
Un batch de classification, une génération de rapport de 3 min : ça ne tient pas dans une requête HTTP. On le pousse en queue (@nestjs/bullmq). Règles propres aux jobs IA :
| Concern | Règle |
|---|---|
| Idempotency | jobId = generationId (déterministe) — un retry réseau ne relance pas une génération payée |
| Retry | Backoff exponentiel, mais pas sur les 4xx (prompt invalide, contenu refusé) — seulement sur 429/5xx/timeout |
| Cost-guard | Budget de tokens/$ par tenant vérifié avant enqueue ; un compteur Redis décrémenté à chaque appel |
| Partial output | Persiste les tokens au fil de l'eau (Redis stream / colonne partial) pour reprendre sans tout régénérer |
| Observabilité | Logge model, input_tokens, output_tokens, stop_reason, latency_ms, cost_usd par appel |
// 1. Cost-guard AVANT enqueue : on refuse d'engager des $ si le tenant est à sec.
// Le compteur Redis est décrémenté à l'usage réel (output_tokens), pas à l'estimation.
const remaining = await this.budget.peek(tenantId);
if (remaining <= 0) throw new BudgetExceededError(tenantId);
// 2. idempotency : la generationId pilote le jobId. BullMQ déduplique sur jobId —
// un retry réseau du POST HTTP ne crée PAS un second job (donc pas de double facturation).
await this.queue.add(
'generate-report',
{ generationId, tenantId, prompt },
{
jobId: generationId,
attempts: 3,
// backoff seulement pour les erreurs transitoires ; un 4xx (prompt invalide,
// contenu refusé) ne doit JAMAIS être rejoué — on le marque échec définitif dans le worker.
backoff: { type: 'exponential', delay: 2_000 },
removeOnComplete: { age: 86_400 }, // garde 24h pour l'audit, puis purge
},
);Partial output et reprise. Si le worker crashe à 80% d'une génération de 3 min, un retry naïf régénère tout — et re-paie tout. La parade : persister les tokens au fil de l'eau (colonne partial ou Redis stream keyé sur generationId), et au redémarrage, soit reprendre le messages array là où il s'est arrêté, soit — plus simple et souvent suffisant — vérifier d'abord si la génération est déjà terminée (idempotency au niveau métier) avant de relancer. Le SDK Anthropic gère déjà les retries réseau en interne ; ce que tu gères ici, c'est le retry processus (crash du worker), un niveau au-dessus.
Observabilité — la métrique qui n'existe pas ailleurs. Un endpoint classique se mesure en latence + taux d'erreur. Un endpoint IA a une troisième dimension : le coût par requête. Logge, par appel LLM : model, input_tokens, output_tokens, cache_read_input_tokens (le caching de prompt divise le coût par ~10 sur la partie réutilisée), stop_reason, latency_ms, cost_usd. Sans ça, tu découvres ta facture en fin de mois au lieu de la voir par tenant en temps réel. C'est exactement le genre de concern transversal qu'on place dans un Interceptor (cf. section edge ci-dessous), jamais dispersé dans les services.
5. À l'edge : guard de coût, rate-limit, idempotency
Les concerns transversaux IA se placent exactement où Nest les attend : Guard (auth + quota tenant), Interceptor (idempotency keyée sur Idempotency-Key, mesure tokens/coût, redaction PII des logs), Filter (mapper un refus de contenu ou un 529 overloaded du fournisseur en réponse HTTP propre). Tu reconnais le même pipeline que le scénario KYC — un agent IA est, architecturalement, un orchestrateur de fournisseurs externes de plus. C'est précisément pourquoi l'adapter pattern + le pipeline standardisé de Nest se transposent sans friction.
6. Exposer un endpoint MCP / agent
Jusqu'ici tu consommes un LLM. L'autre direction : exposer tes capacités métier comme outils qu'un agent tiers (Claude, un autre service) peut appeler — c'est le rôle d'un serveur MCP (Model Context Protocol). Architecturalement, MCP est un transport de plus : un module Nest dont les providers sont les tools (chacun avec un schéma d'entrée validé par Zod/DTO + un handler), monté sur un transport streamable HTTP ou stdio. Même graphe DI, même testabilité, mêmes guards à l'entrée.
// Un tool MCP = une unité injectable + un schéma. Le ToolRegistry de la
// section 3 est réutilisé tel quel — un tool est un tool, qu'il serve la
// boucle agentique interne OU un agent externe via MCP.
@Injectable()
export class GetInvoiceTool implements McpTool {
readonly name = 'get_invoice';
readonly description = 'Récupère une facture par son ID pour le tenant courant.';
readonly inputSchema = z.object({ invoiceId: z.string().uuid() });
constructor(private readonly billing: BillingService) {}
async execute(input: { invoiceId: string }, ctx: AgentContext) {
return this.billing.findForTenant(ctx.tenantId, input.invoiceId);
}
}Les mêmes concerns d'edge s'appliquent — et c'est là que la surface d'attaque change : un endpoint MCP expose tes outils à un agent qui décide seul lesquels appeler et avec quels arguments. Donc : auth forte (un agent tiers = une API key scopée, jamais l'accès complet), autorisation par tool (ce tenant peut-il appeler delete_user ?), validation stricte des entrées (l'agent peut halluciner un payload), et rate-limit/cost-guard identiques au reste. Le transport MCP n'est qu'un HttpAdapter de plus, exactement comme gRPC ou GraphQL dans le multi-transport décrit plus haut — mais le modèle de menace, lui, est nouveau : tu donnes des outils à exécuter à un acteur non déterministe.
🏋️ Exercices
Exercice 1 — Le graphe minimal (échauffement)
Objectif — Construire OrdersModule (controller + service + repository in-memory) importé par AppModule, avec OrdersService exporté et consommé par un InvoicesModule séparé via imports. Indice/Solution — OrdersModule met OrdersService dans exports. InvoicesModule fait imports: [OrdersModule] et injecte OrdersService dans son constructeur. Vérifie qu'oublier l'exports produit l'erreur Nest can't resolve dependencies of InvoicesService — apprends à lire ce message.
Exercice 2 — Adapter pattern + swap au boot
Objectif — Définir un port NotificationPort (token Symbol) avec deux implémentations (EmailAdapter, SmsAdapter). Choisir l'implémentation au démarrage via ConfigService et forRootAsync. Indice/Solution — { provide: NOTIFICATION, useClass: cfg.get('CHANNEL') === 'sms' ? SmsAdapter : EmailAdapter } dans un useFactory. En test, overrideProvider(NOTIFICATION).useValue(fakeSpy). Si tu ne peux pas swap sans toucher au service, c'est que tu as injecté la classe concrète au lieu du token.
Exercice 3 — Production-grade : pipeline transversal complet
Objectif — Sur OrdersModule, brancher un APP_GUARD (API key), un APP_INTERCEPTOR (correlation-id + timing loggé), et un APP_FILTER (mapping erreurs domain → Problem Details RFC 9457). Aucune ligne de try/catch/log dans le controller. Indice/Solution — Tout passe par providers: [{ provide: APP_FILTER, useClass: ... }] dans un module global. Mesure : l'interceptor lit Date.now() avant next.handle(), et dans tap/finalize log la durée. Casse-tête : pourquoi catchError dans l'interceptor n'attrape-t-il pas l'exception que le filter traite ? (Ordre du pipeline — relis-le.)
Exercice 4 — Streaming LLM annulable
Objectif — Endpoint POST /v1/chat/stream en SSE qui streame les tokens de claude-sonnet-4-6, avec un client Anthropic injecté via forRootAsync et annulation propre quand le client se déconnecte. Indice/Solution — req.on('close', () => ac.abort()), et passe { signal: ac.signal } au SDK. Test de non-régression : ouvre le stream, ferme la connexion à mi-parcours, et vérifie dans les logs que l'appel upstream s'est bien arrêté (pas de output_tokens final loggé). Si les tokens continuent à être facturés, ton signal n'est pas propagé.
Exercice 5 — Boucle agentique bornée et observable
Objectif — Implémenter la boucle tool-use (modèle → tool → modèle) dans un service, avec un ToolRegistry injectable, un MAX_TURNS, et un événement tool_call streamé au front à chaque appel d'outil. Indice/Solution — stop_reason === 'tool_use' → exécute, repush tool_result, recommence. Borne la boucle. Test : un tool qui renvoie toujours « demande encore » doit terminer sur stop_reason: 'max_turns', pas boucler à l'infini. Mocke ANTHROPIC pour scripter la séquence de réponses.
Exercice 6 — Casse-le puis répare-le (singleton vs request state)
Objectif — Provoquer un bug multi-tenant : stocker le tenantId de la requête courante dans un champ d'instance d'un service singleton, puis observer la fuite sous charge concurrente. Le corriger sans passer tout le module en REQUEST scope. Indice/Solution — Lance 50 requêtes concurrentes avec des tenantId différents et logge le champ : tu verras des croisements. Fix correct : AsyncLocalStorage (un ClsService / store par requête), pas un champ partagé. Comprends pourquoi le REQUEST scope serait correct mais coûteux (re-instanciation par requête + contamination des consommateurs DEFAULT, qui lève un warning en Nest 11).
Exercice 7 — Cost-guard et observabilité sous charge (production-grade IA)
Objectif — Sur l'endpoint de chat streamé, ajouter (a) un BudgetGuard qui refuse 429/402 un tenant dont le quota de tokens est épuisé, et (b) un LlmMetricsInterceptor qui logge model/input_tokens/output_tokens/cost_usd/stop_reason/latency_ms par appel. Puis : casse-le en simulant une déconnexion client à mi-stream et vérifie que (1) l'appel upstream s'arrête, (2) le compteur de budget n'est décrémenté que des tokens réellement produits, (3) une métrique partielle est tout de même émise. Indice/Solution — Le BudgetGuard lit un compteur Redis avant le handler ; il ne peut pas connaître le coût final (streaming), donc il vérifie un solde minimum et c'est l'interceptor/le finally du stream qui décrémente le réel via usage du finalMessage(). Piège : si tu décrémentes le budget à l'enqueue/au début, une annulation laisse le tenant débité de tokens jamais produits — décrémente dans le finally, sur output_tokens observés. Pour la métrique partielle : émets-la dans le finally du générateur, pas seulement sur le chemin done, sinon une annulation = trou dans tes dashboards de coût.
🎤 En entretien
Q — Pourquoi Nest et pas Express vanilla sur un backend de 40 endpoints ? R — Pour la frontière modulaire et le pipeline transversal : guards/interceptors/filters factorisent auth, tracing, idempotency et mapping d'erreurs une fois pour toutes, là où Express les répète dans chaque handler. Le coût (~5–10 % d'overhead, boot ~500 ms) est négligeable face au gain de testabilité et d'onboarding ; sous 10 endpoints, l'arbitrage s'inverse.
Q — Un provider Nest est singleton par défaut. Quand est-ce un piège, et comment le contourner sans REQUEST scope ? R — Le piège est de stocker de l'état par-requête (tenant, user, correlation-id) dans un champ d'instance : sous concurrence, les requêtes se contaminent. Le réflexe junior est Scope.REQUEST, mais ça ré-instancie le provider et tous ses consommateurs à chaque requête. Le réflexe senior est AsyncLocalStorage : un store par-requête, providers restés singletons, zéro coût de re-câblage.
Q — Comment exposes-tu un agent IA (tool-use streamé) sans casser l'architecture Nest ? R — Le controller reste un pur transport SSE/WS ; la boucle agentique vit dans un service, bornée par MAX_TURNS ; le client LLM est injecté via forRootAsync (mockable, retries centralisés) ; l'AbortController est câblé du req.close jusqu'au SDK pour stopper la facturation à la déconnexion ; les jobs longs partent en BullMQ avec jobId = generationId pour l'idempotency. Architecturalement, un agent n'est qu'un orchestrateur de fournisseurs externes de plus — exactement le pattern du scénario KYC.
Q — imports vs providers vs exports : explique la sémantique exacte. R — providers déclare ce que le module fabrique et rend injectable en interne. exports rend une partie de ces providers visible aux modules qui m'importent (l'équivalent de public vs package-private). imports consomme d'autres modules pour accéder à ce qu'ils exportent. Mettre un service dans imports ne fait rien (ce n'est pas un module) ; oublier un exports produit le fameux Nest can't resolve dependencies chez le consommateur.
Q — Quand prends-tu @Res() natif dans un controller, et qu'est-ce que ça casse ? R — Quand j'ai besoin du contrôle brut du flux de réponse : streaming SSE token-par-token, écriture incrémentale, headers custom. Le coût caché : dès que tu prends @Res() (sans passthrough: true), Nest considère que tu gères la réponse et court-circuite les interceptors post-handler (sérialisation, enveloppe, ClassSerializerInterceptor). Tes guards et interceptors pre-handler tournent toujours, mais la sortie t'appartient. Pour un endpoint LLM streamé c'est voulu ; sur un endpoint normal, c'est un bug silencieux où ton enveloppe de réponse standard disparaît. Alternative pour garder le pipeline : @Sse() (Observable) ou @Res({ passthrough: true }).
Q — Un interceptor global ajoute du coût. Comment raisonnes-tu sur le nombre d'interceptors en prod ? R — Chaque interceptor global s'exécute sur chaque requête, des deux côtés du handler (avant via intercept, après via l'opérateur RxJS retourné). 5 interceptors globaux = 5 frames RxJS + 5 await potentiels par requête, sur le chemin chaud. La règle senior : un interceptor global ne doit faire que du vraiment transversal et léger (correlation-id, timing, tracing). Tout ce qui est métier ou coûteux (idempotency Redis, cost-guard) se cible par route/contrôleur via @UseInterceptors(), pas en global. Et on mesure : un interceptor de tracing OTel mal configuré peut ajouter des millisecondes par span — à profiler, pas à supposer.
🔁 Quand utiliser / éviter
| Utiliser Nest | Éviter Nest |
|---|---|
| Backend > 10 endpoints, plusieurs domaines | Lambda one-shot, script CRON < 200 lignes |
| Équipe ≥ 2 dev, besoin de conventions | Solo dev, prototype 48h |
| Mix HTTP + WebSocket + queue + cron | Pure API GraphQL serverless (préfère Yoga) |
| Besoin de tests d'intégration solides | Edge runtime (Cloudflare Workers, Deno Deploy) |
| Migration progressive depuis Express | App temps-réel ultra-low-latency (uWS direct) |
🔗 Liens
- Doc officielle : https://docs.nestjs.com/
- Source : https://github.com/nestjs/nest
- Comparaison Express/Fastify : https://docs.nestjs.com/techniques/performance
- Kamil Mysliwiec (créateur) — talks sur l'architecture interne
- "Mastering NestJS" — David Guijarro