Skip to content

NestJS — Request lifecycle

TL;DR — L'ordre est strict et doit être mémorisé : Middleware → Guards → Interceptors (pre) → Pipes → Handler → Interceptors (post) → Exception filters → Response. Chaque "slot" a un usage canonique : middleware = HTTP générique, guards = autorisation, interceptors = wrap/cross-cutting, pipes = validation/transform input, filters = mapping d'erreur. Maîtriser ce pipeline = écrire des features cross-cutting (auth, logging, transactions, rate-limit) sans dupliquer.

🧠 Mental model

   ┌────────────────────────────────────────────────────────────────┐
   │                       Incoming HTTP request                    │
   └────────────────────────────────────────────────────────────────┘


   ┌────────────────────────────────────────────────────────────────┐
   │ 1. Middleware (Express/Fastify level)                          │
   │    - cors, helmet, body-parser, request-id, raw logging        │
   │    - Pas accès aux metadata Nest (rôles, etc.)                 │
   └────────────────────────────────────────────────────────────────┘


   ┌────────────────────────────────────────────────────────────────┐
   │ 2. Guards         (CanActivate)                                │
   │    - "Le user a-t-il le droit ?" → true/false                  │
   │    - Auth JWT, RBAC, feature flags                             │
   │    - Si false → ForbiddenException (capturée par filters)      │
   └────────────────────────────────────────────────────────────────┘


   ┌────────────────────────────────────────────────────────────────┐
   │ 3. Interceptors PRE (intercept → next())                       │
   │    - Logging start, timing start, transaction start, tracing   │
   │    - peuvent transformer la requête avant les pipes            │
   └────────────────────────────────────────────────────────────────┘


   ┌────────────────────────────────────────────────────────────────┐
   │ 4. Pipes          (transform/validate args)                    │
   │    - ValidationPipe (class-validator), ParseIntPipe, custom    │
   │    - S'appliquent à chaque param du handler                    │
   └────────────────────────────────────────────────────────────────┘


   ┌────────────────────────────────────────────────────────────────┐
   │ 5. ► Handler (la méthode du controller)                        │
   └────────────────────────────────────────────────────────────────┘


   ┌────────────────────────────────────────────────────────────────┐
   │ 6. Interceptors POST (rxjs operators après next())             │
   │    - mapping output, cache write, timing end, transaction commit│
   └────────────────────────────────────────────────────────────────┘


   ┌────────────────────────────────────────────────────────────────┐
   │ 7. Exception filters (uniquement si une exception est levée)   │
   │    - HttpExceptionFilter, ValidationFilter, AllExceptionsFilter│
   │    - Transforme erreur en réponse HTTP                         │
   └────────────────────────────────────────────────────────────────┘


                          Response sent

Astuce mnémo : M-G-I-P-H-I-F-R (Middleware, Guards, Interceptors-pre, Pipes, Handler, Interceptors-post, Filters, Response). Si tu hésites entre deux slots, demande-toi : "ce code doit-il bloquer la requête, wrap son exécution, ou valider un input ?".

Le modèle mental que tu dois vraiment internaliser

Ce que la liste linéaire cache, et que tout staff engineer doit avoir en tête :

1. Tout sauf le middleware est une "couche oignon" (onion / décorateur). Un guard est binaire (laisse passer ou jette). Mais un interceptor enveloppe le reste de la chaîne : ce que tu écris avant next.handle() s'exécute à l'aller, ce que tu mets dans le .pipe(...) s'exécute au retour, dans l'ordre inverse. Avec deux interceptors A puis B :

A.pre  →  B.pre  →  [Pipes → Handler]  →  B.post  →  A.post

C'est exactement la sémantique d'un middleware Express bidirectionnel, mais exprimé en RxJS. Conséquence : un TransactionInterceptor enregistré avant un AuditInterceptor commitera après que l'audit ait observé la valeur — pose-toi toujours la question de l'ordre quand deux interceptors interagissent.

2. L'ordre de binding pour les guards/interceptors/filters est : global → controller → route. Pour les guards et interceptors pre, c'est l'ordre d'exécution. Pour les interceptors post et surtout les filters, c'est l'inverse : Nest résout @Catch de la route, puis du controller, puis global. Donc un filter posé sur une route override le filter global pour ce type d'exception. Ne mets jamais ton AllExceptionsFilter "fourre-tout" sur une route si un filter plus spécifique global doit gagner.

3. Les pipes ne s'exécutent PAS une fois "à l'entrée" — ils tournent par paramètre, paresseusement, au moment où Nest résout chaque argument du handler. Un @Param, un @Body, un @Query chacun déclenche sa propre passe de pipe. C'est pour ça que les pipes sont après les guards (inutile de valider un body si l'auth a déjà jeté) et après les interceptors-pre (un interceptor peut muter req.body avant la validation).

4. Le middleware ne connaît rien de Nest. Pas de DI complète au sens des enhancers (il vit au niveau Express/Fastify), pas d'ExecutionContext, pas de metadata @Roles(). Il voit req/res bruts. C'est la seule couche qui peut court-circuiter avant que le routing Nest ait même décidé quel handler appeler.

Tableau de décision — quel slot a accès à quoi

SlotExecutionContextMetadata (Reflector)DI containerPeut muter reqPeut transformer la réponseVoit l'exception
Middlewarepartiel¹via res brut
Guard✅²
Interceptor✅²✅ (pre)✅ (post)✅ (catchError)
Pipe⚠️ (ArgumentMetadata)⚠️ limité✅²l'argument seul
Filter✅ (ArgumentsHost)✅²✅ (réécrit tout)✅ (c'est son job)

¹ Le middleware basé sur classe (NestMiddleware) peut injecter des providers. Le middleware fonctionnel non. — ² Uniquement si enregistré via APP_* (token useClass), pas via new X().

🛠️ Code minimal

ts
// 1. Middleware
import { Injectable, NestMiddleware, MiddlewareConsumer, Module } from '@nestjs/common';
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    req.id = req.headers['x-request-id'] ?? crypto.randomUUID();
    res.setHeader('x-request-id', req.id);
    next();
  }
}

