Domain Service vs Application Service

Domain Service vs Application Service

«Куда положить эту логику?» - главный вопрос в проектах с DDD. Разберём, где живёт бизнес-логика, а где - сценарии и оркестрация.

Проблема: куда положить логику?

У тебя есть агрегат CourseProgress с методом CompleteLesson - его инварианты и правило «одна транзакция на один агрегат» разбирались в уроке Aggregates и инварианты. Но кто загружает агрегат из базы? Кто публикует событие после завершения? А если нужно рассчитать рейтинг курса на основе прогресса всех пользователей - это чья ответственность?

Без чёткого разделения вся логика оказывается в handler'е: загрузка данных, бизнес-правила, сохранение, отправка уведомлений - всё в одной функции на 200 строк. DDD предлагает три типа сервисов, у каждого своя роль.

Поток ответственности: HTTP Handler -> Application Service -> Domain -> Infrastructure

Три типа сервисов

Application Service (Use Case)

Оркестратор сценария. Загружает агрегаты, вызывает их методы, сохраняет результат, публикует события. Не содержит доменных инвариантов - правил, верных для агрегата всегда, в любом сценарии. Зато правила именно этого сценария - порядок шагов, что считать успехом, что делать при сбое побочного эффекта - его законное место, и переносить их в домен нечего.

Примеры для BackendStart: CompleteLessonUseCase, EnrollInCourseUseCase, SubmitQuizUseCase.

«Application Service» и «use-case» - это одно и то же, просто первое слово из словаря DDD, а второе из гексагональной архитектуры. Устройство такого класса разобрано детально в уроке Use Case: сценарии вместо «толстых сервисов»: почему на входе отдельная структура вместо набора параметров, чем доменная ошибка отличается от инфраструктурной и почему логировать внутри сценария не стоит.

// Application Service - оркестрирует сценарий "завершить урок"
type CompleteLessonUseCase struct {
    progressRepo ProgressRepository
    courseRepo   CourseRepository
    eventBus     EventPublisher
}

func NewCompleteLessonUseCase(
    pr ProgressRepository,
    cr CourseRepository,
    eb EventPublisher,
) *CompleteLessonUseCase {
    return &CompleteLessonUseCase{
        progressRepo: pr,
        courseRepo:    cr,
        eventBus:     eb,
    }
}

func (uc *CompleteLessonUseCase) Execute(ctx context.Context, userID, courseID, lessonID string) error {
    // 1. Загрузить агрегат
    progress, err := uc.progressRepo.Get(ctx, userID, courseID)
    if err != nil {
        return fmt.Errorf("load progress: %w", err)
    }

    // 2. Вызвать доменную логику (бизнес-правила внутри агрегата)
    if err := progress.CompleteLesson(lessonID); err != nil {
        return fmt.Errorf("complete lesson: %w", err)
    }

    // 3. Сохранить агрегат
    if err := uc.progressRepo.Save(ctx, progress); err != nil {
        return fmt.Errorf("save progress: %w", err)
    }

    // 4. Опубликовать событие для побочных эффектов
    uc.eventBus.Publish(ctx, LessonCompleted{
        UserID:   userID,
        CourseID: courseID,
        LessonID: lessonID,
        Percent:  progress.Percent(),
    })

    return nil
}
<?php
declare(strict_types=1);

namespace App\Learning\Application;

use App\Learning\Domain\CourseRepository;
use App\Learning\Domain\EventPublisher;
use App\Learning\Domain\LessonCompleted;
use App\Learning\Domain\ProgressRepository;

// Application Service - оркестрирует сценарий «завершить урок»
final class CompleteLessonUseCase
{
    public function __construct(
        private readonly ProgressRepository $progressRepo,
        private readonly CourseRepository $courseRepo,
        private readonly EventPublisher $eventBus,
    ) {}

    public function execute(string $userId, string $courseId, string $lessonId): void
    {
        // 1. Загрузить агрегат
        $progress = $this->progressRepo->get($userId, $courseId);

        // 2. Вызвать доменную логику (бизнес-правила внутри агрегата)
        $progress->completeLesson($lessonId);

        // 3. Сохранить агрегат
        $this->progressRepo->save($progress);

        // 4. Опубликовать событие для побочных эффектов
        $this->eventBus->publish(new LessonCompleted(
            userId: $userId,
            courseId: $courseId,
            lessonId: $lessonId,
            percent: $progress->percent(),
        ));
    }
}

