Опирается на правила: R-DS-1R-DS-3 и R-DS-X1R-DS-X2 из DDD Tactical Style Guide → раздел 6. Domain Service.

Важно знать

  • Domain Service — не дефолтное место для логики. Дефолт — Entity или Aggregate Root. Domain Service создаётся только если правило не помещается в один корень.
  • Триггер: операция требует двух и более агрегатов (TransferService между двумя Account-ами), или бизнес-правило не принадлежит ни одному агрегату (PricingService.calculate(product, customer, code)).
  • R-DS-2: stateless plain class без @Injectable. Domain Service живёт в core/<bc>/service/ — без NestJS-декораторов, без TypeORM, без class-validator. Это принципиально: core/ не знает про фреймворк.
  • Принимает Entity / VO, не DTO и не Symbol-токены репозиториев — иначе это уже Handler.
  • Имя — доменная операция: TransferService, PricingService, EligibilityService. Не OrderHelper, не BusinessLogicManager, не SberUtils.
  • Не оркеструет: не загружает из репозитория, не открывает транзакцию (@Transactional здесь отсутствует по архитектурному выбору — транзакции ведёт Handler через DataSource), не публикует события.
  • Тестируется тривиально: new TransferService().transfer(src, dst, amount) — unit-тест без mock-ов и без NestJS-контейнера.
  • В Node нет библиотеки ddd-building-blocksAccount, Money и пр. — это plain TypeScript-классы из core/shared/building-blocks.ts; Domain Service работает с теми же типами.

Domain Service — самый часто переоценённый паттерн тактического DDD. В руководствах он стоит рядом с Entity и Aggregate, и кажется, что любая логика — это «сервис». На практике в нашем стиле Domain Service встречается редко: 90% правил живут в Entity / Aggregate Root, остальное координирует UseCaseHandler. Domain Service — узкая ниша «правило про два агрегата».

Когда создаём

R-DS-1: Domain Service оправдан, если одновременно верно:

  1. Логика касается двух и более агрегатов (или нескольких VO без явного владельца).
  2. Не помещается в один корень — нельзя пристроить методом на Order или Customer без нарушения инкапсуляции.

Классический пример — перевод средств между счетами:

// core/transfer/service/transfer.service.ts
import { Account } from '../aggregate/account';
import { Money } from '../../shared/value-object/money';

export class TransferService {
  transfer(src: Account, dst: Account, amount: Money): void {
    src.withdraw(amount);
    dst.deposit(amount);
  }
}

Почему это не метод на Account:

  • src.transferTo(dst, amount)Account начинает знать про другой Account. Логика «кому из двух принадлежит метод» — искусственный выбор.
  • Правило src.withdraw(amount) принадлежит src, правило dst.deposit(amount)dst. Координация между ними; ни один корень не «владеет» переводом целиком.
  • TransferService — нейтральная точка, в которой правило выражено явно.

Второй пример — ценообразование, которое смотрит на несколько сущностей сразу:

// core/pricing/service/pricing.service.ts
import { Product } from '../../catalog/aggregate/product';
import { Customer } from '../../customer/aggregate/customer';
import { PromoCode } from '../../promo/aggregate/promo-code';
import { Money } from '../../shared/value-object/money';

export class PricingService {
  calculate(product: Product, customer: Customer, code?: PromoCode): Money {
    let price = product.basePrice();

    if (customer.isVip()) {
      price = price.multiply(0.9);
    }

    if (code?.isValidFor(product, customer)) {
      price = code.applyTo(price);
    }

    return price;
  }
}

PricingService не принадлежит ни Product, ни Customer, ни PromoCode. Правило — про их сочетание.

Когда не создаём

R-DS-X2: Domain Service не должен быть «свалкой» для логики, которую правильно положить в Entity / Aggregate.

