Опирается на правила: 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.
  • timestamptzDate (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 в domainR-TYPEORM-MAP-X1Явный toDomain с ручным маппингом каждого поля
return entity из публичного метода репозиторияR-TYPEORM-REPO-X1Маппинг до выхода из репозитория: this.mapper.toDomain(entity)
parseFloat(entity.amount) для numeric-колонкиR-TYPEORM-ENT-X3Big(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-1eager: false + явный relations: [...] в каждом запросе
extends BaseEntity + order.save() на EntityR-TYPEORM-ENT-X2Data 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 инструментов разные.