Value Object: что это и зачем
Определение и характеристики Value Object. Structural equality, иммутабельность, самовалидация, заменяемость. Сравнение с Entity и Primitive Obsession. Value Objects в функциональном программировании и в контексте гексагональной архитектуры. Паттерны создания и анти-паттерны.
Введение
Value Object (объект-значение) — один из фундаментальных строительных блоков доменного моделирования, впервые формализованный Эриком Эвансом в книге “Domain-Driven Design: Tackling Complexity in the Heart of Software” (2003). Несмотря на кажущуюся простоту, Value Object — это концепция, которая радикально меняет подход к проектированию доменных моделей, делая код более выразительным, безопасным и устойчивым к ошибкам.
В контексте гексагональной архитектуры Value Objects живут в самом центре гексагона — в доменном слое. Они не имеют никаких зависимостей от инфраструктуры и представляют собой чистые, неизменяемые структуры данных с бизнес-правилами. Это делает их идеальными кандидатами для функционального программирования и, в частности, для Effect-ts.
Что такое Value Object
Определение
Value Object — это объект, который описывается исключительно своими атрибутами (значениями), а не уникальной идентичностью. Два Value Object с одинаковыми атрибутами считаются полностью взаимозаменяемыми.
Ключевая формулировка Эрика Эванса:
Объект, представляющий описательный аспект предметной области и не имеющий концептуальной идентичности, называется объектом-значением. Объекты-значения создаются для представления элементов дизайна, которые важны нам только тем, что они собой представляют, а не тем, кто или что они собой представляют.
Аналогия из реального мира
Представьте денежную купюру. Вам не важен её серийный номер (идентичность) — вам важен номинал (значение). Две купюры по 100 рублей для вас абсолютно взаимозаменяемы. Это и есть Value Object.
А теперь представьте паспорт. Два паспорта с одинаковыми ФИО — это не одно и то же. Каждый паспорт имеет уникальный номер (идентичность). Это Entity.
// Value Object — нам важно ЗНАЧЕНИЕ
const price1 = Money({ amount: 100, currency: "RUB" })
const price2 = Money({ amount: 100, currency: "RUB" })
// price1 === price2 по смыслу (равны по значению)
// Entity — нам важна ИДЕНТИЧНОСТЬ
const user1 = User({ id: "user-1", name: "Алексей" })
const user2 = User({ id: "user-2", name: "Алексей" })
// user1 !== user2, несмотря на одинаковые имена
Характеристики Value Object
1. Определяется атрибутами (Structural Equality)
Value Object не имеет уникального идентификатора. Его «идентичность» — это совокупность всех его атрибутов. Если два Value Object имеют одинаковые атрибуты — они равны.
import { Data } from "effect"
// Value Object — равенство по структуре
class Email extends Data.Class<{ readonly address: string }> {}
const email1 = new Email({ address: "test@example.com" })
const email2 = new Email({ address: "test@example.com" })
// Data.Class обеспечивает structural equality
console.log(email1 === email2) // false (разные ссылки)
console.log(Data.Equal.equals(email1, email2)) // true (равные по значению)
Это фундаментальное отличие от Entity, где два объекта с одинаковыми данными, но разными id — это разные объекты.
2. Неизменяемость (Immutability)
Value Object никогда не меняется после создания. Любая «модификация» создаёт новый экземпляр. Это критически важно по нескольким причинам:
- Безопасность при совместном использовании: если Value Object неизменяем, его можно безопасно передавать между разными частями программы без риска неожиданного изменения.
- Предсказуемость: функции, принимающие Value Object, всегда получают те же данные, которые были переданы.
- Thread-safety: неизменяемые объекты безопасны для параллельного доступа.
- Кешируемость: неизменяемые значения можно безопасно кешировать.
import { Data } from "effect"
class Temperature extends Data.Class<{
readonly value: number
readonly unit: "celsius" | "fahrenheit"
}> {}
const temp = new Temperature({ value: 36.6, unit: "celsius" })
// ❌ Невозможно — readonly поля
// temp.value = 37.0
// ✅ Создаём новый экземпляр
const newTemp = new Temperature({ value: 37.0, unit: "celsius" })
3. Самовалидация (Self-Validation)
Value Object отвечает за валидность своих данных. Невозможно создать Value Object в невалидном состоянии. Это называется принципом “Make Illegal States Unrepresentable” — сделай невалидные состояния невыразимыми в типах.
import { Schema } from "effect"
// Email, который гарантированно валиден
class Email extends Schema.Class<Email>("Email")({
address: Schema.String.pipe(
Schema.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
Schema.annotations({ message: () => "Invalid email format" })
)
}) {}
// Попытка создать невалидный Email приведёт к ошибке
// Schema.decodeUnknownSync(Email)({ address: "not-an-email" })
// => ParseError: Invalid email format
// Валидный Email создаётся успешно
const email = Schema.decodeUnknownSync(Email)({ address: "user@example.com" })
4. Заменяемость (Replaceability)
Поскольку Value Object определяется только своими значениями, любой экземпляр с теми же значениями может заменить другой. Это упрощает рассуждения о коде:
// Эти два вызова эквивалентны
processPayment(Money({ amount: 100, currency: "USD" }))
processPayment(Money({ amount: 100, currency: "USD" }))
// Неважно, какой именно экземпляр Money мы передали
5. Побочные эффекты отсутствуют (Side-Effect Free)
Операции над Value Object не должны иметь побочных эффектов. Они возвращают новые Value Objects или примитивные значения, но не изменяют состояние системы.
class Money extends Data.Class<{
readonly amount: number
readonly currency: string
}> {}
// Чистая функция — возвращает новый Money, не мутирует исходный
const add = (a: Money, b: Money): Money => {
if (a.currency !== b.currency) {
throw new Error(`Cannot add ${a.currency} and ${b.currency}`)
}
return new Money({ amount: a.amount + b.amount, currency: a.currency })
}
const price = new Money({ amount: 100, currency: "USD" })
const tax = new Money({ amount: 10, currency: "USD" })
const total = add(price, tax)
// price и tax не изменились
// total — новый объект Money({ amount: 110, currency: "USD" })
Value Object vs Primitive Obsession
Проблема: Primitive Obsession
Primitive Obsession (одержимость примитивами) — один из самых распространённых code smell в доменном моделировании. Это ситуация, когда бизнес-концепции представлены примитивными типами (string, number, boolean) вместо специализированных типов.
// ❌ Primitive Obsession — всё строки и числа
interface Order {
customerEmail: string // Любая строка? "not-an-email"?
totalAmount: number // Отрицательный? В какой валюте?
currency: string // "USD"? "usd"? "Dollar"?
shippingAddress: string // Валидный адрес? Пустая строка?
phoneNumber: string // "+7..."? "8..."? "abc"?
}
function processOrder(
email: string, // Можно перепутать с адресом
phone: string, // Можно перепутать с email
amount: number, // Можно передать отрицательное
currency: string // Можно передать невалидную валюту
): void {
// ...
}
// Компилятор не поймает эту ошибку!
processOrder(
"+7-999-123-45-67", // ← Перепутали: это телефон, а не email
"user@example.com", // ← Перепутали: это email, а не телефон
-100, // ← Отрицательная сумма
"INVALID" // ← Несуществующая валюта
)
Решение: Value Objects
import { Schema } from "effect"
// ✅ Каждая бизнес-концепция — отдельный тип
class Email extends Schema.Class<Email>("Email")({
address: Schema.String.pipe(
Schema.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)
)
}) {}
class PhoneNumber extends Schema.Class<PhoneNumber>("PhoneNumber")({
number: Schema.String.pipe(
Schema.pattern(/^\+\d{1,3}\d{4,14}$/)
)
}) {}
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.positive()),
currency: Schema.Literal("USD", "EUR", "RUB")
}) {}
// Теперь компилятор TypeScript защищает от ошибок
function processOrder(
email: Email,
phone: PhoneNumber,
total: Money
): void {
// ...
}
// ❌ Ошибка компиляции! Нельзя передать PhoneNumber вместо Email
// processOrder(phone, email, total)
Таблица сравнения
| Аспект | Примитивы | Value Objects |
|---|---|---|
| Валидация | Разбросана по коду | Централизована в конструкторе |
| Типобезопасность | string = string | Email ≠ PhoneNumber |
| Бизнес-правила | Дублируются | Инкапсулированы |
| Читаемость | amount: number | total: Money |
| Рефакторинг | Опасен | Безопасен через типы |
| Тестирование | Нужны проверки везде | Один набор тестов для VO |
Value Object vs Entity
Понимание различия между Value Object и Entity — ключ к правильному доменному моделированию.
Критерии выбора
| Критерий | Value Object | Entity |
|---|---|---|
| Идентичность | Определяется значениями | Уникальный идентификатор |
| Равенство | По атрибутам | По идентификатору |
| Жизненный цикл | Создать и забыть | Создание → изменение → удаление |
| Изменяемость | Всегда неизменяемый | Может изменять состояние |
| Хранение | Внутри Entity или встроен | Отдельная запись в хранилище |
| Совместное владение | Безопасно | Требует осторожности |
Примеры из разных доменов
Домен: E-commerce
Entity: Order, Customer, Product
Value Object: Money, Address, SKU, Quantity, DateRange
Домен: Banking
Entity: Account, Transaction, Customer
Value Object: Money, IBAN, Currency, InterestRate
Домен: Healthcare
Entity: Patient, Appointment, Doctor
Value Object: BloodPressure, Temperature, Dosage, TimeSlot
Домен: Todo Application (наш проект)
Entity: Todo, User
Value Object: TodoId, Title, Priority, DueDate, CompletionStatus
Спорные случаи
Иногда одна и та же бизнес-концепция может быть и Entity, и Value Object — в зависимости от контекста:
Адрес:
- В службе доставки → Entity (уникальный адрес с историей доставок)
- В заказе → Value Object (просто набор полей: город, улица, дом)
Деньги:
- В большинстве систем → Value Object
- В системе отслеживания купюр → Entity (серийный номер!)
Value Objects в функциональном программировании
Естественное соответствие
Value Objects идеально ложатся на функциональную парадигму, потому что разделяют одни и те же принципы:
| Принцип FP | Проявление в Value Object |
|---|---|
| Иммутабельность | VO не меняется после создания |
| Чистые функции | Операции над VO не имеют побочных эффектов |
| Алгебраические типы | VO = Product Type (набор полей) |
| Referential transparency | VO с одинаковыми полями всегда взаимозаменяемы |
| Декларативность | VO описывает ЧТО, а не КАК |
Value Object как Product Type
В теории типов Value Object — это product type (произведение типов). Он определяется набором полей, и его «мощность» (количество возможных значений) равна произведению мощностей всех полей:
// Product type: количество значений = |amount| × |currency|
// Если amount: PositiveInteger и currency: "USD" | "EUR" | "RUB",
// то Money может принимать PositiveInteger × 3 различных значений
class Money extends Data.Class<{
readonly amount: number // Множество допустимых чисел
readonly currency: string // Множество допустимых валют
}> {}
Value Object как Smart Constructor
В функциональном программировании распространён паттерн Smart Constructor — функция, которая проверяет входные данные перед созданием значения. В Effect-ts этот паттерн реализуется через Schema:
import { Schema, Effect } from "effect"
// Schema — это Smart Constructor для Value Object
const EmailSchema = Schema.String.pipe(
Schema.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
Schema.brand("Email")
)
type Email = typeof EmailSchema.Type
// Создание через smart constructor — может вернуть ошибку
const createEmail = Schema.decode(EmailSchema)
// Effect<Email, ParseError>
const emailEffect = createEmail("user@example.com")
Value Objects в контексте гексагональной архитектуры
Где живут Value Objects
В гексагональной архитектуре Value Objects располагаются в доменном слое — в самом центре гексагона:
┌─────────────────────────────────────────┐
│ Infrastructure │
│ ┌───────────────────────────────────┐ │
│ │ Application Layer │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Domain Layer │ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────────┐ │ │ │
│ │ │ │ VALUE OBJECTS ←──────────── Здесь!
│ │ │ │ Entities │ │ │ │
│ │ │ │ Domain Services │ │ │ │
│ │ │ │ Domain Events │ │ │ │
│ │ │ └──────────────────────┘ │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────┘ │
└─────────────────────────────────────────┘
Правила размещения
- Value Objects НЕ зависят от инфраструктуры (БД, HTTP, файловая система).
- Value Objects НЕ зависят от Application Layer (use cases, commands).
- Value Objects МОГУТ зависеть только от других доменных типов.
- Value Objects ИСПОЛЬЗУЮТСЯ всеми слоями, но определяются только в домене.
// Структура файлов
src/
domain/
value-objects/ ← Value Objects живут здесь
Email.ts
Money.ts
TodoTitle.ts
Priority.ts
DueDate.ts
index.ts ← Barrel export
entities/
Todo.ts ← Использует Value Objects
errors/
DomainErrors.ts
ports/
TodoRepository.ts ← Использует Value Objects в контрактах
adapters/
sqlite/
TodoRepositorySqlite.ts ← Маппит VO в SQL-типы
application/
CreateTodo.ts ← Принимает VO на вход
Value Objects на границах слоёв
Value Objects играют ключевую роль на границах между слоями гексагональной архитектуры. Они служат контрактом, определяющим, какие данные пересекают границы и в каком формате:
// Порт (граница домена) использует Value Objects
interface TodoRepository {
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
}
// Адаптер маппит VO в инфраструктурные типы
// TodoId (VO) → string (SQLite)
// Priority (VO) → number (SQLite)
// DueDate (VO) → string ISO 8601 (SQLite)
Паттерны создания Value Objects
Паттерн 1: Simple Value (одно значение с ограничениями)
Простейший Value Object оборачивает один примитив, добавляя к нему бизнес-правила:
import { Schema } from "effect"
// TodoTitle: непустая строка длиной 1-200 символов
const TodoTitle = Schema.String.pipe(
Schema.minLength(1),
Schema.maxLength(200),
Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type
Паттерн 2: Composite Value (несколько связанных значений)
Составной Value Object группирует несколько значений, которые имеют смысл только вместе:
import { Schema } from "effect"
// Money: сумма + валюта (бессмысленно по отдельности)
class Money extends Schema.Class<Money>("Money")({
amount: Schema.Number.pipe(Schema.nonNegative()),
currency: Schema.Literal("USD", "EUR", "RUB")
}) {}
Паттерн 3: Enumeration Value (ограниченное множество)
Value Object с фиксированным набором допустимых значений:
import { Schema } from "effect"
// Priority: фиксированное множество
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type
// Или через Schema.Enums
enum PriorityEnum {
Low = "low",
Medium = "medium",
High = "high",
Critical = "critical"
}
const PrioritySchema = Schema.Enums(PriorityEnum)
Паттерн 4: Range Value (диапазон значений)
Value Object, описывающий диапазон с инвариантами:
import { Schema } from "effect"
class DateRange extends Schema.Class<DateRange>("DateRange")({
start: Schema.DateFromString,
end: Schema.DateFromString
}) {
// Инвариант: start <= end проверяется на уровне Schema filter
}
const ValidDateRange = DateRange.pipe(
Schema.filter((range) =>
range.start <= range.end
? undefined
: "Start date must be before or equal to end date"
)
)
Анти-паттерны при работе с Value Objects
1. Мутабельный Value Object
// ❌ АНТИПАТТЕРН: мутабельный VO
class Money {
amount: number // ← Мутабельное поле!
currency: string
add(other: Money): void {
this.amount += other.amount // ← Мутация!
}
}
2. Value Object с идентификатором
// ❌ АНТИПАТТЕРН: VO с id — это Entity
class Money {
readonly id: string // ← Зачем VO идентификатор?
readonly amount: number
readonly currency: string
}
3. Гигантский Value Object
// ❌ АНТИПАТТЕРН: слишком много полей — возможно, это Entity
class CustomerProfile {
readonly firstName: string
readonly lastName: string
readonly email: string
readonly phone: string
readonly address: string
readonly dateOfBirth: Date
readonly preferences: Record<string, unknown>
readonly paymentMethods: readonly PaymentMethod[]
// ... ещё 20 полей
}
// Скорее всего, это Entity или несколько отдельных VO
4. Value Object со ссылкой на Entity
// ❌ АНТИПАТТЕРН: VO ссылается на Entity
class OrderLine {
readonly product: Product // ← Ссылка на Entity внутри VO!
readonly quantity: number
readonly price: Money
}
// ✅ ПРАВИЛЬНО: VO хранит только идентификатор
class OrderLine {
readonly productId: ProductId // ← Ссылка через Id (тоже VO)
readonly quantity: Quantity
readonly price: Money
}
5. Value Object без валидации
// ❌ АНТИПАТТЕРН: VO без бизнес-правил — зачем тогда VO?
class Email {
readonly value: string // Любая строка? Тогда зачем не просто string?
}
// ✅ ПРАВИЛЬНО: VO с валидацией
const Email = Schema.String.pipe(
Schema.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
Schema.brand("Email")
)
Преимущества использования Value Objects
1. Выразительность (Expressiveness)
Value Objects делают код самодокументируемым. Сравните:
// ❌ Непонятно, что за данные
function createOrder(email: string, total: number, currency: string): Order
// ✅ Сразу ясно, какие данные ожидаются
function createOrder(email: Email, total: Money): Order
2. Безопасность типов (Type Safety)
TypeScript компилятор предотвращает ошибки при использовании branded types:
type Email = string & Brand<"Email">
type PhoneNumber = string & Brand<"PhoneNumber">
// ❌ Ошибка компиляции: нельзя передать PhoneNumber вместо Email
declare function sendEmail(to: Email): void
declare const phone: PhoneNumber
sendEmail(phone) // Type error!
3. Централизация бизнес-правил
Правила валидации определяются один раз и применяются везде:
// Правило "email must be valid" определено ОДИН раз
const Email = Schema.String.pipe(
Schema.pattern(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/),
Schema.brand("Email")
)
// Используется повсюду — правило не дублируется
class User extends Schema.Class<User>("User")({ email: Email, /* ... */ }) {}
class Newsletter extends Schema.Class<Newsletter>("Newsletter")({ email: Email }) {}
class ContactForm extends Schema.Class<ContactForm>("ContactForm")({ email: Email }) {}
4. Тестируемость
Value Objects — чистые значения, тестировать их тривиально:
import { describe, it, expect } from "bun:test"
import { Schema } from "effect"
describe("Email", () => {
it("accepts valid email", () => {
const result = Schema.decodeUnknownEither(Email)("user@example.com")
expect(Either.isRight(result)).toBe(true)
})
it("rejects invalid email", () => {
const result = Schema.decodeUnknownEither(Email)("not-an-email")
expect(Either.isLeft(result)).toBe(true)
})
})
5. Рефакторинг
Изменение внутренней структуры Value Object безопасно, потому что все зависимости проходят через его публичный интерфейс:
// До рефакторинга
const Money = Schema.Struct({
amount: Schema.Number,
currency: Schema.String
})
// После рефакторинга — добавили точность
const Money = Schema.Struct({
amountInMinorUnits: Schema.Int, // 100 вместо 1.00
currency: Schema.Literal("USD", "EUR", "RUB"),
precision: Schema.Literal(2)
})
// Все использования Money обновятся через типы
Value Objects в Todo-приложении
В нашем сквозном проекте Todo App мы будем использовать следующие Value Objects:
| Value Object | Описание | Тип |
|---|---|---|
TodoId | Уникальный идентификатор задачи | Simple (branded UUID) |
TodoTitle | Заголовок задачи | Simple (ограниченная строка) |
Priority | Приоритет задачи | Enumeration |
DueDate | Срок выполнения | Simple (Date с ограничениями) |
CompletionStatus | Статус выполнения | Enumeration |
CreatedAt | Дата создания | Simple (branded Date) |
UserId | Идентификатор владельца | Simple (branded UUID) |
Каждый из этих Value Objects будет подробно реализован в последующих статьях модуля.
Резюме
Value Object — это фундаментальный паттерн доменного моделирования, который:
- Определяется значениями, а не идентичностью.
- Неизменяем после создания.
- Самовалидируется — невозможно создать невалидный экземпляр.
- Заменяем — два VO с одинаковыми значениями эквивалентны.
- Не имеет побочных эффектов — все операции чистые.
В контексте Effect-ts и гексагональной архитектуры Value Objects реализуются через Schema.Class, Data.Class и branded types, обеспечивая type-safe доменное моделирование с валидацией на границах слоёв. Они живут в доменном слое и не зависят от инфраструктуры, что полностью соответствует принципу Dependency Rule.
В следующей статье мы подробно разберём, как реализовать Value Objects с помощью Schema.Class и branded types в Effect-ts.