Опирается на правила:
R-DS-1…R-DS-3иR-DS-X1…R-DS-X2из DDD Tactical Style Guide → раздел 6. Domain Service.
Важно знать
- Domain Service вводится только когда логика затрагивает два и более агрегата и не может естественно принадлежать ни одному из них.
- Stateless struct — никаких полей-состояния. Конструктор принимает только доменные зависимости (не репозитории, не HTTP-клиенты).
- Имя — доменная операция:
TransferService,PricingService,FraudCheckService. НеOrderHelper, неBusinessLogicService.- Загрузку агрегатов делает UseCase Handler, не Domain Service. DS получает уже загруженные объекты параметрами.
- Если логика помещается в метод одного агрегата — она туда и идёт. Domain Service не вводится «для порядка».
R-DS-X2: Domain Service как свалка бизнес-логики оставляет агрегаты анемичными.
Domain Service — узкий паттерн. В большинстве сервисов 0–3 таких класса, не 20. Если Domain Service растёт — это сигнал, что граница агрегата проведена неверно. Раскрытие раздела 6 гайда на Go-стеке.
Когда вводим
R-DS-1: один из триггеров должен сработать.
Логика касается двух агрегатов и нет очевидного «хозяина»:
// Перевод средств между двумя Account — нельзя поместить ни в src, ни в dst
type TransferService struct{}
func (TransferService) Transfer(src, dst *aggregate.Account, amount vo.Money) error {
if err := src.Withdraw(amount); err != nil {
return err
}
return dst.Deposit(amount)
}
TransferService принимает оба агрегата уже загруженными. Он не знает про persistence; не читает из БД; не публикует события — это делает Handler.
Ценообразование, зависящее от нескольких агрегатов:
// core/order/service/pricing_service.go
package service
import (
"example.com/svc/core/order/aggregate"
"example.com/svc/core/product/aggregate"
"example.com/svc/core/order/vo"
)
type PricingService struct{}
func (PricingService) CalculateTotal(
order *orderaggregate.Order,
products map[vo.ProductID]*productaggregate.Product,
) (vo.Money, error) {
var total vo.Money
for _, line := range order.Lines() {
p, ok := products[line.ProductID()]
if !ok {
return vo.Money{}, fmt.Errorf("product %s not found", line.ProductID())
}
sub := p.CurrentPrice().Multiply(line.Qty())
var err error
total, err = total.Add(sub)
if err != nil {
return vo.Money{}, err
}
}
return total, nil
}
Загрузка products — задача Handler; Domain Service только вычисляет.
Stateless struct
R-DS-2: никаких полей состояния, никаких сохранённых зависимостей (репозиториев, HTTP-клиентов):
// ХОРОШО — stateless, чистая доменная логика
type TransferService struct{}
func (TransferService) Transfer(src, dst *aggregate.Account, amount vo.Money) error {
// только доменные операции
}
// ПЛОХО — репозиторий внутри Domain Service
type TransferService struct {
accounts port.AccountRepository // R-DS-X1: загрузка в DS
}
func (s *TransferService) Transfer(ctx context.Context, srcID, dstID uuid.UUID, amount vo.Money) error {
src, _ := s.accounts.ByID(ctx, srcID) // загрузка — это Handler
dst, _ := s.accounts.ByID(ctx, dstID)
// ...
}
Конструктор Domain Service:
// Если DS принимает доменные конфигурации — их можно передать:
type FraudCheckService struct {
threshold vo.Money // доменная константа, не репозиторий
}
func NewFraudCheckService(threshold vo.Money) FraudCheckService {
return FraudCheckService{threshold: threshold}
}
func (s FraudCheckService) IsSuspicious(order *aggregate.Order) bool {
total, _ := order.Total()
return total.Amount().GreaterThan(s.threshold.Amount())
}
Имя — доменная операция
R-DS-3:
// ХОРОШО — имя выражает операцию
type TransferService struct{}
type PricingService struct{}
type FraudCheckService struct{}
type LoyaltyCalculationService struct{}
// ПЛОХО — слишком широко или бессмысленно
type OrderHelper struct{}
type BusinessLogicService struct{}
type OrderManager struct{}
Как Handler использует Domain Service
// core/account/usecase/transfer_handler.go
type TransferHandler struct {
accounts port.AccountRepository
transfer service.TransferService
clock func() time.Time
}
func (h *TransferHandler) Handle(ctx context.Context, cmd Transfer) error {
src, err := h.accounts.ByID(ctx, cmd.SrcID)
if err != nil {
return err
}
dst, err := h.accounts.ByID(ctx, cmd.DstID)
if err != nil {
return err
}
if err := h.transfer.Transfer(src, dst, cmd.Amount); err != nil {
return err
}
if err := h.accounts.Save(ctx, src); err != nil {
return err
}
return h.accounts.Save(ctx, dst)
}
Handler загружает агрегаты, передаёт DS, сохраняет результат. DS не знает про persistence.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Domain Service загружает агрегаты из репозитория | R-DS-X1 | Загрузка в Handler; DS принимает объекты параметрами |
| Domain Service запускает транзакцию | R-DS-X1 | Транзакция в Handler или репозитории |
| Domain Service публикует события | R-DS-X1 | События регистрируются в агрегате; публикуются через репозиторий |
| Вся бизнес-логика в DS, агрегаты — только данные | R-DS-X2 | Логика, принадлежащая одному агрегату, — в его методах |
OrderService, OrderManager вместо конкретного имени | R-DS-3 | TransferService, PricingService |
Куда дальше
- DDD Tactical → раздел 6. Domain Service — нормативные формулировки
R-DS-*. - go/aggregate-root.md — когда логика помещается в один агрегат.
- go/factory.md — соседний паттерн «создание агрегата с правилами».
- go/module-structure.md — куда кладётся
service/в структуре пакетов. - Use Case Pattern Style Guide — как Handler оркестрирует Domain Service.