DDD Lite: что такое домен и почему это про слова

DDD Lite: что такое домен и почему это про слова

DDD звучит сложно, но на практике это про слова и границы - а не про сертификаты и тяжёлые UML-диаграммы. Разберёмся на живых примерах, без академизма. DDD естественно сочетается с Hexagonal Architecture и CQRS.

Проблема: «UserService из ада»

Представь типичный Go-проект через полгода разработки. Есть UserService на 800 строк. Он умеет всё: регистрирует, отправляет письма, считает статистику, генерирует отчёты. Бизнес просит добавить «пробный период» - и ты не знаешь, куда это засунуть, потому что всё уже переплетено.

// Так выглядит сервис, в который годами складывали всё подряд
type UserService struct {
    db       *sql.DB
    mailer   *smtp.Client
    redis    *redis.Client
    stripe   *stripe.API
}

func (s *UserService) Register(ctx context.Context, name, email, password string) error {
    // валидация email - тут
    if !strings.Contains(email, "@") {
        return errors.New("invalid email")
    }
    // хэширование пароля - тут
    hash, _ := bcrypt.GenerateFromPassword([]byte(password), 12)
    // запись в базу - тут
    _, err := s.db.ExecContext(ctx,
        "INSERT INTO users (name, email, password) VALUES ($1,$2,$3)",
        name, email, hash,
    )
    if err != nil {
        return err
    }
    // отправка email - тут
    s.mailer.Send(email, "Добро пожаловать!")
    // кэш - тут
    s.redis.Incr(ctx, "stats:registrations")
    return nil
}
<?php
declare(strict_types=1);

// Тот же «UserService из ада» - на PHP
final class UserService
{
    public function __construct(
        private readonly PDO $db,
        private readonly Mailer $mailer,
        private readonly Redis $redis,
        private readonly StripeClient $stripe,
    ) {}

    public function register(string $name, string $email, string $password): void
    {
        // валидация email - тут
        if (!str_contains($email, '@')) {
            throw new InvalidArgumentException('invalid email');
        }
        // хэширование пароля - тут
        $hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
        // запись в базу - тут
        $stmt = $this->db->prepare(
            'INSERT INTO users (name, email, password) VALUES (?, ?, ?)'
        );
        $stmt->execute([$name, $email, $hash]);
        // отправка email - тут
        $this->mailer->send($email, 'Добро пожаловать!');
        // кэш - тут
        $this->redis->incr('stats:registrations');
    }
}

Проблемы этого кода:

  • Бизнес-правила размазаны - валидация email, правила регистрации, уведомления - всё в одном методе.
  • Невозможно тестировать без базы, Redis и SMTP.
  • Невозможно переиспользовать - правило «email должен быть валидным» дублируется в 5 местах.

DDD решает именно эту проблему - выделяет бизнес-правила в отдельный слой, который не зависит ни от базы, ни от фреймворка, ни от HTTP.

Что такое домен

Домен (предметная область) - это набор понятий и правил, которые описывают реальный бизнес-процесс.

Примеры доменов:

БизнесДоменКлючевые понятия
Образовательная платформаОбучениеКурс, Урок, Прогресс, Квиз
БанкФинансыСчёт, Перевод, Лимит, Комиссия
ДоставкаЛогистикаЗаказ, Курьер, Маршрут, Зона
Интернет-магазинТорговляТовар, Корзина, Скидка, Заказ

В BackendStart домен - это обучение программированию. Ключевые понятия: Course (трек/курс), Lesson (урок), Quiz (квиз), Progress (прогресс ученика), User (ученик).

Не «какой фреймворк выбрать?», а: - Какие понятия важны для бизнеса? - Какие правила нельзя нарушать? - Где границы ответственности между частями системы?

Почему DDD важен для сложной логики

DDD работает, когда бизнес-логика нетривиальна. Вот реальные правила из BackendStart:

  • Ученик не может перейти к следующему уроку, пока не пройдёт квиз текущего.
  • Прогресс считается завершённым, только если пройдены все уроки трека.
  • Квиз с множественным выбором должен содержать минимум 2 правильных ответа.

