NestJS — Controllers & routing
TL;DR — Un controller est un adaptateur HTTP : il déclare des routes via décorateurs (
@Get,@Post, …), extrait params/body/query (@Param,@Body,@Query), et délègue au service. L'ordre d'exécution est strict : middleware → guards → interceptors(pre) → pipes → handler → interceptors(post) → filters. Le versioning (URI/Header/Media-type) et le routing par hostname sont natifs.
🧠 Mental model
Le controller est un router décoré : Nest lit les métadonnées au boot et construit la table de routes pour Express/Fastify. Une méthode = une route. Les décorateurs ne sont pas "magiques" — ils écrivent juste dans Reflect.metadata, que Nest lit ensuite.
@Controller('users')
│
├── @Get(':id') → GET /users/:id
├── @Post() → POST /users
├── @Patch(':id') → PATCH /users/:id
└── @Delete(':id') → DELETE /users/:id
Per-handler pipeline:
┌──────────────────────────────────────────────────┐
│ Middleware │
│ ↓ │
│ Guards (auth, RBAC) │
│ ↓ │
│ Interceptors PRE (logging, timing start) │
│ ↓ │
│ Pipes (validation, transform of params/body) │
│ ↓ │
│ ► Handler method │
│ ↓ │
│ Interceptors POST (mapping, caching, timing end) │
│ ↓ │
│ Filters (exception → response) │
│ ↓ │
│ Response │
└──────────────────────────────────────────────────┘Distinction critique : middleware = générique, guards = autorisation, pipes = validation/transform, interceptors = wrap, filters = erreurs. Mélanger les rôles = code spaghetti.
Le modèle mental d'un staff engineer
Trois clarifications que beaucoup de seniors ratent :
Le controller n'est pas du code "appelé par Nest" — c'est de la métadonnée lue au boot. À
NestFactory.create(), Nest scanne les classes décorées, litReflect.getMetadata('path', …), et enregistre des routes Express/Fastify une fois pour toutes. Au runtime, c'est le routeur natif (Express/Fastify) qui dispatche, pas Nest. Conséquence : tu ne peux pas ajouter/retirer une route dynamiquement par requête — il faut un@All('*splat')+ dispatch manuel si tu veux du routing data-driven.L'ordre
guards → interceptors(pre) → pipesn'est pas arbitraire. Les guards passent avant les pipes car l'autorisation ne doit pas dépendre d'un body validé : un attaquant ne doit pas pouvoir faire planter la validation pour contourner l'auth. Les pipes passent après les guards et juste avant le handler car ils transforment les arguments résolus (@Param,@Body). Corollaire de sécurité : ne mets jamais de logique d'autorisation dans un pipe (il s'exécute trop tard et son rôle est la donnée, pas l'identité).Un controller doit être amincissable jusqu'à zéro logique. Le test : si tu supprimes tous les décorateurs, la méthode doit devenir un appel de service one-liner. Toute branche
if/trymétier dans un handler est une fuite de la couche service vers la couche transport. Le handler ne fait que : (extraire) → (déléguer) → (retourner). La validation est dans les pipes, l'auth dans les guards, le mapping d'erreur dans les filters, la transformation de sortie dans les interceptors.
Tableau : qui résout quoi dans le pipeline
| Préoccupation | Mécanisme | S'exécute | Accès au handler metadata ? |
|---|---|---|---|
| CORS, helmet, body-parser, traceId | Middleware | avant le routing | ❌ (pas encore résolu) |
| "Le user a-t-il le droit ?" | Guard | après routing | ✅ via Reflector |
| Logging, cache, timing, map sortie | Interceptor | autour du handler | ✅ via Reflector |
| Valider/transformer un input | Pipe | juste avant handler | ✅ via metatype |
| Mapper exception → réponse HTTP | Filter | sur throw | ✅ via ArgumentsHost |
🛠️ Code minimal
import {
Controller, Get, Post, Patch, Delete,
Param, Body, Query, ParseIntPipe, HttpCode, Header,
UseGuards, UseInterceptors,
} from '@nestjs/common';
import { AuthGuard } from './auth.guard';
import { LoggingInterceptor } from './logging.interceptor';
interface CreateUserDto { name: string; email: string; }
@Controller({ path: 'users', version: '1' })
@UseInterceptors(LoggingInterceptor)
export class UsersController {
@Get()
list(@Query('limit', new ParseIntPipe({ optional: true })) limit?: number) {
return { limit: limit ?? 20 };
}
@Get(':id')
byId(@Param('id') id: string) { return { id }; }
@Post()
@HttpCode(201)
@Header('Cache-Control', 'no-store')
@UseGuards(AuthGuard)
create(@Body() dto: CreateUserDto) { return { ...dto, id: 'x' }; }
@Patch(':id')
patch(@Param('id') id: string, @Body() dto: Partial<CreateUserDto>) { return { id, ...dto }; }
@Delete(':id')
@HttpCode(204)
remove(@Param('id') id: string) { return; }
}🎯 Patterns courants
1. Versioning. Trois stratégies au choix, activées globalement.
// main.ts
app.enableVersioning({ type: VersioningType.URI }); // /v1/users
app.enableVersioning({ type: VersioningType.HEADER, header: 'X-API-Version' });
app.enableVersioning({ type: VersioningType.MEDIA_TYPE, key: 'v=' }); // Accept: application/json;v=2
// controller
@Controller({ path: 'users', version: ['1', '2'] })
export class UsersController {
@Get() // accepte v1 et v2
@Version('2') // override : v2 only
listV2() { /* ... */ }
}URI = simple, cache-friendly, mais pollue l'URL. Header = URL propre, plus dur à debug. Media-type = REST-puriste, peu utilisé en pratique.
2. Extraction des params. Les décorateurs @Param, @Body, @Query, @Headers, @Req, @Res, @Ip, @HostParam.
@Get(':id/posts/:postId')
get(
@Param('id', ParseIntPipe) userId: number,
@Param('postId') postId: string,
@Query('include') include?: string,
@Headers('x-trace-id') trace?: string,
) { /* ... */ }Tu peux extraire tout l'objet (@Param() sans clé) ou cibler une clé. Combine avec un pipe pour parser/valider en une ligne.
3. Sub-domain / hostname routing.
@Controller({ host: 'admin.example.com' })
export class AdminController {}
@Controller({ host: ':tenant.example.com' })
export class TenantController {
@Get()
index(@HostParam('tenant') tenant: string) { return { tenant }; }
}Très utile en multi-tenant : tu sépares l'API publique et l'API admin par hostname plutôt que par préfixe.
4. Async controllers + streaming. Toute méthode peut être async ou retourner un Observable (RxJS) — Nest attend la résolution automatiquement.
@Get('stream')
stream(): Observable<string> {
return interval(1000).pipe(map(i => `tick ${i}\n`));
}
@Get('file')
file(@Res() res: Response) {
const stream = createReadStream('big.pdf');
res.set('Content-Type', 'application/pdf');
stream.pipe(res); // mode "pass-through" — Nest ne touche pas la réponse
}Attention : utiliser @Res() (sans passthrough: true) désactive la pipeline interceptors-post / filters. Préfère @Res({ passthrough: true }) si tu veux garder le pipeline et juste modifier des headers.
5. Route-specific middleware vs guard vs interceptor. Choix critique.
// Middleware : pre-routing, idéal pour cors, helmet, body-parser custom
export class TraceMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
req['traceId'] = crypto.randomUUID();
next();
}
}
// Bind via MiddlewareConsumer
configure(consumer: MiddlewareConsumer) {
consumer.apply(TraceMiddleware).forRoutes({ path: 'users/*', method: RequestMethod.ALL });
}
// Guard : auth/RBAC, retourne true/false, lève ForbiddenException
@Injectable()
export class RolesGuard implements CanActivate { /* ... */ }
// Interceptor : wrap autour du handler (timing, transform output)
@Injectable()
export class TimingInterceptor implements NestInterceptor { /* ... */ }Règle : middleware = générique HTTP (avant routing), guard = "le user a-t-il le droit ?", interceptor = "wrap autour" (logging, cache, transform), pipe = "valider/transformer un input".
6. Custom param decorators. Pour extraire proprement de la req.
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.user;
},
);
@Get('me')
@UseGuards(AuthGuard)
me(@CurrentUser() user: User) { return user; }Tu remplaces 4 lignes répétées par un décorateur. Combine avec applyDecorators() pour composer (@Auth() = @UseGuards + @ApiBearerAuth + @CurrentUser).
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- Nest 7 :
@Controller(path)accepte juste un string. Pas de versioning natif.@HostParamexiste déjà. - Nest 8 : Ajout du versioning natif (
enableVersioning).@Controller({ path, host, version })devient la signature riche.@Sse()pour Server-Sent Events. - Nest 9 :
Reflector.getAllAndOverride()simplifie l'écriture de guards/interceptors qui lisent des metadata par-handler. Améliorations des erreurs de routing (conflit/:idvs/me). - Nest 10 :
RouterModule.register([])(depuis@nestjs/core) pour grouper des préfixes.@Res({ passthrough: true })mieux documenté. - Nest 11 : passage à Express v5 + path-to-regexp v8. Trois ruptures concrètes côté routing :
- Le wildcard nu
*n'est plus valide → il faut un paramètre nommé :@Get('files/*path')(et tu le lis via@Param('path')). En middleware :forRoutes('users/*splat')au lieu de'users/*'. - Les regex inline dans les routes (
@Get(':id(\\d+)')) ne sont plus supportées par path-to-regexp v8 → utilise unParseIntPipe/DTO ou un guard à la place. C'est un breaking change silencieux qui plante au boot avec un message obscur. - Les paramètres optionnels passent de
:id?à la syntaxe{/:id}(groupe optionnel). Fastify v5 par défaut côté adaptateur Fastify. Améliorations surenableVersioning(defaultVersion: VERSION_NEUTRALplus prévisible, etVERSION_NEUTRALcomme membre d'un tableau de versions sur un même controller).
- Le wildcard nu
⚠️ Pitfalls
- Confondre l'ordre middleware/guard. Middleware s'exécute avant le routing complet — il ne peut pas lire les decorators (rôles, etc.). Pour de l'auth contextuelle, utilise un guard.
@Res()qui casse les interceptors. Sanspassthrough: true, Nest considère que tu gères la réponse manuellement. Tes interceptors POST ne s'exécutent pas, tes filters non plus pour le mapping.- Routes ambiguës.
@Get(':id')matche aussi/me. Solution : déclarer@Get('me')avant@Get(':id')dans le fichier (ordre d'enregistrement). - Body-parser trop petit. Par défaut 100kb. Pour des uploads JSON ou fichiers, augmente :
app.use(json({ limit: '10mb' }))aprèsNestFactory.create. - Validation côté controller. Mettre des
if (!body.email) throw ...à la main = perte de pipes. UtiliseValidationPipeglobal + class-validator DTO. @Param('id')qui reste string. Tous les params sont string par défaut. ToujoursParseIntPipe,ParseUUIDPipe, ou DTO + transform.- Headers en minuscules. Express normalise tous les headers en lowercase.
@Headers('X-Trace-Id')fonctionne, mais en interne c'estx-trace-id. Pas un piège fatal, mais surprend en debug. - Routing par hostname sans wildcard DNS.
@Controller({ host: ':tenant.example.com' })ne marche en local que si tu as*.example.comqui résout vers ton serveur. Utilisednsmasqou127.0.0.1 foo.example.comdans/etc/hosts.
🧪 Testing
// Unit test du controller (mock du service)
import { Test } from '@nestjs/testing';
describe('UsersController', () => {
let ctrl: UsersController;
let svc: { findAll: jest.Mock };
beforeEach(async () => {
svc = { findAll: jest.fn().mockResolvedValue([{ id: '1' }]) };
const mod = await Test.createTestingModule({
controllers: [UsersController],
providers: [{ provide: UsersService, useValue: svc }],
}).compile();
ctrl = mod.get(UsersController);
});
it('lists users', async () => {
await expect(ctrl.list()).resolves.toEqual([{ id: '1' }]);
});
});
// E2E test (supertest)
import * as request from 'supertest';
const app = (await Test.createTestingModule({ imports: [AppModule] }).compile())
.createNestApplication();
await app.init();
await request(app.getHttpServer()).get('/v1/users').expect(200);Pour tester un guard sans bypasser : .overrideGuard(AuthGuard).useValue({ canActivate: () => true }). Idem pour overrideInterceptor, overridePipe, overrideFilter.
🎬 Cas d'usage concrets
Scénario 1 — Banque : API ouverte avec versioning strict
Qui — Une banque de détail FR (1 200 ETP IT) qui expose des APIs DSP2 + AISP à des agrégateurs et fintechs. Plus de 80 endpoints publics consommés par 350 clients externes.
Problème métier — Toute évolution de signature d'API peut casser des intégrations en production. Les versions doivent coexister 12 mois minimum. La fragmentation par préfixe /v1/, /v2/ polluait l'OpenAPI et compliquait les redirects côté API Gateway.
Comment ce concept aide — enableVersioning({ type: VersioningType.HEADER, header: 'X-API-Version' }) pour des URLs propres. Chaque controller déclare version: ['1', '2'] pour les endpoints inchangés, et un controller dédié v3 pour la nouvelle signature.
@Controller({ path: 'accounts', version: ['1', '2'] })
export class AccountsControllerV1V2 {
@Get(':id')
byId(@Param('id') id: string) { return this.service.getLegacy(id); }
}
@Controller({ path: 'accounts', version: '3' })
export class AccountsControllerV3 {
@Get(':id')
byId(@Param('id') id: string) { return this.service.getEnriched(id); }
}Gains chiffrés — 0 régression client externe sur les 4 dernières montées de version, support v1+v2+v3 en parallèle pendant 18 mois sans dette technique, doc OpenAPI auto-générée propre (3 specs distinctes au lieu d'un fichier 8 000 lignes).
Scénario 2 — Marketplace e-commerce : API multi-tenant par hostname
Qui — Une marketplace française de produits artisanaux (12 ETP). Chaque artisan a sa propre boutique (boutique-de-jean.marketplace.fr) avec son catalogue isolé, mais partage la même app Nest.
Problème métier — Plutôt qu'un préfixe /shops/jean/products, on veut l'expérience d'un domaine dédié par boutique (SEO, marque, redirections email transactionnels). Sans routing par hostname natif, c'était un middleware custom + des if partout.
Comment ce concept aide — @Controller({ host: ':shop.marketplace.fr' }) + @HostParam('shop') pour extraire le slug directement. Combiné avec un wildcard DNS *.marketplace.fr → app.
@Controller({ host: ':shop.marketplace.fr', path: 'products' })
export class ShopProductsController {
constructor(private readonly products: ProductsService) {}
@Get()
list(@HostParam('shop') shop: string, @Query('limit', ParseIntPipe) limit: number) {
return this.products.listForShop(shop, limit);
}
@Get(':sku')
byId(@HostParam('shop') shop: string, @Param('sku') sku: string) {
return this.products.findBySku(shop, sku);
}
}Gains chiffrés — Boutique custom branding par artisan, +18% de conversion sur les boutiques avec domaine personnalisé, 0 fuite cross-shop en 12 mois (le HostParam est vérifié systématiquement par un Guard).
Scénario 3 — RH ATS : endpoints riches pour candidatures
Qui — Un éditeur d'ATS (Applicant Tracking System) FR (60 ETP) — 14 000 recruteurs actifs, 280k candidatures/mois.
Problème métier — Les recruteurs veulent filtrer/trier/exporter, les candidats veulent une expérience fluide, l'app a besoin de polling et de SSE pour notifier les changements de statut. Trois usages différents sur les mêmes endpoints — code en spaghetti.
Comment ce concept aide — Split par controller dédié à l'usage : RecruiterApplicationsController (filtres riches), CandidateApplicationsController (lecture filtrée), ApplicationsEventsController (SSE). Chaque controller a son ensemble de Guards et son interceptor de sérialisation.
@Controller({ path: 'applications', version: '1' })
@UseGuards(JwtAuthGuard, RecruiterRoleGuard)
export class RecruiterApplicationsController {
@Get()
list(@Query() q: ApplicationsQueryDto, @CurrentUser('orgId') orgId: string) {
return this.svc.searchForRecruiter(orgId, q);
}
@Patch(':id/status')
updateStatus(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateStatusDto) {
return this.svc.updateStatus(id, dto);
}
}
@Controller({ path: 'candidate/applications', version: '1' })
@UseGuards(JwtAuthGuard, CandidateRoleGuard)
export class CandidateApplicationsController {
@Get()
myApplications(@CurrentUser('id') candidateId: string) {
return this.svc.listForCandidate(candidateId);
}
}
@Controller({ path: 'applications/events', version: '1' })
@UseGuards(JwtAuthGuard)
export class ApplicationsEventsController {
@Sse(':id/stream')
stream(@Param('id') id: string): Observable<{ data: any }> {
return this.svc.subscribeToChanges(id).pipe(map((event) => ({ data: event })));
}
}Gains chiffrés — Code splitting clair, couverture par controller à 90%+, support SSE déployé en 2 jours (avant : polling toutes les 3s côté front, abandonné), -40% de RPS sur les endpoints REST grâce au SSE.
🛠️ Exemple end-to-end
Use case — API ATS pour un éditeur RH. On expose les endpoints candidatures avec versioning header, validation DTO, custom decorators pour le user/org courant, et un mix de routes REST + SSE.
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version',
defaultVersion: '1',
});
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: { enableImplicitConversion: true },
}));
await app.listen(3000);
}
bootstrap();// src/applications/dto/applications-query.dto.ts
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export enum ApplicationStatus {
Applied = 'applied',
Screening = 'screening',
Interview = 'interview',
Offer = 'offer',
Hired = 'hired',
Rejected = 'rejected',
}
export class ApplicationsQueryDto {
@IsOptional() @IsString()
search?: string;
@IsOptional() @IsEnum(ApplicationStatus, { each: true })
status?: ApplicationStatus[];
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100)
limit?: number = 20;
@IsOptional() @Type(() => Number) @IsInt() @Min(0)
offset?: number = 0;
@IsOptional() @IsString()
jobId?: string;
}// src/applications/dto/update-status.dto.ts
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
import { ApplicationStatus } from './applications-query.dto';
export class UpdateStatusDto {
@IsEnum(ApplicationStatus)
status!: ApplicationStatus;
@IsOptional() @IsString() @MaxLength(2000)
comment?: string;
}// src/common/decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthUser { id: string; orgId: string; roles: string[]; }
export const CurrentUser = createParamDecorator(
(data: keyof AuthUser | undefined, ctx: ExecutionContext) => {
const user = ctx.switchToHttp().getRequest().user as AuthUser;
return data ? user?.[data] : user;
},
);// src/applications/applications.controller.ts
import {
Body, Controller, Delete, Get, HttpCode, Param, ParseUUIDPipe,
Patch, Post, Query, Sse, UseGuards,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { ApplicationsService } from './applications.service';
import { ApplicationsQueryDto } from './dto/applications-query.dto';
import { UpdateStatusDto } from './dto/update-status.dto';
import { CreateApplicationDto } from './dto/create-application.dto';
import { JwtAuthGuard } from '../auth/jwt.guard';
import { RecruiterRoleGuard } from '../auth/recruiter-role.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
@Controller({ path: 'applications', version: '1' })
@UseGuards(JwtAuthGuard)
export class ApplicationsController {
constructor(private readonly applications: ApplicationsService) {}
@Get()
@UseGuards(RecruiterRoleGuard)
list(
@Query() query: ApplicationsQueryDto,
@CurrentUser('orgId') orgId: string,
) {
return this.applications.searchForOrg(orgId, query);
}
@Get(':id')
byId(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser('orgId') orgId: string,
) {
return this.applications.findByIdForOrg(id, orgId);
}
@Post()
@HttpCode(201)
create(
@Body() dto: CreateApplicationDto,
@CurrentUser('id') candidateId: string,
) {
return this.applications.submit(dto, candidateId);
}
@Patch(':id/status')
@UseGuards(RecruiterRoleGuard)
updateStatus(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateStatusDto,
@CurrentUser('id') recruiterId: string,
) {
return this.applications.updateStatus(id, dto, recruiterId);
}
@Delete(':id')
@HttpCode(204)
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser('orgId') orgId: string,
) {
return this.applications.softDelete(id, orgId);
}
@Sse(':id/events')
events(@Param('id', ParseUUIDPipe) id: string): Observable<{ data: any }> {
return this.applications.subscribeToApplicationEvents(id).pipe(
map((event) => ({ data: event })),
);
}
}Le controller illustre : (a) versioning par header sur tout le controller, (b) Guards multi-niveaux (controller + handler), (c) extraction de params typés via pipes (ParseUUIDPipe), (d) custom param decorator @CurrentUser, (e) DTOs validés via ValidationPipe global, (f) SSE natif via @Sse() retournant un Observable, (g) status codes explicites (@HttpCode(201), @HttpCode(204)).
// src/applications/applications.module.ts
import { Module } from '@nestjs/common';
import { ApplicationsController } from './applications.controller';
import { ApplicationsService } from './applications.service';
import { ApplicationsRepository } from './applications.repository';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [ApplicationsController],
providers: [ApplicationsService, ApplicationsRepository],
exports: [ApplicationsService],
})
export class ApplicationsModule {}Le service ApplicationsService (non détaillé ici) reste agnostique HTTP — il prend des arguments typés, retourne des DTOs ou throws des erreurs métier. Cette séparation est ce qui rend le controller testable unitairement et le service réutilisable depuis un microservice RabbitMQ ou un job CRON sans changer une ligne.
🏭 Production : perf, sécurité, observabilité
Un controller en prod n'est pas juste "ça marche en dev". Ce qu'un staff engineer surveille :
Performance.
- Le routing Fastify > Express sur le path-matching à fort volume (≈ 2-3× le throughput sur des micro-benchmarks de routing pur). Si tu fais > 20k RPS de routes simples, l'adaptateur Fastify (
NestFactory.create(AppModule, new FastifyAdapter())) change la donne. Mais attention :@Res()Express ≠@Res()Fastify (API de réponse différente) — ton code de streaming doit en tenir compte. - L'ordre de déclaration des routes a un coût. Express teste les routes séquentiellement. Une route catch-all
@All('*splat')placée en tête fait matcher toutes les requêtes avant les routes spécifiques. Place le générique en dernier. - Le
ValidationPipeavectransform: trueinstancie une classe par requête (class-transformer fait duplainToInstance). Sur un endpoint hot à 10k RPS avec un DTO profond, c'est mesurable. Profile avant d'optimiser, mais sache-le. - N'utilise pas
@Res()sans raison : tu perds le buffering/keep-alive géré par Nest et tu réintroduis des bugs de réponse (double-send, header après body).
Sécurité (le controller est la surface d'attaque).
forbidNonWhitelisted: trueest non-négociable sur une API publique : sans lui, un attaquant peut envoyer des champs non déclarés qui finissent en mass-assignment si ton service spread le DTO ({ ...dto }). Lewhiteliststrip, leforbidNonWhitelistedrejette en 400.- Tous les
@Param/@Querysont des strings non fiables.ParseUUIDPipe/ParseIntPipene sont pas du confort, c'est une barrière d'injection (unidnon parsé qui part en requête SQL/Mongo, ou enpath.joinpour un download, est une faille). - Rate-limiting au niveau handler via
@Throttle()(@nestjs/throttler) sur les endpoints coûteux (login, export, génération IA) — pas seulement un rate-limit global. Le coût par endpoint varie de 3 ordres de grandeur. - Ne jamais retourner l'entité brute : un interceptor de sérialisation (
ClassSerializerInterceptor+@Exclude()) ou un DTO de sortie explicite évite de fuiterpasswordHash,internalNotes, etc. La règle : le type de retour d'un handler public est un DTO de sortie, jamais une entité ORM.
Observabilité.
- Corréle tout par
traceId: un middleware qui posereq.traceId = randomUUID()(ou propage letraceparentW3C entrant), un interceptor qui le rajoute dans chaque log et dans le header de réponseX-Request-Id. Sans ça, debugger un incident multi-service est impossible. - L'interceptor est l'endroit canonique pour les métriques : latence par route (histogramme), compteur par status code, taille de payload. Branche-le sur
context.getHandler().name+context.getClass().namepour des labels propres (et bas-cardinalité — n'utilise jamais l'URL avec l':idrésolu comme label Prometheus, ça explose la cardinalité). - Les erreurs passent par un filter global : c'est le seul endroit où tu mappes proprement une exception métier vers un status code et logs la stack une seule fois, sans la fuiter au client.
🤖 Servir un agent IA depuis un controller
C'est le cas où "controller = adaptateur HTTP" devient un vrai sujet d'architecture : un endpoint qui stream des tokens LLM et qui orchestre une boucle d'outils (tool-use) côté serveur. Le controller reste mince, mais il doit gérer trois choses que les CRUD classiques ignorent : le streaming, l'annulation, et le coût.
1. Le client LLM est un provider DI'd, jamais new Anthropic() dans un champ
Instancier le SDK en dur dans le controller tue la testabilité, casse la config par environnement et empêche le mocking. Injecte-le via forRootAsync :
// src/llm/llm.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC');
@Module({
imports: [ConfigModule],
providers: [
{
provide: ANTHROPIC,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Anthropic({
apiKey: config.getOrThrow<string>('ANTHROPIC_API_KEY'),
maxRetries: 3, // le SDK retry les 429/5xx avec backoff exponentiel
timeout: 60_000,
}),
// Modèles Anthropic actuels : claude-opus-4-8 (flagship, le plus capable),
// claude-sonnet-4-6 (meilleur ratio vitesse/intelligence), claude-haiku-4-5 (le plus rapide).
// Choisis le modèle par route : haiku pour la classification/routing, sonnet pour le chat,
// opus pour le raisonnement long et l'agentique.
},
],
exports: [ANTHROPIC],
})
export class LlmModule {}2. Streamer les tokens via SSE — avec annulation sur déconnexion client
Le piège n°1 : si le client ferme l'onglet, tu dois arrêter l'appel LLM (sinon tu paies des tokens dans le vide). On câble un AbortController sur l'événement close de la réponse. Ici on n'utilise pas @Sse() (qui veut un Observable froid) mais @Res({ passthrough: false }) pour un contrôle fin du flux SSE :
// src/chat/chat.controller.ts
import {
Controller, Post, Body, Res, Req, Inject, UseGuards,
} from '@nestjs/common';
import type { Response, Request } from 'express';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../llm/llm.module';
import { JwtAuthGuard } from '../auth/jwt.guard';
import { CostGuard } from './cost.guard';
import { ChatDto } from './dto/chat.dto';
@Controller({ path: 'chat', version: '1' })
@UseGuards(JwtAuthGuard, CostGuard) // cost-guard AU bord, avant de brûler des tokens
export class ChatController {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}
@Post('stream')
async stream(@Body() dto: ChatDto, @Req() req: Request, @Res() res: Response) {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // empêche nginx de bufferiser le SSE
});
res.flushHeaders();
const ac = new AbortController();
// Annulation : le client a fermé → on stoppe l'appel API → on arrête de payer
req.on('close', () => ac.abort());
try {
const stream = await this.anthropic.messages.stream(
{
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: [{ role: 'user', content: dto.prompt }],
},
{ signal: ac.signal },
);
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
res.write(`data: ${JSON.stringify({ token: event.delta.text })}\n\n`);
}
}
const final = await stream.finalMessage();
// usage = tokens réellement facturés → log pour la compta de coût
res.write(`event: done\ndata: ${JSON.stringify({ usage: final.usage })}\n\n`);
} catch (err) {
if (!ac.signal.aborted) {
res.write(`event: error\ndata: ${JSON.stringify({ message: 'stream_failed' })}\n\n`);
}
} finally {
res.end();
}
}
}Points seniors : X-Accel-Buffering: no (sinon un proxy bufferise et le stream arrive d'un bloc) ; on lit finalMessage().usage pour la comptabilité de coût (input + output tokens) ; le finally { res.end() } garantit qu'on ne laisse jamais une connexion SSE pendante.
3. La boucle agentique (tool-use) côté serveur
Quand le modèle veut appeler un outil, la boucle est serveur : tu renvoies le tool_result au modèle et tu re-streams. Le controller délègue à un service AgentService, et chaque étape d'outil est émise au client comme un événement typé (timeline pending|running|done|error) :
// src/agent/agent.service.ts (extrait — le controller ne fait que .pipe vers SSE)
async *run(prompt: string, signal: AbortSignal) {
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: prompt }];
for (let step = 0; step < MAX_STEPS; step++) { // garde-fou anti-boucle infinie
const res = await this.anthropic.messages.create(
{ model: 'claude-sonnet-4-6', max_tokens: 1024, tools: this.toolDefs, messages },
{ signal },
);
messages.push({ role: 'assistant', content: res.content });
const toolUses = res.content.filter((b) => b.type === 'tool_use');
if (toolUses.length === 0) { yield { type: 'final', content: res.content }; return; }
const results: Anthropic.ToolResultBlockParam[] = [];
for (const call of toolUses) {
yield { type: 'tool', status: 'running', name: call.name, id: call.id };
try {
const out = await this.dispatchTool(call.name, call.input); // tes vrais outils
results.push({ type: 'tool_result', tool_use_id: call.id, content: JSON.stringify(out) });
yield { type: 'tool', status: 'done', name: call.name, id: call.id };
} catch (e) {
results.push({ type: 'tool_result', tool_use_id: call.id, content: 'error', is_error: true });
yield { type: 'tool', status: 'error', name: call.name, id: call.id };
}
}
messages.push({ role: 'user', content: results });
}
}4. Jobs IA longs : BullMQ plutôt que la requête HTTP
Une génération de 90 s ne tient pas dans une requête HTTP (timeouts proxy/LB). Le controller accepte (202) + retourne un generationId, enfile un job BullMQ, et le client poll/SSE sur le statut. Règles de production :
- Idempotence keyée sur le
generationId: un retry BullMQ ne doit pas regénérer (donc re-facturer) si le job a déjà produit un output. Vérifie l'état persisté avant l'appel LLM. - Retry conscient du coût : ne retry que les erreurs réseau/429 (le SDK le fait déjà via
maxRetries), jamais une erreur métier ou un refus — sinon tu paies N fois pour le même échec. - Output partiel : si le job crashe à 70 %, persiste le texte déjà streamé pour pouvoir reprendre/afficher, plutôt que de tout perdre.
@Post('generate')
@HttpCode(202)
async enqueue(@Body() dto: GenerateDto, @CurrentUser('id') userId: string) {
const generationId = randomUUID();
await this.queue.add('generate', { generationId, userId, dto }, {
jobId: generationId, // idempotence : BullMQ dédoublonne par jobId
attempts: 3,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000,
});
return { generationId, status: 'queued' };
}5. Exposer un endpoint d'agent / MCP
Pour qu'un autre agent (ou un client MCP) consomme ton service, expose deux endpoints : un qui décrit tes outils (le schema JSON que l'agent appelant lira pour savoir quoi invoquer) et un qui exécute une invocation. Garde le rate-limit + cost-guard + idempotency-key au bord : un agent appelant qui boucle peut générer une facture à 4 chiffres en minutes. Le controller reste l'endroit où tu poses ces garde-fous avant que la requête n'atteigne le LLM.
// src/agent/mcp.controller.ts
import {
Controller, Get, Post, Body, Headers, UseGuards, ConflictException,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt.guard';
import { CostGuard } from '../chat/cost.guard';
import { ToolRegistry } from './tool-registry';
import { AgentService } from './agent.service';
import { InvokeToolDto } from './dto/invoke-tool.dto';
@Controller({ path: 'agent', version: '1' })
@UseGuards(JwtAuthGuard, CostGuard) // garde-fous AVANT d'atteindre le LLM
export class McpController {
constructor(
private readonly tools: ToolRegistry,
private readonly agent: AgentService,
) {}
// Discovery : l'agent appelant lit ce manifest pour connaître les outils dispo.
// Réponse stable + cacheable → mets un ETag / Cache-Control côté edge.
@Get('tools')
listTools() {
return { tools: this.tools.toJsonSchema() };
}
// Invocation : idempotence keyée sur l'Idempotency-Key fournie par l'appelant.
// Un retry réseau de l'agent ne doit PAS ré-exécuter (ni re-facturer) l'outil.
@Post('invoke')
async invoke(
@Body() dto: InvokeToolDto,
@Headers('idempotency-key') idemKey?: string,
) {
if (idemKey) {
const cached = await this.agent.getCachedResult(idemKey);
if (cached) return cached; // rejoue le résultat, pas l'effet de bord
}
return this.agent.invokeTool(dto.name, dto.input, idemKey);
}
}Le ToolRegistry.toJsonSchema() renvoie exactement la forme que le SDK Anthropic attend dans tools: [{ name, description, input_schema }] — un seul endroit qui sert à la fois la discovery MCP et la définition d'outils passée au modèle. Règle senior : la description de chaque outil doit être prescriptive sur le QUAND (« Appelle ceci quand l'utilisateur demande un prix actuel »), pas seulement sur le QUOI — les modèles récents (opus 4.7+) déclenchent les outils plus prudemment, et un trigger explicite dans la description augmente mesurablement le bon taux d'appel.
| Préoccupation IA | Où la placer dans le controller |
|---|---|
| Annulation client | req.on('close') → AbortController.abort() |
| Rate-limit par coût | Guard (@Throttle + cost-guard) au bord |
| Idempotence | Idempotency-Key header → cache du generationId |
| Comptabilité de tokens | Interceptor lisant usage en sortie de stream |
| Jobs longs | 202 + BullMQ, jamais dans la requête HTTP |
| Mocking en test | Provider DI'd (@Inject(ANTHROPIC)), pas new |
🔁 Quand utiliser / éviter
| Decorator / pattern | Utiliser | Éviter |
|---|---|---|
@Res() | Stream / SSE / file download | Cas standards (préfère retour de valeur) |
| Versioning URI | API publique, simple | Si tu veux des URLs propres |
| Versioning Header | API interne typée | Public sans doc claire |
@HostParam | Multi-tenant par sous-domaine | App mono-tenant |
| Custom param decorator | Extraction répétée (user courant) | Cas one-shot |
| Middleware | Cross-cutting non lié à la logique | Auth/rôles (préfère guard) |
🏋️ Exercices
Progression : implémenter → durcir en production → casser puis réparer. Fais-les dans l'ordre.
Exercice 1 — Le custom decorator composite @Auth()
Objectif : remplacer la répétition @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(...) + @ApiBearerAuth() par un seul décorateur paramétrable @Auth('recruiter').
Indice/Solution : applyDecorators(UseGuards(JwtAuthGuard, RolesGuard), SetMetadata('roles', roles), ApiBearerAuth()). Le RolesGuard lit les rôles via Reflector.getAllAndOverride('roles', [ctx.getHandler(), ctx.getClass()]) — le getAllAndOverride fait que le rôle au niveau handler écrase celui du controller. Vérifie que @Auth() sans argument exige juste l'authentification.
Exercice 2 — Pagination cursor-based type-safe via DTO + pipe
Objectif : un endpoint GET /items?cursor=...&limit=... où limit est borné [1,100], cursor est un base64 décodé/validé, et la réponse renvoie { items, nextCursor }. Tout doit être typé et validé sans un seul if dans le handler.
Indice/Solution : DTO avec @Type(() => Number) @Min(1) @Max(100) limit, et un CursorPipe custom (PipeTransform) qui décode le base64 → throw BadRequestException si invalide. Le handler reste un one-liner return this.svc.page(query). Bonus : transformOptions.enableImplicitConversion au niveau global et observe ce qui casse si tu l'enlèves.
Exercice 3 — Stream LLM en SSE avec annulation (production-grade)
Objectif : implémenter POST /chat/stream qui stream les tokens d'un appel claude-sonnet-4-6, s'arrête réellement quand le client ferme la connexion, et log le usage final. Client DI'd, pas de new Anthropic().
Indice/Solution : reprends la section "Servir un agent IA". Teste l'annulation : curl -N puis Ctrl-C, et vérifie dans tes logs que l'appel API est bien aborted (pas juste la réponse HTTP coupée). Le bug classique : oublier X-Accel-Buffering: no → tout arrive en bloc derrière le proxy. Deuxième bug : req.on('close') qui ne déclenche pas car tu as utilisé @Sse() (Observable froid) au lieu de @Res().
Exercice 4 — Casse-le : le piège de l'ordre de routes + Express 5
Objectif : reproduire puis corriger deux bugs réels. (a) @Get(':id') déclaré avant @Get('export') → /export matche :id='export'. (b) Migrer une route @Get('files/*') de Nest 10 vers Nest 11 (Express 5) qui plante au boot.
Indice/Solution : (a) réordonne — les routes littérales avant les paramétrées ; ajoute un test e2e qui appelle /export et asserte le bon handler. (b) @Get('files/*') → @Get('files/*path') + @Param('path'). Bonus : trouve pourquoi @Get(':id(\\d+)') ne boot plus sous path-to-regexp v8 et remplace par un ParseIntPipe.
Exercice 5 — Job IA long en BullMQ idempotent
Objectif : POST /generate retourne 202 + generationId, enfile un job BullMQ idempotent (un double-submit avec le même Idempotency-Key ne crée pas deux jobs), et GET /generate/:id renvoie le statut + l'output partiel si crash.
Indice/Solution : jobId: generationId dédoublonne dans BullMQ. Persiste l'état (queued|running|partial|done|failed) en base, et avant l'appel LLM dans le worker, vérifie l'état → si done, ne regénère pas. Casse-le : force un crash worker à 70 % et vérifie que le retry ne re-facture pas un appel déjà complété.
Exercice 6 — La boucle agentique bornée
Objectif : implémenter AgentService.run() (tool-use) avec un garde-fou MAX_STEPS, propagation de l'AbortSignal, et émission d'une timeline d'étapes typée vers le client.
Indice/Solution : reprends l'extrait de la section IA. Discriminated union { type: 'tool', status: 'running'|'done'|'error' }. Casse-le : crée un outil qui renvoie toujours une erreur et vérifie que la boucle s'arrête à MAX_STEPS (pas de boucle infinie qui draine ton budget). Vérifie aussi que signal.abort() interrompt bien au milieu d'une étape, pas seulement entre deux.
Exercice 7 — Endpoint MCP idempotent : casse l'invariant, puis répare-le
Objectif : implémenter GET /agent/tools (manifest) + POST /agent/invoke avec Idempotency-Key. Un outil charge_card (effet de bord non-réversible + appel LLM facturé en aval) ne doit JAMAIS s'exécuter deux fois pour la même clé.
Indice/Solution : reprends McpController. Le piège classique : cacher le résultat après l'exécution mais ne pas verrouiller l'exécution concurrente — deux requêtes simultanées avec la même clé passent toutes deux le check getCachedResult (qui renvoie null) avant que l'une ait écrit. Casse-le avec deux curl en parallèle et observe le double-débit. Répare : pose un verrou atomique sur la clé (SET key NX Redis, ou une contrainte unique en base sur (idempotency_key) qui throw ConflictException 409) avant l'appel, pas après. Le manifest GET /agent/tools doit rester cacheable (ETag) et sa description par outil doit être prescriptive sur le quand l'appeler.
🎤 En entretien
Q : Pourquoi les guards s'exécutent-ils avant les pipes, et pas l'inverse ? R : Parce que l'autorisation ne doit pas dépendre d'un input validé — un attaquant ne doit pas pouvoir contourner l'auth en faisant planter la validation. Les guards décident de l'accès sur l'identité (déjà présente après le routing) ; les pipes transforment les arguments après que l'accès est accordé. Mettre de l'auth dans un pipe est une faille.
Q : Quand utiliser @Res() et quel est son coût caché ? R : Uniquement pour le streaming, SSE ou file download où tu pilotes le flux toi-même. Le coût : sans passthrough: true, tu sors de la pipeline Nest — les interceptors POST et le mapping des filters ne s'exécutent plus, tu gères headers/statut/fin de réponse à la main, et tu réintroduis des bugs (double-send, header-after-body). Pour juste modifier un header, utilise @Res({ passthrough: true }).
Q : Comment streamer une réponse LLM tout en arrêtant de payer si le client se déconnecte ? R : Endpoint SSE en @Res(), un AbortController câblé sur req.on('close'), et le signal passé à anthropic.messages.stream(..., { signal }). La déconnexion HTTP seule ne stoppe pas l'appel upstream — il faut explicitement abort le signal, sinon les tokens continuent d'être générés et facturés côté serveur.
Q : URI vs Header vs Media-Type versioning — quel arbitrage en API publique ? R : URI (/v1/...) pour le public : simple, cache-friendly (le CDN voit la version dans le path), debuggable au curl, mais pollue l'URL et duplique l'OpenAPI. Header pour l'interne typé : URLs propres mais invisible en cache et plus dur à debug. Media-Type est REST-puriste mais quasi-introuvable en pratique. Règle : public → URI, sauf contrainte forte d'URLs stables.
Q : Un controller expose un endpoint d'invocation d'outil pour des agents IA. Pourquoi l'Idempotency-Key est-elle critique ici plus qu'ailleurs ? R : Parce qu'un agent appelant retry agressivement (timeout réseau, boucle de raisonnement) et que chaque invocation peut (a) avoir un effet de bord non-réversible et (b) déclencher un appel LLM facturé. Sans idempotence keyée, un seul retry double l'effet et le coût. Le pattern : cacher le résultat indexé par la clé, et rejouer le résultat sans ré-exécuter l'effet de bord. C'est exactement le même invariant que jobId: generationId côté BullMQ, posé une couche plus haut, au bord HTTP — le controller est l'endroit canonique pour ce garde-fou car c'est la dernière barrière avant le LLM.
Q : Pourquoi @Sse() ne convient pas pour streamer un LLM qu'on veut pouvoir annuler, alors que @Res() oui ? R : @Sse() attend un Observable froid que Nest souscrit et sérialise — tu n'as pas la main sur l'objet réponse Express/Fastify, donc tu ne peux pas câbler req.on('close') proprement pour abort() l'appel upstream. Avec @Res() tu pilotes le flux toi-même : tu poses les headers SSE (text/event-stream, X-Accel-Buffering: no), tu écris chaque token, et surtout tu attaches l'AbortController sur la fermeture de connexion. La déconnexion HTTP seule ne stoppe pas l'appel Anthropic — il faut explicitement signal.abort(), sinon le SDK continue de consommer (et facturer) des tokens dans le vide.
🔗 Liens
- Controllers : https://docs.nestjs.com/controllers
- Versioning : https://docs.nestjs.com/techniques/versioning
- Custom decorators : https://docs.nestjs.com/custom-decorators
- Middleware : https://docs.nestjs.com/middleware
- Guards / Interceptors / Pipes / Filters : https://docs.nestjs.com/guards