Skip to content

Pipes & Validation

TL;DR — Un Pipe implémente PipeTransform.transform(value, metadata). Deux usages : valider ou transformer un argument du handler. C'est l'outil pour passer d'un unknown HTTP (string, json) à une instance de DTO typée et validée. ValidationPipe + class-validator + class-transformer couvrent 95% des cas. Le reste : Pipes custom pour des coercions métier (ULID, ObjectId, slugs).

🧠 Mental model

Request body/param ─► Guard ─► Interceptor (pre) ─► [Pipe.transform()] ─► Handler(arg: DTO)

                                                          ├─ valid ⇒ value (typée)
                                                          └─ invalid ⇒ throw BadRequestException

Analogie — Un Pipe c'est un adaptateur de douane : tu présentes un objet brut (string, json), il vérifie qu'il respecte la déclaration (DTO + annotations), et te renvoie un objet propre, typé, parfois transformé (string '42' → number 42).

Un Pipe agit sur un argument du handler à la fois. Il reçoit (value, metadata)metadata contient { type, metatype, data }metatype est la classe DTO, data le nom du param (@Param('id')data='id').

Ce que metadata.type te dit

type vaut 'body' | 'query' | 'param' | 'custom' — la valeur 'custom' correspond à un argument extrait par un décorateur custom (@CurrentUser(), cf. 06-custom-decorators.md). C'est ce qui permet à un même ValidationPipe de se comporter différemment selon la source. Un piège classique : metatype est undefined quand l'argument n'a pas de type de classe (string brut, ou param décoré sans DTO) — d'où le toValidate() interne du ValidationPipe qui skip les primitifs (String, Boolean, Number, Array, Object). C'est aussi pourquoi un @Query('limit') limit: number n'est pas validé : metatype est Number, considéré comme primitif non-validable. Il faut un ParseIntPipe explicite.

Précision staff sur metatype — la résolution de metatype repose sur emitDecoratorMetadata (TypeScript) qui émet le design:paramtypes. Avec un build swc (@swc/core) ou esbuild mal configuré, cette métadonnée n'est PAS émise → metatype est Object partout → le ValidationPipe skip silencieusement TOUTE validation, en prod, sans erreur. C'est le pire mode de défaillance des pipes : 0 validation, 0 log. Le filet : un test e2e qui POST un body invalide et asserte le 400. Si ce test passe en CI mais que la prod ne valide rien, c'est ton transpileur. Avec swc : jsc.transform.legacyDecorator: true + jsc.transform.decoratorMetadata: true dans .swcrc.

Les 4 niveaux de binding — et leur ordre

Un Pipe peut être attaché à 4 niveaux. L'ordre d'exécution va du plus global au plus local, et tous s'exécutent (ce ne sont pas des fallbacks) :

NiveauBindingDI ?Portée
Globalapp.useGlobalPipes(new P())❌ pas de DI (instancié hors contexte)toute l'app
Global (DI){ provide: APP_PIPE, useClass: P }✅ DI complètetoute l'app
Controller@UsePipes(P) sur la classetous les handlers
Method@UsePipes(P) sur la méthodeun handler
Param@Param('id', P)un argument
ts
// Pipe global AVEC injection de dépendances — la bonne façon si le pipe a besoin d'un service
import { APP_PIPE } from '@nestjs/core';

@Module({
  providers: [
    { provide: APP_PIPE, useClass: ValidationPipe }, // DI-aware, testable, mockable
  ],
})
export class AppModule {}

Pourquoi APP_PIPE plutôt que useGlobalPipes(new ValidationPipe()) : un pipe instancié avec new dans main.ts vit hors du conteneur DI. Il ne peut pas injecter un ConfigService, un logger, un repo. Pour un ValidationPipe pur c'est sans conséquence ; pour tout pipe custom global qui a une dépendance, c'est APP_PIPE obligatoire. Mental model staff : un new X() dans bootstrap est un cul-de-sac de testabilité — tu ne peux pas le remplacer dans un TestingModule.

Ordre dans la pipeline complète

Middleware ─► Guard ─► Interceptor(pre) ─► Pipe ─► Handler ─► Interceptor(post) ─► ExceptionFilter

Le Pipe s'exécute après les Guards. Conséquence de sécurité concrète : un payload malveillant volumineux est déjà passé l'auth avant d'être validé. Si tu veux rejeter avant de parser un body de 50 Mo, c'est un body-parser limit (app.use(json({ limit: '100kb' }))) ou un guard, pas le pipe. Le pipe protège l'intégrité des données, pas la surface DoS.

🛠️ Code minimal

ts
// 1) DTO + class-validator
import { IsEmail, IsInt, IsOptional, IsString, Length, Min } from 'class-validator';
import { Type } from 'class-transformer';

export class CreateUserDto {
  @IsString() @Length(2, 50)
  name!: string;

  @IsEmail()
  email!: string;

  @IsOptional() @IsInt() @Min(13) @Type(() => Number)
  age?: number;
}
ts
// 2) ValidationPipe global
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,                  // strip props non listées dans le DTO
    forbidNonWhitelisted: true,       // throw si props inconnues
    forbidUnknownValues: true,        // throw si pas un objet attendu (security default Nest 9+)
    transform: true,                  // instancie le DTO (au lieu de plain object)
    transformOptions: { enableImplicitConversion: true }, // '42' → 42 via type TS
    stopAtFirstError: false,          // accumule toutes les erreurs
  }));
  await app.listen(3000);
}
ts
// 3) Usage handler
@Controller('users')
export class UsersController {
  @Post()
  create(@Body() dto: CreateUserDto) {                  // dto est typé + validé
    return this.users.create(dto);
  }

  @Get(':id')
  findOne(@Param('id', ParseUUIDPipe) id: string) {     // built-in
    return this.users.findOne(id);
  }

