Опирается на правила: R-TYPEORM-REPO-1R-TYPEORM-REPO-4 и R-TYPEORM-REPO-X1R-TYPEORM-REPO-X3 из TypeORM Style Guide → раздел 1. Repository-pattern. Здесь — те же правила подробно, с примерами и контекстом на идиомах TypeORM 0.3+.

Важно знать

  • Repository — это две сущности: интерфейс в core/<bc>/port/ и реализация TypeOrm<X>Repository в adapters/out/persistence/. Handler зависит только от интерфейса.
  • Публичные методы принимают и возвращают доменные объекты (Aggregate, Value Object, read-DTO). ORM-Entity (@Entity-класс) — деталь persistence-адаптера, наружу не выходит.
  • EntityManager и Repository<T> инжектируются через DI-токен из DataSource; внутри методов не создаются.
  • В репозитории нет бизнес-логики: никаких if (order.status === ...), никаких событий, никаких HTTP-вызовов.
  • Явный маппер toDomain(entity) и toEntity(aggregate) — в отдельном классе рядом с репозиторием.
  • ORM-Entity не наследует BaseEntity — только Data Mapper: DataSource.getRepository(T) или EntityManager.
  • Каждый репозиторий покрыт интеграционным тестом против Testcontainers Postgres, без моков EntityManager.

Repository — граница между domain-слоем и persistence-слоем. Domain знает «как устроен Order», persistence знает «как достать Order из PostgreSQL». Всё, что выходит из adapters/out/persistence/, уже превращено в доменный объект; внутрь домена TypeORM-Entity не проникает, бизнес-логика в persistence не проникает.

Это не зеркало jOOQ-статьи — TypeORM предлагает другие идиомы: EntityManager/Repository<T> вместо DSLContext, Data Mapper с явным @Entity-классом, явная передача транзакционного EntityManager через typeorm-transactional CLS-hooked контекст; ошибки драйвера TypeORM оборачивает стандартно через QueryFailedError.

Доменный порт отдельно от реализации

R-TYPEORM-REPO-1: интерфейс в core/<bc>/port/, реализация TypeOrm<X>Repository в adapters/out/persistence/, биндится через DI-токен.

// core/order/port/order.repository.ts
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  findAll(filter: OrderFilter, page: number, size: number): Promise<PaginationView<Order>>;
  save(order: Order): Promise<void>;
}

export const ORDER_REPOSITORY = Symbol('OrderRepository');
// adapters/out/persistence/order/typeorm-order.repository.ts
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
  constructor(
    @InjectRepository(OrderEntity)
    private readonly repo: Repository<OrderEntity>,
    private readonly mapper: OrderEntityMapper,
  ) {}

  async findById(id: string): Promise<Order | null> {
    const entity = await this.repo.findOne({
      where: { id },
      relations: ['items'],
    });
    return entity ? this.mapper.toDomain(entity) : null;
  }

  async findAll(filter: OrderFilter, page: number, size: number): Promise<PaginationView<Order>> {
    const qb = this.repo
      .createQueryBuilder('o')
      .leftJoinAndSelect('o.items', 'i')
      .where(this.buildWhere(filter))
      .take(size)
      .skip(page * size);

    const [entities, total] = await qb.getManyAndCount();
    return PaginationView.of(entities.map(this.mapper.toDomain.bind(this.mapper)), page, size, total);
  }

  async save(order: Order): Promise<void> {
    await this.repo.save(this.mapper.toEntity(order));
  }

  private buildWhere(filter: OrderFilter) { /* ... */ }
}

DI-токен в модуле:

// order.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([OrderEntity])],
  providers: [
    { provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
    OrderEntityMapper,
    CreateOrderCommandHandler,
  ],
})
export class OrderModule {}

Почему именно так:

  • Domain-модуль не зависит от TypeORM — в core/ нет ни @Entity, ни Repository<T>, ни typeorm в импортах. Domain-тесты конструируют агрегаты через factory, без похода в БД.
  • Имя TypeOrm<X>Repository — конвенция, по которой сразу видна ORM-технология. Если появится Redis<X>Repository для cache-aside — имена не путаются.
  • Handler инжектит OrderRepository (интерфейс через токен), не TypeOrmOrderRepository напрямую — иначе сам смысл интерфейса теряется.

Публичные методы — только доменные типы

R-TYPEORM-REPO-2: на входе и выходе — Aggregate, Value Object, read-DTO. Entity остаётся внутри адаптера.

// ХОРОШО
async findById(id: string): Promise<Order | null>
async findAll(filter: OrderFilter, page: number, size: number): Promise<PaginationView<Order>>

// ПЛОХО
async findById(id: string): Promise<OrderEntity | null>   // Entity протекла наружу
async findRaw(): Promise<object[]>

Handler работает с Order, вызывает order.cancel(), order.addItem(...). Если репозиторий возвращал бы OrderEntity, эти доменные методы негде было бы разместить — логика растекалась бы по handler'ам.

EntityManager инжектируется, не создаётся

R-TYPEORM-REPO-3: EntityManager или Repository<T> приходит через конструктор из DataSource или из транзакционного контекста — не создаётся внутри методов.

