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-лист | Платформа обучения, банк |
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.
Как понять, что ты работаешь с доменом
Задай себе три вопроса:
-
Это правило бизнеса или техническая деталь? «Ученик не может пропускать уроки» - домен. «Данные хранятся в PostgreSQL» - инфраструктура.
-
Если я заменю базу данных, правило останется? Если да - это домен.
-
Эксперт предметной области (преподаватель, менеджер) поймёт это правило? Если да - это домен.
Мини-задание
- Выпиши 10 ключевых слов домена твоего проекта (или BackendStart: Course, Lesson, Progress, Quiz...)
- Укажи 3 бизнес-правила, которые нельзя нарушать
- Найди в своём коде место, где бизнес-правило живёт в хэндлере или репозитории, и перенеси его в доменную структуру
- Определи: твой проект - это CRUD или DDD-кандидат? Почему?