Типобезопасный домен: Гексагональная архитектура на базе Effect Реализация Value Objects через Schema.Class и Brand
Глава

Реализация Value Objects через Schema.Class и Brand

Все инструменты Effect для создания VO: Schema.brand для branded примитивов, Schema.Class для составных VO, Data.Class для легковесных VO, Data.TaggedEnum для перечислений, Schema.Literal для фиксированных множеств. Паттерн Smart Constructor. Разница между new и decode. Рецепты для Simple, Composite и Enumeration VO.

Введение

В предыдущей статье мы разобрали теорию Value Objects. Теперь пришло время практики — реализации Value Objects в Effect-ts. Библиотека Effect предоставляет несколько мощных инструментов для создания Value Objects, и выбор между ними зависит от конкретной задачи.

В этой статье мы подробно разберём все способы создания Value Objects в Effect-ts:

  1. Schema.brand — branded примитивы (Simple VO)
  2. Schema.Class — составные VO с равенством и декодированием
  3. Data.Class — легковесные VO без Schema-валидации
  4. Data.TaggedEnum — VO-перечисления
  5. Schema.Struct + Schema.brand — составные branded VO

Инструменты Effect для Value Objects

Обзор инструментов

ИнструментНазначениеРавенствоВалидацияСериализацияКогда использовать
Schema.brandBranded примитивЧерез ===Да (Schema)ДаПростые VO: Email, TodoId
Schema.ClassПолноценный классStructural (Equal)Да (Schema)ДаСоставные VO: Money, Address
Data.ClassЛегковесный классStructural (Equal)НетНетВнутренние VO без decode
Data.TaggedEnumТегированное перечислениеStructural (Equal)НетНетСтатусы, приоритеты
Schema.LiteralЛитеральный тип===ДаДаФиксированные множества

1. Schema.brand — Branded Примитивы

Что такое Brand

Brand — это механизм TypeScript, позволяющий создать номинальный тип из структурного. По умолчанию TypeScript использует структурную типизацию: два типа совместимы, если их структуры совпадают. Brand ломает эту совместимость, добавляя «невидимое» свойство к типу.

import { Schema, Brand } from "effect"

// Без Brand — это просто string
type Email1 = string
type PhoneNumber1 = string
declare const email1: Email1
declare const phone1: PhoneNumber1
const x: Email1 = phone1  // ✅ TypeScript разрешит — оба string

// С Brand — это разные типы!
type Email2 = string & Brand.Brand<"Email">
type PhoneNumber2 = string & Brand.Brand<"PhoneNumber">
declare const email2: Email2
declare const phone2: PhoneNumber2
// const y: Email2 = phone2  // ❌ Type error!

Создание Branded Schema

Schema.brand добавляет brand к существующей Schema, превращая примитивный тип в Value Object:

import { Schema } from "effect"

// ===========================
// Паттерн: Branded String VO
// ===========================

// TodoId — уникальный идентификатор задачи
const TodoId = Schema.String.pipe(
  Schema.pattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i),
  Schema.brand("TodoId")
)
type TodoId = typeof TodoId.Type
// TodoId = string & Brand<"TodoId">

