Опирается на правила: R-VLD-WHERE-1R-VLD-WHERE-4 и R-VLD-WHERE-X1R-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;
}

Как работает:

  1. NestJS видит DTO-класс в сигнатуре контроллера и прогоняет его через ValidationPipe.
  2. При нарушениях exceptionFactory создаёт InputValidationError с массивом violations.
  3. AllExceptionsFilter ловит его и отдаёт 400 с problem+json по RFC 9457.
  4. Клиент получает структурированный список: какое поле, что не так.

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 — там же проверяет.
  • Конкретный тип. OrderAlreadyConfirmedErrorAllExceptionsFilter даёт 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-команды после DTOR-VLD-WHERE-X2Валидация один раз — на edge
ConfigModule.forRoot() без validateR-VLD-WHERE-X3validate-функция с 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.