Опирается на правила:
R-VLD-WHERE-1…R-VLD-WHERE-4иR-VLD-WHERE-X1…R-VLD-WHERE-X4из Validation Style Guide → раздел 1. Где валидируем.
Важно знать
- В UCP-сервисе на NestJS валидация живёт в трёх местах: глобальный
ValidationPipe(входной HTTP DTO),ConfigModule.forRoot({ validate })(конфиг), агрегат (доменные инварианты).- Контроллер: DTO-класс с class-validator-декораторами в сигнатуре,
ValidationPipeперехватывает до Handler, бросаетInputValidationError→ 400 сviolations.- Конфиг:
validate-функция вConfigModule.forRootвызываетvalidateSyncна старте. Невалидный конфиг роняет старт, не «поднялся с битым флагом».- Домен:
if (status !== 'CREATED') throw new OrderDomainError(...)в методах агрегата. Не class-validator на полях.- Nested DTO —
@ValidateNested+@Type(() => ItemClass)обязательны. Без@Typeобъект останется plain и не провалидируется.- Handler не валидирует. К входу в Handler DTO уже проверен
ValidationPipe.- Manual
if (cmd.amount < 0) throw ...в Handler — антипаттерн. Теряется единый форматviolationsв ответе.
Валидация в NestJS-проекте без явной архитектуры расползается: кто-то проверяет в Handler, кто-то в сервисе, кто-то через @IsOptional() на всех полях подряд. В UCP-стиле — три места, у каждого свой инструмент. Раскрытие раздела 1 гайда.
Место 1: глобальный ValidationPipe — входной DTO
R-VLD-WHERE-1: первая линия защиты, срабатывает до Handler.
// main.ts — один глобальный pipe на приложение
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errors) => new InputValidationError(formatViolations(errors)),
}));
whitelist: true выбрасывает поля, не объявленные в DTO. forbidNonWhitelisted: true превращает их в ошибку 400 вместо молчаливого игнорирования. transform: true приводит plain-объект к экземпляру класса — без этого class-validator-декораторы на number-полях не работают при получении из query string.
export class CreateOrderRequest {
@IsUUID()
customerId: string;
@IsString()
@IsNotEmpty()
@MaxLength(200)
comment: string;
@ValidateNested({ each: true })
@Type(() => OrderItemRequest)
@ArrayMinSize(1)
items: OrderItemRequest[];
}
export class OrderItemRequest {
@IsUUID()
productId: string;
@IsInt()
@Min(1)
quantity: number;
}
Как работает:
- NestJS видит DTO-класс в сигнатуре контроллера и прогоняет его через
ValidationPipe. - При нарушениях
exceptionFactoryсоздаётInputValidationErrorс массивомviolations. AllExceptionsFilterловит его и отдаёт 400 с problem+json по RFC 9457.- Клиент получает структурированный список: какое поле, что не так.
Nested DTO — @ValidateNested + @Type обязательны
R-VLD-WHERE-4: без обоих декораторов вложенный объект не провалидируется.
// ПЛОХО — @Type отсутствует
@ValidateNested({ each: true })
@ArrayMinSize(1)
items: OrderItemRequest[]; // остаётся plain object, class-validator не видит декораторы
// ХОРОШО
@ValidateNested({ each: true })
@Type(() => OrderItemRequest)
@ArrayMinSize(1)
items: OrderItemRequest[];
@Type из class-transformer создаёт экземпляр OrderItemRequest. Только после этого class-validator может прочитать метаданные декораторов на полях класса. Без @Type проверки на productId и quantity молча пропускаются.
Место 2: конфиг — validate-функция в ConfigModule
R-VLD-WHERE-2: невалидный конфиг должен ломать старт, не первый запрос.
// config/app-config.ts
export class AppConfig {
@IsString()
@IsNotEmpty()
DATABASE_URL: string;
@IsString()
@IsNotEmpty()
PAYMENT_BASE_URL: string;
@IsInt()
@Min(1)
@Max(100)
MAX_POOL_SIZE: number;
}
export function validate(env: Record<string, unknown>): AppConfig {
const cfg = plainToInstance(AppConfig, env, { enableImplicitConversion: true });
const errors = validateSync(cfg, { skipMissingProperties: false });
if (errors.length) throw new Error(errors.toString());
return cfg;
}
// app.module.ts
ConfigModule.forRoot({ validate })
С опечаткой PAYMNT_BASE_URL вместо PAYMENT_BASE_URL:
validateSyncнайдёт нарушение@IsNotEmptyнаPAYMENT_BASE_URL.validate-функция бросает ошибку.- NestJS не поднимается. Healthcheck сразу недоступен, деплой откатывается.
Без validate: сервис стартует, первый платёж падает с Cannot read properties of undefined в глубине стека — час отладки.
Место 3: домен — exception в методе агрегата
R-VLD-WHERE-3: доменные инварианты — не class-validator. Агрегат проверяет состояние и бросает domain exception.
// domain/order/order.ts
export class Order {
private status: OrderStatus;
private items: OrderItem[];
confirm(): void {
if (this.status !== OrderStatus.CREATED) {
throw new OrderAlreadyConfirmedError(this.id, this.status);
}
if (this.items.length === 0) {
throw new EmptyOrderError(this.id);
}
this.status = OrderStatus.CONFIRMED;
this.addEvent(new OrderConfirmed(this.id));
}
}
Что хорошего:
- Правило живёт рядом с состоянием.
confirm()знает проstatusиitems— там же проверяет. - Конкретный тип.
OrderAlreadyConfirmedError→AllExceptionsFilterдаёт 409 сcode=ORDER_ALREADY_CONFIRMED. - Класс-validator на агрегате — запрет (
R-VLD-WHERE-X4). Декораторы class-validator на полях агрегата путают DTO-контракт и доменный инвариант; состояние агрегата меняется через методы, не через перевалидацию полей.
Handler не валидирует
R-VLD-WHERE-X1, R-VLD-WHERE-X2: Handler — оркестратор, не валидатор.
// ПЛОХО — Handler с ручной валидацией
@Injectable()
class CreateOrderHandler {
async handle(cmd: CreateOrder): Promise<OrderId> {
if (!cmd.customerId) {
throw new Error('customerId required'); // теряется формат violations
}
if (cmd.items.some(i => i.quantity <= 0)) {
throw new Error('quantity must be positive');
}
// ...
}
}
// ХОРОШО — Handler доверяет ValidationPipe
@Injectable()
class CreateOrderHandler {
constructor(
private readonly customers: CustomerRepository,
private readonly orders: OrderRepository,
private readonly factory: OrderFactory,
) {}
async handle(cmd: CreateOrder): Promise<OrderId> {
const customer = await this.customers.findById(cmd.customerId);
if (!customer) throw new CustomerNotFoundError(cmd.customerId);
const order = this.factory.createFor(customer, cmd.items);
await this.orders.save(order);
return order.id;
}
}
Дублирование @IsUUID декоратора на DTO + ручная проверка в Handler — двойная работа. При смене правила правим два места.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Manual if (cmd.x < 0) throw в Handler для входной валидации | R-VLD-WHERE-X1 | Декоратор на DTO, ValidationPipe на границе |
| Повторная валидация UseCase-команды после DTO | R-VLD-WHERE-X2 | Валидация один раз — на edge |
ConfigModule.forRoot() без validate | R-VLD-WHERE-X3 | validate-функция с validateSync, fail-fast |
| class-validator-декораторы на полях агрегата | R-VLD-WHERE-X4 | Проверка в методе агрегата + бросание domain exception |
@ValidateNested без @Type(...) | R-VLD-WHERE-4 | @ValidateNested({ each: true }) @Type(() => NestedClass) |
Куда дальше
- Validation → раздел 1. Где валидируем — нормативные формулировки
R-VLD-WHERE-*. - Стандартные constraints —
@IsNotEmpty,@Length,@IsEmailи компания. - Custom constraints — когда стандартных недостаточно.
- Валидация конфигурации —
ConfigModule.forRoot({ validate })подробно. - OpenAPI и DTO в NestJS — code-first: DTO-класс как источник правды.
- Error Handling → ProblemDetails — что клиент получает после
InputValidationError.