Опирается на правила:
R-TYPEORM-REPO-1…R-TYPEORM-REPO-4иR-TYPEORM-REPO-X1…R-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-X3 | string + 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 vstypeorm-transactional; одна и та же граница TX, разные механизмы.