Опирается на правила:
R-REP-1…R-REP-5иR-REP-X1…R-REP-X3из DDD Tactical Style Guide → раздел 5. Repository.
Важно знать
- Repository — port между доменом и persistence. Интерфейс + Symbol-токен — в
core/<bc>/port/, реализация — вadapters/out/persistence/.- Один репозиторий — один корень агрегата.
OrderRepositoryподнимает и сохраняетOrderцеликом.OrderLineRepository— антипаттерн.- Сигнатуры — только в доменных типах:
Order,Order | null,Order[]. Никаких TypeORM Entity, raw row, DTO.saveатомарно сохраняет состояние агрегата. Handler послеsaveвызываетpullEvents()и публикует в Outbox.- Методы названы по бизнес-смыслу:
byId,activeByCustomer,findExpiredReservations— неselectFromOrders.- Инъекция через Symbol-токен (
@Inject(ORDER_REPOSITORY)) — домен не зависит от NestJS-декораторов.
Repository — единственный объект, преобразующий агрегат в строки в БД и обратно. Доменный код SQL не знает. SQL-код о бизнес-правилах не знает. На стыке — Repository. Раскрытие раздела 5 гайда.
Интерфейс + Symbol-токен в core/port/
R-REP-1: порт репозитория — интерфейс плюс Symbol-токен, оба в core/<bc>/port/. Symbol используется для NestJS-инъекции без привязки к конкретной реализации.
// core/order/port/order-repository.ts
import { Order } from '../aggregate/order';
import { OrderId } from '../value-object/ids';
import { CustomerId } from '../value-object/ids';
export const ORDER_REPOSITORY = Symbol('OrderRepository');
export interface OrderRepository {
byId(orderId: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
activeByCustomer(customerId: CustomerId): Promise<Order[]>;
findExpiredReservations(before: Date): Promise<Order[]>;
}
Symbol-токен нужен, потому что TypeScript интерфейсы стираются в runtime — NestJS не может инжектировать по типу интерфейса. Symbol — стабильный runtime-ключ.
Реализация в adapters/out/persistence/
R-REP-2: реализация — в adapters/out/persistence/, домен не знает про TypeORM.
// adapters/out/persistence/typeorm-order.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { OrderRepository } from '../../../core/order/port/order-repository';
import { Order } from '../../../core/order/aggregate/order';
import { OrderId, CustomerId } from '../../../core/order/value-object/ids';
import { OrderEntity } from './entity/order.entity';
import { OrderMapper } from './mapper/order.mapper';
@Injectable()
export class TypeOrmOrderRepository implements OrderRepository {
constructor(
@InjectRepository(OrderEntity)
private readonly repo: Repository<OrderEntity>,
private readonly mapper: OrderMapper,
) {}
async byId(orderId: OrderId): Promise<Order | null> {
const entity = await this.repo.findOne({
where: { id: orderId },
relations: ['lines'],
});
return entity ? this.mapper.toDomain(entity) : null;
}
async save(order: Order): Promise<void> {
const entity = this.mapper.toEntity(order);
await this.repo.save(entity);
}
async activeByCustomer(customerId: CustomerId): Promise<Order[]> {
const entities = await this.repo.find({
where: { customerId, status: 'NEW' },
relations: ['lines'],
});
return entities.map(e => this.mapper.toDomain(e));
}
async findExpiredReservations(before: Date): Promise<Order[]> {
const entities = await this.repo
.createQueryBuilder('order')
.where('order.status = :status', { status: 'NEW' })
.andWhere('order.createdAt < :before', { before })
.leftJoinAndSelect('order.lines', 'lines')
.getMany();
return entities.map(e => this.mapper.toDomain(e));
}
}
Что даёт разделение:
core/не зависит от TypeORM. dependency-cruiser / eslint-boundaries фиксирует отсутствиеtypeormвcore/.- Реализацию можно заменить — in-memory для тестов, Redis для cache-aside. Контракт (
Symbol+interface) стабилен.
Регистрация в AppModule
// app/app.module.ts
import { Module } from '@nestjs/common';
import { ORDER_REPOSITORY } from '../core/order/port/order-repository';
import { TypeOrmOrderRepository } from '../adapters/out/persistence/typeorm-order.repository';
@Module({
providers: [
{
provide: ORDER_REPOSITORY,
useClass: TypeOrmOrderRepository,
},
],
})
export class AppModule {}
Handler инжектирует через @Inject(ORDER_REPOSITORY):
// core/order/usecase/command/confirm-order.handler.ts
@Injectable()
export class ConfirmOrderHandler {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orderRepository: OrderRepository,
) {}
}
Один репозиторий = один агрегат
R-REP-3:
// ХОРОШО
export interface OrderRepository { /* поднимает/сохраняет Order целиком */ }
export interface CustomerRepository { /* поднимает/сохраняет Customer */ }
// ПЛОХО
export interface OrderLineRepository { // ← OrderLine — внутренняя Entity, не корень
byId(id: OrderLineId): Promise<OrderLine | null>;
}
Зачем: атомарность. Когда Order изменился (добавили line, пересчитали total), всё сохраняется одним orderRepository.save(order). Не «сохрани Order, потом отдельно сохрани lines».
Сигнатуры — только доменные типы
R-REP-X1:
// ХОРОШО
byId(orderId: OrderId): Promise<Order | null>;
activeByCustomer(customerId: CustomerId): Promise<Order[]>;
// ПЛОХО
findRaw(id: string): Promise<OrderEntity | null>; // ← TypeORM Entity протекла
getDto(id: string): Promise<OrderDto | null>; // ← DTO в репозитории
Handler работает с Order — вызывает order.confirm(), не entity.status = 'CONFIRMED'. Бизнес-методы живут на доменных объектах.
Маппинг OrderEntity ↔ Order — в отдельном OrderMapper в adapters/out/persistence/mapper/.
Методы в терминах домена
R-REP-5:
// ХОРОШО — бизнес-смысл
byId(orderId: OrderId): Promise<Order | null>;
activeByCustomer(customerId: CustomerId): Promise<Order[]>;
findExpiredReservations(before: Date): Promise<Order[]>;
// ПЛОХО — SQL-термины
selectFromOrdersWhereStatusEq(status: string): Promise<Order[]>;
updateStatusInDb(id: string, status: string): Promise<void>;
R-REP-X2: методы вроде updateStatusInDb — деталь хранения, не доменная операция. Статус меняется через order.cancel(reason, now), репозиторий сохраняет агрегат целиком.
Specification ≠ Repository
R-REP-X3: Specification<T> — доменное правило в памяти (isSatisfiedBy(order): boolean), не построитель SQL. Если репозиторий принимает Specification и строит через неё TypeORM QueryBuilder — это нарушение границ core/.
// ПЛОХО — Specification строит SQL
interface OrderRepository {
findAll(spec: Specification<Order>): Promise<Order[]>;
}
// ХОРОШО — для сложных read-сценариев отдельный ViewRepository
export const ORDER_VIEW_REPOSITORY = Symbol('OrderViewRepository');
export interface OrderViewRepository {
findByFilter(filter: OrderFilter): Promise<OrderView[]>;
}
Specification остаётся в домене: isSatisfiedBy(order) — in-memory проверка. Для построения SQL — отдельный OrderFilter и TypeORM QueryBuilder в adapters/out/persistence/.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Возврат OrderEntity, raw row, DTO из репозитория | R-REP-X1 | Доменные типы: Order, Order \| null, Order[] |
Метод под одну таблицу (updateStatusInDb) | R-REP-X2 | Изменение через метод агрегата + save(order) |
Specification в репозитории, строящая SQL | R-REP-X3 | Read через OrderViewRepository + TypeORM QueryBuilder |
| Один репозиторий на несколько корней | R-REP-3 | Один репозиторий = один корень |
| Имя метода в SQL-терминах | R-REP-5 | Имя в терминах домена |
Куда дальше
- DDD Tactical → раздел 5. Repository — нормативные формулировки
R-REP-*. - node/aggregate-root.md — что
saveсохраняет и где вызываетсяpullEvents. - node/domain-event.md — публикация событий через Outbox.
- node/specification.md — in-memory Specification vs SQL-фильтрация.
- node/module-structure.md — папки
port/иadapters/out/persistence/.