// 2. Guard
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
@Injectable()
export class JwtGuard implements CanActivate {
  canActivate(ctx: ExecutionContext) {
    const req = ctx.switchToHttp().getRequest();
    const token = req.headers.authorization?.split(' ')[1];
    if (!token) throw new UnauthorizedException();
    req.user = { sub: 'decoded-sub' };
    return true;
  }
}

// 3. Interceptor (timing)
import { CallHandler, NestInterceptor } from '@nestjs/common';
import { tap } from 'rxjs';
@Injectable()
export class TimingInterceptor implements NestInterceptor {
  intercept(ctx: ExecutionContext, next: CallHandler) {
    const start = Date.now();
    return next.handle().pipe(tap(() => console.log(`${ctx.getHandler().name} ${Date.now() - start}ms`)));
  }
}

// 4. Pipe (validation)
import { ValidationPipe } from '@nestjs/common';
// dans main.ts :
// app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

// 5. Filter
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
@Catch(HttpException)
export class HttpFilter implements ExceptionFilter {
  catch(exc: HttpException, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const status = exc.getStatus();
    res.status(status).json({ statusCode: status, message: exc.message });
  }
}

// Wiring
@Module({ /* ... */ })
export class AppModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(RequestIdMiddleware).forRoutes('*');
  }
}

🎯 Patterns courants

1. Où loguer ? Middleware pour le log brut (méthode, URL, statut final). Interceptor pour le log enrichi (user, timing, payload — après guards donc user connu).

ts
// Middleware : log avant tout
res.on('finish', () => logger.log(`${req.method} ${req.url} ${res.statusCode}`));

2. Où mettre l'authentification ? Toujours en guard. Pourquoi pas middleware ? Parce qu'un guard a accès à l'ExecutionContext (handler, classe) → peut lire les decorators (@Public(), @Roles('admin')).

ts
@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private reflector: Reflector) {}
  canActivate(ctx: ExecutionContext) {
    const isPublic = this.reflector.getAllAndOverride('isPublic', [ctx.getHandler(), ctx.getClass()]);
    if (isPublic) return true;
    // verify JWT
    return true;
  }
}

3. Où mettre la validation ? Pipe (ValidationPipe global) + DTO class-validator. Jamais dans le handler.

ts
// main.ts
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,         // supprime les props non décorées
  forbidNonWhitelisted: true, // rejette si props en trop
  transform: true,         // string → number selon le type TS
  transformOptions: { enableImplicitConversion: true },
}));

// dto
import { IsEmail, MinLength } from 'class-validator';
export class CreateUserDto {
  @IsEmail() email!: string;
  @MinLength(2) name!: string;
}

4. Où mettre les transactions ? Interceptor (pre = begin, post = commit, catch = rollback). Combine avec AsyncLocalStorage pour propager la transaction dans les repos.

ts
@Injectable()
export class TransactionInterceptor implements NestInterceptor {
  constructor(private ds: DataSource) {}
  intercept(_: ExecutionContext, next: CallHandler) {
    return new Observable(subscriber => {
      this.ds.transaction(async (mgr) => {
        await new Promise<void>((resolve, reject) => {
          next.handle().subscribe({
            next: v => subscriber.next(v),
            error: e => { reject(e); subscriber.error(e); },
            complete: () => { resolve(); subscriber.complete(); },
          });
        });
      }).catch(() => {/* rollback handled */});
    });
  }
}

5. Où mettre le rate-limiting ? Guard (avant le handler, sans coût d'exécution). @nestjs/throttler fournit ThrottlerGuard officiel.

ts
@Module({
  imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 30 }])],
  providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})

6. Où mettre la transformation de réponse (envelope) ? Interceptor POST. Wrap { data, meta } autour de chaque réponse.

ts
@Injectable()
export class EnvelopeInterceptor implements NestInterceptor {
  intercept(_: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(map(data => ({ data, timestamp: new Date().toISOString() })));
  }
}

7. Où capturer les erreurs ? Filter. Soit global (AllExceptionsFilter), soit par type (@Catch(ValidationError)). Le filter mappe l'erreur métier → statut HTTP + corps standardisé.

