Мини-проект: доменная модель BackendStart

За предыдущие уроки мы разобрали каждый строительный блок DDD Lite по отдельности. Теперь соберём их вместе в единую рабочую модель для реального проекта - образовательной платформы BackendStart.

Цель: пользователь проходит уроки, решает квизы, видит свой прогресс по курсу. Когда все уроки пройдены - курс завершён.

Ubiquitous Language: словарь проекта

Прежде чем писать код, зафиксируем единый язык. Каждый термин имеет ровно одно значение во всём контексте Learning:

ТерминОпределение
TrackНаправление обучения (Go, Docker, DDD). Содержит курсы
CourseНабор уроков по одной теме внутри трека
LessonЕдиница обучения: markdown-контент + опциональный квиз
QuizНабор вопросов к уроку. Считается пройденным при Score >= 70%
CourseProgressСостояние прохождения курса конкретным пользователем
LessonProgressСтатус одного урока внутри прогресса курса
CompletedУрок пройден: контент прочитан и квиз сдан (если есть)
CourseFinishedВсе уроки курса в статусе Completed
Ubiquitous Language обновляется вместе с проектом. Если бизнес начинает говорить "модуль" вместо "урок" - код должен измениться тоже. Расхождение между речью и кодом - источник багов.

Структура пакетов

Проект организован по гексагональной архитектуре внутри Bounded Context learning:

internal/learning/
  domain/
    course.go          # Entity: Course, Lesson
    progress.go        # Aggregate: CourseProgress + LessonProgress
    value_objects.go   # VO: CourseSlug, LessonSlug, Email, QuizScore
    events.go          # Domain Events
    repository.go      # Repository interfaces
    quiz_evaluator.go  # Domain Service
  app/
    complete_lesson.go # Use-case: CompleteLessonUseCase
    start_course.go    # Use-case: StartCourseUseCase
  infra/
    gorm_progress.go   # GORM-реализация ProgressRepository
    gorm_course.go     # GORM-реализация CourseRepository
    event_publisher.go # InMemoryPublisher
    handlers/
      grant_certificate.go
      update_stats.go

Правило: domain/ не импортирует ничего из app/ и infra/. app/ не импортирует infra/. Зависимости направлены только внутрь.

Value Objects

Value Object не имеет идентичности. Два объекта с одинаковыми значениями равны. Валидация - в конструкторе:

package domain

import (
    "fmt"
    "regexp"
    "strings"
)

// CourseSlug - URL-безопасный идентификатор курса
type CourseSlug string

var slugRegex = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*

  
    
    
    
    
    
    Мини-проект: доменная модель BackendStart (Course/Lesson/Progress) | BackendStart.ru
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
      
    
    
  
  
    )

func NewCourseSlug(raw string) (CourseSlug, error) {
    s := strings.TrimSpace(strings.ToLower(raw))
    if !slugRegex.MatchString(s) {
        return "", fmt.Errorf("invalid course slug: %q", raw)
    }
    return CourseSlug(s), nil
}

func (s CourseSlug) String() string { return string(s) }

// LessonSlug - URL-безопасный идентификатор урока
type LessonSlug string

func NewLessonSlug(raw string) (LessonSlug, error) {
    s := strings.TrimSpace(strings.ToLower(raw))
    if !slugRegex.MatchString(s) {
        return "", fmt.Errorf("invalid lesson slug: %q", raw)
    }
    return LessonSlug(s), nil
}

func (s LessonSlug) String() string { return string(s) }

// Email пользователя
type Email string

func NewEmail(raw string) (Email, error) {
    trimmed := strings.TrimSpace(raw)
    if !strings.Contains(trimmed, "@") || len(trimmed) < 5 {
        return "", fmt.Errorf("invalid email: %q", raw)
    }
    return Email(strings.ToLower(trimmed)), nil
}

// QuizScore - результат квиза (0..100)
type QuizScore int

func NewQuizScore(val int) (QuizScore, error) {
    if val < 0 || val > 100 {
        return 0, fmt.Errorf("quiz score must be 0..100, got %d", val)
    }
    return QuizScore(val), nil
}

