Опирается на правила: R-REP-1R-REP-5 и R-REP-X1R-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'. Бизнес-методы живут на доменных объектах.

Маппинг OrderEntityOrder — в отдельном 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 в репозитории, строящая SQLR-REP-X3Read через 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/.