Постепенное внедрение валидации

Внедрение строгой валидации в уже работающую кодовую базу редко происходит одномоментно. В реальных проектах обычно присутствует смесь старого кода, частично типизированных данных, неявных контрактов между слоями и отсутствия централизованной проверки входящих данных. Библиотека Class-validator позволяет вводить валидацию постепенно, не ломая существующую архитектуру и не требуя полной переработки системы.

Ключевая идея постепенного внедрения заключается в том, чтобы валидация сначала появлялась только на границах системы, затем распространялась внутрь доменных объектов и лишь после этого становилась обязательной частью всех входных потоков данных.


Валидация на границе системы как первый этап

Первым шагом становится добавление проверки входных данных только в точках входа: HTTP-запросы, события очередей, внешние API.

На этом этапе не вводятся DTO-классы и декораторы. Валидация выполняется вручную, чтобы зафиксировать текущие реальные данные, поступающие в систему.

function validateCreateUser(payload) {
  const errors = [];

  if (typeof payload.email !== 'string') {
    errors.push('email must be string');
  }

  if (!payload.email?.includes('@')) {
    errors.push('email format is invalid');
  }

  if (typeof payload.age !== 'number') {
    errors.push('age must be number');
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}

Этот этап фиксирует фактическое поведение API и помогает выявить несоответствия между ожиданиями и реальностью. Он важен как точка отсчёта.


Переход к структурированной валидации через Class-validator

После стабилизации входных данных вводятся DTO-классы и базовые декораторы Class-validator. На этом этапе библиотека используется только для новых модулей или новых эндпоинтов.

import { IsEmail, IsInt, Min } from 'class-validator';

class CreateUserDto {
  @IsEmail()
  email;

  @IsInt()
  @Min(0)
  age;
}

Валидация выполняется явно:

import { validate } from 'class-validator';

async function validateDto(dto) {
  const errors = await validate(dto);
  return errors;
}

Важно, что старые участки системы продолжают работать без изменений. Class-validator внедряется как дополнительный слой, а не как замена существующей логики.


Параллельное существование старой и новой схемы

При постепенной миграции неизбежно сосуществование двух подходов: ручной валидации и декларативной через декораторы.

Для этого вводится единый адаптер:

async function validateCreateUserInput(payload) {
  if (payload.__legacy === true) {
    return validateLegacy(payload);
  }

  const dto = Object.assign(new CreateUserDto(), payload);
  return validate(dto);
}

Такой подход позволяет мигрировать эндпоинты по одному, не затрагивая остальную систему.


Использование class-transformer для автоматизации преобразования

На следующем этапе добавляется преобразование plain-object в классы. Это снижает количество ручного кода и делает валидацию более предсказуемой.

import { plainToInstance } from 'class-transformer';

const dto = plainToInstance(CreateUserDto, payload);
const errors = await validate(dto);

Это особенно важно, когда входящие данные приходят из JSON и не имеют прототипов.


Постепенное включение строгих правил

Class-validator предоставляет возможность усиливать строгость постепенно.

whitelist и forbidNonWhitelisted

Эти настройки позволяют контролировать лишние поля:

import { validate } from 'class-validator';

await validate(dto, {
  whitelist: true,
  forbidNonWhitelisted: true,
});

Постепенное внедрение начинается с whitelist: true, который просто удаляет лишние поля, не ломая поведение API. После стабилизации можно включить forbidNonWhitelisted, который начинает возвращать ошибки.


Пошаговая миграция существующих DTO

В больших проектах DTO обычно вводятся поэтапно.

Этап 1: пустой DTO без декораторов

class UpdateUserDto {}

На этом этапе система уже использует классы, но не применяет валидацию.

Этап 2: частичная валидация

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

class UpdateUserDto {
  @IsOptional()
  @IsString()
  name;
}

Здесь вводится только часть правил, не затрагивая остальные поля.

Этап 3: полная спецификация

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

class UpdateUserDto {
  @IsOptional()
  @IsString()
  name;

  @IsOptional()
  @IsEmail()
  email;
}

Валидация частичных обновлений (PATCH)

Одной из сложностей постепенного внедрения является различие между POST и PATCH. PATCH-запросы требуют частичной валидации.

Class-validator решает это через @IsOptional() и skipMissingProperties.

await validate(dto, {
  skipMissingProperties: true,
});

Это позволяет валидировать только переданные поля, не требуя полного объекта.


Использование validation groups для поэтапного включения правил

Validation groups позволяют разделить строгие и мягкие правила в рамках одного DTO.

import { IsEmail, Length } from 'class-validator';

class UserDto {
  @IsEmail({ groups: ['create'] })
  email;

  @Length(3, 20, { groups: ['create', 'update'] })
  name;
}

Вызов:

await validate(dto, { groups: ['create'] });

Такой механизм позволяет постепенно усиливать требования для разных сценариев без дублирования классов.


Интеграция в существующие Express-обработчики

При постепенном внедрении важно не переписывать контроллеры.

app.post('/users', async (req, res) => {
  const dto = plainToInstance(CreateUserDto, req.body);

  const errors = await validate(dto);
  if (errors.length > 0) {
    return res.status(400).json(errors);
  }

  // существующая бизнес-логика
});

Старые обработчики могут использовать ту же схему выборочно, без обязательной миграции всего API.


Постепенное внедрение в архитектуру сервисного слоя

На уровне сервисов валидация внедряется осторожно, чтобы не нарушить внутренние зависимости.

Сначала проверяются только внешние данные:

class UserService {
  async createUser(dto) {
    // предполагается, что dto уже проверен
    return this.repository.save(dto);
  }
}

Позднее можно добавить защитную валидацию:

import { validateOrReject } from 'class-validator';

class UserService {
  async createUser(dto) {
    await validateOrReject(dto);
    return this.repository.save(dto);
  }
}

Такой переход позволяет гарантировать корректность данных на уровне домена.


Стратегия миграции модулей

При крупной кодовой базе внедрение Class-validator обычно выполняется по слоям:

  1. Новые модули сразу используют DTO и декораторы
  2. Старые модули остаются на ручной валидации
  3. Общие утилиты вводят адаптеры преобразования
  4. Постепенно заменяются контроллеры
  5. Включаются строгие режимы (forbidNonWhitelisted, groups)
  6. Удаляется legacy-валидация

Управление рисками при постепенной миграции

Основная сложность заключается в несогласованности данных между слоями. Чтобы минимизировать риски, вводится промежуточный слой нормализации:

function normalizeUserPayload(payload) {
  return {
    email: String(payload.email || ''),
    age: Number(payload.age || 0),
  };
}

Этот слой временно стабилизирует данные до полного перехода на DTO.


Комбинация с feature flags

В крупных системах внедрение часто привязывается к флагам:

if (featureFlags.useClassValidator) {
  const dto = plainToInstance(CreateUserDto, req.body);
  const errors = await validate(dto);
}

Это позволяет включать валидацию по частям: по пользователям, по сервисам или по версиям API.


Контроль деградации и обратной совместимости

Постепенное внедрение требует сохранения старого поведения. Поэтому часто вводятся параллельные контракты:

// v1 - legacy
app.post('/v1/user', legacyHandler);

// v2 - class-validator
app.post('/v2/user', modernHandler);

Такой подход обеспечивает плавный переход без нарушения существующих клиентов.


Итоговая модель постепенного внедрения

Переход к Class-validator в зрелой кодовой базе обычно развивается от:

  • неструктурированной ручной проверки
  • частичной валидации на границе
  • введения DTO без правил
  • добавления базовых декораторов
  • использования групп и опциональности
  • включения строгих режимов
  • полной миграции API на декларативную валидацию