// TodoTitle — заголовок задачи
const TodoTitle = Schema.String.pipe(
  Schema.trimmed(),
  Schema.minLength(1),
  Schema.maxLength(200),
  Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type
// TodoTitle = string & Brand<"TodoTitle">

// ===========================
// Паттерн: Branded Number VO
// ===========================

// Quantity — количество элементов
const Quantity = Schema.Number.pipe(
  Schema.int(),
  Schema.positive(),
  Schema.brand("Quantity")
)
type Quantity = typeof Quantity.Type
// Quantity = number & Brand<"Quantity">

// Percentage — процент (0–100)
const Percentage = Schema.Number.pipe(
  Schema.greaterThanOrEqualTo(0),
  Schema.lessThanOrEqualTo(100),
  Schema.brand("Percentage")
)
type Percentage = typeof Percentage.Type

Декодирование и кодирование

Branded Schema поддерживает полный цикл decode/encode:

import { Schema, Effect, Either } from "effect"

const TodoTitle = Schema.String.pipe(
  Schema.trimmed(),
  Schema.minLength(1),
  Schema.maxLength(200),
  Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type

// === Синхронное декодирование ===

// decodeUnknownSync — бросает исключение при ошибке
const title1 = Schema.decodeUnknownSync(TodoTitle)("Buy groceries")
// title1: TodoTitle

// decodeUnknownEither — возвращает Either
const result = Schema.decodeUnknownEither(TodoTitle)("")
// result: Either<TodoTitle, ParseError>

if (Either.isLeft(result)) {
  console.log("Validation failed:", result.left)
} else {
  console.log("Valid title:", result.right)
}

// === Асинхронное декодирование через Effect ===

// decodeUnknown — возвращает Effect
const titleEffect: Effect.Effect<TodoTitle, Schema.ParseError> =
  Schema.decodeUnknown(TodoTitle)("Buy groceries")

// === Кодирование (encode) ===

// Brand удаляется при encode — получаем обычный string
const encoded = Schema.encodeSync(TodoTitle)(title1)
// encoded: string (без Brand)

Комбинирование Brands

Можно создавать Value Objects с несколькими brands для дополнительной типобезопасности:

import { Schema } from "effect"

// UUID v4 brand
const UUID = Schema.String.pipe(
  Schema.pattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i),
  Schema.brand("UUID")
)

// Конкретные идентификаторы — разные brands
const TodoId = UUID.pipe(Schema.brand("TodoId"))
const UserId = UUID.pipe(Schema.brand("UserId"))
const ProjectId = UUID.pipe(Schema.brand("ProjectId"))

type TodoId = typeof TodoId.Type
// TodoId = string & Brand<"UUID"> & Brand<"TodoId">

type UserId = typeof UserId.Type
// UserId = string & Brand<"UUID"> & Brand<"UserId">

// Нельзя перепутать!
declare const todoId: TodoId
declare const userId: UserId
// const wrong: TodoId = userId  // ❌ Type error!

Паттерн: VO с кастомным сообщением об ошибке

import { Schema } from "effect"

const TodoTitle = Schema.String.pipe(
  Schema.trimmed(),
  Schema.minLength(1, {
    message: () => "Заголовок задачи не может быть пустым"
  }),
  Schema.maxLength(200, {
    message: () => "Заголовок задачи не может превышать 200 символов"
  }),
  Schema.brand("TodoTitle"),
  Schema.annotations({
    identifier: "TodoTitle",
    title: "Todo Title",
    description: "Заголовок задачи: непустая строка от 1 до 200 символов"
  })
)

2. Schema.Class — Составные Value Objects

Когда использовать Schema.Class

Schema.Class идеально подходит для составных Value Objects, которые:

  • Состоят из нескольких полей.
  • Требуют валидации при декодировании.
  • Нуждаются в сериализации/десериализации (границы слоёв).
  • Должны поддерживать structural equality.

Базовое создание

import { Schema } from "effect"

// Money — классический составной Value Object
class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number.pipe(Schema.nonNegative()),
  currency: Schema.Literal("USD", "EUR", "RUB")
}) {}

// Создание экземпляра (без валидации — прямой конструктор)
const price = new Money({ amount: 100, currency: "USD" })
price.amount    // 100
price.currency  // "USD"

// Создание с валидацией (через decode)
const priceEffect = Schema.decodeUnknown(Money)({
  amount: 100,
  currency: "USD"
})
// Effect<Money, ParseError>

Важно: new vs decode

Критически важное различие — new и Schema.decode работают по-разному:

class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number.pipe(Schema.nonNegative()),
  currency: Schema.Literal("USD", "EUR", "RUB")
}) {}

