Типобезопасный домен: Гексагональная архитектура на базе Effect Типы домена: Entity, Value Object, Aggregate, Event
Глава

Типы домена: Entity, Value Object, Aggregate, Event

Подробный разбор каждого типа доменных объектов: Entity (идентичность, жизненный цикл, изменяемое состояние), Value Object (равенство по значению, неизменяемость, самовалидация), Aggregate (граница согласованности, корень, единица персистентности), Domain Event (прошедшее время, неизменяемость, данные), Domain Service (межагрегатная логика), Domain Error (бизнес-нарушения). Матрица выбора типа, сравнительная таблица Entity vs Value Object

Введение: классификация доменных объектов

Доменная модель состоит из нескольких фундаментальных категорий объектов, каждая из которых имеет свою семантику, правила равенства и жизненный цикл. Эта классификация пришла из Domain-Driven Design (DDD) Эрика Эванса и была развита Воном Верноном.

Понимание различий между этими типами — критически важный навык. Ошибка в классификации приводит к серьёзным архитектурным проблемам: неправильные границы транзакций, избыточная сложность, хрупкие тесты.

┌─────────────────────────────────────────────────────┐
│                   DOMAIN MODEL                       │
│                                                      │
│  ┌──────────────┐  ┌──────────────┐                  │
│  │   ENTITY     │  │ VALUE OBJECT │                  │
│  │              │  │              │                  │
│  │ • Identity   │  │ • No identity│                  │
│  │ • Lifecycle  │  │ • Immutable  │                  │
│  │ • Mutable    │  │ • Equality   │                  │
│  │   state      │  │   by value   │                  │
│  └──────┬───────┘  └──────────────┘                  │
│         │                                            │
│  ┌──────┴───────────────────────────┐                │
│  │          AGGREGATE               │                │
│  │                                  │                │
│  │ • Cluster of Entities + VOs      │                │
│  │ • Transactional boundary         │                │
│  │ • Single entry point (Root)      │                │
│  │ • Invariant protection           │                │
│  └──────────────┬───────────────────┘                │
│                 │                                    │
│  ┌──────────────┴───────────────────┐                │
│  │        DOMAIN EVENT              │                │
│  │                                  │                │
│  │ • Fact that happened             │                │
│  │ • Immutable, past tense          │                │
│  │ • Carries data about what        │                │
│  │   happened                       │                │
│  └──────────────────────────────────┘                │
│                                                      │
│  ┌──────────────────────────────────┐                │
│  │       DOMAIN SERVICE             │                │
│  │                                  │                │
│  │ • Logic spanning aggregates      │                │
│  │ • Stateless operations           │                │
│  │ • Named by domain action         │                │
│  └──────────────────────────────────┘                │
└─────────────────────────────────────────────────────┘

Entity (Сущность)

Определение

Entity — это доменный объект, который обладает уникальной идентичностью. Идентичность сохраняется на протяжении всего жизненного цикла объекта, даже если все его атрибуты изменяются.

Классический пример: человек. Если человек сменит имя, адрес, работу — он останется тем же человеком. Его идентичность (в системе — ID) не меняется.

Ключевые свойства Entity

1. Идентичность (Identity)

Два объекта Entity равны тогда и только тогда, когда у них одинаковый идентификатор. Значения остальных полей не влияют на равенство.

import { Schema, Equal } from "effect"

class TodoId extends Schema.Class<TodoId>("TodoId")({
  value: Schema.String.pipe(Schema.brand("TodoId"))
}) {}

class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: Schema.String,
  status: Schema.Literal("Active", "Completed", "Archived"),
}) {}

// Два Todo с одинаковым id — это ОДНА И ТА ЖЕ сущность
const todoV1 = new Todo({
  id: new TodoId({ value: "abc" as TodoId["value"]["value"] }),
  title: "Original title",
  status: "Active",
})

const todoV2 = new Todo({
  id: new TodoId({ value: "abc" as TodoId["value"]["value"] }),
  title: "Changed title",   // Другой заголовок
  status: "Completed",      // Другой статус
})

// todoV1 и todoV2 — это одна и та же задача в разные моменты времени!
// Идентичность определяется по id, не по содержимому

2. Жизненный цикл (Lifecycle)

