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

Entity vs Value Object: идентичность имеет значение

Фундаментальное различие между Entity и Value Object. Идентичность как непрерывность объекта во времени. Три вопроса для определения типа доменного объекта. Entity в контексте Hexagonal Architecture — нулевые зависимости, ядро бизнес-логики, работа через порты.

Два фундаментальных строительных блока домена

В предыдущем модуле мы подробно изучили Value Objects — неизменяемые объекты, определяемые исключительно своими атрибутами. Два Value Object с одинаковыми значениями полей идентичны, как два экземпляра числа 42 или строки "hello".

Но не всё в предметной области описывается значениями. Существуют объекты, для которых критически важно отслеживать их на протяжении времени, различая экземпляры даже при полном совпадении атрибутов. Эти объекты — Entity (сущности).

Эрик Эванс в «Domain-Driven Design» (2003) формулирует ключевой вопрос так: «Имеет ли значение, какой именно это экземпляр, или только его атрибуты?»

Если два объекта с одинаковыми полями — это один и тот же объект, перед вами Value Object. Если два объекта с одинаковыми полями — это два разных объекта, перед вами Entity.


Что такое идентичность

Идентичность — это не id: string

Распространённое заблуждение: «Entity — это объект с полем id». Это поверхностное понимание. Идентичность — это концептуальная непрерывность объекта во времени. Поле id — лишь техническая реализация этой концепции.

Рассмотрим пример. У вас есть задача «Купить молоко». Сегодня она в статусе pending, завтра перейдёт в completed. Атрибуты изменились, но это та же самая задача. Вы можете изменить её заголовок, приоритет, дату — она останется той же задачей, которую вы создали вчера. Это и есть идентичность.

Сравните с приоритетом High. Если у вас два значения High, они полностью взаимозаменяемы. Нет смысла спрашивать «какой именно это High?» — все High одинаковы.

Формальное определение

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

// Два Value Objects с одинаковыми значениями — ОДИНАКОВЫ
const priority1 = Priority.High
const priority2 = Priority.High
// priority1 === priority2 (концептуально)

// Две Entity с одинаковыми атрибутами — РАЗЛИЧНЫ
const todo1 = Todo.create({ title: "Купить молоко", priority: Priority.High })
const todo2 = Todo.create({ title: "Купить молоко", priority: Priority.High })
// todo1 !== todo2 (это две разные задачи!)

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

ХарактеристикаEntityValue Object
ОпределяетсяИдентичностьюАтрибутами
РавенствоПо идентификаторуПо значению всех полей
МутабельностьКонцептуально мутабельна (атрибуты меняются со временем)Строго иммутабельна
Жизненный циклСоздаётся, изменяется, удаляетсяСоздаётся, используется, отбрасывается
УникальностьКаждый экземпляр уникаленЭкземпляры с одинаковыми значениями взаимозаменяемы
ПерсистентностьОбычно хранится в БД с первичным ключомХранится как часть Entity или inline
СозданиеФабричный метод с генерацией IDКонструктор с валидацией значений
ПримерUser, Order, Todo, AccountEmail, Money, DateRange, Priority

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

1. Влияние на равенство

Равенство — это не деталь реализации, а бизнес-правило. Неправильное определение равенства приводит к багам, которые крайне сложно отладить.

import { Equal, Hash } from "effect"

// Value Object: равенство по ЗНАЧЕНИЮ
// Если email1 и email2 содержат "user@example.com", они равны
class Email extends Schema.Class<Email>("Email")({
  value: Schema.String.pipe(Schema.pattern(/@/))
}) {
  [Equal.symbol](that: Equal.Equal): boolean {
    return that instanceof Email && this.value === that.value
  }
  [Hash.symbol](): number {
    return Hash.string(this.value)
  }
}