ts
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exc: unknown, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const status = exc instanceof HttpException ? exc.getStatus() : 500;
    res.status(status).json({
      statusCode: status,
      error: exc instanceof Error ? exc.message : 'Unknown',
      timestamp: new Date().toISOString(),
    });
  }
}

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

  • Nest 7 : Pipeline en place. ValidationPipe requiert class-validator ≥ 0.12. Pas de @nestjs/throttler officiel.
  • Nest 8 : @nestjs/throttler lib officielle. Reflector.getAllAndOverride() simplifie la lecture des metadata cross-handler.
  • Nest 9 : Optimisations sur l'ordre d'exécution global vs local (les global interceptors enregistrés via APP_INTERCEPTOR ont accès au container DI, contrairement à app.useGlobalInterceptors(new X()) qui n'a pas DI).
  • Nest 10 : Améliorations error reporting (stack trace plus claire quand un guard lève). enableShutdownHooks() plus stable pour drain graceful (utile pour finir les requêtes en cours).
  • Nest 11 : Express v5 / Fastify v5. Comportement des middlewares async unifié (Express v5 supporte les middlewares qui retournent une Promise sans next() explicite). ValidationPipe accepte des options de transformation plus fines (exposeUnsetFields).

Différence cruciale useGlobal* vs APP_*

ts
// SANS accès DI
app.useGlobalInterceptors(new LoggingInterceptor());

// AVEC accès DI (à privilégier)
@Module({
  providers: [{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }],
})

Idem pour APP_GUARD, APP_PIPE, APP_FILTER. Sans DI tu ne peux pas injecter Logger, Reflector, etc.

⚠️ Pitfalls

  1. Mettre l'auth en middleware. Pas d'accès aux decorators handler → tu ne peux pas distinguer @Public() des autres. Utilise un guard avec Reflector.
  2. useGlobalGuards(new X()) sans DI. L'instance créée hors container ne reçoit aucune dep. Préfère { provide: APP_GUARD, useClass: X }.
  3. Interceptor qui retourne une Promise au lieu d'un Observable. L'API intercept() renvoie Observable | Promise<Observable>. Si tu fais return result; directement, ça casse les opérateurs RxJS suivants.
  4. Filter qui n'envoie pas de réponse. Si tu oublies res.status().json(...), la requête pend jusqu'au timeout. Toujours répondre dans catch.
  5. Ordre @Res() + interceptor. Si tu fais @Res() res sans passthrough: true, les interceptors POST ne voient pas la réponse → ton envelope ne s'applique pas, ton filter non plus.
  6. ValidationPipe sans transform: true. Sans transform, un @Param('id') id: number reste une string. Active transform: true ou utilise des pipes ciblés.
  7. Exception levée dans un interceptor POST. Elle est capturée par les filters comme une exception normale, mais l'output du handler est déjà parti dans le pipe RxJS — l'exception remplace la réponse. Comportement parfois surprenant pour le caching.
  8. Transactions sans AsyncLocalStorage. Si ton interceptor ouvre une transaction mais que ton repo ne sait pas comment la récupérer (parce que dans une fonction async ailleurs), tu commit dans le vide. Utilise ALS ou un store de contexte.

🧪 Testing

ts
// Tester un guard
import { Reflector } from '@nestjs/core';
import { ExecutionContext } from '@nestjs/common';

const guard = new AuthGuard(new Reflector());
const ctx = {
  switchToHttp: () => ({ getRequest: () => ({ headers: { authorization: 'Bearer xyz' } }) }),
  getHandler: () => () => {},
  getClass: () => class {},
} as unknown as ExecutionContext;

expect(guard.canActivate(ctx)).toBe(true);

// Tester un interceptor
import { of, lastValueFrom } from 'rxjs';
const interceptor = new EnvelopeInterceptor();
const result$ = interceptor.intercept(ctx, { handle: () => of({ x: 1 }) } as any);
await expect(lastValueFrom(result$)).resolves.toEqual({ data: { x: 1 }, timestamp: expect.any(String) });

// Tester un pipe
const pipe = new ValidationPipe({ transform: true });
await expect(pipe.transform({ email: 'bad' }, { metatype: CreateUserDto, type: 'body' }))
  .rejects.toThrow(/email must be an email/);

// E2E : override global guard
const moduleRef = await Test.createTestingModule({ imports: [AppModule] })
  .overrideGuard(JwtGuard)
  .useValue({ canActivate: () => true })
  .compile();

🎬 Cas d'usage concrets

Scénario 1 — FinTech KYC : validation multi-étape

Qui — Une fintech parisienne (75 ETP, agrément CIP) qui automatise l'onboarding KYC pour des CGP (conseillers en gestion de patrimoine). ≈ 3 000 dossiers/mois, exigences AMF strictes.

Problème métier — Chaque dossier KYC traverse 6 étapes : auth → vérif tenant CGP → idempotency check → validation DTO complexe → orchestration providers → audit log immuable. Avant Nest, c'était un long handler procédural de 400 lignes avec des if/throw imbriqués, impossible à maintenir et à auditer pour l'AMF.

Comment ce concept aide — Chaque étape dans le bon slot du pipeline. Auth en Guard, tenant en Guard chaîné, idempotency en Interceptor, validation en Pipe global + DTO, orchestration dans le service, audit en Interceptor post-handler, mapping erreur AMF en Filter. Chaque slot a son propre fichier, sa propre suite de tests, son propre responsable côté équipe.

ts
@Module({
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_GUARD, useClass: TenantCgpGuard },
    { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
    { provide: APP_INTERCEPTOR, useClass: AuditTrailInterceptor },
    { provide: APP_FILTER, useClass: AmfErrorMapperFilter },
  ],
})
export class AppModule {}