// new — НЕ выполняет валидацию Schema filters!
// TypeScript проверяет типы, но runtime-ограничения не проверяются
const dangerous = new Money({ amount: -100, currency: "USD" })
// ⚠️ Создастся без ошибки, хотя amount отрицательный!
// TypeScript видит number, а не "nonNegative number"

// Schema.decode — ВЫПОЛНЯЕТ все проверки
const safe = Schema.decodeUnknownSync(Money)({ amount: -100, currency: "USD" })
// ❌ ParseError: Expected a non-negative number, actual -100

// РЕКОМЕНДАЦИЯ: используйте smart constructor
const createMoney = Schema.decodeUnknown(Money)

// Теперь вызов гарантирует валидацию
const money = createMoney({ amount: 100, currency: "USD" })
// Effect<Money, ParseError>

Паттерн: Smart Constructor для Schema.Class

import { Schema, Effect, pipe } from "effect"

class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number.pipe(Schema.nonNegative()),
  currency: Schema.Literal("USD", "EUR", "RUB")
}) {
  // Smart constructor как статический метод
  static readonly create = Schema.decodeUnknown(Money)

  // Удобный синхронный конструктор (бросает при ошибке)
  static readonly unsafeCreate = Schema.decodeUnknownSync(Money)
}

// Использование
const money1 = Money.create({ amount: 100, currency: "USD" })
// Effect<Money, ParseError>

const money2 = Money.unsafeCreate({ amount: 100, currency: "USD" })
// Money (или бросит исключение)

Добавление поведения (методов)

Schema.Class поддерживает добавление методов — бизнес-логики Value Object:

import { Schema, Equal, Data } from "effect"

class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number.pipe(Schema.nonNegative()),
  currency: Schema.Literal("USD", "EUR", "RUB")
}) {
  // Сложение денег одной валюты
  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error(
        `Cannot add ${this.currency} and ${other.currency}: currency mismatch`
      )
    }
    return new Money({
      amount: this.amount + other.amount,
      currency: this.currency
    })
  }

  // Умножение на коэффициент
  multiply(factor: number): Money {
    if (factor < 0) {
      throw new Error("Factor must be non-negative")
    }
    return new Money({
      amount: this.amount * factor,
      currency: this.currency
    })
  }

  // Проверка: больше нуля
  isPositive(): boolean {
    return this.amount > 0
  }

  // Проверка: нулевая сумма
  isZero(): boolean {
    return this.amount === 0
  }

  // Форматирование для отображения
  format(): string {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: this.currency
    }).format(this.amount)
  }
}

// Использование
const price = new Money({ amount: 100, currency: "USD" })
const tax = new Money({ amount: 8.5, currency: "USD" })
const total = price.add(tax)
console.log(total.format())  // "$108.50"

Однако: предпочитайте чистые функции

В функциональном стиле лучше выносить операции в отдельные функции вместо методов класса. Это делает логику более композируемой и тестируемой:

import { Schema, Effect, Either, pipe } from "effect"

class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number.pipe(Schema.nonNegative()),
  currency: Schema.Literal("USD", "EUR", "RUB")
}) {}

// === Чистые функции для операций над Money ===

// Тегированная ошибка
class CurrencyMismatch extends Schema.TaggedError<CurrencyMismatch>()(
  "CurrencyMismatch",
  {
    left: Schema.String,
    right: Schema.String,
    message: Schema.String
  }
) {}

const addMoney = (a: Money, b: Money): Either.Either<Money, CurrencyMismatch> =>
  a.currency !== b.currency
    ? Either.left(new CurrencyMismatch({
        left: a.currency,
        right: b.currency,
        message: `Cannot add ${a.currency} and ${b.currency}`
      }))
    : Either.right(new Money({
        amount: a.amount + b.amount,
        currency: a.currency
      }))

const multiplyMoney = (money: Money, factor: number): Money =>
  new Money({
    amount: money.amount * Math.abs(factor),
    currency: money.currency
  })

const formatMoney = (money: Money): string =>
  new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: money.currency
  }).format(money.amount)

