Разделение валидации по слоям приложения

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

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


Уровень транспортного слоя (DTO)

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

DTO (Data Transfer Object) выступает контрактом между внешним миром и приложением. На этом уровне проверяются:

  • типы данных;
  • обязательность полей;
  • базовые ограничения формата;
  • простейшие диапазоны значений.

В экосистеме JavaScript и TypeScript для этих целей широко применяется class-validator, часто совместно с class-transformer.

Пример DTO с базовой валидацией:

import { IsEmail, IsString, Length, IsOptional, IsInt, Min, Max } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @Length(2, 30)
  name: string;

  @IsEmail()
  email: string;

  @IsOptional()
  @IsInt()
  @Min(18)
  @Max(120)
  age?: number;
}

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


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

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

Основные особенности:

  • декларативное описание ограничений;
  • расширяемость через кастомные валидаторы;
  • возможность композиции правил;
  • интеграция с трансформацией объектов.

Типичный набор базовых декораторов:

  • @IsString()
  • @IsNumber()
  • @IsEmail()
  • @IsBoolean()
  • @IsArray()
  • @ValidateNested()

Пример композиции правил:

import { IsString, Matches, Length } from 'class-validator';

export class PasswordDto {
  @IsString()
  @Length(8, 64)
  @Matches(/[A-Z]/)
  @Matches(/[a-z]/)
  @Matches(/[0-9]/)
  password: string;
}

Здесь проверка остаётся на уровне синтаксиса и структуры данных, не затрагивая бизнес-правила.


Границы между DTO и доменной моделью

Частая архитектурная ошибка заключается в переносе доменной логики в DTO-валидацию. DTO предназначен для проверки формы данных, а не их смысла.

Разделение выглядит следующим образом:

  • DTO: проверка структуры и формата;
  • доменная модель: проверка инвариантов предметной области.

Пример нарушения границы:

export class CreateOrderDto {
  @Min(1)
  totalPrice: number;
}

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

Корректная модель разделения:

  • DTO проверяет, что totalPrice — число и больше нуля;
  • доменный слой проверяет экономическую корректность.

Доменные ограничения и бизнес-валидация

Доменный слой содержит правила, которые отражают смысл системы. Эти правила не должны зависеть от формата входных данных или внешних протоколов.

Примеры доменных ограничений:

  • статус заказа не может изменяться в произвольный переход;
  • пользователь не может заблокировать сам себя;
  • баланс не может уходить в отрицательное значение (если это запрещено бизнесом).

Пример доменной проверки:

class Order {
  constructor(public status: string) {}

  pay() {
    if (this.status !== 'created') {
      throw new Error('Оплата возможна только для созданного заказа');
    }
    this.status = 'paid';
  }
}

Валидация здесь становится частью поведения объекта, а не внешним фильтром.


Слой сервиса

Сервисный слой объединяет DTO и доменную модель, координируя процесс выполнения бизнес-операций.

На этом уровне обычно:

  • вызывается валидация DTO;
  • осуществляется трансформация входных данных;
  • применяются доменные правила;
  • выполняется оркестрация действий.

Пример:

async function createUser(dto: CreateUserDto) {
  const user = new User(dto.name, dto.email);

  user.ensureCanBeCreated();

  await userRepository.save(user);
}

Сервис не должен содержать сложных правил валидации. Его задача — композиция шагов.


Слой хранения данных

Слой базы данных также накладывает ограничения, но они относятся к целостности данных:

  • уникальность;
  • внешние ключи;
  • ограничения NOT NULL;
  • индексы и constraints.

Пример конфликта слоёв:

  • DTO проверяет уникальность email (частично);
  • домен может дополнительно проверять бизнес-уникальность;
  • база данных гарантирует финальную целостность.

Пример с уникальным индексом:

CREATE UNIQUE INDEX users_email_unique ON users(email);

Даже при наличии всех предыдущих проверок именно слой хранения остаётся последней линией защиты.


Избыточность и дублирование правил

Дублирование валидации между слоями не является ошибкой само по себе. В распределённой проверке данных важна не экономия кода, а устойчивость системы.

Типичная структура дублирования:

  • DTO: @IsEmail()
  • сервис: проверка существования пользователя
  • БД: уникальный индекс

Каждый слой защищает свою часть системы от некорректного состояния.


Композиция правил и повторное использование

class-validator позволяет выносить повторяющиеся правила в отдельные классы и использовать их повторно через наследование или вложенные структуры.

Пример вложенной валидации:

import { ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

class AddressDto {
  @IsString()
  city: string;

  @IsString()
  street: string;
}

class UserDto {
  @IsString()
  name: string;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

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


Типичные ошибки при смешивании слоёв

Одной из наиболее частых проблем становится смешение ответственности:

  • использование DTO как доменной модели;
  • размещение бизнес-логики в валидаторах class-validator;
  • дублирование сложных условий в каждом слое без структуры;
  • отсутствие границ между сервисом и доменом.

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


Практические паттерны

Структура приложения с разделённой валидацией обычно опирается на следующие принципы:

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

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

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

import { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator';

@ValidatorConstraint({ name: 'isEven', async: false })
class IsEvenConstraint implements ValidatorConstraintInterface {
  validate(value: number) {
    return value % 2 === 0;
  }
}

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


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