// ХОРОШО — инжектируется через @InjectRepository
@Injectable()
export class TypeOrmProductRepository implements ProductRepository {
  constructor(
    @InjectRepository(ProductEntity)
    private readonly repo: Repository<ProductEntity>,
    private readonly mapper: ProductEntityMapper,
  ) {}
}

// ПЛОХО — создаётся внутри метода
async findById(id: string): Promise<Product | null> {
  const em = this.dataSource.createEntityManager();
  const entity = await em.findOne(ProductEntity, { where: { id } });
  return entity ? this.mapper.toDomain(entity) : null;
}

Когда репозиторий работает внутри транзакции Handler'а, он должен использовать транзакционный EntityManager из CLS-контекста (typeorm-transactional), а не глобальный DataSource. Создание нового EntityManager внутри метода всегда обходит транзакцию.

Явный маппер toDomain / toEntity

R-TYPEORM-MAP-1: маппер toDomain(entity): Aggregate и toEntity(aggregate): 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,
      status: entity.status as OrderStatus,
      total: Big(entity.total),
      items: entity.items.map((i) => this.itemToDomain(i)),
      createdAt: entity.createdAt,
    });
  }

  toEntity(order: Order): OrderEntity {
    const entity = new OrderEntity();
    entity.id = order.id;
    entity.customerId = order.customerId;
    entity.status = order.status;
    entity.total = order.total.toFixed(4);   // Big → string, R-TYPEORM-ENT-2
    entity.items = order.items.map((i) => this.itemToEntity(i));
    return entity;
  }

  private itemToDomain(entity: OrderItemEntity): OrderItem { /* ... */ }
  private itemToEntity(item: OrderItem): OrderItemEntity { /* ... */ }
}

Несколько деталей, которые важны в маппере:

  • Big(entity.total) в toDomain — колонка numeric(19, 4) возвращается TypeORM как string, и это правильно (R-TYPEORM-ENT-2). parseFloat не использовать — binary float теряет точность на деньгах.
  • Сборка агрегата полностью в маппере — не размазана по репозиторию. Репозиторий передаёт Entity в маппер и получает обратно доменный объект.
  • Object.assign/spread Entity → domain запрещён (R-TYPEORM-MAP-X1) — Entity и Aggregate — разные типы с разными инвариантами.

Тестируем против настоящего PostgreSQL

R-TYPEORM-REPO-4: интеграционный тест против Testcontainers, без моков EntityManager.

// adapters/out/persistence/order/typeorm-order.repository.spec.ts
describe('TypeOrmOrderRepository', () => {
  let repository: OrderRepository;
  let dataSource: DataSource;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [TypeOrmModule.forRoot({ /* testcontainers DSN */ }), OrderPersistenceModule],
    }).compile();

    repository = module.get<OrderRepository>(ORDER_REPOSITORY);
    dataSource = module.get(DataSource);
  });

  it('findById возвращает Order с позициями', async () => {
    const orderId = await givenOrder(dataSource).withItems([
      { productId: 'p-1', qty: 2, price: '199.00' },
    ]).insert();

    const found = await repository.findById(orderId);

    expect(found).not.toBeNull();
    expect(found!.items).toHaveLength(1);
    expect(found!.total.toFixed(2)).toBe('199.00');
  });

  it('findById возвращает null для несуществующего заказа', async () => {
    const found = await repository.findById('00000000-0000-0000-0000-000000000000');
    expect(found).toBeNull();
  });
});

Зачем не моки:

  • TypeORM-запрос — это SQL — мокать EntityManager означает «доверять, что наш ORM-вызов валиден». А проверить нужно именно это: правильно ли настроены relations, корректен ли where, не упадёт ли save на FK-ограничении.
  • Lazy relations, generated columns, unique-constraints — всё это работает только в живой БД. Моки пропустят большинство реальных багов.
  • Testcontainers поднимается один раз на beforeAll и переиспользуется между тестами — overhead минимален.

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

АнтипаттернПравилоЧто взамен
Возврат OrderEntity из публичного методаR-TYPEORM-REPO-X1Возвращать доменный объект Order через маппер toDomain
Бизнес-логика в репозитории (if (order.status === ...))R-TYPEORM-REPO-X2Логика в доменных методах агрегата; репозиторий — только query/persist
DataSource/createQueryBuilder напрямую в core/ (Handler, UseCase)R-TYPEORM-REPO-X3Только через порт OrderRepository; TypeORM — деталь адаптера
Object.assign / spread Entity → domain в маппереR-TYPEORM-MAP-X1Явный toDomain с ручным маппингом каждого поля
number для колонки numeric (money)R-TYPEORM-ENT-X3string + Big.js / decimal.js; parseFloat не использовать
BaseEntity / ActiveRecord-паттерн (order.save())R-TYPEORM-ENT-X2Только Data Mapper: repo.save(entity) через инжектированный Repository<T>

Куда дальше

  • node/transactions.md — граница транзакции на Handler через typeorm-transactional; как передаётся транзакционный EntityManager.
  • node/mapping.md — @Entity-аннотации Data Mapper-стиля, типы колонок, маппер toDomain/toEntity подробно.
  • node/queries.md — find* с явными relations, QueryBuilder, пагинация take/skip, keyset, bind-параметры, запрет lazy relations.
  • Транзакции в jOOQ (Java) — сравнение: @Transactional на Handler vs typeorm-transactional; одна и та же граница TX, разные механизмы.