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
| Характеристика | Entity | Value Object |
|---|---|---|
| Определяется | Идентичностью | Атрибутами |
| Равенство | По идентификатору | По значению всех полей |
| Мутабельность | Концептуально мутабельна (атрибуты меняются со временем) | Строго иммутабельна |
| Жизненный цикл | Создаётся, изменяется, удаляется | Создаётся, используется, отбрасывается |
| Уникальность | Каждый экземпляр уникален | Экземпляры с одинаковыми значениями взаимозаменяемы |
| Персистентность | Обычно хранится в БД с первичным ключом | Хранится как часть Entity или inline |
| Создание | Фабричный метод с генерацией ID | Конструктор с валидацией значений |
| Пример | User, Order, Todo, Account | Email, 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.
Ключевые выводы
- Entity ≠ «объект с полем id». Entity — это концепция непрерывности объекта во времени
- Равенство Entity определяется ТОЛЬКО по идентификатору, не по атрибутам
- Используйте три вопроса для определения: отслеживание во времени, взаимозаменяемость, жизненный цикл
- Entity иммутабельна в функциональном подходе — каждое изменение создаёт новый экземпляр
- Entity живёт в центре гексагона — ноль зависимостей от инфраструктуры
- Branded types для идентификаторов — предотвращают смешивание разных типов ID