// ПЛОХО — логика cancel вытащена в сервис, агрегат анемичен
export class OrderService {
  cancel(order: Order): void {
    if (order.status === OrderStatus.SHIPPED) {
      throw new DomainError('cannot cancel shipped order');
    }
    order.status = OrderStatus.CANCELLED;   // публичный мутабельный доступ
    order.cancelledAt = new Date();
  }
}

// ХОРОШО — логика на агрегате
export class Order extends AggregateRoot<OrderId> {
  cancel(now: Date): void {
    if (this.status === OrderStatus.SHIPPED) {
      throw new DomainError('cannot cancel shipped order');
    }
    this.status = OrderStatus.CANCELLED;
    this.cancelledAt = now;
    this.registerEvent(new OrderCancelled(uuidv7(), now, this.id));
  }
}

Признаки того, что Domain Service лишний:

  • Метод принимает один агрегат и возвращает результат, принадлежащий тому же агрегату.
  • Внутри — прямые присваивания на публичных полях (order.status = ...). Логика должна была жить на агрегате, автор обошёл инкапсуляцию.
  • Имя сервиса — <Aggregate>Service (OrderService, CustomerService). В большинстве случаев это «менеджер на агрегат» — анемичная модель (R-ENT-X5).

Stateless и принимает доменные объекты

R-DS-2: Domain Service не хранит состояние. Никаких полей с данными, никаких Symbol-токенов репозиториев, никаких NestJS-зависимостей.

// ПЛОХО — класс держит репозиторий, это уже Handler
@Injectable()
export class TransferService {
  constructor(
    @Inject(ACCOUNT_REPOSITORY) private readonly accounts: AccountRepository,
  ) {}

  async transfer(fromId: AccountId, toId: AccountId, amount: Money): Promise<void> {
    const src = await this.accounts.byId(fromId);
    const dst = await this.accounts.byId(toId);
    src.withdraw(amount);
    dst.deposit(amount);
    await this.accounts.save(src);
    await this.accounts.save(dst);
  }
}

// ХОРОШО — plain class, принимает домен, ничего не знает про инфру
export class TransferService {
  transfer(src: Account, dst: Account, amount: Money): void {
    src.withdraw(amount);
    dst.deposit(amount);
  }
}

Следствия:

  • Domain Service ⊂ core/. В core/ нет @Injectable, нет @Inject, нет TypeORM. Если в Domain Service появляется NestJS-декоратор — это Handler.
  • Stateless = тестируется без контейнера: new TransferService().transfer(src, dst, amount) — чистый unit-тест.
  • Reusable: тот же PricingService вызывается из command-Handler-а и из query-Handler-а без привязки к конкретному use case.

Важно для Node: функция transfer(src, dst, amount) синхронная — Domain Service не обращается к БД, нет оснований делать её async. Асинхронность появляется в Handler-е при загрузке агрегатов из репозитория.

Имя — доменная операция

R-DS-3: TransferService, PricingService, EligibilityService, FraudDetectionService. Не OrderHelper, не SberUtil, не BusinessLogicManager.

Что даёт правильное имя:

  • Понятно, что объект делает. EligibilityService.isEligibleForRefund(order, customer) — самообъясняющий вызов.
  • Discoverable. Когда аналитик говорит «как у нас проверяется право на возврат», grep EligibilityService находит правило сразу.
  • Граница ответственности. OrderService — поле для любой логики «связанной с Order». PricingService — для одной конкретной операции; класс не разрастается за счёт расплывчатого имени.

Util, Helper, Manager, Service без доменного префикса — флаги, что класс не выражает домен.

Не оркеструет

R-DS-X1: Domain Service не загружает агрегаты из репозитория, не управляет транзакцией, не публикует события, не вызывает внешние системы. Это всё — UseCaseHandler.

// ПЛОХО — Domain Service оркеструет
export class TransferService {
  constructor(
    @Inject(ACCOUNT_REPOSITORY) private readonly accounts: AccountRepository,
  ) {}