Entity проходит через последовательность состояний: создание → изменения → удаление/архивация. У Entity есть история.

// Жизненный цикл Todo Entity:
//
// Created ──→ Active ──→ Completed ──→ Archived
//                │                        ▲
//                └────────────────────────┘
//                    (direct archive)

const todoLifecycle = {
  // Создание
  create: (id: TodoId, title: string, now: Date) =>
    Effect.succeed(new Todo({
      id,
      title,
      status: "Active",
      priority: "Medium",
      createdAt: now,
      completedAt: null,
    })),

  // Изменение (через методы Entity)
  complete: (todo: Todo) => todo.complete(),
  
  // Архивирование
  archive: (todo: Todo) => todo.archive(),
}

3. Изменяемое состояние (через иммутабельные обновления)

В функциональном подходе Entity «изменяется» через создание новой версии. Мы не мутируем объект — мы возвращаем новый объект с обновлёнными полями.

// Иммутабельное обновление — новый объект, старый не тронут
const updatedTodo = new Todo({
  ...originalTodo,
  title: "New title",
})
// originalTodo.title === "Old title"  — не изменился
// updatedTodo.title === "New title"   — новый объект

Когда использовать Entity

  • Объект имеет уникальный ID в системе
  • Нужно отслеживать изменения во времени
  • Два объекта с одинаковыми данными, но разными ID — это разные объекты
  • Объект имеет жизненный цикл (создаётся, изменяется, удаляется)

Примеры Entity: Todo, User, Order, Product, Invoice, Account.

Value Object (Объект-значение)

Определение

Value Object — это доменный объект без идентичности. Два Value Object равны, если все их поля равны. Value Object неизменяем — после создания его нельзя модифицировать.

Классический пример: денежная сумма. 100 рублей — это 100 рублей, независимо от того, какая именно купюра у вас в руках. Нет смысла различать «эти 100 рублей» и «те 100 рублей».

Ключевые свойства Value Object

1. Равенство по значению (Value Equality)

import { Schema, Equal } from "effect"

class Priority extends Schema.Class<Priority>("Priority")({
  value: Schema.Literal("Low", "Medium", "High", "Critical"),
}) {}

const p1 = new Priority({ value: "High" })
const p2 = new Priority({ value: "High" })

// p1 и p2 — это одно и то же значение.
// Нет смысла спрашивать "какой именно High?"
Equal.equals(p1, p2) // true (Schema.Class реализует Equal)

2. Неизменяемость (Immutability)

Value Object никогда не изменяется. Если нужно «изменить» Value Object — создаётся новый.

class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number.pipe(Schema.finite()),
  currency: Schema.Literal("USD", "EUR", "RUB"),
}) {
  // «Изменение» — создание нового объекта
  add(other: Money): Effect.Effect<Money, CurrencyMismatchError> {
    return this.currency !== other.currency
      ? Effect.fail(
          new CurrencyMismatchError({
            expected: this.currency,
            actual: other.currency,
          })
        )
      : Effect.succeed(
          new Money({
            amount: this.amount + other.amount,
            currency: this.currency,
          })
        )
  }

  // Умножение на скаляр
  multiply(factor: number): Money {
    return new Money({
      amount: this.amount * factor,
      currency: this.currency,
    })
  }
}

3. Самовалидация (Self-Validation)

Value Object гарантирует свою корректность при создании. Невозможно создать невалидный Value Object.

class EmailAddress extends Schema.Class<EmailAddress>("EmailAddress")({
  value: Schema.String.pipe(
    Schema.trimmed(),
    Schema.lowercased(),
    Schema.pattern(
      /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
    ),
    Schema.brand("EmailAddress")
  )
}) {}

// Невалидный email НЕ может существовать:
// Schema.decodeUnknown(EmailAddress)({ value: "not-an-email" })
// → ParseError (ошибка валидации)

// Валидный email гарантированно корректен:
// Schema.decodeUnknown(EmailAddress)({ value: "user@example.com" })
// → EmailAddress { value: "user@example.com" }

4. Заменяемость (Replaceability)

Один Value Object можно свободно заменить другим с тем же значением. Нет «привязки» к конкретному экземпляру.