Если эти правила разбросаны по хэндлерам, сервисам и репозиториям - при изменении одного правила ты будешь искать все места, где оно применяется. DDD собирает правила в одном месте - в домене.

DDD vs CRUD: когда DDD - overkill

DDD не нужен везде. Вот простой критерий:

Если твоя «бизнес-логика» - это SELECT/INSERT/UPDATE/DELETE
с минимальной валидацией - CRUD достаточно.

Если у тебя есть ПРАВИЛА, СОСТОЯНИЯ, ПЕРЕХОДЫ -
DDD начинает окупаться.
КритерийCRUD достаточноDDD оправдан
Бизнес-правила1-2 простых проверкиМного, они меняются
СостоянияНет или одноНесколько с переходами
Зависимости между сущностямиМинимальныеСложные
Команда1-2 человека3+ разработчиков
ПримерБлог, TODO-листПлатформа обучения, банк
Если вся логика сводится к «сохрани запись в базу» - Domain Layer только добавит ненужные абстракции. Начинай с CRUD и переходи к DDD, когда логика усложнится.

Strategic vs Tactical DDD

DDD делится на две части:

Strategic DDD - это про «что строить»:

  • Bounded Context - границы контекста (у нас: обучение, авторизация, контент - это разные контексты)
  • Ubiquitous Language - общий язык (следующий урок)
  • Context Map - как контексты связаны

Tactical DDD - это про «как строить»:

  • Entity, Value Object (урок 3)
  • Aggregate (урок 4)
  • Repository, Domain Service (уроки 5-6)

В этом треке мы фокусируемся на Tactical DDD - конкретных паттернах, которые ты применишь в Go-коде.

Домен BackendStart в коде

Вот как выглядит доменная модель BackendStart - чистая, без зависимостей от базы или HTTP:

package domain

import "time"

// Course - учебный трек (Go, Docker, DDD и т.д.)
type Course struct {
    ID             string
    Slug           string
    Title          string
    Description    string
    TotalLessons   int
    EstimatedHours int
    Difficulty     Difficulty
    Order          int
}

// Lesson - отдельный урок внутри курса
type Lesson struct {
    ID        string
    CourseID  string
    Slug      string
    Title     string
    Order     int
    Content   string
}

// Progress - прогресс ученика по конкретному курсу
type Progress struct {
    UserID       string
    CourseID     string
    LessonsDone  []string
    QuizzesDone  []string
    StartedAt    time.Time
    CompletedAt  *time.Time
}

// IsCompleted проверяет, завершён ли курс
func (p Progress) IsCompleted(totalLessons int) bool {
    return len(p.LessonsDone) >= totalLessons
}

// CanAccessLesson проверяет, доступен ли урок ученику
func (p Progress) CanAccessLesson(lessonOrder int) bool {
    // первый урок всегда доступен
    if lessonOrder <= 1 {
        return true
    }
    // остальные - только если предыдущие пройдены
    return len(p.LessonsDone) >= lessonOrder-1
}
<?php
declare(strict_types=1);

namespace App\Learning\Domain;

// Course - учебный трек (Go, Docker, DDD и т.д.)
final class Course
{
    public function __construct(
        public readonly string $id,
        public readonly string $slug,
        public readonly string $title,
        public readonly string $description,
        public readonly int $totalLessons,
        public readonly int $estimatedHours,
        public readonly Difficulty $difficulty,
        public readonly int $order,
    ) {}
}

// Lesson - отдельный урок внутри курса
final class Lesson
{
    public function __construct(
        public readonly string $id,
        public readonly string $courseId,
        public readonly string $slug,
        public readonly string $title,
        public readonly int $order,
        public readonly string $content,
    ) {}
}

