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

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 = stringEmail ≠ PhoneNumber
Бизнес-правилаДублируютсяИнкапсулированы
Читаемостьamount: numbertotal: Money
РефакторингОпасенБезопасен через типы
ТестированиеНужны проверки вездеОдин набор тестов для VO

Value Object vs Entity

Понимание различия между Value Object и Entity — ключ к правильному доменному моделированию.

Критерии выбора

КритерийValue ObjectEntity
ИдентичностьОпределяется значениямиУникальный идентификатор
РавенствоПо атрибутамПо идентификатору
Жизненный циклСоздать и забытьСоздание → изменение → удаление
ИзменяемостьВсегда неизменяемыйМожет изменять состояние
ХранениеВнутри 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 transparencyVO с одинаковыми полями всегда взаимозаменяемы
Декларативность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      │  │  │  │
│  │  │   └──────────────────────┘  │  │  │
│  │  └─────────────────────────────┘  │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

Правила размещения

  1. Value Objects НЕ зависят от инфраструктуры (БД, HTTP, файловая система).
  2. Value Objects НЕ зависят от Application Layer (use cases, commands).
  3. Value Objects МОГУТ зависеть только от других доменных типов.
  4. 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 — это фундаментальный паттерн доменного моделирования, который:

  1. Определяется значениями, а не идентичностью.
  2. Неизменяем после создания.
  3. Самовалидируется — невозможно создать невалидный экземпляр.
  4. Заменяем — два VO с одинаковыми значениями эквивалентны.
  5. Не имеет побочных эффектов — все операции чистые.

В контексте Effect-ts и гексагональной архитектуры Value Objects реализуются через Schema.Class, Data.Class и branded types, обеспечивая type-safe доменное моделирование с валидацией на границах слоёв. Они живут в доменном слое и не зависят от инфраструктуры, что полностью соответствует принципу Dependency Rule.

В следующей статье мы подробно разберём, как реализовать Value Objects с помощью Schema.Class и branded types в Effect-ts.