Мини-проект: доменная модель BackendStart
За предыдущие уроки мы разобрали каждый строительный блок DDD Lite по отдельности. Теперь соберём их вместе в единую рабочую модель для реального проекта - образовательной платформы BackendStart.
Цель: пользователь проходит уроки, решает квизы, видит свой прогресс по курсу. Когда все уроки пройдены - курс завершён.
Ubiquitous Language: словарь проекта
Прежде чем писать код, зафиксируем единый язык. Каждый термин имеет ровно одно значение во всём контексте Learning:
| Термин | Определение |
|---|---|
| Track | Направление обучения (Go, Docker, DDD). Содержит курсы |
| Course | Набор уроков по одной теме внутри трека |
| Lesson | Единица обучения: markdown-контент + опциональный квиз |
| Quiz | Набор вопросов к уроку. Считается пройденным при Score >= 70% |
| CourseProgress | Состояние прохождения курса конкретным пользователем |
| LessonProgress | Статус одного урока внутри прогресса курса |
| Completed | Урок пройден: контент прочитан и квиз сдан (если есть) |
| CourseFinished | Все уроки курса в статусе Completed |
Структура пакетов
Проект организован по гексагональной архитектуре внутри 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;
}
}
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,
]);
}
}
Интеграция контекстов 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 два новых термина из своего проекта