  @Get()
  list(@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number) {
    return this.users.list(limit);
  }
}
ts
// 4) Pipe custom — ULID
import { ArgumentMetadata, BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
import { isValid as isULID } from 'ulidx';

@Injectable()
export class ParseULIDPipe implements PipeTransform<string, string> {
  transform(value: string, _meta: ArgumentMetadata): string {
    if (!isULID(value)) throw new BadRequestException(`invalid_ulid:${value}`);
    return value;
  }
}

// Usage
@Get(':id')
get(@Param('id', ParseULIDPipe) id: string) { /* ... */ }

🎯 Patterns courants

  1. Built-in pipesParseIntPipe, ParseFloatPipe, ParseBoolPipe, ParseUUIDPipe, ParseArrayPipe, ParseEnumPipe, ParseFilePipe, DefaultValuePipe. Couvrent 80% des coercions de params.
  2. DTO d'entrée ≠ entité — toujours. Les DTOs sont des contrats d'API ; les entités sont des modèles persistents. Les coller ⇒ leak de champs internes (tenantId, passwordHash).
  3. Nested validation@ValidateNested() + @Type(() => ChildDto) pour valider récursivement. Ne pas oublier @IsArray() ou @IsObject() selon le cas.
  4. Conditional / discriminated unions@ValidateIf((o) => o.type === 'a') ou plus propre : @nestjs/swagger getSchemaPath + DTOs séparés par type discriminant.
  5. Sanitize avant validate@Transform(({ value }) => value?.trim().toLowerCase()) sur email. À combiner avec transform: true.
  6. Custom ValidationPipe factory — pour personnaliser le format d'erreur : exceptionFactory: (errors) => new UnprocessableEntityException(formatErrors(errors)). Convertit le format verbose class-validator en { error: 'validation_failed', fields: { email: ['must_be_email'] } }.
  7. PartialType, PickType, OmitType, IntersectionType depuis @nestjs/mapped-types — éviter de dupliquer les DTOs Create vs Update. UpdateUserDto extends PartialType(CreateUserDto) {} rend tous les champs optionnels et héritera des validators.
  8. File upload validation@UploadedFile(new ParseFilePipe({ validators: [new MaxFileSizeValidator({ maxSize: 1024 * 1024 }), new FileTypeValidator({ fileType: /(jpeg|png)/ })] })). Vérifie taille + MIME avant de toucher au filesystem.
  9. Cross-field validation — pour passwordConfirm === password, utiliser un validator custom au niveau classe : @ValidatorConstraint({ name: 'Match', async: false }) + @Validate(Match, ['password']).
  10. Validation groups (Create vs Update sur le MÊME DTO) — alternative à PartialType quand les règles diffèrent selon le contexte (un password requis à la création, optionnel à l'update). On tague les décorateurs avec { groups: [...] } et on passe validatorPackage/groups au pipe. Détail et tradeoff vs mapped-types plus bas.
  11. Ordre des décorateurs = pipeline de transform/validateclass-transformer applique @Transform/@Type AVANT que class-validator ne valide ; entre eux, l'ordre de déclaration compte. @IsString() @Transform(({value}) => value.trim()) valide la string BRUTE puis trim (le trim ne nettoie pas avant @IsString). Pour sanitize-puis-valider, mets @Transform en premier (plus proche de la propriété) — les décorateurs s'évaluent de bas en haut. C'est une source de bugs subtils sur la normalisation d'email.

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

VersionNotes
Nest 7ValidationPipe opt-in, transform false par défaut.
Nest 8forbidUnknownValues recommandé. class-validator 0.13+.
Nest 9forbidUnknownValues: true par défaut dans ValidationPipe — peut casser des DTOs primitifs. class-validator 0.14, class-transformer 0.5.
Nest 10Pas de changement majeur sur les pipes. ParseFilePipe plus mature avec MaxFileSizeValidator, FileTypeValidator.
Nest 11Compat Node 20+. Possibilité de pipes async natifs sans Promise<T> boilerplate. class-validator 0.14+ recommandé, attention aux peerDeps.

class-validator 0.14 piège@IsEnum(MyEnum) accepte les clés ET les valeurs par défaut. Forcer via @IsIn(Object.values(MyEnum)) si tu veux strict.

⚠️ Pitfalls

  1. transform: falsedto est un plain object, pas une instance. dto instanceof CreateUserDto === false, donc @Exclude(), instanceof, méthodes de classe ne marchent pas.
  2. whitelist: false — un payload { name: 'x', isAdmin: true } passe isAdmin au service ⇒ mass-assignment vuln. Toujours whitelist: true.
  3. forbidNonWhitelisted: false — silencieusement stripe les props inconnues. Préférer true pour aider le client à corriger (réponse 400 claire).
  4. Validation au mauvais endroit — valider dans le service au lieu du Pipe ⇒ duplication, perte de la 400 standard. Sauf logique métier (ex. unicity check qui hit la DB) qui reste service.
  5. @Body() sans DTO — Nest passe le body brut, aucune validation. Toujours typer.
  6. DTO sans @Type(() => Child) sur nested@ValidateNested() ne suffit pas car class-transformer ne sait pas hydrater la classe enfant.
  7. Number / Boolean coercion — sans enableImplicitConversion: true, @IsInt() sur un query string '42' échoue (c'est une string). Soit enableImplicitConversion, soit @Type(() => Number) explicite.
  8. Custom validator async lent — un @IsUnique() qui hit la DB à chaque validation rend la route lente et le testing pénible. Le faire dans le service avec une erreur métier dédiée (ConflictException).
  9. @IsOptional() vs @IsDefined() confusion@IsOptional() skip TOUS les validators si la valeur est null ou undefined. Pour "peut être undefined mais doit être string si présent", c'est @IsOptional() @IsString(). Pour "doit être présent même si null", @IsDefined().
  10. enableImplicitConversion: true casse les strings — convertit "true" en true, mais aussi "1" en 1 pour un champ typed string. Si le DTO a code: string, un envoi de "123" passera... en string "123". Mais un champ id: number recevant "abc" devient NaN. Tester les cas edge.
  11. Class-validator decorators stripped en prod build — si tsconfig n'a pas emitDecoratorMetadata, ou si tu utilises swc mal configuré, les décorateurs disparaissent. Vérifier qu'un test e2e attrape vraiment les 400.

🧪 Testing

ts
// DTO + ValidationPipe unitaire
import { ValidationPipe } from '@nestjs/common';

const pipe = new ValidationPipe({ whitelist: true, transform: true });

it('valide un payload correct', async () => {
  const dto = await pipe.transform(
    { name: 'John', email: '[email protected]', age: 21 },
    { type: 'body', metatype: CreateUserDto },
  );
  expect(dto).toBeInstanceOf(CreateUserDto);
  expect(dto.age).toBe(21);
});

it('refuse email invalide', async () => {
  await expect(pipe.transform(
    { name: 'John', email: 'nope' },
    { type: 'body', metatype: CreateUserDto },
  )).rejects.toThrow(/email/);
});
ts
// Pipe custom
const pipe = new ParseULIDPipe();
expect(() => pipe.transform('not-a-ulid', {} as any)).toThrow(BadRequestException);
expect(pipe.transform('01HQX9SZ8M0K7BF4Y5W4E1XKQH', {} as any)).toBe('01HQX9SZ8M0K7BF4Y5W4E1XKQH');
ts
// e2e
await request(app.getHttpServer())
  .post('/users')
  .send({ name: 'x', email: 'bad' })
  .expect(400)
  .expect(({ body }) => expect(body.message).toContain('email'));

🎬 Cas d'usage concrets

Scénario 1 — LegalTech : validation d'un contrat juridique

Qui — Une LegalTech FR (28 ETP) qui édite un outil de génération de contrats commerciaux pour PME. ≈ 12 000 contrats générés/mois.

Problème métier — Les contrats ont des règles métier complexes : un contrat de prestation B2B doit avoir un SIREN client, des montants TVA cohérents, une durée minimale légale, des clauses obligatoires selon le type (bail commercial, CDD, freelance). Sans validation typée stricte, les contrats générés étaient parfois invalides (rejets URSSAF, jugements défavorables).

Comment ce concept aide — DTOs class-validator avec validators custom métier (@IsSiren, @IsValidTvaRate, @IsLegalDuration selon le type). Cross-field validation pour cohérence (endDate > startDate, tvaAmount === total * tvaRate). Discriminated unions pour les types de contrat.

ts
export enum ContractType { Prestation = 'prestation', Bail = 'bail', Freelance = 'freelance' }

export class PrestationContractDto {
  @Equals(ContractType.Prestation) type!: ContractType.Prestation; // discriminant figé
  @IsSiren() clientSiren!: string;
  @IsString() @Length(2, 200) clientName!: string;
  @IsNumber() @Min(0) @Type(() => Number) amountExclTax!: number;
  @IsValidTvaRate() tvaRate!: number;
  @IsDate() @MinDate(new Date()) @Type(() => Date) startDate!: Date;
  @IsDate() @AfterField('startDate') @Type(() => Date) endDate!: Date; // @AfterField = validator cross-field custom
  @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => ClauseDto) clauses!: ClauseDto[];
}

Gains chiffrés — Contrats invalides passés de ~3% à 0.05%, plaintes clients sur génération divisées par 30, équipe légale interne libérée du contrôle manuel (gain ~25h/semaine).

Scénario 2 — Banque : validation KYC schema strict

Qui — Une néobanque B2B FR (110 ETP, agrément EME). Onboarding KYC PME : SIRET, statuts, beneficial owners, justificatifs.

Problème métier — Le formulaire KYC a 47 champs, dont 22 conditionnels (un bénéficiaire effectif > 25% déclenche 8 champs supplémentaires). Sans validation centralisée, l'équipe avait des if/else étendus dans le service, et des dossiers KYC incomplets passaient quand même (rejets ACPR ultérieurs).

Comment ce credit aide — DTOs imbriqués avec @ValidateNested(), conditional validation via @ValidateIf, discriminated polymorphism. Le pipe valide à l'arrivée, le service ne reçoit que des données 100% structurées.

ts
export class BeneficialOwnerDto {
  @IsString() firstName!: string;
  @IsString() lastName!: string;
  @IsNumber() @Min(25) @Max(100) ownershipPercentage!: number;
  @IsDateString() birthDate!: string;
  @IsISO31661Alpha2() nationality!: string;
}

export class KycCompanyDto {
  @IsSiret() siret!: string;
  @IsString() legalName!: string;
  @IsEnum(LegalForm) legalForm!: LegalForm;
  @IsNumber() @Min(0) annualRevenue!: number;

  @ValidateNested({ each: true })
  @Type(() => BeneficialOwnerDto)
  @ArrayMinSize(1)
  beneficialOwners!: BeneficialOwnerDto[];

  @ValidateIf((o: KycCompanyDto) => o.annualRevenue > 1_000_000)
  @ValidateNested() @Type(() => FinancialsDto)
  financials?: FinancialsDto;

  @ValidateIf((o: KycCompanyDto) => o.legalForm === LegalForm.SAS || o.legalForm === LegalForm.SARL)
  @IsString() @Length(1, 200)
  capitalRepartition?: string;
}

Gains chiffrés — Dossiers KYC incomplets passés de 12% à 0.3%, validation ACPR sans réserve, MTTR sur dossiers refusés divisé par 4 (erreurs détectées à la saisie, pas en backoffice).

Scénario 3 — E-commerce : fiche produit Shopify

Qui — Un éditeur SaaS e-commerce FR (35 ETP, ≈ 4 200 boutiques) qui synchronise les catalogues vers Shopify, Amazon, Cdiscount. Chaque marketplace a ses contraintes (Amazon : longueur titre 80 chars, Shopify : prix > 0, Cdiscount : EAN obligatoire).

Problème métier — Avant : un seul DTO produit générique, validation lâche, push vers les marketplaces échouait silencieusement à 8%. Pas de retour clair au marchand.

Comment ce concept aide — Un DTO de base + des DTOs spécialisés par marketplace via PickType/PartialType/IntersectionType. Validation stricte avant push, erreurs en JSON détaillé par champ retournées au marchand.

ts
export class BaseProductDto {
  @IsString() @Length(2, 80) title!: string;
  @IsString() @MinLength(20) description!: string;
  @IsNumber() @Min(0.01) priceExclTax!: number;
  @IsString() sku!: string;
  @IsArray() @ArrayMinSize(1) @IsUrl({}, { each: true }) images!: string[];
}

export class AmazonProductDto extends BaseProductDto {
  @IsString() @Length(1, 80) title!: string; // override stricter
  @IsString() @Matches(/^[A-Z0-9]{10}$/) asin?: string;
  @IsString() brandName!: string;
}

// IntersectionType veut des classes NOMMÉES — une classe anonyme inline perd
// ses métadonnées class-validator dans certaines configs swc/tsc. Toujours déclarer.
class CdiscountSpecificDto {
  @IsEAN() ean!: string;
  @IsString() category!: string;
}

export class CdiscountProductDto extends IntersectionType(
  PickType(BaseProductDto, ['title', 'description', 'priceExclTax', 'sku', 'images'] as const),
  CdiscountSpecificDto,
) {}

Gains chiffrés — Taux de rejet marketplaces passé de 8% à 0.4%, retours d'erreurs côté marchand actionnables (vs message générique avant), 32% de gain de productivité sur la gestion catalogue.

🛠️ Exemple end-to-end

Use case — Néobanque B2B. Endpoint POST /v1/kyc/companies pour soumettre un dossier KYC PME. Validation stricte avec DTOs imbriqués, validators custom métier (SIREN, IBAN, TVA), exception factory pour produire un format d'erreur exploitable par le front.

ts
// src/validation/validators/siren.validator.ts
import { ValidatorConstraint, ValidatorConstraintInterface, registerDecorator } from 'class-validator';

@ValidatorConstraint({ name: 'IsSiren', async: false })
export class IsSirenConstraint implements ValidatorConstraintInterface {
  validate(value: any): boolean {
    if (typeof value !== 'string') return false;
    const cleaned = value.replace(/\s/g, '');
    if (!/^\d{9}$/.test(cleaned)) return false;
    // Algorithme de Luhn
    let sum = 0;
    for (let i = 0; i < cleaned.length; i++) {
      let digit = parseInt(cleaned[i], 10);
      if (i % 2 === 1) {
        digit *= 2;
        if (digit > 9) digit -= 9;
      }
      sum += digit;
    }
    return sum % 10 === 0;
  }
  defaultMessage() { return 'invalid_siren'; }
}

export function IsSiren(opts?: any) {
  return (object: object, propertyName: string) => {
    registerDecorator({
      target: object.constructor,
      propertyName,
      options: opts,
      validator: IsSirenConstraint,
    });
  };
}
ts
// src/validation/validators/siret.validator.ts
@ValidatorConstraint({ name: 'IsSiret', async: false })
export class IsSiretConstraint implements ValidatorConstraintInterface {
  validate(value: any): boolean {
    if (typeof value !== 'string') return false;
    const cleaned = value.replace(/\s/g, '');
    if (!/^\d{14}$/.test(cleaned)) return false;
    let sum = 0;
    for (let i = 0; i < cleaned.length; i++) {
      let digit = parseInt(cleaned[i], 10);
      if (i % 2 === 0) {
        digit *= 2;
        if (digit > 9) digit -= 9;
      }
      sum += digit;
    }
    return sum % 10 === 0;
  }
  defaultMessage() { return 'invalid_siret'; }
}

export function IsSiret(opts?: any) {
  return (o: object, p: string) => registerDecorator({ target: o.constructor, propertyName: p, options: opts, validator: IsSiretConstraint });
}
ts
// src/kyc/dto/beneficial-owner.dto.ts
import { IsDateString, IsISO31661Alpha2, IsNumber, IsString, Length, Max, Min } from 'class-validator';

export class BeneficialOwnerDto {
  @IsString() @Length(1, 80)
  firstName!: string;

  @IsString() @Length(1, 80)
  lastName!: string;

  @IsNumber() @Min(25) @Max(100)
  ownershipPercentage!: number;

  @IsDateString()
  birthDate!: string;

  @IsISO31661Alpha2()
  nationality!: string;

  @IsString() @Length(2, 200)
  address!: string;
}
ts
// src/kyc/dto/create-kyc.dto.ts
import { Type } from 'class-transformer';
import {
  ArrayMinSize, IsArray, IsEnum, IsNumber, IsOptional, IsString, Length, Min,
  ValidateIf, ValidateNested,
} from 'class-validator';
import { IsSiret } from '../../validation/validators/siret.validator';
import { BeneficialOwnerDto } from './beneficial-owner.dto';

export enum LegalForm {
  Sas = 'SAS',
  Sarl = 'SARL',
  Sa = 'SA',
  Eurl = 'EURL',
  AutoEntrepreneur = 'AE',
}

export class FinancialsDto {
  @IsNumber() @Min(0) lastYearRevenue!: number;
  @IsNumber() @Min(0) lastYearProfit!: number;
  @IsNumber() @Min(0) totalAssets!: number;
}

export class CreateKycCompanyDto {
  @IsSiret()
  siret!: string;

  @IsString() @Length(2, 200)
  legalName!: string;

  @IsEnum(LegalForm)
  legalForm!: LegalForm;

  @IsNumber() @Min(0) @Type(() => Number)
  annualRevenue!: number;

  @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true })
  @Type(() => BeneficialOwnerDto)
  beneficialOwners!: BeneficialOwnerDto[];

  @ValidateIf((o: CreateKycCompanyDto) => o.annualRevenue > 1_000_000)
  @ValidateNested() @Type(() => FinancialsDto)
  financials?: FinancialsDto;

  @ValidateIf((o: CreateKycCompanyDto) => [LegalForm.Sas, LegalForm.Sarl, LegalForm.Sa].includes(o.legalForm))
  @IsString() @Length(10, 1000)
  capitalRepartition?: string;

  @IsOptional() @IsString() @Length(0, 2000)
  comment?: string;
}