Gains chiffrés — Audit AMF passé en 1 itération (vs 3 sur l'ancienne stack), 0 doublon de dossier détecté en 14 mois (grâce à l'idempotency), code de chaque slot couvert à 95%+ (vs 22% sur le handler procédural d'avant).

Scénario 2 — E-commerce : checkout robuste

Qui — Un retailer FR (CA 180M€, 320 ETP) avec une plateforme e-commerce custom. Le checkout doit gérer cart → stock → paiement → commande → confirmation, le tout idempotent (le client clique 3 fois sur "Payer").

Problème métier — Sans pipeline strict, les bugs apparaissaient : double débit Stripe sur double-clic, stock décrémenté 2 fois, email envoyé 3 fois, audit incomplet. Coût direct : ~40k€/an de remboursements et incidents.

Comment ce concept aide — Idempotency interceptor qui lit Idempotency-Key et cache la réponse 24h. Validation pipe sur le CheckoutDto (cart consistency). Transaction interceptor wrap autour du handler (commit en tap, rollback en catchError). Filter qui mappe les erreurs Stripe en codes business clairs.

ts
@Controller('checkout')
@UseInterceptors(IdempotencyInterceptor, TransactionInterceptor)
@UseFilters(StripeErrorFilter)
export class CheckoutController {
  @Post()
  @HttpCode(201)
  async checkout(@Body() dto: CheckoutDto, @CurrentUser() user: User) {
    return this.checkoutService.execute(dto, user);
  }
}

Gains chiffrés — Doubles débits réduits à ~0 (vs 40-50/mois avant), incidents stock désynchronisé éliminés, MTTR sur problèmes checkout divisé par 3 (chaque étape traçable dans le pipeline).

Scénario 3 — LegalTech : compliance RGPD

Qui — Un éditeur LegalTech FR (22 ETP) qui édite un coffre-fort numérique pour avocats avec docs ultra-sensibles (correspondance client). Exigences RGPD + CNIL strictes.

Problème métier — Chaque accès à un doc doit être : authentifié, autorisé (le user appartient au cabinet du doc), validé (params bien formés), loggé immuablement (qui a accès à quoi, quand). Sans pipeline propre, le code de logging était dupliqué dans 50 handlers, incohérent, et oubli fréquent.

Comment ce concept aide — Pipeline complet déclaratif. Auth en Guard JWT. Ownership en Guard (OwnershipGuard qui vérifie req.user.cabinetId === resource.cabinetId). Validation en Pipe. Le GdprAuditInterceptor log chaque accès en tap (post-handler, donc on a la réponse ET on sait que ça a réussi). Filter qui masque les détails sensibles en cas d'erreur (pas de leak de paths de docs en stack trace).

ts
@Controller('documents')
@UseGuards(JwtAuthGuard, OwnershipGuard)
@UseInterceptors(GdprAuditInterceptor)
@UseFilters(SafeErrorFilter)
export class DocumentsController {
  @Get(':id') get(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user) {
    return this.docs.findById(id, user.cabinetId);
  }
}

Gains chiffrés — Audit CNIL passé sans réserve (vs 2 réserves à l'audit précédent), 100% des accès tracés dans le journal immuable, temps moyen pour traiter une demande RGPD Article 15 (droit d'accès) tombé de 4h à 8 min.

🛠️ Exemple end-to-end

Use case — Plateforme KYC pour CGP. On expose POST /v1/kyc/applications qui crée un dossier KYC. Le pipeline complet est mobilisé : auth, tenant, idempotency, validation DTO, transaction, audit, mapping erreurs métier.

ts
// src/auth/jwt.guard.ts
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private readonly jwt: JwtService) {}

  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const token = req.headers.authorization?.split(' ')[1];
    if (!token) throw new UnauthorizedException('missing_token');
    try {
      req.user = await this.jwt.verifyAsync(token);
      return true;
    } catch {
      throw new UnauthorizedException('invalid_token');
    }
  }
}
ts
// src/auth/cgp-tenant.guard.ts
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';

@Injectable()
export class CgpTenantGuard implements CanActivate {
  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest();
    if (!req.user?.cgpId) throw new ForbiddenException('not_a_cgp');
    req.cgpId = req.user.cgpId;
    return true;
  }
}
ts
// src/interceptors/idempotency.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, from, of, mergeMap, tap } from 'rxjs';
import Redis from 'ioredis';

@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
  constructor(private readonly redis: Redis) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    if (!['POST', 'PUT', 'PATCH'].includes(req.method)) return next.handle();
    const key = req.headers['idempotency-key'];
    if (!key) return next.handle();

    const cacheKey = `idem:${req.cgpId}:${key}`;
    return from(this.redis.get(cacheKey)).pipe(
      mergeMap((cached) => {
        if (cached) return of(JSON.parse(cached));
        return next.handle().pipe(
          tap((response) => this.redis.set(cacheKey, JSON.stringify(response), 'EX', 86_400)),
        );
      }),
    );
  }
}

L'interceptor d'idempotency court-circuite l'exécution si la clé a déjà été vue. Le cache est scopé par CGP pour éviter les collisions cross-tenant.

ts
// src/interceptors/audit-trail.interceptor.ts
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { AuditService } from '../audit/audit.service';

@Injectable()
export class AuditTrailInterceptor implements NestInterceptor {
  constructor(private readonly audit: AuditService) {}

  intercept(ctx: ExecutionContext, next: CallHandler): Observable<any> {
    const req = ctx.switchToHttp().getRequest();
    const handler = ctx.getHandler().name;
    const start = Date.now();

    return next.handle().pipe(
      tap({
        next: (response) => {
          this.audit.record({
            actor: req.user?.id,
            cgpId: req.cgpId,
            action: `${ctx.getClass().name}.${handler}`,
            requestId: req.id,
            durationMs: Date.now() - start,
            resourceId: response?.id,
            outcome: 'success',
          });
        },
        error: (err) => {
          this.audit.record({
            actor: req.user?.id,
            cgpId: req.cgpId,
            action: `${ctx.getClass().name}.${handler}`,
            requestId: req.id,
            durationMs: Date.now() - start,
            outcome: 'failure',
            errorCode: err.code ?? 'unknown',
          });
        },
      }),
    );
  }
}

L'audit interceptor log chaque appel en post-handler. Le tap({ next, error }) permet de tracer succès ET échec sans bloquer la réponse au client.

ts
// src/kyc/dto/create-application.dto.ts
import { IsEmail, IsISO31661Alpha2, IsInt, IsString, Length, Min } from 'class-validator';
import { Type } from 'class-transformer';

