Skip to content

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.

CoucheA accès àN'a PAS accès àCourt-circuite avant Nest ?DI ?Cas idéal
Middlewarereq/res bruts, headers, raw bodyhandler ciblé, metadata, DTO validé✅ ouiclasse oui, fn nonrequest-id, raw body, rate-limit grossier, ALS, helmet
GuardExecutionContext, Reflector, req.user (si auth amont)response body❌ (pipeline déjà instancié)authz, @Roles(), feature flags par route
Pipevaleur d'argument + metatypeautres args, responsevalidation/transformation DTO (class-validator)
InterceptorExecutionContext + flux RxJS de la réponseraw socketwrap réponse, timing handler propre, cache, retry/timeout
Exception Filterl'exception + ArgumentsHostnormalisation 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

ts
// 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();
  }
}
ts
// 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 });
  }
}
ts
// 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

  1. Request-id / Correlation-id — middleware fonctionnel global, propage l'id dans AsyncLocalStorage pour le logger. Le request-id est aussi posé en header de réponse pour que le client puisse le citer en cas de bug.
  2. Raw body capture — Stripe webhooks, HMAC : intercepter le Buffer avant body-parser. À mettre avant app.use(express.json()) sinon le buffer est déjà consommé. Pattern : app.use('/webhooks', express.raw({ type: '*/*' })) puis app.use(express.json()).
  3. Tenant resolution — extraire le sous-domaine ou header x-tenant, attacher req.tenant. Fait en middleware car nécessaire dès le DI scope si on utilise Scope.REQUEST. Pour un multi-tenant strict, le middleware doit throw si le tenant est inconnu — c'est défensif.
  4. Helmet / CORS / compression — typiquement via app.use() global, pas via MiddlewareConsumer. helmet({ contentSecurityPolicy: false }) si Swagger UI casse à cause de CSP. CORS via app.enableCors({...}) est en pratique un middleware sous le capot.
  5. Rate limiting brut (express-rate-limit) en middleware ; le @nestjs/throttler est un Guard plus intégré (lit @Throttle() metadata par route, supporte Redis store).
  6. Maintenance gate — court-circuiter avec res.status(503) sans appeler next() quand MAINTENANCE_MODE=1. Exclure /health du middleware pour que k8s ne tue pas le pod.
  7. 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.
  8. HTTPS redirect / HSTS — derrière un LB qui termine TLS : lire x-forwarded-proto, rediriger si http. Avec app.set('trust proxy', ...) côté Express.

🔄 Versions — Nest 7 / 8 / 9 / 10 / 11

VersionChangements clés
Nest 7MiddlewareConsumer.with() deprecated. Fastify v2 par défaut sur l'adapter.
Nest 8Fastify v3, Express reste 4.x. Middleware fonctionnel pleinement supporté.
Nest 9RxJS 7 partout — n'impacte pas les middlewares (Express style). Fastify v4.
Nest 10Node 16 minimum. Fastify v4 stable. apply() accepte des fonctions et classes mélangées.
Nest 11Node 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

  1. Oublier next() — la requête pend jusqu'au timeout. Symptôme : 504 silencieux, pas d'erreur claire. Settings : toujours appeler next() (ou next(err)) sur chaque chemin d'exécution.
  2. 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.
  3. Mettre de la logique d'autorisation en middleware — pas d'accès au handler/metadata, donc impossible de lire @Roles(). Utiliser un Guard.
  4. 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.
  5. Async middleware sans await — un middleware peut être async, mais Nest n'attend pas la promise rejetée. Toujours try/catch et appeler next(err) pour propager au Exception Filter.
  6. Ordre piégeuxconsumer.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 dans AppModule.
  7. Middleware sur route paramétréeforRoutes('users/:id') matche aussi users/:id/posts. Préciser via objets { path, method } ou utiliser une regex (sur Express 4 uniquement).
  8. Fastify vs Express incompatibilitéreq.originalUrl n'existe pas en Fastify natif (utiliser req.raw.originalUrl ou abstraire via HttpAdapterHost). Les middlewares typés (req: Request, res: Response, next) Express ne marchent pas tels quels sur Fastify (qui a son propre FastifyRequest/FastifyReply).
  9. Body parsing custom + Nest body parser — par défaut Nest active express.json() au boot. Si tu poses un raw body sur /webhooks après, il est déjà parsed. Solution : NestFactory.create(AppModule, { bodyParser: false }) puis activer manuellement les parsers dans l'ordre voulu.
  10. req.params non disponible — un middleware global n'a pas le contexte du routeur Nest, donc req.params peut être {} même sur une route :id. Pour lire un param dans un middleware, attacher via forRoutes({ path: 'users/:id' }) + Express route-level middleware.

🧪 Testing

ts
// 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.

ts
@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.

ts
@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.

ts
@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.

ts
// 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());
  }
}
ts
// 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.

ts
// 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);
    }
  }
}
ts
// 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();
  }
}
ts
// 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 });
  }
}
ts
// 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();
ts
// 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'AsyncLocalStorage qui doit couvrir toute la chaîne (y compris Guards/Interceptors).

Éviter middleware, préférer Guard :

  • Authentification / autorisation — Guard a accès à ExecutionContext et au metadata via Reflector.
  • Décisions liées au handler (rôles, scopes, feature flags par route).

Éviter middleware, préférer Interceptor :

  • Transformer le body de 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)

ts
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

ts
@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)

ts
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

ts
// 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'AsyncLocalStorage comme generationId. En aval, un job BullMQ utilise ce generationId comme jobId → 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 get puis set ci-dessus n'est pas atomique. Deux requêtes simultanées avec la même idempotency-key passent toutes deux le check « cache vide ». La version production-grade pose un lock atomique : SET ai:idem:{key} '<pending>' NX EX 300 — le perdant du NX poll 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.

ts
// 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
    };
  }
}
ts
// 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.

ts
// 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') vs res.on('close') — en Express, res.on('close') tire aussi sur une fin normale ; le garde res.writableEnded distingue « le client a coupé » d'« on a fini d'écrire ». En Express 5 / Node 18+, le plus robuste est req.on('close') (l'objet IncomingMessage), 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 via MiddlewareConsumer.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à du res.writableEnded.
  • Fastifyreq.raw.on('close', ...) (l'objet Node sous-jacent), car FastifyRequest n'est pas un EventEmitter Node 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/Solutionals.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/Solutionexclude({ 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/SolutionNestFactory.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/SolutionINCR + 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/Solutionconst 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

Bibliothèque tech perso — Achref