Опирается на правила:
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/swaggerCLI-plugin, не наоборот. Правило живёт в одном месте — в DTO.- Интерфейс вместо класса — молчаливый провал. TypeScript-интерфейсы стираются в runtime;
ValidationPipeне видит декораторов и пропускает невалидные данные без ошибки.- Глобальный
ValidationPipeобязателен.whitelist: trueотрезает неизвестные поля;forbidNonWhitelisted: trueвозвращает 400 на опечатку;transform: trueконвертирует plain-объект в instance.- Nested — только через
@ValidateNested+@Type(). Без@Typeclass-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-чек того же правила в Handler | R-VLD-OAS-X4 | Правило только в декораторе на DTO |
@ValidateNested без @Type(...) | R-VLD-WHERE-4 | @ValidateNested + @Type(() => NestedClass) |
| class-validator-декораторы на доменном агрегате или UseCase-команде | R-VLD-WHERE-X4 | Инварианты — в конструкторе агрегата |
ValidationPipe без whitelist / exceptionFactory | R-VLD-WHERE-1 | Глобальный pipe с whitelist, forbidNonWhitelisted, exceptionFactory |
| Повторная валидация UseCase-команды после DTO | R-VLD-OAS-6 | Команда считается чистой после маппинга |
| Custom constraint inline в файле DTO | R-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 → Node —
InputValidationException,formatViolations, problem+json format.