Обрати внимание: в Application Service нет ни одного if, который проверяет бизнес-правило. Все проверки - внутри progress.CompleteLesson(). Use case только координирует шаги.

Старая формулировка «Application Service не содержит бизнес-логики» короче, но учит неверному: по ней любое `if` в сценарии выглядит утечкой из домена, и правило вроде «ошибка письма не отменяет регистрацию» уезжает в агрегат, которому до письма нет дела. Различать надо не «логика или нет», а по трём категориям:
  • Инвариант домена - утверждение, верное для агрегата всегда, в любом сценарии. «Урок нельзя завершить дважды» - такое. Место: метод агрегата или Domain Service;
  • Правило сценария - решение о том, как проходит именно этот сценарий: в каком порядке шаги, что считать успехом, что делать при сбое побочного эффекта. «Ошибка отправки письма не отменяет регистрацию» - такое. Место: Application Service;
  • Инфраструктурное решение - чем отправить письмо, какой таймаут, куда положить файл. Место: реализация за интерфейсом.

Разница между первыми двумя - в области действия. Инвариант принадлежит модели и верен при любом входе: и при регистрации через веб, и при импорте из CSV. Правило сценария принадлежит одному входу, и в другом сценарии может быть другим - импорт вполне может решить, что без письма пользователь не создан.

Отсюда рабочий критерий: если правило придётся продублировать во втором сценарии - оно доменное. Если во втором сценарии оно осмысленно другое - это правило сценария, и переносить его в домен нечего. Тот же критерий разбирается со стороны паттернов в уроке про Facade и Use Case.

Шаги 1 и 3 держит репозиторий, и от него требуется вернуть собранный агрегат, готовый к вызову бизнес-методов, а не плоскую строку из таблицы. Почему у такого репозитория три метода, а не тридцать, - в уроке Repositories в DDD.

Domain Service

Бизнес-логика, которая не помещается в один агрегат. Если правило требует данных из нескольких сущностей или агрегатов - это Domain Service.

Примеры для BackendStart: ProgressCalculator (рассчитывает общий прогресс по всем курсам), QuizScorer (проверяет ответы с учётом весов вопросов).

// Domain Service - бизнес-логика, требующая нескольких сущностей.
// Живёт в пакете domain, не зависит от инфраструктуры.
type QuizScorer struct{}

func NewQuizScorer() *QuizScorer {
    return &QuizScorer{}
}

