Опирается на правила: R-VLD-OAS-1, R-VLD-OAS-4, R-VLD-OAS-6, R-VLD-OAS-X4, R-VLD-OAS-X5, R-VLD-WHERE-1, R-VLD-WHERE-4 из Validation Style Guide → раздел 6. Контракт-схема как источник правды.

Важно знать

  • NestJS code-first. DTO-класс с декораторами — источник правды; OpenAPI-схема генерируется из него через @nestjs/swagger CLI-plugin, не наоборот. Правило живёт в одном месте — в DTO.
  • Интерфейс вместо класса — молчаливый провал. TypeScript-интерфейсы стираются в runtime; ValidationPipe не видит декораторов и пропускает невалидные данные без ошибки.
  • Глобальный ValidationPipe обязателен. whitelist: true отрезает неизвестные поля; forbidNonWhitelisted: true возвращает 400 на опечатку; transform: true конвертирует plain-объект в instance.
  • Nested — только через @ValidateNested + @Type(). Без @Type class-transformer не создаёт instance вложенного класса, и валидация nested-полей молча не выполняется.
  • После маппинга в UseCase-команду повторная валидация не делается (R-VLD-OAS-6). Команда пришла уже чистой; доменные инварианты — на агрегате.
  • exceptionFactory в ValidationPipe превращает errors class-validator в единый problem+json формат (R-ERR-MAP-2); дефолтные сообщения на английском в ответ не идут.
  • Custom constraint в common/validation/, никогда не inline в DTO.

NestJS работает в парадигме code-first: DTO-класс — это и есть контракт. @nestjs/swagger с CLI-plugin читает декораторы class-validator и @ApiProperty и строит OpenAPI-схему автоматически. Java-подход (YAML → generated DTO) здесь неприменим; принцип тот же — одно место правды.

Глобальный ValidationPipe

R-VLD-WHERE-1: входной DTO валидируется на границе, до Handler. В NestJS — один глобальный ValidationPipe в main.ts:

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    exceptionFactory: (errors) => new InputValidationException(formatViolations(errors)),
  }));

  await app.listen(3000);
}

whitelist: true — поля без декораторов не попадают в DTO-инстанс. forbidNonWhitelisted: true — если клиент прислал незнакомое поле, ответ 400 немедленно, а не молчаливое игнорирование. transform: true — query/path-параметры-строки приводятся к числу/булеву по типу поля.

exceptionFactory обязателен: дефолтный BadRequestException class-validator возвращает английский массив строк, а не problem+json с violations. formatViolations — утилита адаптера, которая формирует { field, message, code } по каждой ошибке.

DTO-класс — не интерфейс

R-VLD-OAS-X5: inbound-DTO как any или TypeScript-интерфейс — нарушение:

// ❌ интерфейс стирается в runtime — ValidationPipe видит plain object без метаданных
@Post('/orders')
async create(@Body() req: CreateOrderRequest) { ... }

interface CreateOrderRequest {
  customerId: string;
  items: OrderItemRequest[];
}

Правильно — только класс с декораторами:

// order/dto/create-order.request.ts
export class CreateOrderRequest {
  @IsUUID()
  customerId: string;

  @ValidateNested({ each: true })
  @Type(() => OrderItemRequest)
  @ArrayMinSize(1)
  @ArrayMaxSize(100)
  items: OrderItemRequest[];
}

export class OrderItemRequest {
  @IsUUID()
  productId: string;

  @IsInt()
  @Min(1)
  quantity: number;

  @IsString()
  @IsNotEmpty()
  @Length(1, 255)
  name: string;
}

ValidationPipe с transform: true создаёт инстанс класса через class-transformer, после чего class-validator проверяет декораторы. Без класса — нечего создавать, декораторов нет.

Nested — @ValidateNested + @Type обязательно

R-VLD-WHERE-4: nested-поля валидируются рекурсивно. В NestJS для этого нужна пара декораторов:

export class CreateProductRequest {
  @IsString()
  @IsNotEmpty()
  @Length(1, 200)
  name: string;

  @IsInt()
  @Min(0)
  stock: number;

  @ValidateNested()          // включает рекурсивную валидацию
  @Type(() => PriceRequest)  // class-transformer создаёт инстанс PriceRequest
  price: PriceRequest;

  @ValidateNested({ each: true })  // each: true — для массива
  @Type(() => AttributeRequest)
  @IsArray()
  attributes: AttributeRequest[];
}

Без @Type(() => PriceRequest) — объект в поле price останется plain {}, декораторы PriceRequest не сработают, и невалидные вложенные данные пройдут молча.

Контроллер и DTO-класс как контракт

R-VLD-OAS-4: контракт — типизированный DTO-класс в сигнатуре контроллера:

// order/order.controller.ts
@Controller('/orders')
export class OrderController {
  constructor(private readonly dispatcher: UseCaseDispatcher) {}

