Repository<Product> удобен для CRUD, но доменный сервис не обязан знать его API. Собственный репозиторий переводит доменные вызовы в persistence-операции и возвращает доменные объекты.
Порт вместо TypeORM в домене
interface ProductStore {
byId(id: number): Promise<Product | null>;
save(product: Product): Promise<void>;
}
class TypeOrmProductStore implements ProductStore {
constructor(private readonly dataSource: DataSource) {}
byId(id: number): Promise<Product | null> {
return this.dataSource.manager.findOne(ProductEntity, { where: { id } })
.then(row => row ? toDomain(row) : null);
}
async save(product: Product): Promise<void> {
await this.dataSource.manager.save(ProductEntity, fromDomain(product));
}
}
ProductEntity и Product могут совпадать в маленьком проекте, но разведение помогает не тащить декораторы и детали схемы в бизнес-правила.
Маппер toDomain/fromDomain преобразует типы и инварианты. Он не должен выполнять отдельные запросы ради маппинга каждого поля - иначе N+1 возвращается.
Не копировать Repository целиком
Собственный порт содержит только нужные сценарии операции: byId, save, activeBySeller. Методы delete, update, findAndCount не добавляют на будущее без использования.
EntityManager и QueryBuilder остаются внутри адаптера. Транзакционная граница принадлежит сценарию: репозиторий получает manager, а не открывает собственную транзакцию посреди операции.
Коротко
- Порт выражает потребности домена, а не API ORM.
- Entity и доменную модель разделяют через маппер при необходимости.
- QueryBuilder и DataSource остаются в адаптере.
- Порт не копирует универсальный CRUD.
- Транзакцией владеет сценарий, а не отдельный метод репозитория.
Что почитать дальше
- DataSource и EntityManager - что получает адаптер.
- Транзакции и isolation - кто владеет manager.