Le DTO illustre : (a) validators custom (@IsSiret) avec algorithme Luhn, (b) nested validation des bénéficiaires, (c) @ValidateIf pour les champs conditionnels (financials uniquement si CA > 1M€, capitalRepartition uniquement pour SAS/SARL/SA), (d) @Type + @ArrayMinSize pour les tableaux d'objets, (e) @IsOptional() pour les champs vraiment optionnels.

ts
// src/validation/exception-factory.ts
import { BadRequestException } from '@nestjs/common';
import { ValidationError } from 'class-validator';

export function nestedValidationFactory(errors: ValidationError[]): BadRequestException {
  const fields: Record<string, string[]> = {};

  const walk = (err: ValidationError, path: string[] = []) => {
    const here = [...path, err.property];
    if (err.constraints) {
      fields[here.join('.')] = Object.values(err.constraints);
    }
    if (err.children?.length) {
      err.children.forEach((c) => walk(c, here));
    }
  };
  errors.forEach((e) => walk(e));

  return new BadRequestException({
    error: 'validation_failed',
    fields,
    timestamp: new Date().toISOString(),
  });
}

L'exception factory parcourt récursivement les erreurs nested et produit un format { "beneficialOwners.0.firstName": ["firstName must be longer than..."] } directement exploitable par le front pour afficher des messages contextuels.