Когда использовать Value Object

  • Объект описывает характеристику или атрибут
  • Два объекта с одинаковыми полями — это одно и то же
  • Объект не имеет жизненного цикла — он просто есть
  • Нужна встроенная валидация — невалидное значение не существует
  • Операции над объектом возвращают новый объект (неизменяемость)

Примеры Value Object: EmailAddress, Money, Priority, TodoTitle, DateRange, GeoCoordinates, PhoneNumber.

Entity vs Value Object: сравнительная таблица

СвойствоEntityValue Object
ИдентичностьУникальный IDНет ID
РавенствоПо IDПо всем полям
ИзменяемостьДа (через новую версию)Нет (immutable)
Жизненный циклСоздание → изменения → удалениеСоздание (и всё)
ПерсистентностьОтдельная запись в БДЧасть записи Entity
ПримерUser, Order, TodoEmail, Money, Priority

Эвристика: Entity или Value Object?

Задайте вопрос: «Важно ли различать два экземпляра с одинаковыми данными?»

  • «У Маши и Пети одинаковые email?» → Сравнение по значению → Value Object
  • «Это тот же заказ?» → Нужен ID для определения → Entity
  • «5 долларов — это 5 долларов?» → Да, любые 5 долларов одинаковы → Value Object
  • «Это тот же пользователь?» → Нужен ID → Entity

Aggregate (Агрегат)

Определение

Aggregate — это кластер связанных Entity и Value Object, объединённых границей транзакционной согласованности. Агрегат имеет корень (Aggregate Root) — единственную сущность, через которую внешний мир взаимодействует с агрегатом.

Агрегат гарантирует, что все его инварианты соблюдаются при любом изменении. Изменения внутри агрегата происходят атомарно — либо все, либо ни одно.

Ключевые свойства Aggregate

1. Граница согласованности (Consistency Boundary)

Внутри агрегата данные всегда согласованы. Если бизнес-правило говорит «в списке не может быть больше 10 задач» — агрегат гарантирует это правило.

class TodoList extends Schema.Class<TodoList>("TodoList")({
  id: TodoListId,
  name: Schema.String.pipe(Schema.minLength(1)),
  todos: Schema.Array(Todo),
  maxTodos: Schema.Number.pipe(Schema.int(), Schema.positive()),
  ownerId: UserId,
}) {
  // Инвариант: не превышен лимит задач
  addTodo(
    todo: Todo
  ): Effect.Effect<TodoList, TodoListFullError | DuplicateTitleError> {
    // Проверка инварианта 1: лимит
    if (this.todos.length >= this.maxTodos) {
      return Effect.fail(
        new TodoListFullError({
          maxTodos: this.maxTodos,
          currentCount: this.todos.length,
        })
      )
    }

    // Проверка инварианта 2: уникальность заголовков
    if (this.todos.some(
      (t) => t.title.toLowerCase() === todo.title.toLowerCase()
    )) {
      return Effect.fail(
        new DuplicateTitleError({ title: todo.title })
      )
    }

    // Все инварианты соблюдены — добавляем
    return Effect.succeed(
      new TodoList({
        ...this,
        todos: [...this.todos, todo],
      })
    )
  }
}

2. Aggregate Root (Корень агрегата)

Внешний мир взаимодействует с агрегатом только через корень. Нельзя напрямую изменить внутреннюю Entity — все изменения проходят через методы корня.

// ✅ ПРАВИЛЬНО: изменение через корень агрегата
const completeTodoInList = (
  list: TodoList,
  todoId: TodoId
): Effect.Effect<TodoList, TodoNotFoundError | InvalidStatusTransitionError> =>
  pipe(
    // Находим задачу внутри агрегата
    Array.findFirst(list.todos, (t) => Equal.equals(t.id, todoId)),
    Option.match({
      onNone: () => Effect.fail(new TodoNotFoundError({ todoId: todoId.value })),
      onSome: (todo) =>
        pipe(
          todo.complete(),
          Effect.map((completed) =>
            new TodoList({
              ...list,
              todos: list.todos.map((t) =>
                Equal.equals(t.id, todoId) ? completed : t
              ),
            })
          ),
        ),
    })
  )

// ❌ НЕПРАВИЛЬНО: прямое изменение внутренней Entity
// todo.complete() // Минуя агрегат — нарушение инвариантов!

3. Единица персистентности

