Использование с TypeORM

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

TypeORM предоставляет хуки жизненного цикла (@BeforeInsert, @BeforeUpdate, @BeforeRemove), которые естественно сочетаются с механизмом валидации class-validator через функции validate и validateOrReject.


Базовая интеграция class-validator в сущности TypeORM

Сущность TypeORM представляет собой обычный класс с декораторами @Entity, @Column, дополненный валидаторами:

import { Entity, PrimaryGeneratedColumn, Column, BeforeInsert, BeforeUpdate } fr om "typeorm";
import { IsEmail, Length, IsNotEmpty, validateOrReject } from "class-validator";

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @IsNotEmpty()
  @Length(2, 50)
  name: string;

  @Column()
  @IsEmail()
  email: string;

  @BeforeInsert()
  @BeforeUpdate()
  async validate() {
    await validateOrReject(this);
  }
}

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


Механика validateOrReject внутри жизненного цикла

validateOrReject использует метаданные, которые class-validator сохраняет через reflect-metadata. При вызове происходит:

  1. Сканирование свойств класса
  2. Получение списка активных валидаторов
  3. Последовательная проверка каждого ограничения
  4. Формирование массива ошибок при нарушении

При использовании внутри @BeforeInsert и @BeforeUpdate TypeORM прерывает сохранение, если выбрасывается исключение.

@BeforeInsert()
async beforeInsertHook() {
  await validateOrReject(this, {
    whitelist: true,
    forbidNonWhitelisted: true,
  });
}

Использование DTO вместо валидации сущностей

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

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

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

  @IsEmail()
  email: string;
}

Сервисный слой выполняет проверку до передачи данных в репозиторий:

import { validateOrReject } from "class-validator";

async function createUser(dto: CreateUserDto) {
  await validateOrReject(dto);

  const user = userRepository.create(dto);
  return userRepository.save(user);
}

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


Валидация через репозитории TypeORM

Репозиторий можно расширить, добавив централизованную проверку перед сохранением:

import { Repository } from "typeorm";
import { validateOrReject } from "class-validator";

export class UserRepository extends Repository<User> {
  async saveWithValidation(entity: User) {
    await validateOrReject(entity);
    return this.save(entity);
  }
}

Этот слой становится точкой контроля, исключающей обход валидации.


Кастомные валидаторы в связке с TypeORM

class-validator позволяет создавать собственные правила, которые могут учитывать состояние базы данных через dependency injection.

import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  ValidationArguments,
} from "class-validator";

import { DataSource } from "typeorm";
import { User } from "./User";

@ValidatorConstraint({ async: true })
export class IsEmailUnique implements ValidatorConstraintInterface {
  constructor(private dataSource: DataSource) {}

  async validate(email: string) {
    const repo = this.dataSource.getRepository(User);
    const count = await repo.count({ wh ere: { email } });
    return count === 0;
  }

  defaultMessage(args: ValidationArguments) {
    return `Email ${args.value} уже используется`;
  }
}

Использование в сущности:

import { Validate } from "class-validator";

@Column()
@Validate(IsEmailUnique)
email: string;

Проблема доступа к DI контейнеру

class-validator сам по себе не знает о TypeORM контейнере зависимостей. Для интеграции используется useContainer:

import { useContainer } from "class-validator";
import { Container } from "typedi";

useContainer(Container);

Это позволяет внедрять сервисы внутрь кастомных валидаторов, включая репозитории и внешние сервисы.


Валидация вложенных сущностей

TypeORM часто работает с отношениями OneToMany, ManyToOne, где требуется рекурсивная проверка:

import { ValidateNested, IsNotEmpty } from "class-validator";
import { Type } from "class-transformer";

export class Address {
  @IsNotEmpty()
  city: string;
}

@Entity()
export class User {
  @ValidateNested()
  @Type(() => Address)
  address: Address;
}

class-transformer необходим для корректного преобразования plain objects в классы перед валидацией.


Cascade-сохранение и риски пропуска валидации

TypeORM поддерживает каскадные операции:

@OneToMany(() => Post, (post) => post.user, { cascade: true })
posts: Post[];

При cascade: true вложенные сущности могут быть сохранены без явного вызова валидации, если она не встроена в lifecycle hooks.

Для предотвращения этого часто используется ручная рекурсивная проверка:

@BeforeInsert()
async validateAll() {
  await validateOrReject(this);

  if (this.posts) {
    for (const post of this.posts) {
      await validateOrReject(post);
    }
  }
}

Группы валидации в ORM-сценариях

Разные операции требуют разных правил: создание, обновление, частичное обновление.

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

export class User {
  @Length(2, 50, { groups: ["create", "update"] })
  name: string;

  @IsEmail({ groups: ["create"] })
  email: string;
}

Использование:

await validateOrReject(user, { groups: ["create"] });

В TypeORM это часто применяется в хуках:

@BeforeInsert()
async beforeInsert() {
  await validateOrReject(this, { groups: ["create"] });
}

Частичное обновление и validate

Метод save в TypeORM позволяет выполнять partial update, что создаёт проблему неполных объектов. class-validator по умолчанию ожидает полную модель.

Решение заключается в использовании skipMissingProperties:

await validateOrReject(entity, {
  skipMissingProperties: true,
});

Ограничения подхода entity-driven validation

Использование валидаторов прямо в сущностях приводит к нескольким системным особенностям:

  • смешивание ответственности ORM и бизнес-логики
  • необходимость учитывать lazy-loading при проверке
  • сложность при тестировании изолированных правил
  • потенциальные побочные эффекты при cascade операциях

Альтернативный поток данных в архитектуре

Часто формируется следующая цепочка:

DTO → validateOrReject → service layer → entity creation → repository save

или

request → transformation (class-transformer) → DTO validation → mapping → TypeORM entity

Такой поток снижает вероятность того, что некорректные данные попадут в persistence layer, независимо от используемого ORM поведения.


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

При работе с транзакциями TypeORM валидация обычно выполняется до открытия транзакции, чтобы избежать отката из-за ошибок данных:

await validateOrReject(dto);

await dataSource.transaction(async manager => {
  const user = manager.create(User, dto);
  await manager.save(user);
});

Если же валидация находится внутри entity hooks, она выполняется уже в рамках транзакционного контекста, что может увеличивать стоимость отката.


Ошибки валидации и структура исключений

validateOrReject выбрасывает массив объектов ValidationError:

[
  {
    property: "email",
    constraints: {
      isEmail: "email must be an email"
    }
  }
]

TypeORM не интерпретирует эти ошибки, поэтому они должны быть обработаны на уровне сервиса или middleware.


Использование в больших доменных моделях

При росте модели до десятков полей и связей рекомендуется разделять:

  • entity (структура БД)
  • DTO (валидация входных данных)
  • domain model (бизнес-логика без аннотаций ORM)

class-validator в этом случае становится инструментом DTO-слоя, а TypeORM остаётся исключительно persistence-механизмом.