ts
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
import { nestedValidationFactory } from './validation/exception-factory';

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

forbidNonWhitelisted: true + whitelist: true empêche tout mass-assignment. enableImplicitConversion: false car les types primitifs sont explicites via @Type().

ts
// src/kyc/parse-siret.pipe.ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseSiretPipe implements PipeTransform<string, string> {
  transform(value: string): string {
    const cleaned = value?.replace(/\s/g, '');
    if (!cleaned || !/^\d{14}$/.test(cleaned)) {
      throw new BadRequestException('invalid_siret_format');
    }
    return cleaned;
  }
}
ts
// src/kyc/kyc.controller.ts
import { Body, Controller, Get, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt.guard';
import { KycService } from './kyc.service';
import { CreateKycCompanyDto } from './dto/create-kyc.dto';
import { ParseSiretPipe } from './parse-siret.pipe';

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

  @Post()
  @HttpCode(201)
  submit(@Body() dto: CreateKycCompanyDto) {
    return this.kyc.submit(dto);
  }

  @Get(':siret')
  getStatus(@Param('siret', ParseSiretPipe) siret: string) {
    return this.kyc.findBySiret(siret);
  }
}

L'ensemble illustre les patterns : (a) ValidationPipe global avec config stricte, (b) DTOs class-validator avec validators custom métier (SIREN/SIRET Luhn), (c) nested validation pour les bénéficiaires, (d) conditional validation avec @ValidateIf, (e) pipe custom pour les params d'URL, (f) exception factory pour un format d'erreur exploitable. Le service ne reçoit que des données 100% structurées et valides — il peut se concentrer sur la logique métier.

