Опирается на правила: R-TYPEORM-ENT-1, R-TYPEORM-ENT-2, R-TYPEORM-ENT-3, R-TYPEORM-ENT-X1, R-TYPEORM-ENT-X2, R-TYPEORM-ENT-X3 из TypeORM Style Guide → раздел 2. Entity.

Важно знать

  • Entity живёт в adapters/out/persistence/ — не в core/. Домен её не видит.
  • Entity — анемичная persistence-структура. Доменные инварианты и бизнес-методы — только в агрегате в core/.
  • Деньги в TypeORM отдаются строкой из PostgreSQL: numeric(19, 4)stringBig(amount) в маппере. parseFloat и number — запрещены.
  • Время: timestamptzDate (UTC-инстант). Локальная интерпретация — только на edge, не в Entity и не в маппере.
  • Relations: eager: false везде, без исключений. Lazy-Promise-relations — запрещены.
  • extends BaseEntity не используем — это ActiveRecord, он размывает границу транзакции.
  • Entity — не DTO и не агрегат. Три разных типа, три разных слоя.

Entity в TypeORM — это инструмент описания схемы для Data Mapper. Она не несёт поведения, не проверяет инварианты, не отправляет события. Её единственная задача — представлять строку таблицы как TypeScript-объект: поля, типы, связи. Всё остальное — в доменном агрегате.

Это не зеркало jOOQ-статьи. jOOQ генерирует типы из схемы через codegen; TypeORM работает наоборот — Entity описывает таблицу декораторами, и TypeORM строит по ней SQL. Разные инструменты, разные идиомы, но граница ответственности та же: persistence-структура не должна проникать в domain, domain не должен зависеть от ORM.

Где живёт Entity

R-TYPEORM-ENT-1: Entity располагается в adapters/out/persistence/, рядом с репозиторием и маппером.

adapters/out/persistence/
  order/
    order.entity.ts           ← Entity
    typeorm-order.repository.ts
    order.mapper.ts
  product/
    product.entity.ts
    typeorm-product.repository.ts
    product.mapper.ts

В core/ нет ни import { Entity } from 'typeorm', ни ссылок на ORM-типы. Handler работает с Order из core/order/order.ts, не с OrderEntity.

Почему это важно: если Entity попадёт в core/, domain начнёт зависеть от TypeORM. Смена ORM или схемы — уже задача домена. Тест агрегата потребует поднятия PostgreSQL. Архитектурная изоляция рушится.

Типы полей: деньги, время, идентификаторы

R-TYPEORM-ENT-2 — самое критичное правило Entity, которое нарушают чаще всего.

Деньги — string, не number

PostgreSQL возвращает numeric строкой. TypeORM передаёт её как есть. Это корректное поведение — numeric(19, 4) не умещается в binary float без потери точности.

@Entity('orders')
export class OrderEntity {
  @PrimaryColumn({ type: 'uuid' })
  id: string;

  @Column({ type: 'numeric', precision: 19, scale: 4 })
  amount: string;

  @Column({ type: 'numeric', precision: 19, scale: 4 })
  discount: string;
}

В маппере — Big(entity.amount), не parseFloat(entity.amount). Big.js или decimal.js дают точную арифметику:

// order.mapper.ts
import Big from 'big.js';

export function toDomain(entity: OrderEntity): Order {
  return new Order({
    id: entity.id,
    amount: new Money(Big(entity.amount), entity.currency),
    discount: Big(entity.discount),
  });
}

R-TYPEORM-ENT-X3 запрещает number для денег — parseFloat('999999999.9999') даёт 1000000000 при достаточно длинной мантиссе. Ошибка молчаливая, обнаруживается на проде при нестандартных суммах.

Transformer с parseFloat — тоже запрещён:

// ЗАПРЕЩЕНО
@Column({
  type: 'numeric',
  transformer: { to: (v) => v, from: (v) => parseFloat(v) },
})
amount: number;

Время — Date (UTC-инстант)

@Column({ type: 'timestamptz' })
createdAt: Date;

