Domain Service vs Application Service
Domain Service vs Application Service
«Куда положить эту логику?» - главный вопрос в проектах с DDD. Разберём, где живёт бизнес-логика, а где - сценарии и оркестрация.
Проблема: куда положить логику?
У тебя есть агрегат CourseProgress с методом CompleteLesson. Но кто загружает агрегат из базы? Кто публикует событие после завершения? А если нужно рассчитать рейтинг курса на основе прогресса всех пользователей - это чья ответственность?
Без чёткого разделения вся логика оказывается в handler'е: загрузка данных, бизнес-правила, сохранение, отправка уведомлений - всё в одной функции на 200 строк. DDD предлагает три типа сервисов, у каждого своя роль.
Три типа сервисов
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 в другом месте кода. Инвариант не защищён.
// ХОРОШО: богатая доменная модель - поведение внутри
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 при завершении курса»