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

Важно знать

  • Domain Service — это не дефолтное место для логики. Дефолт — Entity или Aggregate Root. Domain Service создаётся, только когда правило не помещается в один корень.
  • Triggers: операция требует двух и более агрегатов одновременно (TransferService между двумя Account-ами), или бизнес-правило не принадлежит ни одному агрегату (PricingService.calculate(...) смотрит на Product, Customer, PromoCode).
  • Stateless. Никаких полей с состоянием. Принимает Entity/VO на вход, возвращает результат.
  • Имя — доменная операция: TransferService, PricingService, EligibilityService. Не OrderHelper, не BusinessLogicManager, не OrderUtil.
  • Не оркеструет. Не загружает из репозиториев, не открывает транзакции, не публикует события — это Application Service (UseCaseHandler).
  • Не «свалка». Если Domain Service разрастается за счёт логики, которая принадлежит агрегату — это сигнал об анемичной модели, а не повод плодить методы в сервисе.

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

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

R-DS-1: Domain Service оправдан, если обе проверки выполняются:

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

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

public final class TransferService {

    public TransferResult transfer(Account from, Account to, Money amount) {
        Objects.requireNonNull(from);
        Objects.requireNonNull(to);
        Objects.requireNonNull(amount);
        if (!from.canWithdraw(amount)) {
            throw new InsufficientFundsException(from.id(), amount, from.balance());
        }
        from.withdraw(amount);
        to.deposit(amount);
        return new TransferResult(from.id(), to.id(), amount, Instant.now());
    }
}

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

  • from.transferTo(to, amount)Account начинает знать про другой Account. Симметрично — to тоже мог бы быть инициатором. Логика «которому из двух принадлежит метод» — искусственный выбор.
  • Правило проверки from.canWithdraw(amount) — это про from. Правило to.deposit(amount) — про to. Координация — между ними; ни один не «владеет» переводом целиком.
  • TransferService — нейтральная точка, в которой правило выражено явно: «перевод — это withdraw из одного + deposit в другой + invariant amount > 0».

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

public final class PricingService {

    public Money calculate(Product product, Customer customer, Optional<PromoCode> code) {
        Money base = product.basePrice();
        if (customer.isVip()) {
            base = base.multiply(BigDecimal.valueOf(0.9));
        }
        if (code.isPresent() && code.get().isValidFor(product, customer)) {
            base = code.get().applyTo(base);
        }
        return base;
    }
}

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

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

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

// ПЛОХО — логика «cancel order» вытащена в сервис
public class OrderService {
    public void cancel(Order order) {
        if (order.getStatus() == OrderStatus.SHIPPED) {
            throw new OrderAlreadyShippedException(order.id());
        }
        order.setStatus(OrderStatus.CANCELLED);
        order.setCancelledAt(Instant.now());
    }
}

// ХОРОШО — логика на агрегате
public final class Order extends AggregateRoot<OrderId> {
    public void cancel() {
        if (this.status == OrderStatus.SHIPPED) {
            throw new OrderAlreadyShippedException(this.id);
        }
        this.status = OrderStatus.CANCELLED;
        this.cancelledAt = Instant.now();
        registerEvent(new OrderCancelled(this.id, this.cancelledAt));
    }
}

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

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

Если такие методы появляются — это сигнал об анемичной модели (R-ENT-X5), а не повод сохранить структуру «сервис на агрегат».

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

R-DS-2: Domain Service не хранит состояние. На входе — Entity, VO, доменные объекты. Не DTO с edge, не репозитории, не PortException-ы.

// ХОРОШО — stateless, принимает Account
public final class TransferService {
    public TransferResult transfer(Account from, Account to, Money amount) { /* ... */ }
}

// ПЛОХО — внутри есть состояние / репозиторий
@Service
public class TransferService {
    private final AccountRepository accountRepository;  // ← это уже Application Service
    private final TransferAuditLog auditLog;            // ← тоже инфра

    public void transfer(AccountId fromId, AccountId toId, Money amount) {
        Account from = accountRepository.findById(fromId, FOR_UPDATE).orElseThrow();
        Account to = accountRepository.findById(toId, FOR_UPDATE).orElseThrow();
        // ...
        accountRepository.save(from);
        accountRepository.save(to);
        auditLog.log(/* ... */);
    }
}

Почему важно:

  • Domain Service ⊂ core/. В core/ нет Spring, нет Repository-инжекции (репозиторий — это outbound port в adapter-направлении). Если в Domain Service есть Autowired — он не Domain Service.
  • Stateless = trivially тестируется. new TransferService().transfer(account1, account2, money) — unit-тест без mock-ов.
  • Reusable. Тот же PricingService вызывается из UseCaseHandler-а в command-side и из read-handler-а в query-side. Нет привязки к конкретному use-case.

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

R-DS-3: TransferService, PricingService, EligibilityChecker, FraudDetectionService. Не OrderHelper, не OrderUtil, не BusinessLogicManager.

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

  • Понятно, что объект делает. EligibilityChecker.isEligibleForRefund(order, customer) — самообъясняющий вызов.
  • Discoverable. Когда BA говорит «как у нас проверяется eligibility для возврата», git grep EligibilityChecker находит правило сразу.
  • Граница ответственности. OrderHelper — это поле для любой логики «как-то связанной с Order». RefundEligibilityChecker — для одной конкретной операции. Класс не разрастается за счёт расплывчатого имени.

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

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

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

// ПЛОХО — Domain Service оркеструет
public final class TransferService {
    private final AccountRepository accountRepository;     // ← инфра в core/

    @Transactional                                          // ← транзакция в Domain Service
    public void transfer(AccountId fromId, AccountId toId, Money amount) {
        Account from = accountRepository.findById(fromId).orElseThrow();
        // ...
        accountRepository.save(from);
    }
}

// ХОРОШО — Application Service оркеструет, Domain Service считает
@Component
@RequiredArgsConstructor
class TransferMoneyHandler implements UseCaseHandler<TransferMoney, TransferResult> {

    private final AccountRepository accountRepository;
    private final TransferService transferService;   // pure domain

    @Override
    @Transactional
    public TransferResult handle(TransferMoney command) {
        Account from = accountRepository.findById(command.fromId(), FOR_UPDATE).orElseThrow();
        Account to = accountRepository.findById(command.toId(), FOR_UPDATE).orElseThrow();
        TransferResult result = transferService.transfer(from, to, command.amount());
        accountRepository.save(from);
        accountRepository.save(to);
        return result;
    }
}

Разделение:

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

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

АнтипаттернПравилоЧто взамен
Оркестрация (репозитории, @Transactional, события) в Domain ServiceR-DS-X1Оркестрация — в UseCaseHandler. Domain Service — только pure-логика
Domain Service как свалка для всей логики (OrderService.cancel)R-DS-X2Логика на агрегате (order.cancel()). DS — только для cross-aggregate правил
Stateful Domain Service (поля с данными)R-DS-2Stateless. Состояние передаётся параметрами
Имя OrderHelper, Util, ManagerR-DS-3Доменное имя: TransferService, PricingService, EligibilityChecker

Куда дальше

  • DDD Tactical → раздел 6. Domain Service — нормативные формулировки R-DS-*.
  • Aggregate Root — дефолтное место для логики, до того как думать про Domain Service.
  • Entity — анемичная модель и почему методы должны жить рядом с состоянием.
  • Use Case Pattern Style Guide — что такое UseCaseHandler и почему оркестрация — это он.
  • Distributed Patterns Style Guide — когда «правило про два агрегата» — это уже saga.