Агрегат загружается и сохраняется целиком. Репозиторий работает с агрегатами, а не с отдельными Entity внутри них.

// Репозиторий работает с агрегатом TodoList, не с отдельными Todo
interface TodoListRepository {
  readonly findById: (
    id: TodoListId
  ) => Effect.Effect<TodoList, TodoListNotFoundError>

  readonly save: (
    list: TodoList
  ) => Effect.Effect<void>
  
  // НЕ должно быть:
  // findTodoById: (id: TodoId) => Effect.Effect<Todo, ...>
  // saveTodo: (todo: Todo) => Effect.Effect<void>
}

Проектирование границ агрегатов

Размер агрегата — одно из самых важных проектных решений. Слишком большой агрегат → проблемы с производительностью и конкурентным доступом. Слишком маленький → сложно защищать инварианты.

Правило Вона Вернона: Делайте агрегаты максимально маленькими. Включайте в агрегат только то, что необходимо для защиты инвариантов.

// Вариант 1: Todo — самостоятельный агрегат (маленький)
// ✅ Подходит, если задачи независимы друг от друга
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: Schema.String,
  status: Schema.Literal("Active", "Completed", "Archived"),
  priority: Priority,
}) {
  // Инварианты только внутри одной задачи
  complete(): Effect.Effect<Todo, InvalidStatusTransitionError> { /* ... */ }
}

// Вариант 2: TodoList — агрегат, содержащий Todo (больше)
// ✅ Подходит, если есть кросс-задачные инварианты
//    (уникальность заголовков, лимит задач, и т.д.)
class TodoList extends Schema.Class<TodoList>("TodoList")({
  id: TodoListId,
  todos: Schema.Array(Todo),
  maxTodos: Schema.Number,
}) {
  // Инварианты между задачами
  addTodo(todo: Todo): Effect.Effect<TodoList, ...> { /* ... */ }
}

Domain Event (Доменное событие)

Определение

Domain Event — это запись о факте, произошедшем в домене. Событие неизменяемо, описано в прошедшем времени и содержит данные о том, что случилось.

Доменные события — мощный механизм развязки (decoupling). Вместо того чтобы один компонент напрямую вызывал другой, он публикует событие, а заинтересованные компоненты подписываются на него.

Ключевые свойства Domain Event

1. Прошедшее время

Событие — это факт, который уже произошёл. Не «создай задачу» (это команда), а «задача создана».

// ✅ Правильные имена событий (прошедшее время)
type TodoEvent =
  | TodoCreated
  | TodoCompleted
  | TodoArchived
  | TodoTitleChanged
  | TodoPriorityUpdated

// ❌ Неправильные имена (императив — это команды, не события)
type NotEvent =
  | CreateTodo      // Это команда
  | CompleteTodo    // Это команда
  | UpdatePriority  // Это команда

2. Неизменяемость

Событие нельзя изменить после создания. Факт уже произошёл — его нельзя «отменить» (можно только создать новое компенсирующее событие).

class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
  "TodoCreated",
  {
    // Все поля readonly (Schema.Class гарантирует это)
    todoId: TodoId,
    title: Schema.String,
    priority: Schema.Literal("Low", "Medium", "High", "Critical"),
    createdBy: UserId,
    occurredAt: Schema.DateFromSelf,
  }
) {}

3. Событие несёт данные

Событие содержит достаточно информации, чтобы обработчик мог среагировать, не запрашивая дополнительные данные.

// ✅ Событие содержит полезные данные
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
  "TodoCompleted",
  {
    todoId: TodoId,
    title: Schema.String,        // Полезно для уведомлений
    completedBy: UserId,         // Кто завершил
    completedAt: Schema.DateFromSelf,
    occurredAt: Schema.DateFromSelf,
  }
) {}

// ❌ Событие без данных — бесполезно
class TodoCompleted2 extends Schema.TaggedClass<TodoCompleted2>()(
  "TodoCompleted2",
  {
    todoId: TodoId,
    // Только ID — обработчику придётся делать запрос
    // для получения деталей. Это создаёт лишнюю связанность.
  }
) {}

4. Событие как результат поведения

В идеале, доменная операция возвращает пару: обновлённое состояние + событие(я).

