Ubiquitous Language: общий словарь команды
Ubiquitous Language: общий словарь команды
Каждый в команде называет одно и то же по-своему - и это медленно убивает проект. Поговорим о словаре, без которого DDD просто не работает.
Со стороны DDD выглядит как закрытый клуб бородатых архитекторов. Aggregate. Bounded Context. Ubiquitous Language. Звучит как заклинания, поэтому многие и не суются.
А идея под всем этим до смешного простая: код должен говорить на языке бизнеса, а не базы данных.
Вот так писать не надо: UpdateStatus(order, 3). Что такое 3? Отменён? Оплачен? Возвращён? Лезь в справочник, ищи.
А надо так: order.Cancel(). Читаешь код и понимаешь, что происходит в бизнесе. Не «строка в таблице со статусом 3», а «заказ отменили».
Начать можно с малого - с честных имён. Весь этот урок про то, как их выбирать.
Проблема: «А что ты имеешь в виду?»
Представь: дизайнер говорит «модуль», менеджер - «раздел», разработчик пишет Section, в базе лежит module_group, в API возвращается unit. Все про одно и то же. Но каждый раз, когда кто-то говорит «модуль», команда тратит 5 минут на уточнение: «Ты про раздел в курсе или про npm-модуль?»
Это не мелочь. Это системный источник багов. Вот реальный сценарий:
Менеджер: «Пользователь завершил курс, но ему не показался сертификат.»
Разработчик: «У него completed = true?»
Менеджер: «Да, все уроки пройдены.»
Разработчик: «А квизы?»
Менеджер: «Квизы? Я думал, "завершил" - это прошёл все уроки.»
Разработчик: «В коде completed = все уроки И все квизы.»
Баг не в коде. Баг в словах. У менеджера и разработчика разное понимание термина «завершил».
Что такое Ubiquitous Language
Ubiquitous Language (единый язык) - это набор терминов, которые:
- Одинаково понимают все - бизнес, разработчики, тестировщики, дизайнеры.
- Используются везде - в коде, API, документации, разговорах.
- Определены явно - у каждого термина есть чёткое определение.
Пример: BackendStart
Вот как мы определяем термины на платформе BackendStart:
| Термин | Определение | НЕ путать с |
|---|---|---|
| Course | Учебный трек (Go, Docker, DDD) | module, program |
| Lesson | Один урок внутри курса | lecture, topic, article |
| Quiz | Тест после урока (7-8 вопросов) | exam, test, assignment |
| Progress | Состояние прохождения курса учеником | stats, analytics |
| Completed | Все уроки И все квизы курса пройдены | finished, done |
| Slug | URL-friendly идентификатор (ddd-lite) | id, code, key |
Обрати внимание: Completed имеет точное определение. Не «примерно все пройдено», а конкретное правило. Это убивает класс багов, описанный в начале.
Naming-конфликты: реальные примеры
Пример 1: Lesson vs Lecture vs Topic
// ПЛОХО: три разных имени для одного понятия
type Lecture struct { ... } // в пакете api
type LessonItem struct { ... } // в пакете db
type Topic struct { ... } // в пакете domain
// в каждом месте - маппинг
func toLecture(t Topic) Lecture { ... }
func fromLessonItem(li LessonItem) Topic { ... }
// ПЛОХО: три разных имени для одного понятия
namespace App\Api;
final class Lecture { /* ... */ }
namespace App\Db;
final class LessonItem { /* ... */ }
namespace App\Domain;
final class Topic { /* ... */ }
// в каждом месте - маппинг
function toLecture(Topic $t): Lecture { /* ... */ }
function fromLessonItem(LessonItem $li): Topic { /* ... */ }
Каждый маппинг - это место, где можно ошибиться. А ещё - когнитивная нагрузка: «LessonItem - это Topic или Lecture?»
// ХОРОШО: одно имя - Lesson - везде
type Lesson struct { ... } // domain
// в API: Lesson
// в базе: таблица lessons
// в UI: «Урок»
// ХОРОШО: одно имя - Lesson - везде
namespace App\Learning\Domain;
final class Lesson { /* ... */ }
// в API: Lesson
// в базе: таблица lessons
// в UI: «Урок»
Пример 2: User vs Student vs Learner
// ПЛОХО: кто есть кто?
type User struct { ... } // аутентификация
type Student struct { ... } // обучение
type Learner struct { ... } // аналитика
// один и тот же человек - три структуры
<?php
declare(strict_types=1);
// ПЛОХО: кто есть кто?
namespace App\Auth;
final class User { /* ... */ } // аутентификация
namespace App\Learning;
final class Student { /* ... */ } // обучение
namespace App\Analytics;
final class Learner { /* ... */ } // аналитика
// один и тот же человек - три класса
Решение: определить границы. User - в контексте аутентификации. Student - если мы решили, что в контексте обучения ученик называется Student. И зафиксировать это в глоссарии.
Глоссарий: как создать и поддерживать
Глоссарий - это файл docs/glossary.md с определениями всех терминов домена.
# Глоссарий BackendStart
## Course (Курс)
Учебный трек, объединяющий уроки по одной теме.
Примеры: Go Basics, Docker, DDD Lite.
В коде: `domain.Course`. В базе: таблица `courses`.
## Lesson (Урок)
Один учебный материал внутри курса. Содержит текст в Markdown.
Порядок уроков определяется полем `order`.
В коде: `domain.Lesson`. В базе: таблица `lessons`.
## Quiz (Квиз)
Тест после урока. Содержит 7-8 вопросов с вариантами ответов.
Квиз считается пройденным при >= 70% правильных ответов.
В коде: `domain.Quiz`. В базе: таблица `quizzes`.
## Progress (Прогресс)
Состояние прохождения курса конкретным учеником.
Включает: пройденные уроки, пройденные квизы, дату начала.
Completed = все уроки И все квизы пройдены.
## Slug
URL-friendly идентификатор. Формат: строчные буквы, дефис вместо пробелов.
Примеры: `ddd-lite`, `go-basics`, `entity-vo`.
UL в коде: naming conventions
Единый язык влияет на всё: имена структур, пакетов, методов, переменных.
Структуры - существительные из домена
// ПЛОХО: технические имена
type CourseDTO struct { ... }
type LessonModel struct { ... }
type ProgressEntity struct { ... }
// ХОРОШО: доменные имена (суффиксы не нужны в domain-пакете)
package domain
type Course struct { ... }
type Lesson struct { ... }
type Progress struct { ... }
<?php
declare(strict_types=1);
// ПЛОХО: технические имена
namespace App\Learning\Dto;
final class CourseDto { /* ... */ }
namespace App\Learning\Model;
final class LessonModel { /* ... */ }
namespace App\Learning\Entity;
final class ProgressEntity { /* ... */ }
// ХОРОШО: доменные имена (суффиксы не нужны в Domain-namespace)
namespace App\Learning\Domain;
final class Course { /* ... */ }
final class Lesson { /* ... */ }
final class Progress { /* ... */ }
Методы - глаголы из домена
// ПЛОХО: технические глаголы
func (p *Progress) SetCompleted() { ... }
func (p *Progress) UpdateLessonStatus(id string, done bool) { ... }
// ХОРОШО: бизнес-глаголы
func (p *Progress) CompleteLesson(lessonID string) error { ... }
func (p *Progress) PassQuiz(quizID string, score int) error { ... }
func (p *Progress) IsCompleted(totalLessons int) bool { ... }
final class Progress
{
// ПЛОХО: технические глаголы
public function setCompleted(): void { /* ... */ }
public function updateLessonStatus(string $id, bool $done): void { /* ... */ }
// ХОРОШО: бизнес-глаголы
public function completeLesson(string $lessonId): void { /* ... */ }
public function passQuiz(string $quizId, int $score): void { /* ... */ }
public function isCompleted(int $totalLessons): bool { /* ... */ }
}
CompleteLesson - это то, что скажет менеджер. UpdateLessonStatus - это то, что скажет разработчик, думающий о базе. Ровно та же разница, что между UpdateStatus(order, 3) и order.Cancel().
Пакеты - контексты домена
// ПЛОХО: технические пакеты
package models
package helpers
package utils
package managers
// ХОРОШО: доменные пакеты
package course // всё про курсы
package progress // всё про прогресс
package quiz // всё про квизы
package auth // всё про аутентификацию
<?php
declare(strict_types=1);
// ПЛОХО: технические namespaces
namespace App\Models;
namespace App\Helpers;
namespace App\Utils;
namespace App\Managers;
// ХОРОШО: доменные namespaces (Symfony bundle-структура)
namespace App\Course; // всё про курсы
namespace App\Progress; // всё про прогресс
namespace App\Quiz; // всё про квизы
namespace App\Auth; // всё про аутентификацию
Рефакторинг: от плохих имён к UL
Вот полный пример рефакторинга Go-кода с учётом единого языка. По сути это тот же переход от «строки в таблице» к бизнес-действию, только на масштабе целого пакета:
// БЫЛО: имена не из домена
package handlers
type CourseController struct {
db *gorm.DB
}
func (c *CourseController) HandleGetModules(w http.ResponseWriter, r *http.Request) {
var items []ModuleRecord
c.db.Where("program_id = ?", r.URL.Query().Get("program_id")).Find(&items)
var result []ModuleResponse
for _, item := range items {
result = append(result, ModuleResponse{
Code: item.Code,
Label: item.Label,
NumUnits: item.UnitCount,
})
}
json.NewEncoder(w).Encode(result)
}
// БЫЛО: имена не из домена
namespace App\Controllers;
final class CourseController
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function handleGetModules(Request $request): JsonResponse
{
$programId = $request->query->get('program_id');
$items = $this->em->getRepository(ModuleRecord::class)
->findBy(['programId' => $programId]);
$result = [];
foreach ($items as $item) {
$result[] = [
'code' => $item->code,
'label' => $item->label,
'numUnits' => $item->unitCount,
];
}
return new JsonResponse($result);
}
}
// СТАЛО: имена из единого языка
package course
// В domain-пакете
type Course struct {
ID string
Slug string
Title string
TotalLessons int
}
// В application-пакете
type ListCoursesUseCase struct {
courses CourseRepository
}
func (uc *ListCoursesUseCase) Execute(ctx context.Context) ([]Course, error) {
return uc.courses.FindAll(ctx)
}
// В handler-пакете
func (h *Handler) ListCourses(w http.ResponseWriter, r *http.Request) {
courses, err := h.listCourses.Execute(r.Context())
if err != nil {
http.Error(w, "internal error", 500)
return
}
json.NewEncoder(w).Encode(courses)
}
<?php
declare(strict_types=1);
// СТАЛО: имена из единого языка
namespace App\Learning\Domain;
final class Course
{
public function __construct(
public readonly string $id,
public readonly string $slug,
public readonly string $title,
public readonly int $totalLessons,
) {}
}
namespace App\Learning\Application;
final class ListCoursesUseCase
{
public function __construct(
private readonly CourseRepository $courses,
) {}
/** @return Course[] */
public function execute(): array
{
return $this->courses->findAll();
}
}
namespace App\Learning\Http;
final class CourseController
{
public function __construct(
private readonly ListCoursesUseCase $listCourses,
) {}
public function listCourses(): JsonResponse
{
return new JsonResponse($this->listCourses->execute());
}
}
Что изменилось:
Module->Course(термин из глоссария)Program-> убрано (это был лишний уровень)Unit->LessonCode->SlugLabel->TitleController->Handler(стандарт Go)
Анти-паттерны единого языка
1. Транслитерация
// ПЛОХО: русские слова в латинице
type Urok struct { ... }
type Kurs struct { ... }
func (p *Progress) ZavershitUrok(id string) { ... }
// ХОРОШО: английские термины из глоссария
type Lesson struct { ... }
type Course struct { ... }
func (p *Progress) CompleteLesson(id string) error { ... }
<?php
declare(strict_types=1);
// ПЛОХО: русские слова в латинице
final class Urok { /* ... */ }
final class Kurs { /* ... */ }
final class Progress
{
public function zavershitUrok(string $id): void { /* ... */ }
}
// ХОРОШО: английские термины из глоссария
final class Lesson { /* ... */ }
final class Course { /* ... */ }
final class Progress
{
public function completeLesson(string $id): void { /* ... */ }
}
2. Аббревиатуры без контекста
// ПЛОХО: что такое LP? CP? UPR?
type LP struct { ... }
func GetCP(uid string) *UPR { ... }
// ХОРОШО: полные имена
type LessonProgress struct { ... }
func GetCourseProgress(userID string) (*UserProgress, error) { ... }
<?php
declare(strict_types=1);
// ПЛОХО: что такое LP? CP? UPR?
final class LP { /* ... */ }
function getCp(string $uid): UPR { /* ... */ }
// ХОРОШО: полные имена
final class LessonProgress { /* ... */ }
function getCourseProgress(string $userId): UserProgress { /* ... */ }
3. Смешение языков в одном идентификаторе
// ПЛОХО: микс русского и английского
func PoluchitSpisakUrokov() { ... } // транслитерация + английский
func GetУроки() { ... } // английский + кириллица
// ХОРОШО: один язык
func ListLessons() { ... }
<?php
declare(strict_types=1);
// ПЛОХО: микс русского и английского
function poluchitSpisakUrokov(): array { /* ... */ } // транслитерация + английский
function getУроки(): array { /* ... */ } // английский + кириллица
// ХОРОШО: один язык
function listLessons(): array { /* ... */ }
Как синхронизировать UL в команде
- Глоссарий в репозитории -
docs/glossary.md, ревьюится как код. - Code review - проверяй не только логику, но и именование. «Ты назвал это
module, у нас этоcourse.» - PR-шаблон - добавь чекбокс: «Новые термины добавлены в глоссарий».
- Onboarding - первое, что читает новый разработчик - глоссарий.
- Рефакторинг при расхождении - если обнаружил несоответствие, исправь сразу, не копи техдолг.
<!-- .gitlab/merge_request_templates/default.md -->
## Checklist
- [ ] Код соответствует единому языку (glossary.md)
- [ ] Новые термины добавлены в глоссарий
- [ ] Нет транслитерации и аббревиатур без контекста
Мини-задание
- Составь глоссарий из 10 терминов для своего проекта (или BackendStart): термин + определение в 1 строку
- Найди в коде 3 места, где одно и то же понятие названо по-разному, и унифицируй
- Добавь в PR-шаблон чекбокс проверки единого языка
- Проверь свой код на транслитерацию и аббревиатуры - замени на термины из глоссария
- Покажи глоссарий коллеге (или ментору) и обсуди: все ли термины понятны?