Опирается на правила: R-DS-1R-DS-3 и R-DS-X1R-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-3TransferService, 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.