  async transfer(fromId: AccountId, toId: AccountId, amount: Money): Promise<void> {
    const src = await this.accounts.byId(fromId);   // ← загрузка из репозитория
    const dst = await this.accounts.byId(toId);
    src.withdraw(amount);
    dst.deposit(amount);
    await this.accounts.save(src);                   // ← сохранение
    await this.accounts.save(dst);
  }
}

// ХОРОШО — Handler оркеструет, Domain Service считает
// adapters/in/http/transfer/transfer-money.handler.ts
@Injectable()
export class TransferMoneyHandler implements UseCaseHandler<TransferMoney, void> {
  constructor(
    @Inject(ACCOUNT_REPOSITORY) private readonly accounts: AccountRepository,
    private readonly transferService: TransferService,
  ) {}

  async handle(command: TransferMoney): Promise<void> {
    const src = await this.accounts.byId(AccountId(command.fromId));
    const dst = await this.accounts.byId(AccountId(command.toId));
    if (!src || !dst) throw new DomainError('account not found');

    this.transferService.transfer(src, dst, command.amount);

    await this.accounts.save(src);
    await this.accounts.save(dst);
  }
}

Регистрация TransferService в NestJS — не через @Injectable() на самом классе, а через провайдер в модуле:

// app/transfer/transfer.module.ts
@Module({
  providers: [
    { provide: TransferService, useClass: TransferService },
    TransferMoneyHandler,
  ],
})
export class TransferModule {}

Или ещё проще — Handler создаёт TransferService напрямую через new, раз тот stateless:

@Injectable()
export class TransferMoneyHandler implements UseCaseHandler<TransferMoney, void> {
  private readonly transferService = new TransferService();

  constructor(
    @Inject(ACCOUNT_REPOSITORY) private readonly accounts: AccountRepository,
  ) {}

  async handle(command: TransferMoney): Promise<void> {
    const src = await this.accounts.byId(AccountId(command.fromId));
    const dst = await this.accounts.byId(AccountId(command.toId));
    if (!src || !dst) throw new DomainError('account not found');
    this.transferService.transfer(src, dst, command.amount);
    await this.accounts.save(src);
    await this.accounts.save(dst);
  }
}

Разделение ответственности:

  • UseCaseHandler (Application Service): знает про репозитории, транзакции, события, порты. Координирует.
  • TransferService (Domain Service): pure-функция. Принимает Account-ы, меняет их состояние через их же методы, ничего не знает про БД и NestJS.

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

АнтипаттернПравилоЧто взамен
Репозитории, транзакции, публикация событий в Domain ServiceR-DS-X1Оркестрация — в UseCaseHandler; Domain Service — только pure-логика
Domain Service как свалка для логики одного агрегата (OrderService.cancel)R-DS-X2Логика на агрегате (order.cancel(now)); DS — только для cross-aggregate правил
Stateful Domain Service (поля с данными или Symbol-токенами)R-DS-2Stateless plain class; состояние передаётся параметрами
@Injectable() на Domain Service в core/R-DS-2Без NestJS-декоратора; если нужен DI — провайдер в модуле или new в Handler-е
Имя OrderHelper, SberUtil, BusinessLogicManagerR-DS-3Доменное имя: TransferService, PricingService, EligibilityService
async Domain Service без реальной причиныR-DS-2Синхронный метод; async появляется только в Handler-е при работе с репозиторием

Куда дальше

  • node/aggregate-root.md — дефолтное место для логики, прежде чем думать про Domain Service.
  • node/entity.md — анемичная модель и почему методы должны жить рядом с состоянием.
  • node/repository.md — порт + Symbol-токен в core/, реализация в adapters/out/persistence/.
  • node/domain-event.md — публикация событий после save в Handler-е, а не в Domain Service.
  • node/value-object.md — Money, AccountId и branded types как параметры Domain Service.
  • node/factory.md — когда конструктор не справляется и нужна фабрика.
  • node/specification.md — бизнес-правило в ≥ 2 местах.
  • node/module-structure.md — куда класть service/ внутри core/<bc>/.
  • Use Case Pattern — что такое UseCaseHandler и почему оркестрация — это он.