Domain Service vs Application Service

Domain Service vs Application Service

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

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

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

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

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

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

Application Service (Use Case)

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

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

// 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 только координирует шаги.

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;

// 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;

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

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

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

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

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

// ПЛОХО: анемичная модель - структура без поведения
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, вернуть ответ.

Принцип stateless

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

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

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

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

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

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

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

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