⚡ Performance, observabilité & scale (niveau staff)

Coût réel de class-validator — La réflexion (reflect-metadata) et l'instanciation par class-transformer ne sont pas gratuites. Sur un DTO plat de 10 champs, l'overhead est sub-microseconde, négligeable. Mais ça dégénère vite :

PatternCoûtMitigation
DTO plat, 10 champs~5-20 µsnon-problème
@ValidateNested({ each: true }) sur 10k élémentsO(n) instanciations + walkborner la taille via @ArrayMaxSize, paginer l'input
Validator custom async (DB hit) par élémentn requêtes DB sérialiséessortir du pipe → batch dans le service
enableImplicitConversion: trueparse supplémentaire par champpréférer @Type() explicite

Règle de borne d'abord — Toujours mettre un @ArrayMaxSize(N) AVANT un @ValidateNested({ each: true }). Sans ça, un client envoie un tableau de 1M d'objets et tu instancies 1M de DTOs avant même de rejeter : DoS algorithmique trivial. La validation de taille doit court-circuiter la validation de structure (stopAtFirstError n'aide pas ici — l'ordre des décorateurs et la borne explicite oui).

stopAtFirstErrorfalse (défaut) accumule TOUTES les erreurs : meilleur DX front, mais sur un gros payload invalide ça walk l'arbre entier. En façade publique exposée (rate-limited), true réduit le travail sur les payloads hostiles. Tradeoff DX interne vs surface d'attaque.

Observabilité — Une 400 de validation n'est pas une erreur serveur : ne pas la logguer en error (pollue Sentry, fausse le taux d'erreur SLO). Mais émettre une métrique validation_failed_total{route, field} est précieux — un pic sur email signale souvent un bug de contrat front, pas un client malveillant. Implémentation : compteur Prometheus dans l'exceptionFactory.

ts
exceptionFactory: (errors) => {
  for (const e of errors) validationFailures.inc({ field: e.property });
  return new BadRequestException(formatErrors(errors)); // 400, PAS de log error
}

Cache de schémaclass-validator ne recompile pas les métadonnées à chaque requête (elles sont lues une fois au démarrage via les décorateurs), donc pas de cache à gérer côté toi. Le coût récurrent est l'instanciation + le walk, pas la résolution de schéma. Si tu as besoin de validation ultra-haute fréquence (>50k req/s, hot path), évalue une alternative compile-time : Zod (pas de réflexion, mais pas d'instances de classe) ou typia/@nestjs/typia (validation générée à la compilation, ~20x plus rapide, zéro réflexion runtime). class-validator reste le défaut idiomatique Nest ; Zod/typia sont des optimisations ciblées.

🤖 Valider & router des inputs d'agents IA depuis NestJS

Le topic des pipes est exactement là où la robustesse d'un backend qui sert un agent IA se joue : tout ce qui entre dans un LLM (prompt utilisateur, arguments d'un tool-call, paramètres de génération) doit être validé au bord, et tout ce qui SORT d'un LLM (un tool-call que le modèle te demande d'exécuter) doit être re-validé comme un input hostile. Un argument de tool produit par le modèle n'est pas digne de confiance — traite-le comme un body HTTP utilisateur.

1) DTO d'entrée de chat — borner le coût avant de toucher l'API

