Опирается на правила:
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)→string→Big(amount)в маппере.parseFloatиnumber— запрещены.- Время:
timestamptz→Date(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-1 | Entity только в adapters/out/persistence/ |
eager: true на связях | R-TYPEORM-ENT-1 | eager: false, явный relations: [...] в репозитории |
Promise<Entity[]> lazy-relations | R-TYPEORM-QRY-X1 | Явный leftJoinAndSelect или relations |
extends BaseEntity + entity.save() | R-TYPEORM-ENT-X2 | Data Mapper: сохранение через em.save(Entity, obj) |
number для денег / parseFloat transformer | R-TYPEORM-ENT-X3 | string → Big(value) в маппере |
| Доменная логика / инварианты в Entity | R-TYPEORM-ENT-X1 | Инварианты в агрегате core/, Entity анемична |
@ManyToOne между агрегатами разных Bounded Context | смежно с R-TYPEORM-ENT-1 | UUID-ссылка (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-типы.