В приложениях, построенных по принципам слоистой архитектуры, валидация данных не должна концентрироваться в одном месте. Разделение проверок по слоям позволяет разграничить ответственность между транспортным уровнем, доменной моделью, сервисами и слоем хранения данных. Каждый слой проверяет только те инварианты, которые относятся к его зоне ответственности, что снижает связанность и повышает предсказуемость поведения системы.
Ключевая идея заключается в том, что валидация — это не единый процесс, а цепочка проверок, каждая из которых отсеивает некорректные данные на своём уровне абстракции.
Транспортный слой отвечает за первичную проверку входящих данных, поступающих извне: 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 реализует декларативный
подход к описанию правил валидации через декораторы. В основе лежит
метаданные, привязываемые к свойствам классов.
Основные особенности:
Типичный набор базовых декораторов:
@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 предназначен для проверки формы данных, а не их смысла.
Разделение выглядит следующим образом:
Пример нарушения границы:
export class CreateOrderDto {
@Min(1)
totalPrice: number;
}
Ограничение минимальной цены может быть допустимо на уровне транспорта, но бизнес-правило вроде «цена не может быть ниже себестоимости» уже относится к домену.
Корректная модель разделения:
totalPrice — число и больше
нуля;Доменный слой содержит правила, которые отражают смысл системы. Эти правила не должны зависеть от формата входных данных или внешних протоколов.
Примеры доменных ограничений:
Пример доменной проверки:
class Order {
constructor(public status: string) {}
pay() {
if (this.status !== 'created') {
throw new Error('Оплата возможна только для созданного заказа');
}
this.status = 'paid';
}
}
Валидация здесь становится частью поведения объекта, а не внешним фильтром.
Сервисный слой объединяет DTO и доменную модель, координируя процесс выполнения бизнес-операций.
На этом уровне обычно:
Пример:
async function createUser(dto: CreateUserDto) {
const user = new User(dto.name, dto.email);
user.ensureCanBeCreated();
await userRepository.save(user);
}
Сервис не должен содержать сложных правил валидации. Его задача — композиция шагов.
Слой базы данных также накладывает ограничения, но они относятся к целостности данных:
Пример конфликта слоёв:
Пример с уникальным индексом:
CREATE UNIQUE INDEX users_email_unique ON users(email);
Даже при наличии всех предыдущих проверок именно слой хранения остаётся последней линией защиты.
Дублирование валидации между слоями не является ошибкой само по себе. В распределённой проверке данных важна не экономия кода, а устойчивость системы.
Типичная структура дублирования:
@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;
}
Такой подход позволяет строить составные модели без дублирования правил.
Одной из наиболее частых проблем становится смешение ответственности:
class-validator;Особенно критичной является ситуация, когда кастомные валидаторы начинают содержать доступ к базе данных. Это приводит к скрытым зависимостям и усложняет тестирование.
Структура приложения с разделённой валидацией обычно опирается на следующие принципы:
Кастомные валидаторы class-validator целесообразно
ограничивать синтаксическими проверками или проверками, не требующими
сложной бизнес-логики.
Пример кастомного валидатора:
import { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator';
@ValidatorConstraint({ name: 'isEven', async: false })
class IsEvenConstraint implements ValidatorConstraintInterface {
validate(value: number) {
return value % 2 === 0;
}
}
Такие валидаторы должны оставаться чистыми функциями без побочных эффектов.
Разделение валидации по слоям формирует предсказуемую структуру обработки данных, в которой каждая проверка существует на своём уровне абстракции и обслуживает строго определённую часть системы.