ts
import { IsArray, IsIn, IsInt, IsString, Max, Min, MaxLength, ArrayMaxSize, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class ChatMessageDto {
  @IsIn(['user', 'assistant']) role!: 'user' | 'assistant';
  @IsString() @MaxLength(20_000) content!: string; // borne le coût en tokens dès le bord
}

export class ChatRequestDto {
  @IsArray() @ArrayMaxSize(50)            // borne la fenêtre AVANT @ValidateNested
  @ValidateNested({ each: true }) @Type(() => ChatMessageDto)
  messages!: ChatMessageDto[];

  // whitelist STRICTE des modèles — jamais un model id arbitraire passé au provider
  @IsIn(['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'])
  model!: string;

  @IsInt() @Min(1) @Max(8192) @Type(() => Number)
  maxTokens!: number;

  @IsString() @MaxLength(40_000) systemPrompt?: string;
}

Pourquoi @IsIn sur model et pas @IsString : un model id non whitelisté est une faille de coût (un client force claude-opus-4-8 sur un plan gratuit) et de sécurité (model deprecated, ou injection d'un id provider-specific). Le pipe est ton cost-guard de premier niveau. Combine avec un rate-limit et un cost-budget par tenant en amont (guard).

2) Re-valider un tool-call émis par le LLM (boucle agentique)

Quand le modèle renvoie tool_use, ses input sont du JSON arbitraire généré par un système probabiliste. Avant d'exécuter le tool, valide ces arguments avec le MÊME ValidationPipe.transform() que pour un input HTTP :

ts
import { ValidationPipe } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';

@Injectable()
export class ToolArgsValidator {
  private pipe = new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true });

  // toolInput vient du modèle → traité comme hostile
  async validate<T>(dto: new () => T, toolInput: unknown): Promise<T> {
    return this.pipe.transform(toolInput, { type: 'body', metatype: dto, data: '' }) as Promise<T>;
  }
}

// Dans la boucle agentique
for (const block of response.content) {
  if (block.type === 'tool_use' && block.name === 'transfer_funds') {
    // SANS cette ligne : le modèle pourrait halluciner amount: -999999 ou un IBAN malformé
    const args = await toolValidator.validate(TransferFundsDto, block.input);
    const result = await this.bank.transfer(args); // args est typé + validé
  }
}

C'est LE point que les juniors ratent : ils font confiance au JSON Schema passé au modèle pour garantir la forme de sortie. Le schéma guide le modèle, il ne le contraint pas dur — un modèle peut produire un tool_use hors-schéma (rare mais réel), ou un argument valide en forme mais aberrant en valeur (amount: 1e12). Le @Min/@Max du DTO l'attrape.

3) DI'd LLM client — jamais new Anthropic() dans un champ

Le pipe valide l'input ; le client qui consomme cet input doit être injecté, pas instancié inline (testabilité, retries SDK, config centralisée). Pattern forRootAsync :

ts
@Module({})
export class AnthropicModule {
  static forRootAsync(): DynamicModule {
    return {
      module: AnthropicModule,
      providers: [{
        provide: 'ANTHROPIC',
        inject: [ConfigService],
        useFactory: (cfg: ConfigService) =>
          new Anthropic({ apiKey: cfg.getOrThrow('ANTHROPIC_API_KEY'), maxRetries: 3 }), // retries SDK
      }],
      exports: ['ANTHROPIC'],
      global: true,
    };
  }
}

Dans un job BullMQ de génération, l'idempotency key est l'id de génération (pas un uuid aléatoire par retry) : un retry après timeout réseau ne doit pas relancer — et donc re-facturer — une génération déjà partie. Le DTO du job porte ce generationId validé (@IsUUID()), et le worker check done:{generationId} avant d'appeler le modèle. Sur erreur transitoire (overloaded_error, 429), retry cost-aware : backoff exponentiel + jitter, et NE PAS retry un invalid_request_error (400 provider) — c'est un bug de payload, pas un aléa réseau, le retry brûle du budget pour rien.

4) Streamer la génération en SSE — et annuler des DEUX côtés sur déconnexion

Le DTO validé entre dans le pipe ; la réponse, elle, se stream. Pattern complet : @Sse, un AbortController lié à la déconnexion client, et le SDK Anthropic en mode stream. Si le client ferme l'onglet, on doit abort() l'appel modèle — sinon le serveur continue à consommer (et facturer) des tokens dans le vide.

ts
import { Controller, Post, Body, Res, Req, Inject } from '@nestjs/common';
import type { Request, Response } from 'express';
import type Anthropic from '@anthropic-ai/sdk';

@Controller('chat')
export class ChatController {
  constructor(@Inject('ANTHROPIC') private readonly anthropic: Anthropic) {}

  @Post('stream')
  async stream(@Body() dto: ChatRequestDto, @Req() req: Request, @Res() res: Response) {
    // dto est DÉJÀ validé par le ValidationPipe global (model whitelisté, maxTokens borné)
    res.set({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });

    const ac = new AbortController();
    req.on('close', () => ac.abort()); // client part → on coupe l'appel modèle

    try {
      const stream = this.anthropic.messages.stream(
        { model: dto.model, max_tokens: dto.maxTokens, messages: dto.messages },
        { signal: ac.signal }, // propage l'annulation au SDK (et au fetch sous-jacent)
      );
      for await (const event of stream) {
        if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
          res.write(`data: ${JSON.stringify({ token: event.delta.text })}\n\n`);
        }
      }
      res.write('data: [DONE]\n\n');
    } catch (err) {
      if (ac.signal.aborted) return; // annulation client : pas une erreur, on log en info
      res.write(`event: error\ndata: ${JSON.stringify({ error: 'generation_failed' })}\n\n`);
    } finally {
      res.end();
    }
  }
}

Pourquoi @Res() brut plutôt que @Sse() ici : @Sse() attend un Observable et masque le contrôle fin sur AbortController/req.on('close'). Pour du LLM token-streaming avec annulation, le @Res() explicite est plus lisible (au prix de perdre l'auto-sérialisation Nest — assumé). Le lien avec ce chapitre : le pipe garantit que dto.maxTokens est borné AVANT d'ouvrir le stream — sans ça un client demande max_tokens: 1e9 et tu streames jusqu'à épuisement du budget. La validation au bord est ce qui rend le cost-guard fiable. Pour le détail de l'agentic loop multi-tour et du rendu côté Angular, voir interceptors/streaming et le chapitre Angular AI-UI.

🔁 Quand utiliser / éviter

Utiliser un Pipe :

  • Valider et transformer un argument typé (DTO d'input).
  • Coercions standards (id → int/uuid/ulid, enable → bool).
  • Sanitization basique (trim, toLowerCase) via @Transform().

Éviter un Pipe, préférer Guard :

  • Décisions d'autorisation (le Pipe n'a pas accès aux roles).

Éviter un Pipe, préférer le service :

  • Règles métier qui dépendent de la DB (unicité d'email — utiliser ConflictException côté service après tentative d'insert ou via contrainte DB).

Éviter un Pipe, préférer Interceptor :

  • Transformer la sortie. Un Pipe est input-only.

Quand faire un Pipe custom plutôt que ValidationPipe :

  • Type primitif spécifique (ULID, ObjectId, slug normalisé).
  • Coercion qui dépend du context (ex. parser un range 2025-01-01..2025-12-31).
  • Validation dépendante d'un service injecté (@IsUserActive() avec accès au repo). Bien que ce dernier soit borderline — souvent mieux dans le service.

Anti-patterns :

  • DTO qui hérite d'une entité ORM (class CreateUserDto extends User) — couple persistence et contrat d'API.
  • Validation custom async qui throw au lieu de retourner false — class-validator ne sait pas la propager proprement.
  • Pipe qui mute value au lieu de retourner une nouvelle valeur — fragile, surtout avec class-transformer derrière.

🧰 Exemples avancés

Cross-field validator (passwordConfirm === password)

ts
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments, registerDecorator } from 'class-validator';