const completeTodo = (
  todo: Todo,
  completedBy: UserId,
  now: Date
): Effect.Effect<
  readonly [Todo, TodoCompleted],
  InvalidStatusTransitionError
> =>
  todo.status === "Active"
    ? Effect.succeed([
        new Todo({ ...todo, status: "Completed", completedAt: now }),
        new TodoCompleted({
          todoId: todo.id,
          title: todo.title,
          completedBy,
          completedAt: now,
          occurredAt: now,
        }),
      ] as const)
    : Effect.fail(
        new InvalidStatusTransitionError({
          from: todo.status,
          to: "Completed",
        })
      )

Применение Domain Events

Domain Events используются для:

  • Обновления Read Models (в CQRS)
  • Уведомлений (email, push при завершении задачи)
  • Аудита (лог всех действий)
  • Интеграции (события для внешних систем)
  • Восстановления состояния (Event Sourcing)

Domain Service (Доменный сервис)

Определение

Domain Service — это доменная операция, которая не принадлежит ни одной конкретной Entity или Value Object. Обычно это логика, затрагивающая несколько агрегатов или требующая координации.

Когда нужен Domain Service

  • Операция не «принадлежит» одному объекту
  • Логика требует данных нескольких агрегатов
  • Операция является процессом, а не свойством объекта
// Domain Service: проверка, что пользователь не превысил лимит задач
// Эта логика не принадлежит ни Todo, ни User
const checkUserTodoLimit = (
  userTodos: ReadonlyArray<Todo>,
  maxTodosPerUser: number
): Effect.Effect<void, UserTodoLimitExceededError> =>
  userTodos.filter((t) => t.status === "Active").length >= maxTodosPerUser
    ? Effect.fail(
        new UserTodoLimitExceededError({
          current: userTodos.filter((t) => t.status === "Active").length,
          max: maxTodosPerUser,
        })
      )
    : Effect.void

// Domain Service: вычисление статистики по задачам
const calculateTodoStats = (
  todos: ReadonlyArray<Todo>
): TodoStats => ({
  total: todos.length,
  active: todos.filter((t) => t.status === "Active").length,
  completed: todos.filter((t) => t.status === "Completed").length,
  archived: todos.filter((t) => t.status === "Archived").length,
  byPriority: {
    Low: todos.filter((t) => t.priority === "Low").length,
    Medium: todos.filter((t) => t.priority === "Medium").length,
    High: todos.filter((t) => t.priority === "High").length,
    Critical: todos.filter((t) => t.priority === "Critical").length,
  },
})

Domain Service vs Application Service

Критически важно не путать Domain Service и Application Service:

Domain ServiceApplication Service
Содержит бизнес-логику✅ Да❌ Нет
Зависит от инфраструктуры❌ Нет (R = never)✅ Да (R = порты)
ПримерРасчёт приоритетовОркестрация Use Case
Расположениеdomain/services/app/use-cases/
// Domain Service — чистая бизнес-логика, R = never
const prioritizeTodos = (
  todos: ReadonlyArray<Todo>,
  now: Date
): ReadonlyArray<Todo> =>
  [...todos].sort((a, b) => {
    const scoreA = calculatePriorityScore(a, now)
    const scoreB = calculatePriorityScore(b, now)
    return scoreB - scoreA  // Высший приоритет первым
  })

// Application Service — оркестрация, R = TodoRepository
const listPrioritizedTodos = (
  userId: UserId
): Effect.Effect<ReadonlyArray<Todo>, never, TodoRepository> =>
  pipe(
    TodoRepository,
    Effect.flatMap((repo) => repo.findByOwner(userId)),
    Effect.map((todos) => prioritizeTodos(todos, new Date()))
    //                     ^^^^^^^^^^^^^^^^
    //                     Вызов Domain Service
  )

Domain Error (Доменная ошибка)

Определение

Domain Error — это ожидаемая бизнес-ситуация, в которой операция не может быть выполнена из-за нарушения бизнес-правила. Это не техническая ошибка (разрыв соединения с БД), а доменная (попытка завершить уже завершённую задачу).

Иерархия ошибок

                    Все ошибки

          ┌─────────────┼─────────────────┐
          │             │                 │
    Domain Errors   App Errors     Infrastructure Errors
          │             │                 │
    TodoNotFound   Unauthorized    SqliteError
    InvalidState   Forbidden       ConnectionTimeout
    DuplicateTitle ValidationError FileNotFoundError