func (s QuizScore) Passed() bool { return int(s) >= 70 }
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// CourseSlug - URL-безопасный идентификатор курса
final readonly class CourseSlug
{
    private const PATTERN = '/^[a-z0-9]+(-[a-z0-9]+)*$/';

    private function __construct(private string $value) {}

    public static function fromString(string $raw): self
    {
        $s = strtolower(trim($raw));
        if (preg_match(self::PATTERN, $s) !== 1) {
            throw new \InvalidArgumentException('invalid course slug: ' . $raw);
        }
        return new self($s);
    }

    public function value(): string { return $this->value; }
    public function equals(self $other): bool { return $this->value === $other->value; }
    public function __toString(): string { return $this->value; }
}

// LessonSlug - URL-безопасный идентификатор урока
final readonly class LessonSlug
{
    private const PATTERN = '/^[a-z0-9]+(-[a-z0-9]+)*$/';

    private function __construct(private string $value) {}

    public static function fromString(string $raw): self
    {
        $s = strtolower(trim($raw));
        if (preg_match(self::PATTERN, $s) !== 1) {
            throw new \InvalidArgumentException('invalid lesson slug: ' . $raw);
        }
        return new self($s);
    }

    public function value(): string { return $this->value; }
    public function equals(self $other): bool { return $this->value === $other->value; }
    public function __toString(): string { return $this->value; }
}

// Email пользователя
final readonly class Email
{
    private function __construct(private string $value) {}

    public static function fromString(string $raw): self
    {
        $trimmed = trim($raw);
        if (!str_contains($trimmed, '@') || strlen($trimmed) < 5) {
            throw new \InvalidArgumentException('invalid email: ' . $raw);
        }
        return new self(strtolower($trimmed));
    }

    public function value(): string { return $this->value; }
    public function equals(self $other): bool { return $this->value === $other->value; }
}

// QuizScore - результат квиза (0..100)
final readonly class QuizScore
{
    private function __construct(private int $value) {}

    public static function fromInt(int $val): self
    {
        if ($val < 0 || $val > 100) {
            throw new \InvalidArgumentException(
                sprintf('quiz score must be 0..100, got %d', $val)
            );
        }
        return new self($val);
    }

    public function value(): int { return $this->value; }
    public function passed(): bool { return $this->value >= 70; }
    public function equals(self $other): bool { return $this->value === $other->value; }
}

Entities: Course и Lesson

Entity имеет уникальный идентификатор. Два Course с одинаковым названием, но разным ID - разные объекты:

package domain

type CourseID int64
type LessonID int64

type Course struct {
    ID          CourseID
    Title       string
    Slug        CourseSlug
    TrackSlug   string
    Lessons     []Lesson
    Description string
}

func (c *Course) TotalLessons() int {
    return len(c.Lessons)
}

func (c *Course) FindLesson(slug LessonSlug) (Lesson, bool) {
    for _, l := range c.Lessons {
        if l.Slug == slug {
            return l, true
        }
    }
    return Lesson{}, false
}