@Column({ type: 'timestamptz', nullable: true })
confirmedAt: Date | null;

Date в Node.js — UTC-инстант. TypeORM при timestamptz отдаёт объект Date с правильным временем. Локализация (форматирование под часовой пояс пользователя) — на edge: в HTTP-ответе или в GraphQL-резолвере. В Entity и маппере — только Date.

Идентификаторы — uuid

@PrimaryColumn({ type: 'uuid' })
id: string;

@Column({ type: 'uuid' })
customerId: string;

string — идиоматичный тип для UUID в TypeScript. TypeORM передаёт UUID из PostgreSQL строкой без конверсий. Кросс-реф PG-T-013.

Relations: eager: false везде

R-TYPEORM-ENT-1 требует eager: false на всех связях без исключений.

@Entity('orders')
export class OrderEntity {
  @PrimaryColumn({ type: 'uuid' })
  id: string;

  @OneToMany(() => OrderItemEntity, (item) => item.order, { eager: false })
  items: OrderItemEntity[];

  @ManyToOne(() => CustomerEntity, { eager: false })
  customer: CustomerEntity;
}

Зачем явно: TypeORM по умолчанию eager: false, но явность документирует решение. Если кто-то поставит eager: true на связь — TypeORM начнёт подтягивать связанные сущности в каждом запросе, включая те, где они не нужны. Это N+1 на уровне ORM, скрытый от глаз.

Lazy-Promise-relations — запрещены

R-TYPEORM-QRY-X1 (смежное с ENT-1): Promise<OrderItemEntity[]> в Entity — скрытые запросы при обходе в маппере или сериализаторе.

// ЗАПРЕЩЕНО
@OneToMany(() => OrderItemEntity, (item) => item.order)
items: Promise<OrderItemEntity[]>;

Маппер делает await entity.items → TypeORM идёт в базу → N+1 при обходе списка заказов. Вместо этого — явный relations: ['items'] в репозитории или leftJoinAndSelect в QueryBuilder (см. раздел «Запросы»).

Data Mapper: не extends BaseEntity

R-TYPEORM-ENT-3 и R-TYPEORM-ENT-X2: Entity не наследует BaseEntity.

ActiveRecord через BaseEntity выглядит удобно, но ломает архитектуру:

// ЗАПРЕЩЕНО
@Entity('orders')
export class OrderEntity extends BaseEntity {
  // ...
}

// где-то в handler'е или сервисе:
await order.save();
await order.remove();

order.save() и order.remove() открывают собственные транзакции. Если handler вызывает orderEntity.save() и productEntity.save() последовательно без общей транзакции — при сбое на второй операции первая уже записана. Половина бизнес-операции в базе, половина — нет.

В Data Mapper Handler управляет EntityManager явно (через dataSource.transaction(async (em) => ...) или CLS-хук typeorm-transactional). Entity в этой модели — пассивный объект данных, а не актор.

// ПРАВИЛЬНО — Data Mapper
@Entity('orders')
export class OrderEntity {
  @PrimaryColumn({ type: 'uuid' })
  id: string;

  @Column({ type: 'varchar', length: 50 })
  status: string;

  @Column({ type: 'numeric', precision: 19, scale: 4 })
  amount: string;

  @Column({ type: 'timestamptz' })
  createdAt: Date;
}

Сохранение — через репозиторий, который получает EntityManager из транзакционного контекста:

// typeorm-order.repository.ts
async save(order: Order, em: EntityManager): Promise<void> {
  await em.save(OrderEntity, toEntity(order));
}

Сложный пример: Order с вложенными Item

Полный граф Entity для агрегата Order с коллекцией OrderItem и связью на Customer:

// order.entity.ts
@Entity('orders')
export class OrderEntity {
  @PrimaryColumn({ type: 'uuid' })
  id: string;

  @Column({ type: 'uuid' })
  customerId: string;

  @Column({ type: 'varchar', length: 50 })
  status: string;

  @Column({ type: 'numeric', precision: 19, scale: 4 })
  totalAmount: string;