Реализация через Data.TaggedError

import { Data } from "effect"

// Базовая доменная ошибка
class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{
  readonly todoId: string
}> {
  get message() {
    return `Todo with id '${this.todoId}' not found`
  }
}

class InvalidStatusTransitionError extends Data.TaggedError(
  "InvalidStatusTransitionError"
)<{
  readonly from: string
  readonly to: string
}> {
  get message() {
    return `Cannot transition from '${this.from}' to '${this.to}'`
  }
}

class DuplicateTitleError extends Data.TaggedError("DuplicateTitleError")<{
  readonly title: string
}> {
  get message() {
    return `Todo with title '${this.title}' already exists`
  }
}

// Union всех доменных ошибок
type TodoDomainError =
  | TodoNotFoundError
  | InvalidStatusTransitionError
  | DuplicateTitleError
  | TodoListFullError

Обработка ошибок через Effect

Effect-ts делает обработку доменных ошибок типобезопасной. Компилятор гарантирует, что все возможные ошибки обработаны.

import { Effect, Match } from "effect"

const handleTodoError = <A>(
  effect: Effect.Effect<A, TodoDomainError>
): Effect.Effect<A | string> =>
  effect.pipe(
    Effect.catchTags({
      TodoNotFoundError: (e) =>
        Effect.succeed(`Not found: ${e.todoId}`),
      InvalidStatusTransitionError: (e) =>
        Effect.succeed(`Invalid transition: ${e.from} → ${e.to}`),
      DuplicateTitleError: (e) =>
        Effect.succeed(`Duplicate: ${e.title}`),
      TodoListFullError: (e) =>
        Effect.succeed(`List full: ${e.currentCount}/${e.maxTodos}`),
    })
  )

Связь между типами: полная картина

Все типы домена работают вместе, образуя целостную модель:

// 1. Value Objects определяют базовые понятия
class TodoId extends Schema.Class<TodoId>("TodoId")({ /* ... */ }) {}
class Priority extends Schema.Class<Priority>("Priority")({ /* ... */ }) {}

// 2. Entity использует Value Objects и содержит поведение
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  priority: Priority,
  // ...
}) {
  complete(): Effect.Effect<Todo, InvalidStatusTransitionError> { /* ... */ }
}

// 3. Aggregate группирует Entity и защищает инварианты
class TodoList extends Schema.Class<TodoList>("TodoList")({
  todos: Schema.Array(Todo),
  // ...
}) {
  addTodo(todo: Todo): Effect.Effect<TodoList, TodoListFullError> { /* ... */ }
}

// 4. Domain Events фиксируют факты
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", {
  todoId: TodoId,
  // ...
}) {}

// 5. Domain Errors описывают бизнес-нарушения
class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{
  readonly todoId: string
}> {}

// 6. Domain Services координируют между агрегатами
const prioritizeTodos = (
  todos: ReadonlyArray<Todo>
): ReadonlyArray<Todo> => { /* ... */ }

Матрица выбора типа

Используйте эту матрицу при моделировании:

ВопросОтвет → Тип
Объект имеет уникальный ID?Да → Entity
Два объекта с одинаковыми полями — одинаковы?Да → Value Object
Нужна граница транзакционной согласованности?Да → Aggregate
Что-то произошло, о чём нужно уведомить?Да → Domain Event
Логика не принадлежит одному объекту?Да → Domain Service
Бизнес-правило нарушено?Да → Domain Error

Резюме

Доменная модель состоит из шести фундаментальных типов:

  • Entity — объект с уникальной идентичностью и жизненным циклом
  • Value Object — неизменяемое значение, равное по содержимому
  • Aggregate — кластер объектов с границей согласованности
  • Domain Event — неизменяемый факт о произошедшем
  • Domain Service — бизнес-логика между агрегатами
  • Domain Error — ожидаемое бизнес-нарушение

Каждый тип имеет чёткие правила равенства, жизненного цикла и использования. Понимание этих различий — фундамент качественного доменного моделирования.

В следующей главе мы разберём Ubiquitous Language — как язык предметной области воплощается в типах TypeScript и почему именование критически важно.