const isZero = (money: Money): boolean => money.amount === 0

// Использование через pipe
const result = pipe(
  addMoney(
    new Money({ amount: 100, currency: "USD" }),
    new Money({ amount: 8.5, currency: "USD" })
  ),
  Either.map(formatMoney)
)
// Either<string, CurrencyMismatch>

3. Data.Class — Легковесные Value Objects

Когда использовать Data.Class

Data.Class подходит, когда нужен Value Object с structural equality, но без Schema-валидации и сериализации. Это полезно для:

  • Внутренних доменных VO, которые не пересекают границы слоёв.
  • VO, которые создаются только из уже валидных данных.
  • Случаев, когда нужна максимальная производительность.
import { Data, Equal } from "effect"

// Простой VO через Data.Class
class Point extends Data.Class<{
  readonly x: number
  readonly y: number
}> {}

const p1 = new Point({ x: 1, y: 2 })
const p2 = new Point({ x: 1, y: 2 })
const p3 = new Point({ x: 3, y: 4 })

// Structural equality из коробки
console.log(Equal.equals(p1, p2))  // true
console.log(Equal.equals(p1, p3))  // false

// Иммутабельность
// p1.x = 5  // ❌ Error: readonly property

Data.Class vs Schema.Class

import { Data, Schema, Equal } from "effect"

// Data.Class — без Schema
class DataPoint extends Data.Class<{
  readonly x: number
  readonly y: number
}> {}
// ✅ Structural equality
// ✅ Иммутабельность (readonly)
// ❌ Нет валидации при создании
// ❌ Нет encode/decode
// ❌ Нет Schema интеграции

// Schema.Class — с Schema
class SchemaPoint extends Schema.Class<SchemaPoint>("SchemaPoint")({
  x: Schema.Number,
  y: Schema.Number
}) {}
// ✅ Structural equality
// ✅ Иммутабельность (readonly)
// ✅ Валидация через Schema.decode
// ✅ Encode/decode для сериализации
// ✅ Полная Schema интеграция

Data.struct и Data.tuple — одноразовые VO

Для быстрого создания Value Objects без объявления класса:

import { Data, Equal } from "effect"

// Data.struct — создание VO из объекта
const point1 = Data.struct({ x: 1, y: 2 })
const point2 = Data.struct({ x: 1, y: 2 })
console.log(Equal.equals(point1, point2))  // true

// Data.tuple — создание VO из кортежа
const range1 = Data.tuple(1, 10)
const range2 = Data.tuple(1, 10)
console.log(Equal.equals(range1, range2))  // true

4. Data.TaggedEnum — VO-Перечисления

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

Data.TaggedEnum идеально подходит для Value Objects с фиксированным набором вариантов, где каждый вариант может нести дополнительные данные:

import { Data, Match } from "effect"

// Статус задачи — тегированное перечисление
type TodoStatus = Data.TaggedEnum<{
  readonly Draft: {}
  readonly Active: {}
  readonly Completed: { readonly completedAt: Date }
  readonly Archived: { readonly archivedAt: Date; readonly reason: string }
}>

const { Draft, Active, Completed, Archived } = Data.taggedEnum<TodoStatus>()

// Создание
const status1 = Draft()
const status2 = Active()
const status3 = Completed({ completedAt: new Date() })
const status4 = Archived({
  archivedAt: new Date(),
  reason: "No longer relevant"
})

// Pattern matching с Match
const describeStatus = (status: TodoStatus): string =>
  Match.value(status).pipe(
    Match.tag("Draft", () => "Черновик"),
    Match.tag("Active", () => "Активная"),
    Match.tag("Completed", ({ completedAt }) =>
      `Завершена ${completedAt.toLocaleDateString()}`
    ),
    Match.tag("Archived", ({ reason }) => `Архивирована: ${reason}`),
    Match.exhaustive
  )

Сравнение с Schema.Literal

Для простых перечислений без дополнительных данных Schema.Literal проще:

import { Schema, Data, Match } from "effect"