  @Column({ type: 'timestamptz' })
  createdAt: Date;

  @Column({ type: 'timestamptz', nullable: true })
  confirmedAt: Date | null;

  @OneToMany(() => OrderItemEntity, (item) => item.order, { eager: false })
  items: OrderItemEntity[];
}

// order-item.entity.ts
@Entity('order_items')
export class OrderItemEntity {
  @PrimaryColumn({ type: 'uuid' })
  id: string;

  @Column({ type: 'uuid' })
  orderId: string;

  @Column({ type: 'uuid' })
  productId: string;

  @Column({ type: 'numeric', precision: 19, scale: 4 })
  price: string;

  @Column({ type: 'int' })
  quantity: number;

  @ManyToOne(() => OrderEntity, (order) => order.items, { eager: false })
  @JoinColumn({ name: 'order_id' })
  order: OrderEntity;
}

Агрегат Order в core/order/order.ts не знает о OrderEntity и OrderItemEntity. Маппер order.mapper.ts конвертирует между ними явными функциями toDomain и toEntity.

Product и Customer — другие агрегаты

Entity разных агрегатов не связаны через @ManyToOne / @OneToMany между агрегатами — только через uuid-поле. Граница агрегата — в TypeScript-типах и SQL, не в ORM-relations.

// product.entity.ts
@Entity('products')
export class ProductEntity {
  @PrimaryColumn({ type: 'uuid' })
  id: string;

  @Column({ type: 'varchar', length: 255 })
  name: string;

  @Column({ type: 'numeric', precision: 19, scale: 4 })
  price: string;

  @Column({ type: 'boolean', default: true })
  isAvailable: boolean;

  @Column({ type: 'timestamptz' })
  createdAt: Date;
}

// customer.entity.ts
@Entity('customers')
export class CustomerEntity {
  @PrimaryColumn({ type: 'uuid' })
  id: string;

  @Column({ type: 'varchar', length: 255 })
  fullName: string;

  @Column({ type: 'varchar', length: 320 })
  email: string;

  @Column({ type: 'timestamptz' })
  createdAt: Date;
}

OrderEntity хранит customerId: string — UUID на Customer. Не @ManyToOne(() => CustomerEntity) — это свяжет два агрегата на уровне ORM, и TypeORM начнёт подтягивать Customer при загрузке Order. Связь между агрегатами — через ID и JOIN в репозитории, явно.

Что запрещено

АнтипаттернПравилоЧто взамен
Entity в core/ или domain-пакетеR-TYPEORM-ENT-1Entity только в adapters/out/persistence/
eager: true на связяхR-TYPEORM-ENT-1eager: false, явный relations: [...] в репозитории
Promise<Entity[]> lazy-relationsR-TYPEORM-QRY-X1Явный leftJoinAndSelect или relations
extends BaseEntity + entity.save()R-TYPEORM-ENT-X2Data Mapper: сохранение через em.save(Entity, obj)
number для денег / parseFloat transformerR-TYPEORM-ENT-X3stringBig(value) в маппере
Доменная логика / инварианты в EntityR-TYPEORM-ENT-X1Инварианты в агрегате core/, Entity анемична
@ManyToOne между агрегатами разных Bounded Contextсмежно с R-TYPEORM-ENT-1UUID-ссылка (customerId: string), JOIN в репозитории

Куда дальше

  • Маппинг Entity ↔ domain в TypeORM — функции toDomain и toEntity: где живут, как собирают агрегат с вложенными коллекциями, что нельзя делать через Object.assign.
  • Repository pattern в TypeORM — доменный порт в core/, TypeOrm<X>Repository в адаптере, инжекция EntityManager из транзакционного контекста.
  • Транзакции в TypeORM — dataSource.transaction(async (em) => ...) на Handler, CLS-хук typeorm-transactional, почему queryRunner.startTransaction() в репозитории запрещён.
  • PostgreSQL: типы и схемаPG-T-* правила по numeric, timestamptz, uuid, которые Entity транслирует в TypeScript-типы.