type Lesson struct {
    ID       LessonID
    Title    string
    Slug     LessonSlug
    Order    int
    HasQuiz  bool
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Course - Entity. Поведение внутри: TotalLessons, FindLesson.
final class Course
{
    /** @param Lesson[] $lessons */
    public function __construct(
        public readonly int $id,
        public readonly string $title,
        public readonly CourseSlug $slug,
        public readonly string $trackSlug,
        public readonly array $lessons,
        public readonly string $description,
    ) {}

    public function totalLessons(): int
    {
        return count($this->lessons);
    }

    public function findLesson(LessonSlug $slug): ?Lesson
    {
        foreach ($this->lessons as $l) {
            if ($l->slug->equals($slug)) {
                return $l;
            }
        }
        return null;
    }
}

// Lesson - Entity внутри Course.
final readonly class Lesson
{
    public function __construct(
        public int $id,
        public string $title,
        public LessonSlug $slug,
        public int $order,
        public bool $hasQuiz,
    ) {}
}

Aggregate: CourseProgress

CourseProgress - агрегат (корень) с дочерними LessonProgress. Все изменения идут через корень, он следит за инвариантами и собирает события:

package domain

import (
    "fmt"
    "time"
)

type CourseProgress struct {
    ID         int64
    UserID     int64
    CourseSlug CourseSlug
    Lessons    []LessonProgress
    Percent    int
    StartedAt  time.Time

    events []interface{}
}

type LessonProgress struct {
    LessonSlug LessonSlug
    Completed  bool
    QuizScore  *QuizScore
    CompletedAt *time.Time
}

func NewCourseProgress(userID int64, course Course) *CourseProgress {
    lessons := make([]LessonProgress, 0, len(course.Lessons))
    for _, l := range course.Lessons {
        lessons = append(lessons, LessonProgress{
            LessonSlug: l.Slug,
            Completed:  false,
        })
    }

    return &CourseProgress{
        UserID:     userID,
        CourseSlug: course.Slug,
        Lessons:    lessons,
        Percent:    0,
        StartedAt:  time.Now(),
    }
}

// CompleteLesson отмечает урок пройденным и генерирует события
func (p *CourseProgress) CompleteLesson(slug LessonSlug) error {
    idx := p.findLessonIndex(slug)
    if idx == -1 {
        return fmt.Errorf("lesson %s not found in course %s", slug, p.CourseSlug)
    }

    if p.Lessons[idx].Completed {
        return nil // идемпотентность: повторный вызов не ошибка
    }

    now := time.Now()
    p.Lessons[idx].Completed = true
    p.Lessons[idx].CompletedAt = &now
    p.recalcPercent()

    p.collectEvent(LessonCompleted{
        UserID:     p.UserID,
        CourseSlug: p.CourseSlug.String(),
        LessonSlug: slug.String(),
        OccurredAt: now,
    })

    if p.Percent == 100 {
        p.collectEvent(CourseFinished{
            UserID:     p.UserID,
            CourseSlug: p.CourseSlug.String(),
            OccurredAt: now,
        })
    }

    return nil
}

// RecordQuizScore сохраняет результат квиза для урока
func (p *CourseProgress) RecordQuizScore(slug LessonSlug, score QuizScore) error {
    idx := p.findLessonIndex(slug)
    if idx == -1 {
        return fmt.Errorf("lesson %s not found in course %s", slug, p.CourseSlug)
    }
    p.Lessons[idx].QuizScore = &score

    if score.Passed() {
        p.collectEvent(QuizPassed{
            UserID:     p.UserID,
            LessonSlug: slug.String(),
            Score:      int(score),
            OccurredAt: time.Now(),
        })
    }

    return nil
}

func (p *CourseProgress) findLessonIndex(slug LessonSlug) int {
    for i, lp := range p.Lessons {
        if lp.LessonSlug == slug {
            return i
        }
    }
    return -1
}

func (p *CourseProgress) recalcPercent() {
    if len(p.Lessons) == 0 {
        p.Percent = 0
        return
    }
    completed := 0
    for _, lp := range p.Lessons {
        if lp.Completed {
            completed++
        }
    }
    p.Percent = completed * 100 / len(p.Lessons)
}

func (p *CourseProgress) collectEvent(e interface{}) {
    p.events = append(p.events, e)
}

func (p *CourseProgress) FlushEvents() []interface{} {
    events := p.events
    p.events = nil
    return events
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// LessonProgress - внутренний объект агрегата.
// Изменяемый: completed и quizScore меняются.
final class LessonProgress
{
    public function __construct(
        public readonly LessonSlug $lessonSlug,
        private bool $completed = false,
        private ?QuizScore $quizScore = null,
        private ?\DateTimeImmutable $completedAt = null,
    ) {}

    public function isCompleted(): bool { return $this->completed; }
    public function quizScore(): ?QuizScore { return $this->quizScore; }

    public function markCompleted(\DateTimeImmutable $at): void
    {
        $this->completed = true;
        $this->completedAt = $at;
    }

    public function setQuizScore(QuizScore $score): void
    {
        $this->quizScore = $score;
    }
}

// CourseProgress - Aggregate Root. НЕ readonly: мутируется.
final class CourseProgress
{
    /**
     * @param LessonProgress[] $lessons
     * @param object[]         $events
     */
    public function __construct(
        public readonly int $id,
        public readonly int $userId,
        public readonly CourseSlug $courseSlug,
        private array $lessons,
        private int $percent,
        public readonly \DateTimeImmutable $startedAt,
        private array $events = [],
    ) {}

    public static function start(int $userId, Course $course): self
    {
        $lessons = [];
        foreach ($course->lessons as $l) {
            $lessons[] = new LessonProgress(lessonSlug: $l->slug);
        }
        return new self(
            id: 0,
            userId: $userId,
            courseSlug: $course->slug,
            lessons: $lessons,
            percent: 0,
            startedAt: new \DateTimeImmutable(),
        );
    }

    public function percent(): int { return $this->percent; }
    /** @return LessonProgress[] */
    public function lessons(): array { return $this->lessons; }

    // completeLesson отмечает урок пройденным и генерирует события
    public function completeLesson(LessonSlug $slug): void
    {
        $idx = $this->findLessonIndex($slug);
        if ($idx === null) {
            throw new \DomainException(sprintf(
                'lesson %s not found in course %s',
                $slug,
                $this->courseSlug,
            ));
        }
        if ($this->lessons[$idx]->isCompleted()) {
            return; // идемпотентность: повторный вызов не ошибка
        }

        $now = new \DateTimeImmutable();
        $this->lessons[$idx]->markCompleted($now);
        $this->recalcPercent();

        $this->recordEvent(new LessonCompleted(
            userId: $this->userId,
            courseSlug: $this->courseSlug->value(),
            lessonSlug: $slug->value(),
            occurredAt: $now,
        ));

        if ($this->percent === 100) {
            $this->recordEvent(new CourseFinished(
                userId: $this->userId,
                courseSlug: $this->courseSlug->value(),
                occurredAt: $now,
            ));
        }
    }

    // recordQuizScore сохраняет результат квиза для урока
    public function recordQuizScore(LessonSlug $slug, QuizScore $score): void
    {
        $idx = $this->findLessonIndex($slug);
        if ($idx === null) {
            throw new \DomainException(sprintf(
                'lesson %s not found in course %s',
                $slug,
                $this->courseSlug,
            ));
        }
        $this->lessons[$idx]->setQuizScore($score);

        if ($score->passed()) {
            $this->recordEvent(new QuizPassed(
                userId: $this->userId,
                lessonSlug: $slug->value(),
                score: $score->value(),
                occurredAt: new \DateTimeImmutable(),
            ));
        }
    }

    private function findLessonIndex(LessonSlug $slug): ?int
    {
        foreach ($this->lessons as $i => $lp) {
            if ($lp->lessonSlug->equals($slug)) {
                return $i;
            }
        }
        return null;
    }

    private function recalcPercent(): void
    {
        $total = count($this->lessons);
        if ($total === 0) {
            $this->percent = 0;
            return;
        }
        $completed = 0;
        foreach ($this->lessons as $lp) {
            if ($lp->isCompleted()) {
                $completed++;
            }
        }
        $this->percent = intdiv($completed * 100, $total);
    }

    private function recordEvent(object $e): void
    {
        $this->events[] = $e;
    }

    /** @return object[] */
    public function pullEvents(): array
    {
        $events = $this->events;
        $this->events = [];
        return $events;
    }
}
Процент всегда 0..100. Урок нельзя отметить пройденным дважды (идемпотентность). CourseFinished генерируется только когда Percent достигает 100. Все эти правила живут внутри агрегата - ни handler, ни use-case не должны их дублировать.

Domain Events

Все события - иммутабельные факты в прошедшем времени:

package domain

import "time"

type LessonCompleted struct {
    UserID     int64
    CourseSlug string
    LessonSlug string
    OccurredAt time.Time
}

type QuizPassed struct {
    UserID     int64
    LessonSlug string
    Score      int
    OccurredAt time.Time
}

type CourseFinished struct {
    UserID     int64
    CourseSlug string
    OccurredAt time.Time
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Все Domain Events - final readonly: иммутабельный факт в прошедшем времени.

final readonly class LessonCompleted
{
    public function __construct(
        public int $userId,
        public string $courseSlug,
        public string $lessonSlug,
        public \DateTimeImmutable $occurredAt,
    ) {}
}

final readonly class QuizPassed
{
    public function __construct(
        public int $userId,
        public string $lessonSlug,
        public int $score,
        public \DateTimeImmutable $occurredAt,
    ) {}
}

final readonly class CourseFinished
{
    public function __construct(
        public int $userId,
        public string $courseSlug,
        public \DateTimeImmutable $occurredAt,
    ) {}
}

Domain Service: QuizEvaluator

Доменный сервис содержит логику, которая не принадлежит одной сущности. QuizEvaluator оценивает ответы пользователя:

package domain

// QuizEvaluator - доменный сервис оценки квизов
type QuizEvaluator struct{}

type QuizQuestion struct {
    ID             string
    CorrectAnswers []string
}

type UserAnswer struct {
    QuestionID string
    Answers    []string
}

func (e *QuizEvaluator) Evaluate(
    questions []QuizQuestion,
    answers []UserAnswer,
) (QuizScore, error) {
    if len(questions) == 0 {
        return 0, fmt.Errorf("quiz has no questions")
    }

    correct := 0
    for _, q := range questions {
        for _, a := range answers {
            if a.QuestionID == q.ID && e.isCorrect(q.CorrectAnswers, a.Answers) {
                correct++
                break
            }
        }
    }

    raw := correct * 100 / len(questions)
    return NewQuizScore(raw)
}

func (e *QuizEvaluator) isCorrect(expected, actual []string) bool {
    if len(expected) != len(actual) {
        return false
    }
    set := make(map[string]struct{}, len(expected))
    for _, v := range expected {
        set[v] = struct{}{}
    }
    for _, v := range actual {
        if _, ok := set[v]; !ok {
            return false
        }
    }
    return true
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

final readonly class QuizQuestion
{
    /** @param string[] $correctAnswers */
    public function __construct(
        public string $id,
        public array $correctAnswers,
    ) {}
}

final readonly class UserAnswer
{
    /** @param string[] $answers */
    public function __construct(
        public string $questionId,
        public array $answers,
    ) {}
}

// QuizEvaluator - доменный сервис оценки квизов.
// Stateless: нет полей, только методы.
final class QuizEvaluator
{
    /**
     * @param QuizQuestion[] $questions
     * @param UserAnswer[]   $answers
     */
    public function evaluate(array $questions, array $answers): QuizScore
    {
        if ($questions === []) {
            throw new \DomainException('quiz has no questions');
        }

        $correct = 0;
        foreach ($questions as $q) {
            foreach ($answers as $a) {
                if ($a->questionId === $q->id && $this->isCorrect($q->correctAnswers, $a->answers)) {
                    $correct++;
                    break;
                }
            }
        }

        $raw = intdiv($correct * 100, count($questions));
        return QuizScore::fromInt($raw);
    }

    /**
     * @param string[] $expected
     * @param string[] $actual
     */
    private function isCorrect(array $expected, array $actual): bool
    {
        if (count($expected) !== count($actual)) {
            return false;
        }
        $set = array_flip($expected);
        foreach ($actual as $v) {
            if (!isset($set[$v])) {
                return false;
            }
        }
        return true;
    }
}

Repository Interfaces

Интерфейсы репозиториев определяются в domain-слое. Реализация - в infra:

package domain

import "context"

type ProgressRepository interface {
    FindByUserAndCourse(ctx context.Context, userID int64, slug CourseSlug) (*CourseProgress, error)
    Save(ctx context.Context, progress *CourseProgress) error
}

type CourseRepository interface {
    FindBySlug(ctx context.Context, slug CourseSlug) (*Course, error)
    ListAll(ctx context.Context) ([]Course, error)
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Интерфейсы - не final, это контракты.
interface ProgressRepository
{
    public function findByUserAndCourse(int $userId, CourseSlug $slug): CourseProgress;
    public function save(CourseProgress $progress): void;
}

interface CourseRepository
{
    public function findBySlug(CourseSlug $slug): Course;

    /** @return Course[] */
    public function listAll(): array;
}

Скетч GORM-реализации:

package infra

import (
    "context"
    "gorm.io/gorm"
    "myapp/internal/learning/domain"
)

type GORMProgressRepository struct {
    db *gorm.DB
}

type progressModel struct {
    ID         int64 `gorm:"primaryKey"`
    UserID     int64 `gorm:"index:idx_user_course,unique"`
    CourseSlug string `gorm:"index:idx_user_course,unique"`
    Percent    int
    StartedAt  time.Time
    Lessons    []lessonProgressModel `gorm:"foreignKey:ProgressID"`
}

func (progressModel) TableName() string { return "course_progress" }

type lessonProgressModel struct {
    ID          int64  `gorm:"primaryKey"`
    ProgressID  int64  `gorm:"index"`
    LessonSlug  string
    Completed   bool
    QuizScore   *int
    CompletedAt *time.Time
}

func (lessonProgressModel) TableName() string { return "lesson_progress" }

func (r *GORMProgressRepository) FindByUserAndCourse(
    ctx context.Context, userID int64, slug domain.CourseSlug,
) (*domain.CourseProgress, error) {
    var model progressModel
    err := r.db.WithContext(ctx).
        Preload("Lessons").
        Where("user_id = ? AND course_slug = ?", userID, slug.String()).
        First(&model).Error
    if err != nil {
        return nil, fmt.Errorf("find progress: %w", err)
    }
    return r.toDomain(model), nil
}

func (r *GORMProgressRepository) Save(
    ctx context.Context, p *domain.CourseProgress,
) error {
    model := r.toModel(p)
    return r.db.WithContext(ctx).Save(&model).Error
}
<?php
declare(strict_types=1);

namespace App\Learning\Infrastructure\Doctrine;

use App\Learning\Domain\CourseProgress;
use App\Learning\Domain\CourseSlug;
use App\Learning\Domain\ProgressNotFoundException;
use App\Learning\Domain\ProgressRepository;
use Doctrine\ORM\EntityManagerInterface;

// Doctrine ORM entity-модели (отдельно от доменных классов).
// Маппинг через атрибуты в реальной кодовой базе.
final class ProgressModel
{
    public int $id;
    public int $userId;
    public string $courseSlug;
    public int $percent;
    public \DateTimeImmutable $startedAt;
    /** @var LessonProgressModel[] */
    public array $lessons = [];
}

final class LessonProgressModel
{
    public int $id;
    public int $progressId;
    public string $lessonSlug;
    public bool $completed;
    public ?int $quizScore;
    public ?\DateTimeImmutable $completedAt;
}

final class DoctrineProgressRepository implements ProgressRepository
{
    public function __construct(
        private readonly EntityManagerInterface $em,
    ) {}

    public function findByUserAndCourse(int $userId, CourseSlug $slug): CourseProgress
    {
        $model = $this->em->getRepository(ProgressModel::class)
 ->findOneBy(['userId' => $userId, 'courseSlug' => $slug->value()]);

        if ($model === null) {
            throw new ProgressNotFoundException();
        }
        return $this->toDomain($model);
    }

    public function save(CourseProgress $progress): void
    {
        $model = $this->toModel($progress);
        $this->em->persist($model);
        $this->em->flush();
    }

    private function toDomain(ProgressModel $m): CourseProgress { /* ... */ }
    private function toModel(CourseProgress $p): ProgressModel { /* ... */ }
}

Application Service: CompleteLessonUseCase

Use-case оркестрирует весь поток: загрузить агрегат, выполнить операцию, сохранить, опубликовать события:

package app

import (
    "context"
    "fmt"
    "myapp/internal/learning/domain"
)

type CompleteLessonCommand struct {
    UserID     int64
    CourseSlug string
    LessonSlug string
}

type CompleteLessonUseCase struct {
    progressRepo domain.ProgressRepository
    publisher    domain.EventPublisher
}

func NewCompleteLessonUseCase(
    repo domain.ProgressRepository,
    pub domain.EventPublisher,
) *CompleteLessonUseCase {
    return &CompleteLessonUseCase{
        progressRepo: repo,
        publisher:    pub,
    }
}

func (uc *CompleteLessonUseCase) Execute(
    ctx context.Context, cmd CompleteLessonCommand,
) error {
    courseSlug, err := domain.NewCourseSlug(cmd.CourseSlug)
    if err != nil {
        return fmt.Errorf("invalid course slug: %w", err)
    }

    lessonSlug, err := domain.NewLessonSlug(cmd.LessonSlug)
    if err != nil {
        return fmt.Errorf("invalid lesson slug: %w", err)
    }

    progress, err := uc.progressRepo.FindByUserAndCourse(ctx, cmd.UserID, courseSlug)
    if err != nil {
        return fmt.Errorf("load progress: %w", err)
    }

    if err := progress.CompleteLesson(lessonSlug); err != nil {
        return fmt.Errorf("complete lesson: %w", err)
    }

    if err := uc.progressRepo.Save(ctx, progress); err != nil {
        return fmt.Errorf("save progress: %w", err)
    }

    // Публикуем события ПОСЛЕ успешного сохранения
    uc.publisher.Publish(ctx, progress.FlushEvents()...)
    return nil
}
<?php
declare(strict_types=1);

namespace App\Learning\Application;

use App\Learning\Domain\CourseSlug;
use App\Learning\Domain\EventPublisher;
use App\Learning\Domain\LessonSlug;
use App\Learning\Domain\ProgressRepository;

// Command - final readonly, факт намерения.
final readonly class CompleteLessonCommand
{
    public function __construct(
        public int $userId,
        public string $courseSlug,
        public string $lessonSlug,
    ) {}
}

// Application Service - оркестрирует сценарий.
final class CompleteLessonUseCase
{
    public function __construct(
        private readonly ProgressRepository $progressRepo,
        private readonly EventPublisher $publisher,
    ) {}

    public function execute(CompleteLessonCommand $cmd): void
    {
        $courseSlug = CourseSlug::fromString($cmd->courseSlug);
        $lessonSlug = LessonSlug::fromString($cmd->lessonSlug);

        $progress = $this->progressRepo->findByUserAndCourse($cmd->userId, $courseSlug);

        $progress->completeLesson($lessonSlug);

        $this->progressRepo->save($progress);

        // Публикуем события ПОСЛЕ успешного сохранения
        $this->publisher->publish(...$progress->pullEvents());
    }
}

Event Handler: GrantCertificate

Обработчик реагирует на CourseFinished и выдаёт сертификат:

package handlers

import (
    "context"
    "log/slog"
    "myapp/internal/learning/domain"
)

type GrantCertificate struct {
    certRepo CertificateRepository
    logger   *slog.Logger
}

type CertificateRepository interface {
    Issue(ctx context.Context, userID int64, courseSlug string) error
}

func NewGrantCertificate(
    repo CertificateRepository, logger *slog.Logger,
) *GrantCertificate {
    return &GrantCertificate{certRepo: repo, logger: logger}
}

func (h *GrantCertificate) Handle(ctx context.Context, event interface{}) {
    e, ok := event.(domain.CourseFinished)
    if !ok {
        return
    }

    if err := h.certRepo.Issue(ctx, e.UserID, e.CourseSlug); err != nil {
        h.logger.Error("failed to issue certificate",
            slog.Int64("user_id", e.UserID),
            slog.String("course", e.CourseSlug),
            slog.String("err", err.Error()),
        )
        return
    }

    h.logger.Info("certificate issued",
        slog.Int64("user_id", e.UserID),
        slog.String("course", e.CourseSlug),
    )
}
<?php
declare(strict_types=1);

namespace App\Learning\Application\Handler;

use App\Learning\Domain\CourseFinished;
use App\Learning\Infrastructure\EventHandler;
use Psr\Log\LoggerInterface;

// CertificateRepository - порт в Domain (интерфейс).
namespace App\Learning\Domain;

interface CertificateRepository
{
    public function issue(int $userId, string $courseSlug): void;
}

// Event Handler - реагирует на CourseFinished.
namespace App\Learning\Application\Handler;

use App\Learning\Domain\CertificateRepository;
use App\Learning\Domain\CourseFinished;
use App\Learning\Infrastructure\EventHandler;
use Psr\Log\LoggerInterface;

final class GrantCertificate implements EventHandler
{
    public function __construct(
        private readonly CertificateRepository $certRepo,
        private readonly LoggerInterface $logger,
    ) {}

    public function handle(object $event): void
    {
        if (!$event instanceof CourseFinished) {
            return;
        }

        try {
            $this->certRepo->issue($event->userId, $event->courseSlug);
        } catch (\Throwable $e) {
            $this->logger->error('failed to issue certificate', [
                'user_id' => $event->userId,
                'course'  => $event->courseSlug,
                'err'     => $e->getMessage(),
            ]);
            return;
        }

        $this->logger->info('certificate issued', [
            'user_id' => $event->userId,
            'course'  => $event->courseSlug,
        ]);
    }
}
Обрати внимание на `slog.String("err", err.Error())` вместо `slog.Any("err", err)`. Интерфейс `error` не сериализуется корректно через `json.Marshal`, поэтому для внешних транспортов (Elastic, Sentry) используй `err.Error()`.

Интеграция контекстов Learning и Identity

Контекст Learning не знает о внутренней модели Identity. Связь - через интерфейс и ACL-адаптер:

// В domain/ контекста Learning - только интерфейс
package domain

type UserInfoProvider interface {
    GetEmail(ctx context.Context, userID int64) (Email, error)
}

// В infra/ - адаптер, который обращается к контексту Identity
package infra

type IdentityACLAdapter struct {
    identitySvc identity.UserService
}

func (a *IdentityACLAdapter) GetEmail(
    ctx context.Context, userID int64,
) (domain.Email, error) {
    user, err := a.identitySvc.FindByID(ctx, userID)
    if err != nil {
        return "", fmt.Errorf("identity lookup: %w", err)
    }
    return domain.NewEmail(user.Email)
}
<?php
declare(strict_types=1);

// В Domain контекста Learning - только интерфейс
namespace App\Learning\Domain;

interface UserInfoProvider
{
    public function getEmail(int $userId): Email;
}

// В Infrastructure - адаптер, который обращается к контексту Identity
namespace App\Learning\Infrastructure;

use App\Identity\Domain\UserService;
use App\Learning\Domain\Email;
use App\Learning\Domain\UserInfoProvider;

final class IdentityAclAdapter implements UserInfoProvider
{
    public function __construct(
        private readonly UserService $identitySvc,
    ) {}

    public function getEmail(int $userId): Email
    {
        $user = $this->identitySvc->findById($userId);
        return Email::fromString($user->email);
    }
}

Learning работает только со своим типом Email. Если Identity поменяет структуру пользователя, изменится только адаптер.

Полная картина

Как всё работает вместе при вызове "пользователь прошёл урок":

1. HTTP Handler получает POST /api/lessons/{slug}/complete
2. Handler создаёт CompleteLessonCommand и вызывает UseCase
3. UseCase загружает CourseProgress из ProgressRepository
4. CourseProgress.CompleteLesson() проверяет инварианты,
   меняет состояние, собирает события
5. UseCase сохраняет агрегат через ProgressRepository
6. UseCase публикует FlushEvents() через EventPublisher
7. GrantCertificate handler проверяет CourseFinished
8. UpdateStats handler записывает аналитику

Каждый слой делает одну вещь. Домен не знает о базе данных. Use-case не знает о HTTP. Handler не содержит бизнес-логики.

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

  • Реализуй Value Object TrackSlug по аналогии с CourseSlug
  • Добавь метод ResetLesson(slug) в агрегат CourseProgress с генерацией события LessonReset
  • Напиши use-case StartCourseUseCase, который создаёт новый CourseProgress для пользователя
  • Реализуй обработчик события QuizPassed, который логирует результат через slog
  • Добавь в словарь Ubiquitous Language два новых термина из своего проекта

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