// Score проверяет ответы пользователя по эталону квиза.
// Это не метод агрегата, потому что нужны и Quiz, и UserAnswers.
func (s *QuizScorer) Score(quiz *Quiz, answers []UserAnswer) (*QuizResult, error) {
    if len(answers) == 0 {
        return nil, fmt.Errorf("no answers provided")
    }

    correct := 0
    details := make([]AnswerResult, 0, len(answers))

    for _, a := range answers {
        question, err := quiz.FindQuestion(a.QuestionID)
        if err != nil {
            return nil, fmt.Errorf("question %s: %w", a.QuestionID, err)
        }

        isCorrect := question.CheckAnswer(a.SelectedOption)
        if isCorrect {
            correct++
        }

        details = append(details, AnswerResult{
            QuestionID: a.QuestionID,
            Correct:    isCorrect,
        })
    }

    percent := (correct * 100) / len(quiz.Questions())

    return &QuizResult{
        QuizID:  quiz.ID(),
        Score:   percent,
        Passed:  percent >= quiz.PassThreshold(),
        Details: details,
    }, nil
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

use DomainException;

// Domain Service - бизнес-логика, требующая нескольких сущностей.
// Живёт в namespace Domain, не зависит от инфраструктуры.
// Stateless: нет полей-состояния, только методы.
final class QuizScorer
{
    // score проверяет ответы пользователя по эталону квиза.
    // Это не метод агрегата, потому что нужны и Quiz, и UserAnswer[].
    /** @param UserAnswer[] $answers */
    public function score(Quiz $quiz, array $answers): QuizResult
    {
        if ($answers === []) {
            throw new DomainException('no answers provided');
        }

        $correct = 0;
        $details = [];

        foreach ($answers as $a) {
            $question = $quiz->findQuestion($a->questionId);
            $isCorrect = $question->checkAnswer($a->selectedOption);
            if ($isCorrect) {
                $correct++;
            }
            $details[] = new AnswerResult(
                questionId: $a->questionId,
                correct: $isCorrect,
            );
        }

        $percent = intdiv($correct * 100, count($quiz->questions()));

        return new QuizResult(
            quizId: $quiz->id(),
            score: $percent,
            passed: $percent >= $quiz->passThreshold(),
            details: $details,
        );
    }
}

Если логика работает с данными одного агрегата - это метод агрегата. Если нужны данные из нескольких агрегатов или сущностей - это Domain Service. Domain Service не имеет состояния и живёт в пакете domain.

Infrastructure Service

Техническая реализация, не связанная с бизнесом. Отправка email, публикация событий в очередь, логирование, интеграция с внешними API.

Примеры для BackendStart: EmailNotifier, EventPublisher, FileStorage.

// Интерфейс определяется в domain (порт)
type EventPublisher interface {
    Publish(ctx context.Context, event DomainEvent) error
}

// Реализация живёт в infrastructure (адаптер)
type NATSEventPublisher struct {
    conn *nats.Conn
}

func (p *NATSEventPublisher) Publish(ctx context.Context, event DomainEvent) error {
    data, err := json.Marshal(event)
    if err != nil {
        return fmt.Errorf("marshal event: %w", err)
    }
    return p.conn.Publish(event.EventName(), data)
}
<?php
declare(strict_types=1);

// Интерфейс определяется в Domain (порт). Не final - это интерфейс.
namespace App\Learning\Domain;

interface EventPublisher
{
    public function publish(DomainEvent $event): void;
}

// Реализация живёт в Infrastructure (адаптер).
// Reference: Symfony Messenger или RabbitMQ-клиент.
namespace App\Learning\Infrastructure;

use App\Learning\Domain\DomainEvent;
use App\Learning\Domain\EventPublisher;
use PhpAmqpLib\Channel\AMQPChannel;
use PhpAmqpLib\Message\AMQPMessage;

final class RabbitMQEventPublisher implements EventPublisher
{
    public function __construct(
        private readonly AMQPChannel $channel,
        private readonly string $exchange,
    ) {}

    public function publish(DomainEvent $event): void
    {
        $payload = json_encode($event, JSON_THROW_ON_ERROR);
        $message = new AMQPMessage($payload);
        $this->channel->basic_publish($message, $this->exchange, $event->eventName());
    }
}

Infrastructure Service реализует интерфейс из домена. Домен не знает о NATS, Kafka или SMTP - он работает через абстракцию.

Это тот же приём, что порт и адаптер в гексагональной архитектуре: интерфейс объявлен со стороны того, кто его вызывает, а реализация подставляется снаружи - механика в уроке Ports & Adapters. А если нужна формулировка на уровне принципа, это Dependency Inversion: и домен, и адаптер зависят от абстракции, а не друг от друга.

Анемичная доменная модель: антипаттерн

Анемичная модель - это когда сущности содержат только данные (поля + геттеры/сеттеры), а вся логика живёт в сервисах. Это нарушает инкапсуляцию: любой код может поставить невалидное состояние.

// ПЛОХО: анемичная модель - структура без поведения
type CourseProgress struct {
    UserID    string
    CourseID  string
    Percent   int       // экспортируемое поле, можно поставить 999
    Completed bool      // можно поставить true при percent=0
    Lessons   []string  // можно добавить несуществующий урок
}

// Вся логика в сервисе - модель беззащитна
type ProgressService struct{}

func (s *ProgressService) CompleteLesson(p *CourseProgress, lessonID string) {
    // Правила легко забыть или обойти
    p.Lessons = append(p.Lessons, lessonID)
    p.Percent = len(p.Lessons) * 10 // может стать > 100
    if p.Percent >= 100 {
        p.Completed = true
    }
}
// ПЛОХО: анемичная модель - класс без поведения
final class CourseProgress
{
    public string $userId;
    public string $courseId;
    public int $percent = 0;     // публичное поле, можно поставить 999
    public bool $completed = false; // можно поставить true при percent=0
    /** @var string[] */
    public array $lessons = []; // можно добавить несуществующий урок
}

// Вся логика в сервисе - модель беззащитна
final class ProgressService
{
    public function completeLesson(CourseProgress $p, string $lessonId): void
    {
        // Правила легко забыть или обойти
        $p->lessons[] = $lessonId;
        $p->percent = count($p->lessons) * 10; // может стать > 100
        if ($p->percent >= 100) {
            $p->completed = true;
        }
    }
}

Проблема: ничто не мешает написать progress.Percent = -50 в другом месте кода. Инвариант не защищён.

Анемичная модель: данные отдельно от поведения, percent можно поставить произвольный. Богатая модель: поведение внутри агрегата, инварианты защищены

// ХОРОШО: богатая доменная модель - поведение внутри
type CourseProgress struct {
    userID    string  // неэкспортируемые поля
    courseID  string
    lessons   map[string]*LessonProgress
    percent   int
}

// Единственный способ завершить урок - через метод агрегата.
// Инварианты проверяются здесь и нигде больше.
func (cp *CourseProgress) CompleteLesson(lessonID string) error {
    lp, exists := cp.lessons[lessonID]
    if !exists {
        return ErrLessonNotInCourse
    }
    if lp.completed {
        return ErrLessonAlreadyCompleted
    }
    lp.completed = true
    cp.recalcPercent() // процент всегда 0..100
    return nil
}
// ХОРОШО: богатая доменная модель - поведение внутри
final class CourseProgress
{
    /** @param array<string, LessonProgress> $lessons */
    public function __construct(
        private readonly string $userId,
        private readonly string $courseId,
        private array $lessons,
        private int $percent,
    ) {}

    // Единственный способ завершить урок - через метод агрегата.
    // Инварианты проверяются здесь и нигде больше.
    public function completeLesson(string $lessonId): void
    {
        if (!isset($this->lessons[$lessonId])) {
            throw new LessonNotInCourseException();
        }
        $lp = $this->lessons[$lessonId];
        if ($lp->isCompleted()) {
            throw new LessonAlreadyCompletedException();
        }
        $lp->markCompleted(new DateTimeImmutable());
        $this->recalcPercent(); // процент всегда 0..100
    }
}

Если handler проверяет «можно ли завершить урок», «не превышен ли лимит» и «правильный ли статус» - это анемичная модель в маскировке. Перенеси правила в агрегат. Handler должен только: распарсить запрос, вызвать use case, вернуть ответ.

Насколько тонким выходит handler, если правила действительно унесены в домен, показано в уроке HTTP адаптер - там же разобрано единственное, что ему остаётся решать самому: в какой код ответа превратить пришедшую из домена ошибку.

Принцип stateless

Все три типа сервисов не хранят состояние между вызовами. Это значит:

  • Нет мутабельных полей в сервисе (только зависимости через конструктор)
  • Каждый вызов метода независим от предыдущих
  • Сервисы безопасны для конкурентного использования
// Stateless: зависимости задаются при создании, не меняются
type CompleteLessonUseCase struct {
    progressRepo ProgressRepository  // зависимость, не состояние
    eventBus     EventPublisher       // зависимость, не состояние
}

// Каждый вызов Execute независим
// Два горутины могут вызывать Execute одновременно

Полная картина: кто за что отвечает

HTTP Handler (распарсить запрос, вернуть ответ)
    |
    v
Application Service (загрузить, вызвать домен, сохранить, событие)
    |
    v
Domain Service + Aggregate (бизнес-правила, инварианты)
    |
    v
Infrastructure Service (БД, очереди, email - за интерфейсом)

Четвёртый шаг Application Service - публикация события - заслуживает отдельного разбора: события накапливает агрегат, а публикует их сценарий, и только после успешного сохранения, иначе подписчики узнают о факте, которого в базе нет. Об этом урок Domain Events.

Мини-задание

  • Реализуй SubmitQuizUseCase (Application Service): загрузи квиз, используй QuizScorer (Domain Service) для подсчёта, сохрани результат
  • Возьми три проверки из своего Application Service и разнеси по категориям: инвариант домена / правило сценария / инфраструктурное решение. Инварианты перенеси в агрегат или Domain Service. Правила сценария оставь на месте - и для каждого назови второй сценарий, в котором оно осмысленно было бы другим
  • Найди в своём коде анемичную модель - экспортируемые поля без защиты инвариантов
  • Убедись, что сервисы stateless: нет мутабельных полей, только зависимости через конструктор
  • Определи, какой тип сервиса нужен для «отправить email при завершении курса»

Зарегистрируйтесь бесплатно, чтобы пройти квиз, вести прогресс.