// ✅ Простое перечисление — Schema.Literal
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type
// "low" | "medium" | "high" | "critical"

// ✅ Перечисление с данными — Data.TaggedEnum
type NotificationChannel = Data.TaggedEnum<{
  readonly Email: { readonly address: string }
  readonly SMS: { readonly phone: string }
  readonly Push: { readonly deviceToken: string }
  readonly Webhook: { readonly url: string; readonly secret: string }
}>

5. Schema.Struct + Brand — Составные Branded VO

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

Иногда нужен составной Value Object с brand, но без полноценного класса. Schema.Struct + Schema.brand даёт это:

import { Schema } from "effect"

// Координаты — составной branded тип
const GeoCoordinates = Schema.Struct({
  latitude: Schema.Number.pipe(
    Schema.greaterThanOrEqualTo(-90),
    Schema.lessThanOrEqualTo(90)
  ),
  longitude: Schema.Number.pipe(
    Schema.greaterThanOrEqualTo(-180),
    Schema.lessThanOrEqualTo(180)
  )
}).pipe(Schema.brand("GeoCoordinates"))

type GeoCoordinates = typeof GeoCoordinates.Type
// { latitude: number; longitude: number } & Brand<"GeoCoordinates">

// Декодирование
const coords = Schema.decodeUnknownSync(GeoCoordinates)({
  latitude: 55.7558,
  longitude: 37.6173
})

Полный паттерн создания Value Object

Рецепт для Simple VO (один примитив)

import { Schema } from "effect"

// Шаг 1: Определяем Schema с ограничениями
// Шаг 2: Добавляем brand
// Шаг 3: Экспортируем тип
// Шаг 4: Создаём smart constructors

// === TodoId ===
export const TodoId = Schema.String.pipe(
  Schema.pattern(
    /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
  ),
  Schema.brand("TodoId"),
  Schema.annotations({
    identifier: "TodoId",
    description: "UUID v4 identifier for Todo entity"
  })
)
export type TodoId = typeof TodoId.Type

// Smart constructors
export const decodeTodoId = Schema.decodeUnknown(TodoId)
export const decodeTodoIdSync = Schema.decodeUnknownSync(TodoId)

// Фабрика для генерации новых TodoId
export const generateTodoId = (): TodoId =>
  crypto.randomUUID() as TodoId

Рецепт для Composite VO (несколько полей)

import { Schema, Equal, Data } from "effect"

// Шаг 1: Определяем Schema.Class с полями
// Шаг 2: Добавляем операции как чистые функции
// Шаг 3: Экспортируем тип и smart constructor
// Шаг 4: (Опционально) Добавляем методы

// === DateRange ===
export class DateRange extends Schema.Class<DateRange>("DateRange")({
  start: Schema.DateFromString,
  end: Schema.DateFromString
}) {}

// Schema-level валидация (инварианты)
export const ValidDateRange = DateRange.pipe(
  Schema.filter(
    (range) =>
      range.start <= range.end
        ? undefined
        : "Start must be before or equal to end"
  )
)

// Чистые функции
export const dateRangeDurationDays = (range: DateRange): number =>
  Math.ceil(
    (range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)
  )

export const dateRangeContains = (range: DateRange, date: Date): boolean =>
  date >= range.start && date <= range.end

export const dateRangeOverlaps = (a: DateRange, b: DateRange): boolean =>
  a.start <= b.end && b.start <= a.end

// Smart constructors
export const createDateRange = Schema.decodeUnknown(ValidDateRange)

Рецепт для Enumeration VO

import { Schema } from "effect"

// Вариант A: Schema.Literal (простое перечисление)
export const Priority = Schema.Literal("low", "medium", "high", "critical")
export type Priority = typeof Priority.Type

// Чистые функции
export const priorityWeight = (p: Priority): number => {
  const weights: Record<Priority, number> = {
    low: 1,
    medium: 2,
    high: 3,
    critical: 4
  } as const
  return weights[p]
}