// Progress - прогресс ученика по конкретному курсу
final class Progress
{
    /**
     * @param string[] $lessonsDone
     * @param string[] $quizzesDone
     */
    public function __construct(
        public readonly string $userId,
        public readonly string $courseId,
        public readonly array $lessonsDone,
        public readonly array $quizzesDone,
        public readonly \DateTimeImmutable $startedAt,
        public readonly ?\DateTimeImmutable $completedAt = null,
    ) {}

    // IsCompleted проверяет, завершён ли курс
    public function isCompleted(int $totalLessons): bool
    {
        return count($this->lessonsDone) >= $totalLessons;
    }

    // CanAccessLesson проверяет, доступен ли урок ученику
    public function canAccessLesson(int $lessonOrder): bool
    {
        // первый урок всегда доступен
        if ($lessonOrder <= 1) {
            return true;
        }
        // остальные - только если предыдущие пройдены
        return count($this->lessonsDone) >= $lessonOrder - 1;
    }
}

Обрати внимание:

  • Нет *sql.DB - домен не знает про базу.
  • Нет http.Request - домен не знает про HTTP.
  • Правила внутри - CanAccessLesson и IsCompleted живут рядом с данными.

Плохо vs Хорошо: где должны жить правила

// ПЛОХО: правило в хэндлере
func (h *Handler) GetLesson(w http.ResponseWriter, r *http.Request) {
    progress := h.repo.GetProgress(userID, courseID)
    // бизнес-правило разбросано по хэндлеру
    if len(progress.LessonsDone) < lessonOrder-1 {
        http.Error(w, "урок недоступен", 403)
        return
    }
    // ...
}
// ПЛОХО: правило в контроллере
final class LessonController
{
    public function getLesson(Request $request): Response
    {
        $progress = $this->repo->getProgress($userId, $courseId);
        // бизнес-правило разбросано по контроллеру
        if (count($progress->lessonsDone) < $lessonOrder - 1) {
            return new Response('урок недоступен', 403);
        }
        // ...
    }
}
// ХОРОШО: правило в домене
func (h *Handler) GetLesson(w http.ResponseWriter, r *http.Request) {
    progress := h.repo.GetProgress(userID, courseID)
    if !progress.CanAccessLesson(lessonOrder) {
        http.Error(w, "урок недоступен", 403)
        return
    }
    // ...
}
// ХОРОШО: правило в домене
final class LessonController
{
    public function getLesson(Request $request): Response
    {
        $progress = $this->repo->getProgress($userId, $courseId);
        if (!$progress->canAccessLesson($lessonOrder)) {
            return new Response('урок недоступен', 403);
        }
        // ...
    }
}

Разница - одна строка. Но эффект огромный:

  • Правило тестируется unit-тестом без HTTP.
  • Правило переиспользуется в API, CLI, cron-задачах.
  • Правило читается - CanAccessLesson понятнее, чем len(progress.LessonsDone) < lessonOrder-1.
1. **Domain** - сущности, Value Objects, правила. Нет внешних зависимостей. 2. **Application** - use cases (сценарии). Оркестрирует домен. 3. **Infrastructure** - база, HTTP, внешние API. Зависит от домена, не наоборот.

Три слоя DDD Lite: Domain в центре, Application вокруг, Infrastructure снаружи

Как понять, что ты работаешь с доменом

Задай себе три вопроса:

  1. Это правило бизнеса или техническая деталь? «Ученик не может пропускать уроки» - домен. «Данные хранятся в PostgreSQL» - инфраструктура.

  2. Если я заменю базу данных, правило останется? Если да - это домен.

  3. Эксперт предметной области (преподаватель, менеджер) поймёт это правило? Если да - это домен.

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

  • Выпиши 10 ключевых слов домена твоего проекта (или BackendStart: Course, Lesson, Progress, Quiz...)
  • Укажи 3 бизнес-правила, которые нельзя нарушать
  • Найди в своём коде место, где бизнес-правило живёт в хэндлере или репозитории, и перенеси его в доменную структуру
  • Определи: твой проект - это CRUD или DDD-кандидат? Почему?

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