Типобезопасный домен: Гексагональная архитектура на базе Effect Ubiquitous Language: язык домена в типах TypeScript
Глава

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/CriticalPriority (Value Object)
Status (Статус)Текущее состояние: Active/Completed/ArchivedTodoStatus (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"
//                                                       ^^^^^^^^
//                                              Новый термин из домена!

При изменении языка обновляйте:

  1. Глоссарий — добавьте/обновите термин
  2. Типы — отразите изменение в TypeScript
  3. Тесты — обновите описания тест-кейсов
  4. Документацию — обновите схемы и описания

Практическое упражнение: от разговора к типам

Представьте диалог с бизнес-экспертом:

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

Из этого абзаца извлекаем 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.