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.

Формулу «домен не зависит ни от чего» повторяют так часто, что она начинает читаться буквально - и тогда появляются свои `Decimal` вместо библиотечного, свои строки-обёртки и запрет на любые импорты, кроме стандартной библиотеки. Это не DDD, а его карикатура.

Домен зависит и обязан зависеть - от языка, стандартной библиотеки, типов вроде дат, денег и UUID. Правило про другое:

Домен не зависит от способов доставки и хранения. Не от HTTP, не от драйвера базы, не от брокера, не от ORM-аннотаций - потому что это детали, которые меняются по причинам, не имеющим отношения к бизнесу.

Проверять стоит не по списку импортов, а вопросом: если мы переедем с PostgreSQL на что-то другое или добавим gRPC рядом с REST, придётся ли править домен? Если нет - слой отделён достаточно, сколько бы библиотек в нём ни было.

Практические ориентиры: time.Time и decimal в домене - нормально; gorm.Model и структурные теги json:"..." - нет, это форма хранения и передачи. Между ними серая зона (например, uuid.UUID из внешнего пакета), и решается она тем же вопросом про переезд, а не принципом.

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

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

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

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

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

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

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

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

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

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

Но перед тем как собирать правила, приходится договориться о словах: пока «завершил трек» для менеджера значит одно, а для разработчика другое, любая формулировка правила будет спорной. С этого и начинается работа - урок Ubiquitous Language про то, как составить словарь и не дать ему разъехаться с кодом.

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 - из чего собирается модель
  • Aggregate - кто отвечает за целостность
  • Domain Service и Repository - где живёт логика, не влезшая в один объект, и как объекты сохраняются

В этом треке мы фокусируемся на 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;

use DateTimeImmutable;

// 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-кандидат? Почему?

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