export const comparePriority = (a: Priority, b: Priority): number =>
  priorityWeight(a) - priorityWeight(b)

export const isUrgent = (p: Priority): boolean =>
  p === "high" || p === "critical"

Интеграция с Effect.Service (связь с Hexagonal)

Value Objects используются в портах как типы контрактов:

import { Schema, Effect, Context } from "effect"

// === Value Objects ===
export const TodoId = Schema.String.pipe(
  Schema.pattern(/^[0-9a-f-]+$/),
  Schema.brand("TodoId")
)
export type TodoId = typeof TodoId.Type

export class TodoTitle extends Schema.Class<TodoTitle>("TodoTitle")({
  value: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(200))
}) {}

export const Priority = Schema.Literal("low", "medium", "high", "critical")
export type Priority = typeof Priority.Type

// === Entity (использует Value Objects) ===
export class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(200)),
  priority: Priority,
  completed: Schema.Boolean,
  createdAt: Schema.DateFromSelf
}) {}

// === Port (контракт с Value Objects) ===
export class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly findByPriority: (p: Priority) => Effect.Effect<ReadonlyArray<Todo>>
    readonly save: (todo: Todo) => Effect.Effect<void>
    readonly delete: (id: TodoId) => Effect.Effect<void, TodoNotFound>
  }
>() {}

// Value Objects гарантируют, что:
// 1. В findById нельзя передать UserId вместо TodoId (branded types)
// 2. В findByPriority нельзя передать "invalid" (literal types)
// 3. В save нельзя передать невалидный Todo (Schema.Class)

Типичные ошибки и как их избежать

Ошибка 1: Использование new вместо decode для Schema.Class

class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number.pipe(Schema.positive()),
  currency: Schema.Literal("USD", "EUR")
}) {}

// ❌ ОПАСНО: new не выполняет Schema-валидацию
const bad = new Money({ amount: -100, currency: "USD" })
// amount = -100 проходит без ошибки!

// ✅ БЕЗОПАСНО: decode выполняет все проверки
const good = Schema.decodeUnknownSync(Money)({ amount: 100, currency: "USD" })

Правило: Используйте new только когда вы абсолютно уверены в валидности данных (например, внутри доменных операций, где данные уже прошли валидацию). На границах слоёв — всегда decode.

Ошибка 2: Забыть экспортировать тип

// ❌ Забыли экспортировать тип
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
// Тип доступен только через typeof TodoId.Type

// ✅ Экспортируем и Schema, и тип
export const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
export type TodoId = typeof TodoId.Type

Ошибка 3: Избыточный Brand на Schema.Class

// ❌ Schema.Class уже создаёт уникальный тип — Brand не нужен
class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number,
  currency: Schema.String
}) {}
// Money — уже уникальный номинальный тип благодаря Schema.Class

// ✅ Brand нужен для примитивов, а не для классов
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))

Ошибка 4: Schema.Class без readonly

// В Schema.Class все поля автоматически readonly
class Money extends Schema.Class<Money>("Money")({
  amount: Schema.Number,       // Уже readonly!
  currency: Schema.String      // Уже readonly!
}) {}

const m = new Money({ amount: 100, currency: "USD" })
// m.amount = 200  // ❌ Error: readonly

Резюме

ПодходПодходит дляВалидацияEqualityСериализация
Schema.brandSimple VO (Email, TodoId)=== на примитиве
Schema.ClassComposite VO (Money, Address)✅ Structural
Data.ClassВнутренние VO без decode✅ Structural
Data.TaggedEnumПеречисления с данными✅ Structural
Schema.LiteralПростые перечисления===

Главное правило: на границах слоёв (Ports, HTTP, Database) используйте Schema.Class и Schema.brand — они обеспечивают валидацию и сериализацию. Внутри домена допустимо использовать Data.Class для легковесных VO.

В следующих статьях мы подробно разберём конкретные примеры: Email, Money и другие Value Objects с полной реализацией и тестами.