@ValidatorConstraint({ name: 'Match', async: false })
export class MatchConstraint implements ValidatorConstraintInterface {
  validate(value: any, args: ValidationArguments) {
    const [related] = args.constraints;
    return value === (args.object as any)[related];
  }
  defaultMessage(args: ValidationArguments) {
    return `${args.property} must match ${args.constraints[0]}`;
  }
}

export function Match(property: string, validationOptions?: any) {
  return (object: object, propertyName: string) => {
    registerDecorator({
      target: object.constructor,
      propertyName,
      constraints: [property],
      options: validationOptions,
      validator: MatchConstraint,
    });
  };
}

// Usage
export class SignupDto {
  @IsString() password!: string;
  @IsString() @Match('password', { message: 'passwords_must_match' }) passwordConfirm!: string;
}

Pipe custom avec service injecté

ts
@Injectable()
export class UserExistsPipe implements PipeTransform<string, Promise<User>> {
  constructor(private readonly users: UsersService) {}
  async transform(id: string): Promise<User> {
    const u = await this.users.findOne(id);
    if (!u) throw new NotFoundException('user_not_found');
    return u;
  }
}

// Usage : remplace `(id: string) => svc.findOne(id) || throw` boilerplate
@Get(':id')
get(@Param('id', UserExistsPipe) user: User) { return user; }

Validation groups — un DTO, deux contrats (Create vs Update)

PartialType règle 80% du cas Create/Update (tout optionnel à l'update). Mais quand les règles changent — password requis à la création, interdit à l'update car on a un endpoint dédié — PartialType ne suffit pas. Les groups taguent chaque contrainte avec un contexte :

ts
import { IsString, IsEmail, MinLength, IsOptional } from 'class-validator';

export class UpsertUserDto {
  @IsEmail({}, { groups: ['create', 'update'] })
  email!: string;

  @IsString({ groups: ['create'] })
  @MinLength(12, { groups: ['create'] })
  password?: string; // requis en create, ignoré en update

  @IsOptional({ groups: ['update'] })
  @IsString({ groups: ['update'] })
  displayName?: string;
}
ts
// Binding par méthode : on passe le groupe actif au pipe
@Post()
create(@Body(new ValidationPipe({ groups: ['create'], transform: true })) dto: UpsertUserDto) {}

@Patch(':id')
update(@Body(new ValidationPipe({ groups: ['update'], transform: true })) dto: UpsertUserDto) {}

Tradeoff staff — les groups concentrent toute la logique dans un seul DTO (DRY) mais couplent deux contrats d'API dans une même classe : la lisibilité chute dès 3+ groups, et whitelist/forbidNonWhitelisted interagissent mal avec always/strictGroups (un champ sans group est validé dans TOUS les groups sauf si always: false). Règle : mapped-types (PartialType/PickType) par défaut, groups uniquement quand les règles (pas seulement l'optionalité) divergent. Si tu sors trois @…({ groups }) sur chaque champ, c'est le signal de scinder en deux DTOs.

Exception factory pour ValidationPipe

ts
new ValidationPipe({
  whitelist: true,
  transform: true,
  exceptionFactory: (errors) => {
    const fields: Record<string, string[]> = {};
    const walk = (err: any, path: string[] = []) => {
      const here = [...path, err.property];
      if (err.constraints) fields[here.join('.')] = Object.keys(err.constraints);
      err.children?.forEach((c: any) => walk(c, here));
    };
    errors.forEach((e) => walk(e));
    return new BadRequestException({ error: 'validation_failed', fields });
  },
});

🏋️ Exercices

Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans l'ordre.

1 — ParseObjectIdPipe typé (implémenter)

Objectif — Un pipe custom générique qui valide un MongoDB ObjectId (24 hex) ET coerce en ObjectId instance, utilisable en @Param('id', ParseObjectIdPipe). Indice/Solutionimplements PipeTransform<string, ObjectId>. Valider avec ObjectId.isValid(value) puis return new ObjectId(value). Throw BadRequestException('invalid_object_id') sinon. Test : un id valide retourne une instance, un 'xyz' throw.

2 — Cross-field conditionnel sans class { ... } anonyme (implémenter)

Objectif — Un DTO de virement où requiresApproval est true automatiquement si amount > 10_000, validé côté serveur (le client ne peut pas mentir). Plus : currency doit être dans une whitelist et amount borné à 2 décimales. Indice/Solution — Validator de classe custom @ValidatorConstraint qui lit args.object pour la cohérence inter-champs ; @Transform pour forcer requiresApproval = value.amount > 10000 AVANT validation ; @Matches(/^\d+(\.\d{1,2})?$/) (ou valider en centimes entiers — meilleur). @IsIn(['EUR','USD','GBP']).

3 — exceptionFactory production-grade + métrique (durcir)

Objectif — Transformer le format verbeux de class-validator en { error, fields: Record<path, code[]> } avec chemins nested aplatis (beneficialOwners.0.firstName), émettre un compteur Prometheus par champ, et NE PAS logguer en error. Indice/Solution — Reprends le walk récursif de la section end-to-end. Mappe err.constraints → ses clés (codes stables type isEmail) pas ses messages (i18n côté front). counter.inc({ field }) dans la boucle. Renvoie BadRequestException. Test e2e : 400 + body.fields contient le bon chemin nested.