// Entity: равенство по ИДЕНТИФИКАТОРУ
// Даже если два Todo имеют одинаковый title, они НЕ равны
class Todo {
  [Equal.symbol](that: Equal.Equal): boolean {
    return that instanceof Todo && this.id === that.id
    // НЕ сравниваем title, priority, status!
  }
  [Hash.symbol](): number {
    return Hash.string(this.id)
  }
}

2. Влияние на хранение

Value Objects хранятся как значения внутри Entity. У них нет собственной таблицы в базе данных (за редкими исключениями). Entity — это единица хранения, строка в таблице.

Таблица todos:
┌──────────────────────────────────────────────────────────────┐
│ id (PK)     │ title       │ priority │ status  │ created_at  │
│─────────────│─────────────│──────────│─────────│─────────────│
│ "todo_abc"  │ "Buy milk"  │ "high"   │ "done"  │ 2024-01-15  │
│ "todo_def"  │ "Buy milk"  │ "high"   │ "new"   │ 2024-01-16  │
└──────────────────────────────────────────────────────────────┘

Здесь:
- id         → идентичность Entity
- title      → Value Object (строка с бизнес-правилами)
- priority   → Value Object (перечисление)
- status     → Value Object (перечисление / state machine)
- created_at → Value Object (дата)

3. Влияние на передачу между слоями

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


Паттерн определения: Entity или Value Object?

Задайте себе три вопроса:

Вопрос 1: «Нужно ли отслеживать этот объект во времени?»

Если пользователь (или система) должен иметь возможность позже обратиться к конкретному экземпляру — это Entity.

  • Задачу нужно найти по ID, обновить, завершить → Entity
  • Приоритет High просто используется как значение → Value Object

Вопрос 2: «Два экземпляра с одинаковыми данными — это одно и то же?»

Если да — Value Object. Если нет — Entity.

  • Две задачи «Купить молоко» — это одно и то же? Нет, это две разные задачи → Entity
  • Два приоритета High — это одно и то же? Да → Value Object

Вопрос 3: «Имеет ли объект жизненный цикл с переходами состояний?»

Если объект проходит через стадии (created → active → completed → archived), это сильный индикатор Entity.


Entity в контексте Hexagonal Architecture

В гексагональной архитектуре Entity живёт строго в доменном слое — в центре гексагона. Это означает:

Нулевые зависимости от инфраструктуры

Entity не знает о базах данных, HTTP, файловых системах. Она не импортирует sqlite, express или fs. Её зависимости — только другие доменные типы и Effect (как часть языка).

// ✅ ПРАВИЛЬНО: Entity зависит только от домена
import { Schema } from "effect"
import { TodoId } from "./value-objects/TodoId"
import { TodoTitle } from "./value-objects/TodoTitle"
import { Priority } from "./value-objects/Priority"
import { TodoStatus } from "./value-objects/TodoStatus"

// ❌ НЕПРАВИЛЬНО: утечка инфраструктуры
import { Database } from "better-sqlite3"   // НЕЛЬЗЯ!
import { Request } from "express"            // НЕЛЬЗЯ!

Entity как ядро бизнес-логики

Entity — это не анемичная структура данных (хотя в функциональном стиле мы реализуем поведение через чистые функции, а не методы класса). Бизнес-правила кодируются внутри или рядом с Entity:

// Анемичная модель (антипаттерн) — просто контейнер данных
interface Todo {
  id: string
  title: string
  status: string
}
// Логика размазана по сервисам, контроллерам, утилитам...

// Богатая модель — бизнес-правила внутри домена
// (в функциональном стиле — через чистые функции)
const complete = (todo: Todo): Either<DomainError, Todo> =>
  todo.status === "completed"
    ? Either.left(new AlreadyCompleted({ todoId: todo.id }))
    : Either.right({ ...todo, status: "completed", completedAt: new Date() })

Порты работают с Entity

Порты (интерфейсы для инфраструктуры) определяются в терминах доменных Entity, а не инфраструктурных типов:

// Порт определён в домене, работает с доменным Entity
interface TodoRepository {
  readonly findById: (id: TodoId) => Effect<Todo, TodoNotFound>
  readonly save: (todo: Todo) => Effect<void, PersistenceError>
}

// Адаптер (в инфраструктуре) преобразует Entity ↔ SQL row
// Entity ничего об этом не знает

Типичные ошибки при моделировании Entity

Ошибка 1: Всё — Entity

Новички часто делают каждый объект Entity с id. Это приводит к ненужным таблицам в БД, сложным JOIN-ам и избыточным репозиториям.

// ❌ Не нужно делать Priority сущностью с отдельной таблицей
class PriorityEntity {
  id: PriorityId       // Зачем?
  name: string          // "high", "medium", "low"
}

// ✅ Priority — это Value Object
type Priority = "high" | "medium" | "low"

Ошибка 2: Entity без поведения (Anemic Domain Model)

Entity, которая только хранит данные и не содержит бизнес-правил — это антипаттерн (хотя Мартин Фаулер признаёт, что он распространён). В функциональном подходе «поведение» выражается через чистые функции, работающие с Entity.

Ошибка 3: Идентификатор как примитив

Использование string или number в качестве типа идентификатора приводит к багам вида «случайно передали UserId вместо TodoId». Branded types решают эту проблему.

// ❌ ОПАСНО: можно случайно передать userId вместо todoId
const findTodo = (id: string) => ...

// ✅ БЕЗОПАСНО: типы не совместимы
const findTodo = (id: TodoId) => ...
const findUser = (id: UserId) => ...

// TodoId и UserId — разные типы, даже если оба string

Ошибка 4: Мутабельное состояние Entity

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

// ❌ Мутация — непредсказуемо, side-effects
todo.status = "completed"
todo.completedAt = new Date()

// ✅ Иммутабельное обновление — чисто, предсказуемо
const completedTodo = { ...todo, status: "completed" as const, completedAt: new Date() }

Entity в функциональном мире: философский вопрос

В чисто функциональном программировании всё — значение. Функции — значения, данные — значения. Как тогда моделировать Entity, которая по определению «не является значением»?

Ответ: Entity — это Value Object с выделенным полем идентичности. На уровне данных Entity — иммутабельная запись. На уровне семантики — она отличается тем, что равенство определяется по id, а не по всем полям.

В Effect-ts мы моделируем Entity как Schema.Class с:

  • Полем id (branded type)
  • Реализацией Equal по id
  • Чистыми функциями для поведения (create, update, transition)
  • Инвариантами через Schema.filter

Это сочетание даёт нам лучшее из обоих миров:

  • Иммутабельность — предсказуемость и потокобезопасность
  • Идентичность — возможность отслеживать объект во времени
  • Типобезопасность — компилятор проверяет инварианты
  • Функциональная композиция — Entity участвует в pipe, Effect, Stream

Подготовка к следующим статьям

В этой статье мы установили концептуальный фундамент:

  • Entity определяется идентичностью, Value Object — значениями
  • Entity имеет жизненный цикл с переходами состояний
  • Entity живёт строго в доменном слое без инфраструктурных зависимостей
  • В функциональном подходе Entity — иммутабельна, изменения создают новый экземпляр

В следующей статье мы перейдём к реализации: как создать Entity с использованием Schema.Class, branded types для идентификаторов, и как интегрировать Entity с системой типов Effect.


Ключевые выводы

  1. Entity ≠ «объект с полем id». Entity — это концепция непрерывности объекта во времени
  2. Равенство Entity определяется ТОЛЬКО по идентификатору, не по атрибутам
  3. Используйте три вопроса для определения: отслеживание во времени, взаимозаменяемость, жизненный цикл
  4. Entity иммутабельна в функциональном подходе — каждое изменение создаёт новый экземпляр
  5. Entity живёт в центре гексагона — ноль зависимостей от инфраструктуры
  6. Branded types для идентификаторов — предотвращают смешивание разных типов ID