export class CreateKycApplicationDto {
  @IsString() @Length(1, 80) firstName!: string;
  @IsString() @Length(1, 80) lastName!: string;
  @IsEmail() email!: string;
  @IsISO31661Alpha2() countryCode!: string;
  @Type(() => Number) @IsInt() @Min(0) annualIncome!: number;
  @IsString() @Length(11, 14) socialSecurityNumber!: string;
}
ts
// src/kyc/kyc.controller.ts
import { Body, Controller, Headers, HttpCode, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt.guard';
import { CgpTenantGuard } from '../auth/cgp-tenant.guard';
import { KycService } from './kyc.service';
import { CreateKycApplicationDto } from './dto/create-application.dto';

@Controller({ path: 'kyc/applications', version: '1' })
@UseGuards(JwtAuthGuard, CgpTenantGuard)
export class KycController {
  constructor(private readonly kyc: KycService) {}

  @Post()
  @HttpCode(201)
  create(
    @Body() dto: CreateKycApplicationDto,
    @Headers('idempotency-key') idemKey: string,
  ) {
    return this.kyc.createApplication(dto, idemKey);
  }
}
ts
// src/kyc/kyc.errors.ts
export class IncomeBelowThresholdError extends Error {
  code = 'income_below_threshold' as const;
  constructor(public threshold: number, public actual: number) {
    super(`Income ${actual} below threshold ${threshold}`);
  }
}

// src/filters/amf-error-mapper.filter.ts
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { IncomeBelowThresholdError } from '../kyc/kyc.errors';

@Catch()
export class AmfErrorMapperFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const res = host.switchToHttp().getResponse();
    const req = host.switchToHttp().getRequest();

    if (exception instanceof HttpException) {
      const status = exception.getStatus();
      return res.status(status).json({
        error: 'http_error',
        message: exception.message,
        requestId: req.id,
      });
    }

    if (exception instanceof IncomeBelowThresholdError) {
      return res.status(422).json({
        error: exception.code,
        threshold: exception.threshold,
        actual: exception.actual,
        requestId: req.id,
      });
    }

    res.status(500).json({
      error: 'internal_error',
      requestId: req.id,
    });
  }
}
ts
// src/app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from '@nestjs/core';
import { JwtAuthGuard } from './auth/jwt.guard';
import { CgpTenantGuard } from './auth/cgp-tenant.guard';
import { IdempotencyInterceptor } from './interceptors/idempotency.interceptor';
import { AuditTrailInterceptor } from './interceptors/audit-trail.interceptor';
import { AmfErrorMapperFilter } from './filters/amf-error-mapper.filter';
import { KycModule } from './kyc/kyc.module';

@Module({
  imports: [KycModule],
  providers: [
    { provide: APP_GUARD, useClass: JwtAuthGuard },
    { provide: APP_GUARD, useClass: CgpTenantGuard },
    { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor },
    { provide: APP_INTERCEPTOR, useClass: AuditTrailInterceptor },
    { provide: APP_FILTER, useClass: AmfErrorMapperFilter },
  ],
})
export class AppModule {}
ts
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    transformOptions: { enableImplicitConversion: true },
  }));
  app.enableShutdownHooks();
  await app.listen(3000);
}
bootstrap();

Le pipeline complet est mobilisé sur un seul endpoint : middleware (omis ici, par ex. helmet + request-id), Guard JWT, Guard tenant, Interceptor idempotency, Pipe validation (DTO), handler, Interceptor audit, Filter mapping erreur AMF. Chaque couche est isolée, testable, et réutilisable sur les autres endpoints sans duplication.

🤖 Servir un agent IA à travers le lifecycle

Quand NestJS expose un endpoint qui parle à un LLM (Claude claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5), le request lifecycle devient ton plan de contrôle. Chaque préoccupation d'une route IA tombe dans un slot précis — c'est exactement le même raisonnement que pour le KYC, appliqué au streaming de tokens.

Préoccupation IASlotPourquoi
Auth + quota par utilisateurGuardBloquer avant de dépenser un token
Rate-limit + cost-guard (budget €/jour)Guard (ThrottlerGuard + custom)Pas de coût d'exécution, lit la metadata du modèle
Idempotency clé sur generationIdInterceptor (pre)Court-circuite une régénération coûteuse
Validation du prompt / des tools autorisésPipe (DTO class-validator)Rejeter un prompt mal formé avant l'appel
Streaming SSE des tokensHandler (@Sse ou res brut)Le streaming sort du modèle Observable classique
Annulation sur déconnexion clientHandler + AbortControllerCouper l'appel Anthropic pour ne pas payer dans le vide
Audit coût/tokens/latenceInterceptor (post, tap)Tracer usage.input_tokens / output_tokens / cache_read_input_tokens
Mapping erreurs SDK (429, overloaded) + refusalFilterAPIError Anthropic → HTTP propre ; stop_reason: 'refusal'422 (≠ 500)

Client LLM injecté via forRootAsync (jamais new Anthropic() dans un champ)

ts
// llm.module.ts — un client unique, configuré, testable, mockable
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';

export const ANTHROPIC = Symbol('ANTHROPIC');

@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: ANTHROPIC,
      inject: [ConfigService],
      useFactory: (cfg: ConfigService) =>
        new Anthropic({
          apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'),
          maxRetries: 4,        // le SDK retry 429/5xx/overloaded avec backoff exponentiel (défaut 2)
          timeout: 60_000,
        }),
    },
  ],
  exports: [ANTHROPIC],
})
export class LlmModule {}

Pourquoi DI et pas new Anthropic() inline : tu peux le mocker en test (overrideProvider(ANTHROPIC)), changer la clé par environnement, et partager le pool de connexions HTTP. Un new dans un champ de service est intestable et recrée un client par instance.

Handler de streaming SSE + annulation sur déconnexion

Le point délicat : sur une route IA, l'annulation client doit propager jusqu'à l'appel réseau Anthropic, sinon tu continues à payer des tokens pour une réponse que personne ne lira. On câble le 'close' de la requête à un AbortController.

ts
// chat.controller.ts
import { Controller, Post, Body, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import Anthropic from '@anthropic-ai/sdk';
import { Inject } from '@nestjs/common';
import { ANTHROPIC } from '../llm/llm.module';

@Controller({ path: 'chat', version: '1' })
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',
    });
    res.flushHeaders();

    // 1) le client ferme l'onglet → on coupe l'appel Anthropic
    const ac = new AbortController();
    req.on('close', () => ac.abort());

    try {
      const stream = this.anthropic.messages.stream(
        {
          model: 'claude-sonnet-4-6',
          max_tokens: 1024,
          messages: [{ role: 'user', content: dto.prompt }],
        },
        { signal: ac.signal }, // 2) AbortController passé au SDK
      );

      for await (const event of stream) {
        if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
          res.write(`event: token\ndata: ${JSON.stringify(event.delta.text)}\n\n`);
        }
      }
      const final = await stream.finalMessage();
      if (final.stop_reason === 'refusal') {
        res.write(`event: refusal\ndata: ${JSON.stringify(final.stop_details)}\n\n`);
      } else {
        res.write(`event: done\ndata: ${JSON.stringify({ usage: final.usage })}\n\n`);
      }
    } catch (err) {
      if (ac.signal.aborted) return; // déconnexion volontaire, pas une erreur
      res.write(`event: error\ndata: ${JSON.stringify({ message: 'llm_error' })}\n\n`);
    } finally {
      res.end();
    }
  }
}

