Опирается на правила:
R-TYPEORM-MAP-1,R-TYPEORM-MAP-2,R-TYPEORM-MAP-X1,R-TYPEORM-ENT-1,R-TYPEORM-ENT-2,R-TYPEORM-ENT-X1,R-TYPEORM-ENT-X3из TypeORM Style Guide → раздел 3. Маппинг Entity ↔ domain.
Важно знать
- Маппер — отдельный
@Injectable()-класс вadapters/out/persistence/<bc>/, не методы репозитория и не статические утилиты.toDomain(entity): AggregateиtoEntity(aggregate): Entity— два явных публичных метода; никакого «универсального» маппинга черезObject.assignили spread.- Сборка агрегата из Entity-графа (включая relations) — полностью в маппере; репозиторий передаёт Entity и получает доменный объект.
- Колонка
numeric(p, s)TypeORM возвращает какstring— это правильно; конвертируем черезBig(entity.amount), неparseFloat.timestamptz→Date(UTC-инстант); локальную интерпретацию выполняем на edge-слое, не в маппере.- Entity — анемичная persistence-структура; доменные инварианты живут в агрегате, не в Entity и не в маппере.
Object.assign/spread Entity → domain запрещены: Entity и Aggregate — разные типы с разными полями и инвариантами.
TypeORM в Data Mapper-стиле разделяет два мира: ORM-Entity (@Entity-класс) как точное отражение строки в таблице, и доменный агрегат как носитель бизнес-логики. Задача маппера — соединить их так, чтобы ни persistence, ни домен не знали друг о друге. Репозиторий получает доменный объект и возвращает доменный объект; Entity остаётся деталью адаптера.
Структура маппера
Маппер живёт рядом с репозиторием, в том же пакете adapters/out/persistence/<bc>/:
adapters/out/persistence/order/
typeorm-order.repository.ts
order-entity.mapper.ts // toDomain + toEntity
order.entity.ts // @Entity-класс
order-item.entity.ts
OrderEntityMapper — @Injectable()-класс, инжектируется в репозиторий через конструктор. Репозитория не должны строить агрегат сами — это задача маппера.
Простой случай: одна Entity → агрегат
Для домена Customer из Sber-процессинга (профиль участника в системе платежей):
// adapters/out/persistence/customer/customer.entity.ts
@Entity('customers')
export class CustomerEntity {
@PrimaryColumn({ type: 'uuid' })
id: string;
@Column()
name: string;
@Column({ type: 'numeric', precision: 19, scale: 4 })
creditLimit: string;
@Column({ type: 'timestamptz' })
createdAt: Date;
@Column({ type: 'varchar', length: 32 })
status: string;
}
// adapters/out/persistence/customer/customer-entity.mapper.ts
@Injectable()
export class CustomerEntityMapper {
toDomain(entity: CustomerEntity): Customer {
return Customer.restore({
id: entity.id,
name: entity.name,
creditLimit: Big(entity.creditLimit),
status: entity.status as CustomerStatus,
createdAt: entity.createdAt,
});
}
toEntity(customer: Customer): CustomerEntity {
const entity = new CustomerEntity();
entity.id = customer.id;
entity.name = customer.name;
entity.creditLimit = customer.creditLimit.toFixed(4);
entity.status = customer.status;
entity.createdAt = customer.createdAt;
return entity;
}
}
Big(entity.creditLimit) — TypeORM отдаёт numeric строкой, и это намеренно. parseFloat('1234567890.1234') даст потерю точности на binary float. Big.js оперирует строкой без промежуточного float-представления.
Репозиторий вызывает маппер в одну строку:
async findById(id: string): Promise<Customer | null> {
const entity = await this.repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
Агрегат с вложенной коллекцией
R-TYPEORM-MAP-2: сборка агрегата из Entity-графа — в маппере. TypeORM загружает relations отдельным запросом или JOIN; маппер принимает готовый Entity-граф и строит агрегат целиком.
Для Order из процессинга заказов:
// adapters/out/persistence/order/order.entity.ts
@Entity('orders')
export class OrderEntity {
@PrimaryColumn({ type: 'uuid' })
id: string;
@Column({ type: 'uuid' })
customerId: string;
@Column({ type: 'numeric', precision: 19, scale: 4 })
total: string;
@Column({ type: 'varchar', length: 32 })
status: string;
@Column({ type: 'timestamptz' })
createdAt: Date;
@OneToMany(() => OrderItemEntity, (item) => item.order, { eager: false, cascade: true })
items: OrderItemEntity[];
}
@Entity('order_items')
export class OrderItemEntity {
@PrimaryColumn({ type: 'uuid' })
id: string;
@ManyToOne(() => OrderEntity, (order) => order.items)
@JoinColumn({ name: 'order_id' })
order: OrderEntity;
@Column({ type: 'uuid' })
productId: string;
@Column({ type: 'integer' })
quantity: number;
@Column({ type: 'numeric', precision: 19, scale: 4 })
price: string;
}
Маппер собирает агрегат из всего Entity-графа:
// adapters/out/persistence/order/order-entity.mapper.ts
@Injectable()
export class OrderEntityMapper {
toDomain(entity: OrderEntity): Order {
return Order.restore({
id: entity.id,
customerId: entity.customerId,
total: Big(entity.total),
status: entity.status as OrderStatus,
createdAt: entity.createdAt,
items: (entity.items ?? []).map((i) => this.itemToDomain(i)),
});
}
toEntity(order: Order): OrderEntity {
const entity = new OrderEntity();
entity.id = order.id;
entity.customerId = order.customerId;
entity.total = order.total.toFixed(4);
entity.status = order.status;
entity.createdAt = order.createdAt;
entity.items = order.items.map((i) => this.itemToEntity(i));
return entity;
}
private itemToDomain(entity: OrderItemEntity): OrderItem {
return new OrderItem({
id: entity.id,
productId: entity.productId,
quantity: entity.quantity,
price: Big(entity.price),
});
}
private itemToEntity(item: OrderItem): OrderItemEntity {
const entity = new OrderItemEntity();
entity.id = item.id;
entity.productId = item.productId;
entity.quantity = item.quantity;
entity.price = item.price.toFixed(4);
return entity;
}
}
Репозиторий явно указывает, какие relations нужны — маппер получает уже загруженный граф:
async findById(id: string): Promise<Order | null> {
const entity = await this.repo.findOne({
where: { id },
relations: ['items'],
});
return entity ? this.mapper.toDomain(entity) : null;
}
eager: false на всех relations — TypeORM не должен подгружать коллекции автоматически. Какие relations нужны для конкретного запроса — решает репозиторий, а не Entity.
Агрегат без relations, но со сложными типами
Для Product из Sber Marketplace (карточка товара с ценой в разных валютах):
@Entity('products')
export class ProductEntity {
@PrimaryColumn({ type: 'uuid' })
id: string;
@Column()
name: string;
@Column({ type: 'numeric', precision: 19, scale: 4 })
priceRub: string;
@Column({ type: 'jsonb', nullable: true })
attributes: Record<string, string> | null;
@Column({ type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'boolean', default: false })
archived: boolean;
}
@Injectable()
export class ProductEntityMapper {
toDomain(entity: ProductEntity): Product {
return Product.restore({
id: entity.id,
name: entity.name,
priceRub: Big(entity.priceRub),
attributes: entity.attributes ?? {},
updatedAt: entity.updatedAt,
archived: entity.archived,
});
}
toEntity(product: Product): ProductEntity {
const entity = new ProductEntity();
entity.id = product.id;
entity.name = product.name;
entity.priceRub = product.priceRub.toFixed(4);
entity.attributes = Object.keys(product.attributes).length > 0
? product.attributes
: null;
entity.updatedAt = product.updatedAt;
entity.archived = product.archived;
return entity;
}
}
jsonb-колонку TypeORM отдаёт уже разобранным object — явная десериализация не нужна. Обнулять null → {} в маппере нормально; логика про «нет атрибутов» уже выражена типом домена.
Read-проекции: отдельный маппер
CQRS-запрос возвращает read-DTO, а не агрегат. Для него — отдельный маппер, не переиспользующий toDomain:
// adapters/out/persistence/order/order-summary.mapper.ts
@Injectable()
export class OrderSummaryMapper {
toSummary(raw: { id: string; status: string; total: string; itemCount: string; createdAt: Date }): OrderSummary {
return {
id: raw.id,
status: raw.status as OrderStatus,
total: Big(raw.total),
itemCount: parseInt(raw.itemCount, 10),
createdAt: raw.createdAt,
};
}
}
OrderSummary — read-DTO в core/order/, без доменных методов. Репозиторий для проекций — TypeOrmOrderViewRepository, отдельный от TypeOrmOrderRepository. Подробно о read-проекциях — в node/queries.md.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Object.assign(domainObj, entity) / spread Entity в domain | R-TYPEORM-MAP-X1 | Явный toDomain с ручным маппингом каждого поля |
return entity из публичного метода репозитория | R-TYPEORM-REPO-X1 | Маппинг до выхода из репозитория: this.mapper.toDomain(entity) |
parseFloat(entity.amount) для numeric-колонки | R-TYPEORM-ENT-X3 | Big(entity.amount) — TypeORM отдаёт numeric строкой, это норма |
Бизнес-проверка в маппере (if entity.status === 'cancelled') | R-TYPEORM-ENT-X1 | Логика в доменных методах агрегата; маппер — только структурная конвертация |
| Сборка агрегата из relations в методах репозитория | R-TYPEORM-MAP-2 | Сборка полностью в toDomain; репозиторий передаёт Entity-граф |
lazy-Promise на relations (items: Promise<OrderItemEntity[]>) | R-TYPEORM-ENT-1 | eager: false + явный relations: [...] в каждом запросе |
extends BaseEntity + order.save() на Entity | R-TYPEORM-ENT-X2 | Data Mapper: repo.save(this.mapper.toEntity(order)) |
Куда дальше
- node/repository-pattern.md — как организован
TypeOrmOrderRepository: порт вcore/, DI-токен, инжектированиеEntityManager; здесь маппер появился в разделе «Явный маппер». - node/queries.md —
find*с явнымиrelations, QueryBuilder, пагинацияtake/skip, read-проекции черезgetRawMany. - Маппинг record ↔ domain в jOOQ (Java) — Java-аналог:
assembleAggregateи делегация child-маппера; архитектурные решения совпадают, API инструментов разные.