Middleware
TL;DR — Le middleware est la première couche qui voit la requête HTTP, avant que Nest n'attache son contexte (Guards, Interceptors, Pipes, Filters). Il vit dans l'écosystème de l'adapter (Express ou Fastify), pas dans le monde Nest pur. À utiliser pour la mécanique HTTP transverse (logging brut, parsing custom, CORS, helmet, request-id) — pas pour la logique métier ni l'autorisation fine.
🧠 Mental model
┌─ Express/Fastify world ─┐ ┌─────── Nest world ───────┐
HTTP Request ──► Adapter ─► Middleware(s) ───────► Guards ► Interceptors ► Pipes ► Handler
▲ │
│ ▼
└────────── Exception Filter ◄─── Interceptors ◄──┘Analogie — Le middleware c'est le portier de l'immeuble : il voit tout le monde entrer, peut bloquer, logger, modifier le sac. Mais il ne sait pas qui tu vas voir à quel étage (handler) ni les règles d'accès des appartements (guards). Si tu veux des règles fines liées au métier, monte d'un cran : Guard ou Interceptor.
Le middleware reçoit (req, res, next) brut. Il n'a pas accès à ExecutionContext comme un Guard/Interceptor — donc pas de getHandler(), pas de Reflector propre. C'est précisément ce qui en fait un outil bas niveau.
Tableau de décision — quelle couche pour quoi
Le réflexe staff face à un besoin transverse : remonter du plus bas (middleware) au plus haut (filter) et s'arrêter à la première couche qui a exactement l'accès nécessaire — ni plus, ni moins.
| Couche | A accès à | N'a PAS accès à | Court-circuite avant Nest ? | DI ? | Cas idéal |
|---|---|---|---|---|---|
| Middleware | req/res bruts, headers, raw body | handler ciblé, metadata, DTO validé | ✅ oui | classe oui, fn non | request-id, raw body, rate-limit grossier, ALS, helmet |
| Guard | ExecutionContext, Reflector, req.user (si auth amont) | response body | ❌ (pipeline déjà instancié) | ✅ | authz, @Roles(), feature flags par route |
| Pipe | valeur d'argument + metatype | autres args, response | ❌ | ✅ | validation/transformation DTO (class-validator) |
| Interceptor | ExecutionContext + flux RxJS de la réponse | raw socket | ❌ | ✅ | wrap réponse, timing handler propre, cache, retry/timeout |
| Exception Filter | l'exception + ArgumentsHost | — | — | ✅ | normalisation des erreurs, mapping HTTP |
Règle d'or : si le code dépend du protocole HTTP brut (URL, headers, raw body, court-circuit avant tout coût), c'est un middleware. S'il dépend du handler cible (metadata, response), c'est plus haut. Mettre de l'authz en middleware ou du raw-body en Guard sont les deux erreurs symétriques classiques.
🛠️ Code minimal
// Functional middleware — recommandé quand pas de DI lourde
import { Request, Response, NextFunction } from 'express';
export function requestId(req: Request, res: Response, next: NextFunction) {
const id = (req.headers['x-request-id'] as string) ?? crypto.randomUUID();
(req as any).id = id;
res.setHeader('x-request-id', id);
next();
}
// Class middleware — quand on a besoin de DI (logger, config, services)
import { Injectable, NestMiddleware, Logger } from '@nestjs/common';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
private readonly logger = new Logger('HTTP');
constructor(private readonly metrics: MetricsService) {}
use(req: Request, res: Response, next: NextFunction) {
const start = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
this.metrics.observeLatency(req.method, req.route?.path ?? req.path, res.statusCode, ms);
this.logger.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${ms.toFixed(1)}ms`);
});
next();
}
}// Wiring via MiddlewareConsumer
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common';
@Module({ imports: [...], controllers: [UsersController], providers: [LoggerMiddleware] })
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(requestId, LoggerMiddleware) // ordre = ordre d'exécution
.exclude(
{ path: 'health', method: RequestMethod.GET },
{ path: 'metrics', method: RequestMethod.GET },
)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}// Global middleware (avant tout module) — typiquement helmet, cors, body parser
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(helmet());
app.use(requestId); // functional ok, mais pas de DI ici
await app.listen(3000);
}🎯 Patterns courants
- Request-id / Correlation-id — middleware fonctionnel global, propage l'id dans
AsyncLocalStoragepour le logger. Le request-id est aussi posé en header de réponse pour que le client puisse le citer en cas de bug. - Raw body capture — Stripe webhooks, HMAC : intercepter le
Bufferavantbody-parser. À mettre avantapp.use(express.json())sinon le buffer est déjà consommé. Pattern :app.use('/webhooks', express.raw({ type: '*/*' }))puisapp.use(express.json()). - Tenant resolution — extraire le sous-domaine ou header
x-tenant, attacherreq.tenant. Fait en middleware car nécessaire dès le DI scope si on utiliseScope.REQUEST. Pour un multi-tenant strict, le middleware doit throw si le tenant est inconnu — c'est défensif. - Helmet / CORS / compression — typiquement via
app.use()global, pas viaMiddlewareConsumer.helmet({ contentSecurityPolicy: false })si Swagger UI casse à cause de CSP. CORS viaapp.enableCors({...})est en pratique un middleware sous le capot. - Rate limiting brut (express-rate-limit) en middleware ; le
@nestjs/throttlerest un Guard plus intégré (lit@Throttle()metadata par route, supporte Redis store). - Maintenance gate — court-circuiter avec
res.status(503)sans appelernext()quandMAINTENANCE_MODE=1. Exclure/healthdu middleware pour que k8s ne tue pas le pod. - AsyncLocalStorage binding — un middleware global qui ouvre un
als.run(context, next). Tout le code applicatif (loggers, audit) peut lire le contexte sans le passer en paramètre. - HTTPS redirect / HSTS — derrière un LB qui termine TLS : lire
x-forwarded-proto, rediriger sihttp. Avecapp.set('trust proxy', ...)côté Express.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
| Version | Changements clés |
|---|---|
| Nest 7 | MiddlewareConsumer.with() deprecated. Fastify v2 par défaut sur l'adapter. |
| Nest 8 | Fastify v3, Express reste 4.x. Middleware fonctionnel pleinement supporté. |
| Nest 9 | RxJS 7 partout — n'impacte pas les middlewares (Express style). Fastify v4. |
| Nest 10 | Node 16 minimum. Fastify v4 stable. apply() accepte des fonctions et classes mélangées. |
| Nest 11 | Node 20 minimum. Express 5 supporté (path-to-regexp v6 ⇒ plus de * lâche, utiliser /*splat ou RequestMethod.ALL). Fastify v5. |
Express 5 piège : forRoutes('*') peut casser. Préférer forRoutes({ path: '*', method: RequestMethod.ALL }) ou la nouvelle syntaxe /*path.
⚠️ Pitfalls
- Oublier
next()— la requête pend jusqu'au timeout. Symptôme : 504 silencieux, pas d'erreur claire. Settings : toujours appelernext()(ounext(err)) sur chaque chemin d'exécution. - Appeler
next()ET écrire la réponse — double envoi,ERR_HTTP_HEADERS_SENT. Soit tu réponds (res.json(...)puis return), soit tu passes la main (next()), jamais les deux. - Mettre de la logique d'autorisation en middleware — pas d'accès au handler/metadata, donc impossible de lire
@Roles(). Utiliser un Guard. - DI dans un middleware fonctionnel global — impossible, pas d'injecteur. Passer en classe middleware ou wrapper. Astuce : créer un singleton "service holder" exporté depuis un module, et l'appeler depuis la fonction.
- Async middleware sans
await— un middleware peut êtreasync, mais Nest n'attend pas la promise rejetée. Toujourstry/catchet appelernext(err)pour propager au Exception Filter. - Ordre piégeux —
consumer.apply(A, B).forRoutes(...)exécute A puis B. Mais si tu fais deux.apply()séparés dans des modules différents, l'ordre dépend de l'ordre d'import des modules. Pour l'ordre déterministe, tout grouper dansAppModule. - Middleware sur route paramétrée —
forRoutes('users/:id')matche aussiusers/:id/posts. Préciser via objets{ path, method }ou utiliser une regex (sur Express 4 uniquement). - Fastify vs Express incompatibilité —
req.originalUrln'existe pas en Fastify natif (utiliserreq.raw.originalUrlou abstraire viaHttpAdapterHost). Les middlewares typés(req: Request, res: Response, next)Express ne marchent pas tels quels sur Fastify (qui a son propreFastifyRequest/FastifyReply). - Body parsing custom + Nest body parser — par défaut Nest active
express.json()au boot. Si tu poses un raw body sur/webhooksaprès, il est déjà parsed. Solution :NestFactory.create(AppModule, { bodyParser: false })puis activer manuellement les parsers dans l'ordre voulu. req.paramsnon disponible — un middleware global n'a pas le contexte du routeur Nest, doncreq.paramspeut être{}même sur une route:id. Pour lire un param dans un middleware, attacher viaforRoutes({ path: 'users/:id' })+ Express route-level middleware.
🧪 Testing
// Functional middleware — test pur, aucune dépendance Nest
import { requestId } from './request-id.middleware';
it('génère un id si header absent', () => {
const req: any = { headers: {} };
const res: any = { setHeader: jest.fn() };
const next = jest.fn();
requestId(req, res, next);
expect(req.id).toMatch(/^[0-9a-f-]{36}$/);
expect(res.setHeader).toHaveBeenCalledWith('x-request-id', req.id);
expect(next).toHaveBeenCalledTimes(1);
});
it('réutilise le header existant', () => {
const req: any = { headers: { 'x-request-id': 'abc-123' } };
const res: any = { setHeader: jest.fn() };
requestId(req, res, jest.fn());
expect(req.id).toBe('abc-123');
});
// Class middleware avec DI
import { Test } from '@nestjs/testing';
const mod = await Test.createTestingModule({
providers: [LoggerMiddleware, { provide: MetricsService, useValue: { observeLatency: jest.fn() } }],
}).compile();
const mw = mod.get(LoggerMiddleware);
// Intégration : supertest sur l'app complète
const app = mod.createNestApplication();
await app.init();
await request(app.getHttpServer()).get('/users').expect('x-request-id', /.+/);Pour les middlewares globaux complexes (helmet/CORS) — tester via supertest en e2e, pas en unitaire. Vérifier les headers de réponse (expect('Strict-Transport-Security', /max-age/)). Pour les middlewares ALS-based, écrire un test qui chaîne plusieurs handlers et vérifie que le contexte propage bien à travers await (un piège classique : setImmediate ou des callbacks anciens cassent le contexte).
🎬 Cas d'usage concrets
Scénario 1 — Banque : audit log immuable pour compliance ACPR
Qui — Une néobanque FR (140 ETP, agrément EME ACPR) qui doit logger chaque appel API avec un format normé pour les contrôles ACPR. ≈ 9 000 RPS en pointe.
Problème métier — L'ACPR exige : (1) un log immuable de chaque requête HTTP, (2) corrélation cross-services, (3) traçabilité user/device/IP, (4) durée 5 ans. Logger ça en interceptor cassait sur les routes sans Guard (le user n'était pas attaché). Logger côté API Gateway perdait le contexte applicatif (handler, tenant).
Comment ce concept aide — Un middleware global qui capture la requête brute dès l'entrée, attribue un correlation-id, écrit dans un log immuable (Kafka topic audit.api). Le middleware tourne avant tout — il voit même les 401 et les 4xx non-authentifiés, ce qui répond à l'exigence ACPR de tracer même les tentatives invalides.
@Injectable()
export class AcprAuditMiddleware implements NestMiddleware {
constructor(@Inject('AUDIT_PRODUCER') private readonly kafka: AuditProducer) {}
use(req: any, res: Response, next: NextFunction) {
const correlationId = (req.headers['x-correlation-id'] as string) ?? crypto.randomUUID();
req.correlationId = correlationId;
res.setHeader('x-correlation-id', correlationId);
const start = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
this.kafka.emit({
timestamp: new Date().toISOString(),
correlationId,
method: req.method,
path: req.originalUrl,
status: res.statusCode,
durationMs: ms,
userId: req.user?.id ?? null,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
});
next();
}
}Gains chiffrés — Audit ACPR validé en 1 itération (vs 3 réserves auparavant), retention compliant via Kafka → S3 Glacier, recherche de traces sur 5 ans en < 30s grâce à la partition par jour. Coût stockage divisé par 4 vs CloudWatch.
Scénario 2 — SaaS RH : tenant resolution par sous-domaine
Qui — Un SIRH FR (45 ETP, 600 clients PME). Chaque société accède via son sous-domaine (acme.monsirh.fr).
Problème métier — Le tenant doit être résolu avant que le pipeline Nest ne démarre, car le DatabaseModule doit pointer la bonne shard PG. Avant : un Guard faisait la résolution, mais arrivait trop tard (le repo était déjà initialisé sur la mauvaise DB).
Comment ce concept aide — Middleware global qui lit le sous-domaine, hit le registre tenant (cache 5 min), et pose req.tenant + ouvre un AsyncLocalStorage que le DatabaseModule lit à chaque query.
@Injectable()
export class TenantResolverMiddleware implements NestMiddleware {
constructor(
private readonly registry: TenantRegistryService,
@Inject('TENANT_ALS') private readonly als: AsyncLocalStorage<TenantContext>,
) {}
async use(req: any, res: Response, next: NextFunction) {
try {
const sub = (req.hostname ?? '').split('.')[0];
if (!sub || sub === 'www' || sub === 'api') return next();
const tenant = await this.registry.findBySlug(sub);
if (!tenant) return res.status(404).json({ error: 'tenant_not_found' });
if (!tenant.active) return res.status(403).json({ error: 'tenant_suspended' });
req.tenant = tenant;
this.als.run({ tenantId: tenant.id, shardUrl: tenant.shardUrl }, () => next());
} catch (e) {
next(e);
}
}
}Gains chiffrés — 0 fuite cross-tenant en 14 mois (Prisma plugin lit l'ALS pour scope chaque query), résolution tenant en < 2ms (cache LRU 5 min), DX dev améliorée (pas de paramètre tenantId à passer partout).
Scénario 3 — E-commerce : rate-limit par tier client
Qui — Un marketplace e-commerce FR (60 ETP) qui expose une API publique consommée par 2 800 marchands. Tier gratuit (100 req/min), Pro (1 000), Enterprise (illimité).
Problème métier — Le rate-limit ne peut pas vivre dans un Guard pur (les marchands abusifs envoient 10 000 req/s sur des routes non protégées, le coût d'instancier Nest par requête tue le serveur). Il faut bloquer avant que Nest ne s'engage.
Comment ce concept aide — Un middleware Redis-backed qui lit l'API key, regarde le tier, incrémente un compteur sliding window, et renvoie 429 sans toucher au pipeline Nest si dépassement. Combiné avec un Guard fine-grained sur les routes business pour les quotas par endpoint.
@Injectable()
export class TieredRateLimitMiddleware implements NestMiddleware {
constructor(@Inject('REDIS') private readonly redis: Redis) {}
async use(req: any, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) return next();
const tier = await this.redis.get(`tier:${apiKey}`);
const limits: Record<string, number> = { free: 100, pro: 1000, enterprise: Infinity };
const limit = limits[tier ?? 'free'];
if (limit === Infinity) return next();
const key = `rl:${apiKey}:${Math.floor(Date.now() / 60_000)}`;
const count = await this.redis.incr(key);
if (count === 1) await this.redis.expire(key, 60);
if (count > limit) {
res.setHeader('Retry-After', '60');
return res.status(429).json({ error: 'rate_limit_exceeded', tier, limit });
}
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, limit - count)));
next();
}
}Gains chiffrés — Incidents DDOS/abuse passés de 3-4/mois à 0, coût infra divisé par 1.8 (le middleware court-circuite avant que Nest n'instancie le pipeline), 12% des marchands free ont upgraded vers Pro suite à des dépassements visibles dans le dashboard.
🛠️ Exemple end-to-end
Use case — On bâtit la chaîne middleware d'une néobanque conforme ACPR : raw body capture pour webhooks Stripe, request-id propagé via ALS, audit log immuable Kafka, tenant resolution, et rate-limit par tier. Le tout dans le bon ordre, testé, observable.
// src/middleware/correlation.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContext {
correlationId: string;
startedAt: number;
userId?: string;
tenantId?: string;
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
@Injectable()
export class CorrelationMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const correlationId = (req.headers['x-correlation-id'] as string) ?? crypto.randomUUID();
const ctx: RequestContext = { correlationId, startedAt: Date.now() };
(req as any).correlationId = correlationId;
res.setHeader('x-correlation-id', correlationId);
requestContext.run(ctx, () => next());
}
}// src/middleware/audit.middleware.ts
import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { AuditProducer } from '../audit/audit.producer';
import { requestContext } from './correlation.middleware';
@Injectable()
export class AuditMiddleware implements NestMiddleware {
constructor(private readonly audit: AuditProducer) {}
use(req: Request, res: Response, next: NextFunction) {
res.on('finish', () => {
const ctx = requestContext.getStore();
if (!ctx) return;
this.audit.send({
timestamp: new Date().toISOString(),
correlationId: ctx.correlationId,
method: req.method,
path: req.originalUrl,
status: res.statusCode,
durationMs: Date.now() - ctx.startedAt,
userId: ctx.userId ?? null,
tenantId: ctx.tenantId ?? null,
ip: req.ip,
userAgent: req.headers['user-agent'] ?? null,
});
});
next();
}
}L'audit est posé sur res.on('finish') — il logge même les 404 et 500. Le requestContext.getStore() donne accès au correlation-id et aux infos enrichies par les autres couches.
// src/middleware/tenant-resolver.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { TenantRegistryService } from '../tenants/tenant-registry.service';
import { requestContext } from './correlation.middleware';
@Injectable()
export class TenantResolverMiddleware implements NestMiddleware {
constructor(private readonly registry: TenantRegistryService) {}
async use(req: Request, res: Response, next: NextFunction) {
try {
const headerTid = req.headers['x-tenant'] as string | undefined;
if (!headerTid) return next();
const tenant = await this.registry.findBySlug(headerTid);
if (!tenant) return res.status(404).json({ error: 'tenant_not_found' });
if (!tenant.active) return res.status(403).json({ error: 'tenant_suspended' });
(req as any).tenant = tenant;
const ctx = requestContext.getStore();
if (ctx) ctx.tenantId = tenant.id;
next();
} catch (e) {
next(e);
}
}
}// src/middleware/rate-limit.middleware.ts
import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
@Injectable()
export class RateLimitMiddleware implements NestMiddleware {
constructor(@Inject('REDIS') private readonly redis: Redis) {}
async use(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) return next();
const tier = (await this.redis.get(`tier:${apiKey}`)) ?? 'free';
const limits: Record<string, number> = { free: 100, pro: 1000, enterprise: 10_000 };
const limit = limits[tier];
const window = Math.floor(Date.now() / 60_000);
const key = `rl:${apiKey}:${window}`;
const count = await this.redis.incr(key);
if (count === 1) await this.redis.expire(key, 60);
res.setHeader('X-RateLimit-Limit', String(limit));
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, limit - count)));
if (count > limit) {
res.setHeader('Retry-After', '60');
return res.status(429).json({ error: 'rate_limit_exceeded', tier, limit });
}
next();
}
}// src/app.module.ts
import { Module, MiddlewareConsumer, NestModule, RequestMethod } from '@nestjs/common';
import { CorrelationMiddleware } from './middleware/correlation.middleware';
import { AuditMiddleware } from './middleware/audit.middleware';
import { TenantResolverMiddleware } from './middleware/tenant-resolver.middleware';
import { RateLimitMiddleware } from './middleware/rate-limit.middleware';
import { AuditModule } from './audit/audit.module';
import { TenantsModule } from './tenants/tenants.module';
import { RedisModule } from './redis/redis.module';
import { PaymentsModule } from './payments/payments.module';
@Module({
imports: [AuditModule, TenantsModule, RedisModule, PaymentsModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
// Ordre = ordre d'exécution
consumer
.apply(CorrelationMiddleware, AuditMiddleware, TenantResolverMiddleware, RateLimitMiddleware)
.exclude(
{ path: 'health', method: RequestMethod.GET },
{ path: 'metrics', method: RequestMethod.GET },
)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}// src/main.ts
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import * as express from 'express';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
// 1) Helmet en premier (global)
app.use(helmet());
// 2) Raw body capture UNIQUEMENT sur /webhooks (avant le JSON parser)
app.use('/webhooks', express.raw({
type: 'application/json',
limit: '1mb',
verify: (req: any, _res, buf) => { req.rawBody = buf; },
}));
// 3) JSON pour le reste
app.use(express.json({ limit: '1mb' }));
await app.listen(3000);
}
bootstrap();// src/payments/payments.controller.ts
import { Controller, Headers, Post, Req } from '@nestjs/common';
import { Request } from 'express';
import Stripe from 'stripe';
@Controller('webhooks')
export class WebhooksController {
private stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
@Post('stripe')
handleStripe(@Req() req: Request & { rawBody: Buffer }, @Headers('stripe-signature') sig: string) {
const event = this.stripe.webhooks.constructEvent(req.rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!);
return { received: true, type: event.type };
}
}L'ensemble illustre les patterns clés : (a) middleware globaux dans main.ts (helmet, raw body, JSON parser) avec ordre maîtrisé via bodyParser: false, (b) middlewares Nest dans AppModule avec ordre garanti par l'ordre dans apply(), (c) AsyncLocalStorage pour propager le contexte sans paramètre, (d) exclusion des routes health/metrics pour ne pas bruiter l'audit ni dépendre de Redis pour les probes k8s, (e) audit immuable sur res.on('finish') qui capture même les 4xx/5xx. C'est la fondation d'une app conforme ACPR.
🔁 Quand utiliser / éviter
Utiliser middleware :
- Mécanique HTTP transverse indépendante du handler (logging, request-id, helmet, CORS, body parsing custom, maintenance gate).
- Besoin d'agir avant le pipeline Nest (raw body, tenant resolution pour Scope.REQUEST).
- Intégration de lib Express/Fastify existante (passport stratégies de base, multer, csurf legacy, etc.).
- Initialisation d'
AsyncLocalStoragequi doit couvrir toute la chaîne (y compris Guards/Interceptors).
Éviter middleware, préférer Guard :
- Authentification / autorisation — Guard a accès à
ExecutionContextet au metadata viaReflector. - Décisions liées au handler (rôles, scopes, feature flags par route).
Éviter middleware, préférer Interceptor :
- Transformer le
bodyde la réponse, mesurer la durée du handler proprement (pas du round-trip réseau), wrapper en Observable (timeout, retry). - Cache de réponse, sérialisation conditionnelle.
Éviter middleware, préférer Pipe :
- Validation / transformation du payload entrant typée (DTO + class-validator).
Règle de pouce — si le code dépend du handler cible (lire metadata, transformer la réponse), c'est probablement un Guard, Interceptor ou Pipe. Si le code dépend du protocole HTTP brut (URL, headers, raw body), c'est probablement un middleware.
🧰 Exemples avancés
Middleware + AsyncLocalStorage (request context)
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContext { requestId: string; userId?: string; tenantId?: string }
export const requestContext = new AsyncLocalStorage<RequestContext>();
@Injectable()
export class ContextMiddleware implements NestMiddleware {
use(req: any, res: Response, next: NextFunction) {
const ctx: RequestContext = {
requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
userId: req.user?.id,
tenantId: req.headers['x-tenant'] as string,
};
requestContext.run(ctx, () => next()); // tout le pipeline tourne dans ce store
}
}
// Logger qui lit le contexte sans paramètre
export class ContextAwareLogger extends Logger {
log(message: string) {
const ctx = requestContext.getStore();
super.log(`[${ctx?.requestId}] ${message}`);
}
}Tenant resolution middleware
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private readonly tenants: TenantsService) {}
async use(req: any, res: Response, next: NextFunction) {
try {
// 1) subdomain : acme.example.com → acme
const host = req.hostname ?? '';
const sub = host.split('.')[0];
// 2) ou header explicite
const headerTid = req.headers['x-tenant'] as string | undefined;
const slug = headerTid ?? sub;
if (!slug || slug === 'www' || slug === 'api') {
return next(); // route publique, pas de tenant requis
}
const tenant = await this.tenants.findBySlug(slug);
if (!tenant) return res.status(404).json({ error: 'tenant_not_found' });
if (!tenant.active) return res.status(403).json({ error: 'tenant_suspended' });
req.tenant = tenant;
next();
} catch (e) {
next(e);
}
}
}Raw body pour webhooks (Stripe)
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
// 1) raw uniquement pour /webhooks
app.use('/webhooks', express.raw({ type: 'application/json', verify: (req: any, _res, buf) => { req.rawBody = buf; } }));
// 2) JSON pour le reste
app.use(express.json());
await app.listen(3000);
}
// Dans le controller :
@Post('webhooks/stripe')
handle(@Req() req: any, @Headers('stripe-signature') sig: string) {
const event = stripe.webhooks.constructEvent(req.rawBody, sig, secret);
// ...
}🤖 Servir un agent IA depuis un middleware
Quand NestJS expose des endpoints LLM (chat, agent tool-use, RAG), le middleware est la bonne couche pour trois préoccupations transverses qui doivent agir avant que Nest n'engage le pipeline (et avant qu'on n'appelle un modèle facturé au token) : idempotency, rate-limit / cost-guard, et propagation du contexte de génération (correlation-id + generationId). La règle staff : tout ce qui est cher (un appel à claude-opus-4-8) doit être protégé par un garde bon marché (une lecture Redis) en amont.
Mental model — un appel LLM coûte 100 000× une lecture Redis. Le middleware est le videur qui refuse l'entrée avant que le client ne s'asseye à une table à 200€. Il ne fait pas l'appel LLM lui-même (ça, c'est le service/handler streamé en SSE) — il décide juste si la requête a le droit de coûter de l'argent.
1) Cost-guard + idempotency middleware
// src/ai/ai-guard.middleware.ts
import { Inject, Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
import { requestContext } from '../middleware/correlation.middleware';
@Injectable()
export class AiCostGuardMiddleware implements NestMiddleware {
constructor(@Inject('REDIS') private readonly redis: Redis) {}
async use(req: Request, res: Response, next: NextFunction) {
const apiKey = req.headers['x-api-key'] as string | undefined;
if (!apiKey) return res.status(401).json({ error: 'api_key_required' });
// (a) Cost-guard : budget tokens/jour par clé. Lecture O(1), AVANT tout appel modèle.
const spentKey = `ai:spend:${apiKey}:${new Date().toISOString().slice(0, 10)}`;
const spent = Number((await this.redis.get(spentKey)) ?? 0);
const budget = Number((await this.redis.get(`ai:budget:${apiKey}`)) ?? 1_000_000);
if (spent >= budget) {
res.setHeader('Retry-After', '3600');
return res.status(429).json({ error: 'token_budget_exceeded', spent, budget });
}
// (b) Idempotency : le client fournit une clé ; on rejoue la réponse cachée si rejeu.
const idemKey = req.headers['idempotency-key'] as string | undefined;
if (idemKey) {
const cached = await this.redis.get(`ai:idem:${apiKey}:${idemKey}`);
if (cached) {
res.setHeader('Idempotent-Replay', 'true');
return res.status(200).type('application/json').send(cached);
}
// generationId stable pour les retries BullMQ en aval (clé d'idempotence du job)
const ctx = requestContext.getStore();
if (ctx) (ctx as any).generationId = idemKey;
}
next();
}
}Points staff :
- Le cost-guard lit un compteur de tokens consommés (incrémenté en fin de stream par le service LLM via
usage.input_tokens + usage.output_tokens). Bloquer ici évite qu'un client en boucle ne brûle 500€ avant qu'un Guard ne se réveille. - L'idempotency-key est posée dans l'
AsyncLocalStoragecommegenerationId. En aval, un job BullMQ utilise cegenerationIdcommejobId→ un retry réseau du client ne relance pas la génération, il rejoint le job en cours. C'est la seule façon correcte de rendre un appel LLM (non-déterministe, coûteux, long) idempotent. - Race de concurrence : le bloc
getpuissetci-dessus n'est pas atomique. Deux requêtes simultanées avec la mêmeidempotency-keypassent toutes deux le check « cache vide ». La version production-grade pose un lock atomique :SET ai:idem:{key} '<pending>' NX EX 300— le perdant duNXpoll le résultat au lieu de relancer une génération. Voir l'Exercice 5. - Ne mets jamais
new Anthropic()dans un champ de middleware. Le client LLM doit être un provider DI, injecté dans le service (pas le middleware). Le middleware n'appelle pas le modèle ; il garde la porte.
Le provider LLM DI'd (forRootAsync)
Le client Anthropic est un singleton coûteux à construire (pool de connexions, config retries). Le créer dans un champ (private client = new Anthropic()) le rend non-testable (impossible de mocker), non-configurable (clé en dur), et duplique l'instance à chaque consumer. La forme staff : un module dynamique qui lit la config de façon asynchrone.
// src/ai/anthropic.module.ts
import { Module, DynamicModule, Provider } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC_CLIENT');
@Module({})
export class AnthropicModule {
static forRootAsync(): DynamicModule {
const provider: Provider = {
provide: ANTHROPIC,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Anthropic({
apiKey: config.getOrThrow<string>('ANTHROPIC_API_KEY'),
maxRetries: 3, // le SDK retry 429/5xx avec backoff exponentiel
timeout: 60_000, // par requête ; un stream long doit utiliser .stream(), pas ce timeout
}),
};
return {
module: AnthropicModule,
imports: [ConfigModule],
providers: [provider],
exports: [provider], // exporté pour que les services le consomment
global: true, // un seul client partagé dans toute l'app
};
}
}// src/ai/chat.service.ts — c'est ICI qu'on appelle le modèle, pas dans le middleware
import { Inject, Injectable } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from './anthropic.module';
@Injectable()
export class ChatService {
constructor(@Inject(ANTHROPIC) private readonly client: Anthropic) {}
async *streamTokens(prompt: string, signal: AbortSignal) {
const stream = this.client.messages.stream(
{
model: 'claude-opus-4-8',
max_tokens: 4096,
thinking: { type: 'adaptive' }, // pas de budget_tokens sur Opus 4.8 (400)
messages: [{ role: 'user', content: prompt }],
},
{ signal }, // relaie l'AbortSignal posé par le middleware
);
for await (const event of stream) {
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
yield event.delta.text;
}
}
const final = await stream.finalMessage();
// incrémenter le compteur de cost-guard lu par le middleware en amont
return final.usage.input_tokens + final.usage.output_tokens;
}
}Le middleware lit Redis (bon marché), le service appelle claude-opus-4-8 via le client DI'd (cher). Séparation nette : le middleware décide si la requête a le droit de coûter, le service exécute le coût. Modèles de référence : claude-opus-4-8 (flagship), claude-sonnet-4-6 (équilibre), claude-haiku-4-5 (latence/coût). Toujours SDK officiel + .stream() + retries SDK.
2) AbortController : annuler le stream quand le client se déconnecte
Le middleware ne stream pas, mais il pose une primitive que le handler SSE consommera : un AbortController lié à req.on('close'). Sans ça, un client qui ferme l'onglet laisse claude-opus-4-8 générer (et facturer) dans le vide.
// src/ai/abort.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class AbortSignalMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const ac = new AbortController();
// 'close' sur req = la connexion client est partie. On garde sur res.writableEnded
// pour ne PAS abort une fin de réponse normale (le 'close' tire aussi après res.end()).
req.on('close', () => {
if (!res.writableEnded) ac.abort();
});
(req as any).abortSignal = ac.signal;
next();
}
}Dans le service streamé, on relaie ce signal au SDK (client.messages.stream(params, { signal })) — l'annulation côté client coupe l'appel HTTP sortant vers l'API Anthropic, donc on arrête de payer dès le close. Côté Angular, le bouton « Stop » fait eventSource.close() / controller.abort(), ce qui ferme la socket et déclenche ce req.on('close') côté serveur : double annulation, client ET serveur.
Pièges staff sur cette plomberie :
req.on('close')vsres.on('close')— en Express,res.on('close')tire aussi sur une fin normale ; le garderes.writableEndeddistingue « le client a coupé » d'« on a fini d'écrire ». En Express 5 / Node 18+, le plus robuste estreq.on('close')(l'objetIncomingMessage), qui ne tire que sur déconnexion réelle de la socket entrante.- Pas de DI dans la fonction — ce middleware est fonctionnel-pur (pas de service injecté), donc on pourrait le poser en
app.use()global. Mais comme il pose une primitive lue par un handler Nest, le câbler viaMiddlewareConsumer.forRoutes('ai/*')le scope aux routes LLM uniquement. - Double-abort — si le SDK a déjà fini (
finalMessage()résolu) et que le client coupe juste après,ac.abort()sur un stream terminé est un no-op silencieux côté SDK ; inutile de s'en prémunir au-delà dures.writableEnded. - Fastify —
req.raw.on('close', ...)(l'objet Node sous-jacent), carFastifyRequestn'est pas unEventEmitterNode direct.
3) Exposer un endpoint MCP / agent
Pour exposer un serveur MCP (Model Context Protocol) ou un endpoint agentique, le middleware reste la couche d'authentification de transport et de negotiation (vérifier le header MCP-Protocol-Version, router /mcp vers le bon transport SSE/streamable-http) — la logique tool-use (la boucle agentique : model → tool_use → exécuter → tool_result → model) vit dans un service, pas le middleware. Le middleware borne juste qui peut ouvrir une session, à quel coût, avec quel budget de tokens. Modèles de référence : claude-opus-4-8 (flagship raisonnement/agent), claude-sonnet-4-6 (équilibre), claude-haiku-4-5 (latence/coût). Toujours SDK officiel + streaming + retries SDK, jamais de fetch artisanal vers l'API.
🏋️ Exercices
Exercice 1 — Request-id propagé via ALS (implémenter)
Objectif — Écrire un middleware fonctionnel global qui pose un requestId (réutilise x-request-id s'il existe, sinon crypto.randomUUID()), le renvoie en header de réponse, et le rend lisible par n'importe quel logger via AsyncLocalStorage sans le passer en paramètre.
Indice/Solution — als.run(ctx, () => next()) (et non als.run(ctx, next) — il faut un wrapper pour que tout le pipeline aval, Guards inclus, hérite du store). Le logger fait als.getStore()?.requestId. Tester en chaînant deux handlers async et en vérifiant que le contexte survit à un await.
Exercice 2 — Maintenance gate excluant /health (production-grade)
Objectif — Middleware qui renvoie 503 + Retry-After quand MAINTENANCE_MODE=1, mais laisse passer /health et /metrics (sinon k8s tue le pod et tu ne peux plus le redémarrer). Rendre le toggle hot : lisible depuis Redis sans redéploiement.
Indice/Solution — exclude({ path: 'health', method: RequestMethod.GET }, { path: 'metrics', method: RequestMethod.GET }) dans le MiddlewareConsumer. Le toggle Redis : un cache local 2s (évite un hit Redis par requête à 9k RPS). Piège : si tu court-circuites, fais res.status(503).json(...) puis return — surtout pas de next() après.
Exercice 3 — Raw body Stripe sans casser le JSON parser (break-then-fix)
Objectif — Capturer le Buffer brut sur /webhooks/stripe pour la vérification HMAC, sans casser le parsing JSON du reste de l'app. Puis casser délibérément en mettant express.json() avant le raw et observer constructEvent échouer (signature invalide car body re-sérialisé), puis corriger.
Indice/Solution — NestFactory.create(AppModule, { bodyParser: false }), puis app.use('/webhooks', express.raw({ type: 'application/json', verify: (req, _r, buf) => { req.rawBody = buf } })) avant express.json(). Le bug à reproduire : si json() passe en premier, req.rawBody est absent ou le body est déjà consommé → Stripe signature verification failed. La fix est purement une question d'ordre.
Exercice 4 — Rate-limit Redis sliding-window par tier (production-grade)
Objectif — Middleware qui lit x-api-key, résout le tier (free/pro/enterprise), applique un sliding-window Redis, renvoie 429 + Retry-After + X-RateLimit-Remaining, et court-circuite avant le pipeline Nest. Le rendre résistant : que se passe-t-il si Redis tombe ?
Indice/Solution — INCR + EXPIRE sur rl:{key}:{minute} (fixed window simple ; pour un vrai sliding window, un script Lua avec ZADD/ZREMRANGEBYSCORE). Failure mode Redis down : choisir explicitement fail-open (laisser passer, alerter) ou fail-closed (429) — c'est une décision produit, pas technique. Pour une API publique payante : fail-open + circuit breaker + alerte, jamais laisser Redis être un SPOF qui coupe le revenu.
Exercice 5 — AI cost-guard idempotent (staff)
Objectif — Middleware devant /ai/chat qui (a) refuse si le budget tokens du jour est dépassé, (b) rejoue une réponse cachée sur Idempotency-Key identique, (c) pose un generationId dans l'ALS pour que le job BullMQ aval soit idempotent. Puis casser : envoyer 50 requêtes concurrentes avec la même idempotency-key et vérifier qu'une seule génération part.
Indice/Solution — Le piège de concurrence : deux requêtes simultanées passent toutes deux le check « cache vide » avant qu'aucune n'ait écrit. Solution : SET ai:idem:{k} <pending> NX EX 300 (lock atomique) — la 2e perd le NX et attend/poll le résultat. Le generationId = idempotencyKey devient le jobId BullMQ ; BullMQ dédoublonne nativement par jobId. Mesurer : 1 seul appel à claude-opus-4-8 pour 50 requêtes → coût ÷ 50.
Exercice 6 — AbortController de bout en bout (staff, break-then-fix)
Objectif — Câbler l'annulation client→serveur sur un endpoint SSE qui stream des tokens LLM. Middleware pose req.abortSignal, le service le relaie au SDK. Puis observer la fuite : commenter le req.on('close') et vérifier dans les logs que la génération continue (et facture) après fermeture de l'onglet.
Indice/Solution — const ac = new AbortController(); req.on('close', () => ac.abort()); puis client.messages.stream(params, { signal: ac.signal }). Sans le close handler, le SDK continue jusqu'au bout → tokens facturés pour personne. Vérifier que res.writableEnded n'est pas déjà à true avant d'abort() (sinon double-abort sur une fin normale). La preuve : compter les usage.output_tokens émis après le close.
🎤 En entretien
Q: Pourquoi mettre l'authentification en Guard plutôt qu'en middleware ? Le middleware reçoit (req, res, next) brut, sans ExecutionContext : il ne peut pas lire les metadata du handler (@Roles(), @Public() via Reflector) ni savoir quelle route est ciblée par Nest. L'autorisation dépend du handler → c'est structurellement un Guard. Le middleware ne sait que parler HTTP brut.
Q: Tu poses un AsyncLocalStorage dans un middleware avec als.run(ctx, () => next()). Pourquoi le wrapper arrow plutôt que als.run(ctx, next) ? Parce que next peut recevoir un argument (next(err)) ; le passer directement à als.run change la sémantique et certains chemins d'erreur sortent du store. Le wrapper garantit que tout le pipeline aval (Guards, Interceptors, handler, et même res.on('finish') s'il est attaché dans le store) tourne dans le contexte ALS. Piège connexe : un callback legacy (setImmediate, certains drivers) peut casser la propagation — d'où le test de chaînage async.
Q: Un client spamme une route LLM non protégée à 10k req/s. Pourquoi un Guard @nestjs/throttler ne suffit pas et où mets-tu la défense ? Un Guard s'exécute après que Nest a instancié le pipeline de la requête (résolution DI scope-request, parsing, etc.) — à 10k req/s, ce coût d'instanciation tue le serveur avant même le verdict du Guard. La défense bon marché (lecture Redis O(1)) doit vivre en middleware, en amont, pour court-circuiter avec un 429 avant que Nest ne s'engage. Pour les coûts LLM, on ajoute un cost-guard tokens/jour au même endroit : protéger un appel cher (claude-opus-4-8) par un garde 100 000× moins cher.
Q: Comment rends-tu un endpoint LLM streamé idempotent et annulable ? Idempotent : Idempotency-Key → lock Redis SET ... NX pour dédoublonner les requêtes concurrentes, et generationId = idempotencyKey réutilisé comme jobId BullMQ (dédup native). Annulable : AbortController lié à req.on('close') côté serveur, relayé au SDK Anthropic via { signal } ; côté client un bouton Stop fait abort()/eventSource.close(). La fermeture client déclenche le close serveur → on arrête de payer les tokens immédiatement. Double annulation, client ET serveur. Le client Anthropic lui-même est un provider DI'd (forRootAsync lisant la clé via ConfigService, maxRetries: 3), injecté dans le service, jamais instancié dans un champ de middleware.
Q: Pourquoi als.getStore() peut-il retourner undefined dans un res.on('finish'), et comment garantir la corrélation des logs sous 9k RPS ? Si l'ALS est ouvert après l'attachement du listener, ou si le als.run() n'englobe pas next() (cf. le wrapper arrow), le callback finish s'exécute hors du store → getStore() est undefined, le correlation-id manque sur les logs d'erreur (exactement quand tu en as le plus besoin). La garantie : (1) le middleware ALS est le tout premier dans apply(), (2) il wrappe next() dans als.run(ctx, () => next()), (3) tout setImmediate/callback de driver legacy à l'intérieur est re-wrappé via als.run car certains cassent la propagation. Le coût ALS est négligeable (≈ accès à un slot natif), donc pas de souci à 9k RPS — le vrai risque est la correction, pas la perf.
🔗 Liens
- Docs Nest — Middleware
- Express 5 migration — path-to-regexp v6
- AsyncLocalStorage docs Node
- Helmet
- @nestjs/throttler — rate-limit en Guard
- Anthropic SDK (TS) —
.stream(), retries,signal - Voir aussi
02-guards.md,03-interceptors.mdpour le découpage de responsabilités.