Опирается на правила:
R-DS-1…R-DS-3иR-DS-X1…R-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-blocks—Account,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 оправдан, если одновременно верно:
- Логика касается двух и более агрегатов (или нескольких VO без явного владельца).
- Не помещается в один корень — нельзя пристроить методом на
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 Service | R-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-2 | Stateless plain class; состояние передаётся параметрами |
@Injectable() на Domain Service в core/ | R-DS-2 | Без NestJS-декоратора; если нужен DI — провайдер в модуле или new в Handler-е |
Имя OrderHelper, SberUtil, BusinessLogicManager | R-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и почему оркестрация — это он.