4 — Le DoS algorithmique (casser puis réparer)

Objectif — Prouver qu'un endpoint avec @ValidateNested({ each: true }) SANS borne de taille peut être saturé, puis le corriger. Indice/Solution — Écris un test qui envoie un tableau de 200 000 objets nested et mesure le temps de pipe.transform. Constate la dégradation O(n) (instanciation + walk). Répare : ajoute @ArrayMaxSize(100) AVANT @ValidateNested, vérifie que le rejet est désormais O(1) côté structure. Bonus : limite aussi le body parser (json({ limit: '256kb' })) car le pipe s'exécute après le parsing — le JSON de 50 Mo est déjà en RAM.

5 — Valider un tool-call hostile d'un agent (production-grade IA)

Objectif — Dans une boucle agentique, valider les input d'un tool_use émis par le modèle avec le même ValidationPipe qu'un input HTTP, et faire échouer une génération qui hallucine un amount négatif. Indice/Solution — Réutilise ToolArgsValidator de la section IA. DTO TransferFundsDto avec @Min(1) @Max(50_000) sur amount et @IsIBAN(). Simule un block.input = { amount: -999999, iban: 'x' }. Assert que validate() rejette AVANT tout appel bank.transfer. Mental model à verbaliser : le JSON Schema guide le modèle, le pipe le contraint.

6 — Bench class-validator vs Zod vs typia (casser le mythe)

Objectif — Mesurer l'overhead réel de la validation sur le même DTO (10 champs, 1 niveau de nesting) entre class-validator + class-transformer, Zod, et typia. Indice/Solutiontinybench ou Date.now() sur 100k itérations. Attends-toi à class-validator le plus lent (réflexion + instanciation), typia largement devant (validation générée à la compilation). Conclusion staff à formuler : le défaut Nest est class-validator pour le DX et l'écosystème ; on ne migre vers typia/Zod que sur un hot path prouvé par un profil, pas par principe.

7 — Stream LLM borné + annulation des deux côtés (production-grade IA)

Objectif — Un endpoint POST /chat/stream (@Res() brut, SSE) qui valide ChatRequestDto (model whitelisté @IsIn, maxTokens borné @Max(8192)), streame les tokens du SDK Anthropic, et coupe l'appel modèle quand le client se déconnecte. Indice/Solution — Réutilise le ChatController de la section IA. AbortController + req.on('close', () => ac.abort()) ; passe { signal: ac.signal } à anthropic.messages.stream(...). Le DTO validé garantit maxTokens borné AVANT d'ouvrir le stream (sinon max_tokens: 1e9 épuise le budget). Casse-le : commente la borne @Max, envoie maxTokens: 999999999, observe le coût qui dérape. Répare : rétablis la borne. Bonus : assert que ac.signal.aborted court-circuite le catch (annulation client ≠ erreur serveur, log en info pas error). Retry cost-aware côté worker BullMQ : backoff sur overloaded_error/429, JAMAIS sur invalid_request_error (400) — un 400 est un bug de payload, le retry brûle du budget pour rien.

🎤 En entretien

Q — Pourquoi un @Query('limit') limit: number n'est-il pas validé par le ValidationPipe global, alors qu'un @Body() dto: Dto l'est ? Parce que le ValidationPipe skip les metatype primitifs (Number, String, Boolean, Array, Object) via son toValidate() interne — il n'y a pas de métadonnées de décorateur à valider sur un type natif. Il faut un ParseIntPipe explicite, ou un DTO de query (@Query() q: ListQueryDto).

Q — useGlobalPipes(new ValidationPipe()) vs { provide: APP_PIPE, useClass: ValidationPipe } — quelle différence et quand ça compte ? Le new instancie le pipe HORS du conteneur DI : pas d'injection possible, pas remplaçable dans un TestingModule. APP_PIPE le passe par la DI. Pour un ValidationPipe pur c'est cosmétique ; dès qu'un pipe global a une dépendance (logger, config, repo), APP_PIPE devient obligatoire. Règle : tout new X() en bootstrap est un cul-de-sac de testabilité.

Q — Un Pipe peut-il protéger d'une attaque DoS par gros payload ? Non, pas du payload lui-même : le pipe s'exécute APRÈS le body-parser et après les guards, donc le JSON est déjà parsé en RAM. Le pipe protège l'intégrité/forme des données. La surface DoS se borne en amont : json({ limit }), rate-limit, et @ArrayMaxSize pour empêcher le DoS algorithmique du @ValidateNested({ each: true }).

Q — Tu sers un agent IA. Pourquoi re-valider les arguments d'un tool-call alors que tu as fourni un JSON Schema au modèle ? Parce que le schéma guide le modèle (système probabiliste) sans le contraindre durement : il peut produire un tool-call hors-schéma, ou valide en forme mais aberrant en valeur (amount: 1e12, IBAN malformé). Un argument de tool généré par un LLM se traite comme un body HTTP utilisateur : hostile par défaut. On le repasse dans le ValidationPipe (@Min/@Max/@IsIBAN) avant exécution.

Q — Pourquoi le ValidationPipe est-il ton premier cost-guard quand tu exposes un endpoint de chat LLM, et où se borne le coût réellement ? Le DTO d'entrée borne le coût AVANT de toucher le provider : @IsIn(['claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5']) sur model (un id non whitelisté = faille de coût et de sécurité), @Max(8192) sur maxTokens, @ArrayMaxSize + @MaxLength sur l'historique de messages (borne la fenêtre de tokens). Mais le pipe seul ne suffit pas : il s'exécute après le body-parser, donc le DoS volumétrique se borne en amont (json({ limit }), rate-limit). Le cost-budget par tenant et l'idempotency (keyée sur un generationId, pas un uuid par retry) sont au niveau guard/worker. Et l'annulation : sur déconnexion client (req.on('close')), un AbortController doit couper l'appel modèle — sinon le serveur facture des tokens dans le vide. Le pipe garantit la forme et la borne ; le guard et le worker garantissent le budget et l'idempotence.

🔗 Liens

Bibliothèque tech perso — Achref