Опирается на правила:
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 создаётся, только когда правило не помещается в один корень.
- 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 оправдан, если обе проверки выполняются:
- Логика касается двух и более агрегатов (или нескольких VO без явного владельца).
- Не помещается в один корень — нельзя пристроить методом на
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 в другой + invariantamount > 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 Service | R-DS-X1 | Оркестрация — в UseCaseHandler. Domain Service — только pure-логика |
Domain Service как свалка для всей логики (OrderService.cancel) | R-DS-X2 | Логика на агрегате (order.cancel()). DS — только для cross-aggregate правил |
| Stateful Domain Service (поля с данными) | R-DS-2 | Stateless. Состояние передаётся параметрами |
Имя OrderHelper, Util, Manager | R-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.