Ubiquitous Language: язык домена в типах TypeScript
Концепция единого языка из DDD, код как документация, принципы именования (имена из домена, тип выражает ограничение, доменные перечисления, операции как бизнес-действия, ошибки как бизнес-нарушения), построение глоссария для Todo-домена, паттерны именования для Entity/VO/Events/Errors/Services, антипаттерны именования, эволюция языка, практика «от разговора к типам»
Что такое Ubiquitous Language
Ubiquitous Language (единый язык) — это концепция из Domain-Driven Design, которая гласит: разработчики и бизнес-эксперты должны говорить на одном языке. Этот язык должен пронизывать всё — разговоры, документацию, код, тесты.
Это не просто глоссарий терминов. Ubiquitous Language — это модель предметной области, выраженная словами. Когда бизнес-эксперт говорит «задача завершена», а в коде написано todo.setFlag(STATUS_DONE) — связь между доменом и кодом разорвана. Разработчик думает о «флагах» и «статусах», а бизнес-эксперт — о «завершении задачи». Они говорят о разном.
❌ Разорванный язык:
Бизнес-эксперт: Разработчик:
"Задача завершена" todo.setFlag(2)
"Задача имеет приоритет" todo.level = "H"
"Нельзя завершить архивную задачу" if (s !== 3) throw new Error()
"Добавить задачу в список" arr.push(obj)
✅ Единый язык:
Бизнес-эксперт: Код:
"Задача завершена" todo.complete()
"Задача имеет приоритет" todo.priority — Priority
"Нельзя завершить архивную задачу" InvalidStatusTransitionError
"Добавить задачу в список" todoList.addTodo(todo)
Почему Ubiquitous Language важен
1. Код как документация
Когда код использует язык домена, он становится читаемой документацией бизнес-правил. Новый разработчик, открывший код, видит не технические абстракции, а бизнес-концепции.
// ❌ Код требует знания реализации
const process = (item: Record<string, any>, flag: number): void => {
if (item.s === 1 && flag === 2) {
item.s = 2
item.ts = Date.now()
}
}
// ✅ Код читается как бизнес-спецификация
const completeTodo = (
todo: Todo
): Effect.Effect<Todo, InvalidStatusTransitionError> =>
todo.status === "Active"
? Effect.succeed(
new Todo({
...todo,
status: "Completed",
completedAt: new Date(),
})
)
: Effect.fail(
new InvalidStatusTransitionError({
from: todo.status,
to: "Completed",
})
)
2. Снижение когнитивной нагрузки
Когда язык кода совпадает с языком предметной области, разработчику не нужно переводить между ментальными моделями. Бизнес-эксперт говорит «архивировать задачу», и в коде написано archiveTodo. Никакого перевода.
3. Обнаружение ошибок в модели
Если в коде нет слова для бизнес-концепции — значит, модель неполная. Если в коде есть слово, которого нет в лексиконе бизнес-экспертов — значит, модель содержит лишнее.
// Бизнес-эксперт говорит "задача просрочена"
// Если в коде нет понятия "просрочена" — модель неполная!
// ❌ Нет явного понятия "просрочена"
const isLate = (todo: Todo, now: Date): boolean =>
todo.dueDate !== null && now > todo.dueDate
// ✅ Явное доменное понятие
const isOverdue = (todo: Todo, now: Date): boolean =>
todo.dueDate !== null && now > todo.dueDate && todo.status === "Active"
// "Overdue" — это доменный термин, а не технический
Ubiquitous Language в типах TypeScript
TypeScript с его системой типов — идеальный инструмент для выражения Ubiquitous Language. Типы не могут врать — если тип говорит, что приоритет бывает только Low, Medium, High, Critical, то невозможно присвоить что-то другое.
Принцип: «Имена из домена, не из технологий»
// ❌ Технические имена
type TodoDTO = {
id: string
flag: number // Что это? Статус? Тип?
level: string // Уровень чего?
ts: number // Timestamp? Чего?
}
// ✅ Доменные имена
type Todo = {
readonly id: TodoId
readonly status: TodoStatus // Ясно: статус задачи
readonly priority: Priority // Ясно: приоритет
readonly createdAt: Date // Ясно: когда создана
readonly completedAt: Date | null // Ясно: когда завершена
}
Принцип: «Тип выражает ограничение»
Вместо комментариев «должно быть от 1 до 255 символов» — используйте тип, который гарантирует это.
import { Schema } from "effect"
// ❌ Комментарий-контракт (может быть нарушен)
/** Title must be 1-255 characters, non-empty */
type TodoTitle = string
// ✅ Тип-контракт (невозможно нарушить)
const TodoTitle = Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1, {
message: () => "Заголовок задачи не может быть пустым"
}),
Schema.maxLength(255, {
message: () => "Заголовок задачи не может превышать 255 символов"
}),
Schema.brand("TodoTitle")
)
type TodoTitle = Schema.Schema.Type<typeof TodoTitle>
// Теперь TodoTitle — это БРЕНДИРОВАННЫЙ тип.
// Обычная строка "hello" не является TodoTitle.
// Нужно пройти валидацию Schema.decodeUnknown(TodoTitle).
Принцип: «Перечисления говорят на языке домена»
// ❌ Числовые коды
const STATUS_ACTIVE = 1
const STATUS_COMPLETED = 2
const STATUS_ARCHIVED = 3
// ❌ Аббревиатуры
type Status = "A" | "C" | "AR"
// ✅ Доменные термины
type TodoStatus = "Active" | "Completed" | "Archived"
// ✅ Сложные перечисления как Schema
const TodoStatus = Schema.Literal("Active", "Completed", "Archived")
// ✅ Приоритеты на языке домена
type Priority = "Low" | "Medium" | "High" | "Critical"
Принцип: «Операции именуются по бизнес-действию»
// ❌ Технические имена операций
const setStatus = (todo: Todo, status: string) => { /* ... */ }
const updateField = (todo: Todo, field: string, value: any) => { /* ... */ }
const setFlag = (todo: Todo, flag: number) => { /* ... */ }
// ✅ Бизнес-действия
const completeTodo: (todo: Todo) => Effect.Effect<Todo, InvalidStatusTransitionError>
const archiveTodo: (todo: Todo) => Effect.Effect<Todo, InvalidStatusTransitionError>
const changePriority: (todo: Todo, priority: Priority) => Effect.Effect<Todo>
const renameTodo: (todo: Todo, newTitle: TodoTitle) => Effect.Effect<Todo>
Принцип: «Ошибки описывают бизнес-нарушение»
// ❌ Технические ошибки
class Error400 extends Error { }
class InvalidInputError extends Error { }
class StateError extends Error { }
// ✅ Доменные ошибки на языке бизнеса
class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{
readonly todoId: string
}> {}
class InvalidStatusTransitionError extends Data.TaggedError(
"InvalidStatusTransitionError"
)<{
readonly from: TodoStatus
readonly to: TodoStatus
}> {
get message() {
return `Невозможно перевести задачу из "${this.from}" в "${this.to}"`
}
}
class DuplicateTitleError extends Data.TaggedError("DuplicateTitleError")<{
readonly title: string
}> {
get message() {
return `Задача с заголовком "${this.title}" уже существует`
}
}
Построение глоссария для Todo-домена
Прежде чем писать код, составьте глоссарий — словарь терминов домена. Это первый шаг к Ubiquitous Language.
Глоссарий Todo-приложения
| Доменный термин | Описание | Тип в TypeScript |
|---|---|---|
| Todo (Задача) | Единица работы с заголовком и приоритетом | Todo (Entity) |
| TodoList (Список задач) | Именованная коллекция задач | TodoList (Aggregate) |
| Title (Заголовок) | Название задачи (1-255 символов) | TodoTitle (Value Object) |
| Priority (Приоритет) | Важность задачи: Low/Medium/High/Critical | Priority (Value Object) |
| Status (Статус) | Текущее состояние: Active/Completed/Archived | TodoStatus (Value Object) |
| DueDate (Срок) | Дата, до которой нужно завершить | DueDate (Value Object) |
| Complete (Завершить) | Перевод активной задачи в завершённую | complete() (Поведение) |
| Archive (Архивировать) | Перевод задачи в архив | archive() (Поведение) |
| Overdue (Просрочена) | Задача, срок которой истёк | isOverdue() (Запрос) |
| Owner (Владелец) | Пользователь, создавший задачу | UserId (Value Object) |
От глоссария к типам
// Каждый термин из глоссария → тип или функция в коде
// Сущности
class Todo extends Schema.Class<Todo>("Todo")({ /* ... */ }) {}
class TodoList extends Schema.Class<TodoList>("TodoList")({ /* ... */ }) {}
// Объекты-значения
const TodoTitle = Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1),
Schema.maxLength(255),
Schema.brand("TodoTitle")
)
const Priority = Schema.Literal("Low", "Medium", "High", "Critical")
const TodoStatus = Schema.Literal("Active", "Completed", "Archived")
// Операции (из глоссария: "Завершить", "Архивировать")
const complete: (todo: Todo) => Effect.Effect<Todo, InvalidStatusTransitionError>
const archive: (todo: Todo) => Effect.Effect<Todo, InvalidStatusTransitionError>
// Запросы (из глоссария: "Просрочена")
const isOverdue: (todo: Todo, now: Date) => boolean
// Доменные события (факты: "Задача создана", "Задача завершена")
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", { /* ... */ }) {}
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()("TodoCompleted", { /* ... */ }) {}
Язык в коде: паттерны именования
Сущности: существительные
// Имя класса — существительное из домена
class Todo { }
class User { }
class TodoList { }
class Category { }
Value Objects: существительное или характеристика
class Priority { }
class EmailAddress { }
class Money { }
class DateRange { }
class TodoTitle { }
Методы Entity: глаголы из домена
class Todo {
complete() // "Завершить задачу"
archive() // "Архивировать задачу"
changePriority() // "Изменить приоритет"
rename() // "Переименовать задачу"
reopen() // "Переоткрыть задачу"
}
Domain Events: «существительное + причастие» (прошедшее время)
class TodoCreated { } // "Задача создана"
class TodoCompleted { } // "Задача завершена"
class TodoArchived { } // "Задача архивирована"
class TodoTitleChanged { } // "Заголовок задачи изменён"
class TodoPriorityUpdated { } // "Приоритет задачи обновлён"
Domain Errors: «что пошло не так»
class TodoNotFoundError { } // "Задача не найдена"
class InvalidStatusTransitionError { } // "Недопустимый переход статуса"
class DuplicateTitleError { } // "Дублирующийся заголовок"
class TodoListFullError { } // "Список задач заполнен"
Domain Services: «глагол + контекст»
const prioritizeTodos = () => { /* ... */ } // "Расставить приоритеты"
const checkTitleUniqueness = () => { /* ... */ } // "Проверить уникальность"
const calculateStats = () => { /* ... */ } // "Рассчитать статистику"
Антипаттерны именования
Антипаттерн 1: Технический жаргон вместо доменных терминов
// ❌ Технический жаргон
class TodoManager { } // "Manager" — не доменный термин
class TodoHelper { } // "Helper" — не доменный термин
class TodoUtils { } // "Utils" — не доменный термин
class TodoProcessor { } // "Processor" — не доменный термин
class TodoHandler { } // "Handler" — допустим в Application, не в Domain
// ✅ Доменные термины
class TodoList { } // Список задач — доменная концепция
class TodoPrioritizer { } // Приоритизатор — доменная операция
class TodoStatistics { } // Статистика — доменная концепция
Антипаттерн 2: Суффиксы из инфраструктуры
// ❌ Инфраструктурные суффиксы в домене
class TodoDTO { } // DTO — инфраструктурный термин
class TodoModel { } // Model — неоднозначный термин
class TodoEntity { } // Entity-суффикс избыточен
class TodoBean { } // Bean — Java-термин
// ✅ Просто доменное имя
class Todo { } // Достаточно
class Priority { } // Достаточно
class TodoStatus { } // Достаточно
Антипаттерн 3: Аббревиатуры и сокращения
// ❌ Аббревиатуры
type Prio = "L" | "M" | "H" | "C"
const cmpltTodo = (t: Todo) => { /* ... */ }
class Usr { }
class Cat { } // Category? Cat (животное)?
// ✅ Полные доменные имена
type Priority = "Low" | "Medium" | "High" | "Critical"
const completeTodo = (todo: Todo) => { /* ... */ }
class User { }
class Category { }
Антипаттерн 4: Generic CRUD-именование
// ❌ CRUD-именование (не отражает бизнес-смысл)
const create = (data: any) => { /* ... */ }
const update = (id: string, data: any) => { /* ... */ }
const delete_ = (id: string) => { /* ... */ }
// ✅ Бизнес-именование
const createTodo = (title: TodoTitle, priority: Priority) => { /* ... */ }
const completeTodo = (todo: Todo) => { /* ... */ }
const archiveTodo = (todo: Todo) => { /* ... */ }
const renameTodo = (todo: Todo, newTitle: TodoTitle) => { /* ... */ }
Эволюция языка
Ubiquitous Language не статичен — он развивается вместе с пониманием домена. Когда бизнес-эксперт вводит новый термин или уточняет существующий, код должен быть обновлён.
// Итерация 1: Бизнес говорит "задача может быть срочной"
type Priority = "Normal" | "Urgent"
// Итерация 2: Бизнес уточняет — "есть 4 уровня приоритета"
type Priority = "Low" | "Medium" | "High" | "Critical"
// Итерация 3: Бизнес добавляет — "задача может быть отложена"
type TodoStatus = "Active" | "Completed" | "Archived" | "Deferred"
// ^^^^^^^^
// Новый термин из домена!
При изменении языка обновляйте:
- Глоссарий — добавьте/обновите термин
- Типы — отразите изменение в TypeScript
- Тесты — обновите описания тест-кейсов
- Документацию — обновите схемы и описания
Практическое упражнение: от разговора к типам
Представьте диалог с бизнес-экспертом:
«У нас есть задачи. Каждая задача имеет заголовок и приоритет — низкий, средний, высокий или критический. Задачу можно завершить или архивировать. Завершённую задачу нельзя завершить повторно. У задачи может быть срок — если срок истёк, а задача ещё активна, она считается просроченной. Задачи объединяются в списки, и в одном списке не может быть двух задач с одинаковым заголовком.»
Из этого абзаца извлекаем Ubiquitous Language:
// Существительные → Типы
class Todo { } // "задача"
class TodoList { } // "список задач"
type TodoTitle = /* */ // "заголовок"
type Priority = /* */ // "приоритет"
type DueDate = /* */ // "срок"
// Прилагательные → Значения перечислений
type Priority = "Low" | "Medium" | "High" | "Critical"
// "низкий", "средний", "высокий", "критический"
type TodoStatus = "Active" | "Completed" | "Archived"
// (неявно из контекста: "активная", "завершённая", "архивная")
// Глаголы → Методы/Функции
const complete: (todo: Todo) => Effect.Effect<...> // "завершить"
const archive: (todo: Todo) => Effect.Effect<...> // "архивировать"
// Состояния → Предикаты
const isOverdue: (todo: Todo, now: Date) => boolean // "просроченная"
// Правила → Ошибки
class InvalidStatusTransitionError { } // "нельзя завершить повторно"
class DuplicateTitleError { } // "не может быть двух с одинаковым заголовком"
Каждое слово бизнес-эксперта нашло отражение в коде. Нет потери информации, нет «перевода» между языками.
Резюме
Ubiquitous Language — это мост между бизнесом и кодом:
- Имена типов = существительные из домена (Todo, Priority, TodoList)
- Методы = глаголы из домена (complete, archive, rename)
- Значения = термины из домена (Active, Completed, High, Critical)
- Ошибки = бизнес-нарушения (TodoNotFound, InvalidTransition)
- События = факты из домена (TodoCreated, TodoCompleted)
TypeScript + Effect-ts позволяют выразить Ubiquitous Language через типы, которые невозможно нарушить. Это превращает код из технической реализации в живую документацию бизнес-правил.
В следующей главе мы применим все эти знания на практике — создадим первую доменную модель Todo-приложения с Entity, Value Objects, Events и Errors.