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 (единый язык) - это набор терминов, которые:

  1. Одинаково понимают все - бизнес, разработчики, тестировщики, дизайнеры.
  2. Используются везде - в коде, API, документации, разговорах.
  3. Определены явно - у каждого термина есть чёткое определение.
Слово «ubiquitous» означает «встречающийся повсюду». Единый язык должен пронизывать всё: код, документацию, разговоры, тикеты, UI. Если в Jira написано «задание», в коде `Task`, а в UI «упражнение» - язык не единый.

Один доменный термин Lesson пронизывает код, базу, UI и 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
SlugURL-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`.
Глоссарий не пишется один раз и не забрасывается. Обновляй его при каждом добавлении нового понятия. Лучшее место для обсуждения - code review: «Ты назвал это `Module`. В глоссарии это `Course`. Поправь или обоснуй новый термин.»

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 -> Lesson
  • Code -> Slug
  • Label -> Title
  • Controller -> 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 в команде

  1. Глоссарий в репозитории - docs/glossary.md, ревьюится как код.
  2. Code review - проверяй не только логику, но и именование. «Ты назвал это module, у нас это course
  3. PR-шаблон - добавь чекбокс: «Новые термины добавлены в глоссарий».
  4. Onboarding - первое, что читает новый разработчик - глоссарий.
  5. Рефакторинг при расхождении - если обнаружил несоответствие, исправь сразу, не копи техдолг.
<!-- .gitlab/merge_request_templates/default.md -->
## Checklist
- [ ] Код соответствует единому языку (glossary.md)
- [ ] Новые термины добавлены в глоссарий
- [ ] Нет транслитерации и аббревиатур без контекста

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

  • Составь глоссарий из 10 терминов для своего проекта (или BackendStart): термин + определение в 1 строку
  • Найди в коде 3 места, где одно и то же понятие названо по-разному, и унифицируй
  • Добавь в PR-шаблон чекбокс проверки единого языка
  • Проверь свой код на транслитерацию и аббревиатуры - замени на термины из глоссария
  • Покажи глоссарий коллеге (или ментору) и обсуди: все ли термины понятны?

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