Note lifecycle. Avec @Res() "manuel" (pas passthrough: true), tu sors du flux Observable de Nest : les interceptors post et l'envelope ne s'appliquent plus à la réponse streamée (cf. pitfall #5). C'est voulu ici — un flux SSE ne doit pas être enveloppé. Mets ce qui doit s'exécuter (audit, fermeture de transaction) dans le finally du handler ou dans un interceptor qui n'observe que le début du flux. Alternative idiomatique : le décorateur @Sse() de Nest, qui renvoie un Observable<MessageEvent> et reste dans le pipeline — mais tu perds le contrôle fin sur les headers et le flush.

Gotcha thinking + streaming. Sur Opus 4.8/4.7, le thinking est par défaut display: 'omitted' : les blocs thinking arrivent dans le flux avec un texte vide. Côté UX, ça se traduit par une longue pause avant le premier token visible (le modèle réfléchit sans rien streamer). Si tu veux afficher un indicateur "réflexion en cours", passe thinking: { type: 'adaptive', display: 'summarized' } et filtre les content_block_delta de type thinking_delta vers un event: thinking distinct. Ne mappe pas un bloc thinking vide sur un token utilisateur.

La boucle agentique (tool use) côté serveur

L'endpoint IA "intelligent" ne fait pas un seul appel : il boucle tant que le modèle veut appeler des outils. Cette boucle vit dans le service (le handler reste mince), exactement comme l'orchestration KYC vivait dans le service et pas dans le controller.

ts
async runAgent(prompt: string, signal: AbortSignal) {
  const messages: Anthropic.MessageParam[] = [{ role: 'user', content: prompt }];
  for (let step = 0; step < 8; step++) {            // garde-fou anti-boucle infinie
    const res = await this.anthropic.messages.create(
      {
        model: 'claude-opus-4-8',
        max_tokens: 2048,
        thinking: { type: 'adaptive' },   // Opus 4.8 : adaptive uniquement (budget_tokens → 400)
        output_config: { effort: 'high' },// xhigh/high pour l'agentique ; matters more sur 4.8
        tools: this.toolSchemas,
        messages,
      },
      { signal },
    );

    // Opus 4.8 peut refuser (HTTP 200, stop_reason: 'refusal') ou tronquer (max_tokens).
    // Toujours brancher sur stop_reason AVANT de lire content.
    if (res.stop_reason === 'refusal') throw new LlmRefusalError(res.stop_details);
    if (res.stop_reason === 'max_tokens') throw new Error('agent_output_truncated');

    messages.push({ role: 'assistant', content: res.content });
    if (res.stop_reason !== 'tool_use') return res; // le modèle a fini

    const toolResults = await Promise.all(
      res.content
        .filter((b): b is Anthropic.ToolUseBlock => b.type === 'tool_use')
        .map(async (b) => ({
          type: 'tool_result' as const,
          tool_use_id: b.id,
          content: JSON.stringify(await this.dispatchTool(b.name, b.input)),
        })),
    );
    messages.push({ role: 'user', content: toolResults });
  }
  throw new Error('agent_max_steps_exceeded');
}

Pourquoi thinking: { type: 'adaptive' } et pas budget_tokens. Sur Opus 4.8 (comme 4.7), le thinking par budget de tokens est supprimé{ type: 'enabled', budget_tokens: N } retourne un 400. On laisse le modèle décider de la profondeur via effort. Côté lifecycle, un refusal ou un max_tokens n'est pas une exception réseau : c'est une réponse HTTP 200 que ton filter doit mapper proprement (un LlmRefusalError422 côté API, pas un 500). C'est exactement le pattern "erreur métier dans le bon slot" du KYC, transposé à l'IA.

Jobs IA longs en BullMQ — idempotency, retry cost-aware, sortie partielle

Pour une génération longue (rapport, batch d'embeddings) on sort du request lifecycle synchrone et on passe par une file. Le lifecycle d'un job rejoue les mêmes invariants que celui d'une requête :

  • Idempotency : la clé du job = generationId (pas un UUID aléatoire au moment du add()). BullMQ dédupe sur jobId, donc un double POST qui ré-enqueue n'exécute la génération qu'une fois.
  • Retry cost-aware : ne retry que sur erreurs transitoires (429, overloaded_error/529, 5xx) — jamais sur un stop_reason: 'refusal' ni une invalid_request_error (re-payer un prompt invalide ou refusé jette de l'argent par les fenêtres). Le SDK gère déjà les retries réseau (maxRetries, backoff exponentiel) ; au niveau job, plafonne attempts et distingue le retryable du non-retryable via un UnrecoverableError BullMQ pour court-circuiter les tentatives restantes sur un refus.
  • Sortie partielle : persiste les tokens déjà produits (progress) à chaque chunk pour qu'un retry reprenne ou qu'au pire on serve une réponse dégradée plutôt que rien.
ts
@Processor('ai-generation')
export class AiGenerationProcessor extends WorkerHost {
  constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) { super(); }

  async process(job: Job<{ generationId: string; prompt: string }>) {
    const acc: string[] = [];
    const stream = this.anthropic.messages.stream({
      model: 'claude-sonnet-4-6', max_tokens: 4096,
      messages: [{ role: 'user', content: job.data.prompt }],
    });
    for await (const ev of stream) {
      if (ev.type === 'content_block_delta' && ev.delta.type === 'text_delta') {
        acc.push(ev.delta.text);
        if (acc.length % 50 === 0) await job.updateProgress({ partial: acc.join('') });
      }
    }
    return { text: acc.join(''), usage: (await stream.finalMessage()).usage };
  }
}

// enqueue idempotent : jobId = generationId
await this.queue.add('generate', payload, {
  jobId: payload.generationId,                 // dédup
  attempts: 3,
  backoff: { type: 'exponential', delay: 2000 },
});

Le cost-guard en guard referme la boucle : avant d'enqueue ou de streamer, un CostGuard lit la metadata @MaxCost(0.5) du handler via le Reflector, additionne le coût estimé (tokens × prix du modèle) au compteur Redis du tenant, et jette un 429 si le budget est dépassé — exactement le slot où ThrottlerGuard vit. Tu exposes ensuite cet agent comme endpoint MCP (@Post('mcp')) en réutilisant les mêmes guards/interceptors : le lifecycle ne change pas, seul le contrat d'I/O change.

🔁 Quand utiliser / éviter

BesoinBon slotMauvais slot
Parsing body / CORS / helmetMiddlewareInterceptor
Auth, RBAC, feature flagGuardMiddleware (pas de metadata)
Validation DTOPipe (ValidationPipe)Handler (if (!body.x) ...)
Logging timing / transactionsInterceptorMiddleware
Cache HTTPInterceptor (POST)Filter
Mapping erreur → HTTPFilterHandler try/catch partout
Rate-limitGuard (Throttler)Middleware custom maison
Envelope { data } réponseInterceptor POSTMapping manuel par controller

🔭 Production : observabilité & failure modes

Le lifecycle n'est pas qu'un outil de design — c'est ton point d'ancrage pour l'observabilité distribuée et la résilience.

Tracing (OpenTelemetry). Le span racine de la requête s'ouvre dans un middleware (avant tout) et se ferme sur res.on('finish'). Chaque interceptor crée un child span : tu vois dans Jaeger/Tempo le temps passé en guard vs pipe vs handler vs DB. Propage le traceparent via AsyncLocalStorage (Nest le câble nativement avec nestjs-otel) pour que tes appels sortants (DB, LLM, autre service) héritent du contexte. Le requestId posé par RequestIdMiddleware doit finir dans chaque ligne de log (logger pino avec child logger par requête).

Backpressure & timeouts. Un TimeoutInterceptor (timeout(5000) de RxJS + catchErrorRequestTimeoutException) protège contre un handler qui pend. Mais attention : ce timeout annule l'Observable, il n'annule pas forcément le travail sous-jacent (une requête SQL continue côté DB). Pour vraiment couper, propage un AbortSignal jusqu'à l'I/O — même logique que l'annulation LLM ci-dessus.

Graceful shutdown. app.enableShutdownHooks() + SIGTERM : Nest arrête d'accepter de nouvelles connexions, laisse les requêtes en vol finir (drain), ferme les onModuleDestroy/beforeApplicationShutdown (fermeture des pools DB, flush des files). Sans ça, un rolling deploy K8s coupe des requêtes en cours → 502 visibles côté client. Donne à Kubernetes un terminationGracePeriodSeconds > au temps de drain.

Failure modes à connaître :

SymptômeCause lifecycle probable
Requête qui pend jusqu'au timeout LBFilter qui ne répond pas (res.json oublié) — pitfall #4
Envelope/cache absent sur certaines routesHandler en @Res() sans passthrough — pitfall #5
Guard injecte undefined (Reflector/Logger null)Enregistré via new X() au lieu de APP_GUARD
@Param('id') id: number reçu en stringValidationPipe sans transform: true
Double-exécution d'un effet (mail, débit)Interceptor post + retry client, pas d'idempotency
Exception "avalée", 200 renvoyé malgré l'erreurcatchError dans un interceptor qui remappe en of(...)

🏋️ Exercices

Exercice 1 — Tracer l'ordre réel (échauffement)

Objectif : prouver expérimentalement l'ordre M-G-I-P-H-I-F et l'imbrication oignon des interceptors.

Écris un middleware, deux guards, deux interceptors (A, B), un pipe custom et un filter qui console.log chacun un tag (mw, guard1, intA:pre, intA:post, etc.). Lance une requête et vérifie que la sortie est exactement mw, guard1, guard2, intA:pre, intB:pre, pipe, handler, intB:post, intA:post. Force ensuite une exception dans le handler et observe quels post ne s'exécutent pas.

Indice : mets les tags :post dans next.handle().pipe(tap(...)). L'exception fait sauter le tap({ next }) mais pas le tap({ error }).

Exercice 2 — Interceptor transaction + ALS (production-grade)

Objectif : un @Transactional() déclaratif qui propage la transaction sans la passer en argument.

Construis un TransactionInterceptor qui ouvre une transaction TypeORM, stocke le EntityManager dans un AsyncLocalStorage, commit sur complete, rollback sur error. Tes repos lisent le manager depuis l'ALS. Vérifie qu'une exception levée après une écriture annule bien tout, et qu'un appel imbriqué (service A appelle service B) partage la même transaction.

Indice : als.run(store, () => ...) doit envelopper le next.handle(). Le piège : RxJS peut perdre le contexte ALS sur un changement de tick — utilise defer() ou capture le store et restaure-le dans le subscribe.

Exercice 3 — Idempotency à l'épreuve des courses (production-grade)

Objectif : rendre l'IdempotencyInterceptor du fichier correct sous concurrence.

Le code montré a un bug : deux requêtes simultanées avec la même clé passent toutes les deux le redis.get (vide) avant que la première ait écrit → double exécution. Corrige avec un verrou : SET key "in-progress" NX EX 30. Si le NX échoue, la requête concurrente doit soit attendre/poller le résultat, soit renvoyer 409 Conflict. Gère aussi le cas où le handler jette : tu ne dois pas cacher une erreur transitoire comme un succès.

Indice : le pattern est "lock → exécute → écris le résultat final → libère". Distingue "résultat caché" de "lock en cours". Pense au TTL du lock vs durée max du handler.

Exercice 4 — Streaming LLM annulable de bout en bout

Objectif : câbler l'annulation client → serveur → SDK Anthropic sans fuite de coût.

Reprends le handler SSE. Ajoute : (a) un heartbeat :\n\n toutes les 15 s pour que les proxies ne coupent pas la connexion idle ; (b) la persistance des tokens partiels en Redis sous gen:{id} pour qu'un reload reprenne ; (c) un compteur de coût incrémenté en finally avec usage même en cas d'abort partiel. Teste en fermant l'onglet à mi-réponse et vérifie côté logs Anthropic que l'appel est bien aborted.

Indice : req.on('close')ac.abort(). Le finally doit lire stream.finalMessage() dans un try/catch car après abort il rejette ; estime alors les tokens depuis l'accumulateur local.

Exercice 5 — Casse-le puis répare-le (chaos)

Objectif : reproduire 3 failure modes du tableau ci-dessus et les corriger.

(1) Enregistre un guard via useGlobalGuards(new MyGuard()) qui dépend d'un ConfigService → observe le crash undefined, répare en APP_GUARD. (2) Écris un filter qui oublie res.json() → observe la requête qui pend, répare. (3) Mets un @Res() res sans passthrough: true sur une route enveloppée → observe l'envelope qui disparaît, répare avec passthrough ou en retirant @Res(). Documente pour chacun le signal observable (log, métrique, comportement client) qui t'aurait alerté en prod.

Indice : le #1 se voit en TypeError: Cannot read properties of undefined. Le #2 se voit comme une latence p99 qui explose au timeout du LB. Le #3 est silencieux — d'où l'importance des tests de contrat sur le format de réponse.

Exercice 6 — Cost-guard + budget tenant (architecte)

Objectif : un CostGuard qui refuse une requête IA dépassant le budget €/jour d'un tenant.

Crée un décorateur @MaxCost(euros) (metadata) et un guard qui : lit la metadata via Reflector, estime le coût (max_tokens × prix/token du modèle ciblé), lit/incrémente atomiquement le compteur Redis cost:{tenant}:{yyyy-mm-dd} (Lua ou INCRBYFLOAT), jette 429 avec un header Retry-After à minuit si dépassé. Bonus : réconcilie le coût estimé (au guard) avec le coût réel (usage au filter/interceptor post) pour corriger le compteur.

Indice : le guard ne connaît pas encore les vrais tokens → il réserve une estimation pessimiste. L'interceptor post ajuste. C'est le pattern "authorize-then-capture" des paiements, appliqué aux tokens.

🎤 En entretien

Q : Pourquoi mettre l'authentification dans un guard plutôt que dans un middleware ? Parce que le guard a accès à l'ExecutionContext (handler + classe) et donc au Reflector : il peut lire @Public() / @Roles() pour décider par route. Le middleware tourne avant le routing Nest, sans metadata — il ne peut pas savoir quelle policy s'applique au handler ciblé.

Q : Un interceptor renvoie un Observable. Dans quel ordre s'exécutent deux interceptors enregistrés A puis B, à l'aller et au retour ? À l'aller (avant next.handle()) : A puis B. Au retour (dans le .pipe()) : B puis A — c'est une structure oignon/décorateur. D'où l'attention quand un TransactionInterceptor doit commiter après qu'un AuditInterceptor a observé la valeur : leur ordre d'enregistrement détermine qui enveloppe qui.

Q : Où dans le lifecycle gères-tu l'annulation d'un appel LLM quand le client se déconnecte, et pourquoi ça compte ? Dans le handler : req.on('close') déclenche un AbortController dont le signal est passé au SDK Anthropic. Ça compte parce qu'un stream non annulé continue à générer (et facturer) des tokens pour une réponse que personne ne consomme — fuite de coût directe. Le finally du handler reste le seul endroit fiable pour fermer le res et compter l'usage partiel.

Q : Quelle est la différence d'accès au container DI entre app.useGlobalGuards(new X()) et { provide: APP_GUARD, useClass: X } ? La forme new X() instancie hors du container : aucune dépendance n'est injectée (Reflector, ConfigService, Logger seront undefined). La forme APP_GUARD (token) laisse Nest construire l'instance via DI. Règle staff : tout enhancer qui a une dépendance doit passer par APP_*. La forme new n'est tolérable que pour un enhancer pur sans dépendance.

Q : Un endpoint Claude renvoie HTTP 200 avec stop_reason: 'refusal'. Quel slot du lifecycle gère ça, et pourquoi un try/catch réseau ne suffit pas ? Un refus (ou un max_tokens) n'est pas une APIError : c'est une réponse 200 réussie. Le SDK ne lève rien — si ton code lit res.content[0] aveuglément, il plante sur un refus pré-output (content vide). On branche sur stop_reason dans le service (la boucle agentique) pour transformer le refus en erreur métier typée (LlmRefusalError), puis le filter la mappe en 422 avec un corps clair. Mettre ça dans un catch réseau rate complètement le cas, car aucune exception réseau n'est levée. C'est le même découpage que pour une IncomeBelowThresholdError du KYC : erreur métier → service la lève → filter la mappe.

Q : Pourquoi un flux SSE qui streame des tokens Claude doit-il sortir du pipeline Observable de Nest, et qu'est-ce que ça casse ? Avec @Res() "manuel" (sans passthrough: true), tu prends le contrôle direct de la réponse : les interceptors post (envelope, cache write) et même certains filters ne s'appliquent plus au flux (pitfall #5). C'est voulu — on ne veut pas envelopper un text/event-stream dans un { data }. La conséquence à gérer : tout ce qui devait s'exécuter au retour (audit du usage, fermeture de transaction, comptage de coût) doit migrer dans le finally du handler, pas dans un interceptor tap. L'alternative idiomatique @Sse() reste dans le pipeline mais te fait perdre le contrôle fin sur les headers et le flush.

🔗 Liens

Bibliothèque tech perso — Achref