  @Post()
  @HttpCode(201)
  async create(@Body() req: CreateOrderRequest): Promise<{ orderId: string }> {
    const orderId = await this.dispatcher.dispatch(
      OrderApiMapper.toCommand(req),
    );
    return { orderId: orderId.value };
  }

  @Get(':id')
  async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<OrderResponse> {
    const order = await this.dispatcher.dispatch(new GetOrderQuery(new OrderId(id)));
    return OrderApiMapper.toResponse(order);
  }
}

@Body() req: CreateOrderRequest — тип явный, класс. ParseUUIDPipe на path-параметрах делает UUID-валидацию до контроллера. ValidationPipe берёт DTO, строит instance, проверяет декораторы, и только после этого вызывается метод.

Маппинг в UseCase-команду

R-VLD-OAS-6: после валидации на границе команда считается чистой — повторная валидация не нужна:

// order/order.mapper.ts
export class OrderApiMapper {
  static toCommand(req: CreateOrderRequest): CreateOrderCommand {
    return new CreateOrderCommand(
      new CustomerId(req.customerId),
      req.items.map(item => new CreateOrderItem(
        new ProductId(item.productId),
        item.quantity,
        item.name,
      )),
    );
  }
}

Маппер — чистая трансформация. Никаких if (!req.customerId) — это уже проверено @IsUUID() на DTO. Никаких Jakarta/class-validator декораторов на CreateOrderCommand — это доменный объект в core, независимый от фреймворков:

// order/command/create-order.command.ts (core)
export class CreateOrderCommand {
  constructor(
    readonly customerId: CustomerId,
    readonly items: CreateOrderItem[],
  ) {}
}

Доменные инварианты — в агрегате (Order.create(...) бросает DomainError если нарушено бизнес-правило), не в class-validator.

Пример: CustomerUpdateRequest с опциональными полями

Сценарий PATCH — поля optional, обновляется только то, что пришло:

export class UpdateCustomerRequest {
  @IsOptional()
  @IsString()
  @Length(1, 100)
  firstName?: string;

  @IsOptional()
  @IsString()
  @Length(1, 100)
  lastName?: string;

  @IsOptional()
  @IsEmail({}, { message: 'Некорректный адрес электронной почты' })
  email?: string;

  @IsOptional()
  @IsString()
  @Matches(/^\+7\d{10}$/, { message: 'Телефон должен начинаться с +7 и содержать 10 цифр' })
  phone?: string;
}

@IsOptional() перед остальными декораторами говорит class-validator: если поле undefined, пропустить все проверки. Если поле пришло — применить все следующие декораторы.

Дублирование — нарушение R-VLD-OAS-X4

R-VLD-OAS-X4: правило живёт в одном месте — декоратор:

// ❌ правило одновременно в декораторе и в ручном чеке
@IsUUID()
customerId: string;

async create(@Body() req: CreateOrderRequest) {
  if (!isUUID(req.customerId)) {          // дубль — декоратор уже проверил
    throw new BadRequestException('...');
  }
  ...
}

Если ValidationPipe настроен корректно, до ручного if дело не дойдёт: невалидный customerId уже вернул 400. Оставлять дубль — значит сохранять источник расхождения: когда правило меняется, обновляют декоратор и забывают про if.

Что запрещено

АнтипаттернПравилоЧто взамен
@Body() req: any или интерфейс вместо классаR-VLD-OAS-X5Класс с class-validator-декораторами
Дублирование: декоратор + ручной if-чек того же правила в HandlerR-VLD-OAS-X4Правило только в декораторе на DTO
@ValidateNested без @Type(...)R-VLD-WHERE-4@ValidateNested + @Type(() => NestedClass)
class-validator-декораторы на доменном агрегате или UseCase-командеR-VLD-WHERE-X4Инварианты — в конструкторе агрегата
ValidationPipe без whitelist / exceptionFactoryR-VLD-WHERE-1Глобальный pipe с whitelist, forbidNonWhitelisted, exceptionFactory
Повторная валидация UseCase-команды после DTOR-VLD-OAS-6Команда считается чистой после маппинга
Custom constraint inline в файле DTOR-VLD-CC-X2Пара @ValidatorConstraint + декоратор в common/validation/

Куда дальше

  • node/standard-constraints.md — какие декораторы class-validator покрывают стандартные проверки и когда нужен @IsOptional.
  • node/custom-constraints.md — @ValidatorConstraint + registerDecorator, именование по домену, поведение на null.
  • node/where-to-validate.md — почему UseCase-команда не валидируется повторно и где доменные инварианты.
  • node/cross-field-validation.md — class-level декоратор для правил с 2+ полями (DateRange, PasswordsMatch).
  • node/validation-groups.md — groups vs отдельные DTO-классы; когда groups оправданы.
  • node/configuration-validation.md — ConfigModule.forRoot({ validate }) и fail-fast на старте.
  • node/messages-and-i18n.md — русские сообщения в декораторах, nestjs-i18n, плейсхолдеры.
  • Error Handling → NodeInputValidationException, formatViolations, problem+json format.