Типобезопасный домен: Гексагональная архитектура на базе Effect Schema.Struct vs Schema.Class: моделирование сущностей
Глава

Schema.Struct vs Schema.Class: моделирование сущностей

Schema.Struct — структуры данных, вложенные структуры, optional поля. Schema.Class — классы с идентичностью, номинальная типизация, методы, Smart Constructors. TaggedStruct и TaggedClass. Наследование через extend. Рекурсивные типы. Когда Struct, а когда Class.

Введение: от примитивов к составным типам

В предыдущей главе мы научились создавать схемы для примитивных типов. Но доменные модели — это составные структуры: Entity, Value Object, DTO. Для их описания Effect Schema предоставляет два основных инструмента:

  • Schema.Struct — описывает структуру данных (product type)
  • Schema.Class — описывает класс с идентичностью и поведением

Выбор между ними — архитектурное решение, которое напрямую влияет на дизайн доменной модели.


Schema.Struct — структура данных

Основы

Schema.Struct создаёт схему для объекта с фиксированным набором полей:

import { Schema } from "effect"

const Person = Schema.Struct({
  name: Schema.String,
  age: Schema.Number.pipe(Schema.int(), Schema.nonNegative()),
  email: Schema.String
})

// Автоматическое извлечение типов
type Person = typeof Person.Type
// { readonly name: string; readonly age: number; readonly email: string }

type PersonEncoded = typeof Person.Encoded
// { readonly name: string; readonly age: number; readonly email: string }

// Decode
const person = Schema.decodeUnknownSync(Person)({
  name: "Alice",
  age: 30,
  email: "alice@example.com"
})
// person: Person

Обратите внимание: все поля автоматически readonly. Schema по умолчанию создаёт иммутабельные структуры — это полностью соответствует принципам функционального программирования.

Вложенные структуры

import { Schema } from "effect"

const Address = Schema.Struct({
  street: Schema.String,
  city: Schema.String,
  zip: Schema.String.pipe(Schema.pattern(/^\d{5}$/))
})

const Company = Schema.Struct({
  name: Schema.String,
  address: Address,   // вложенная Schema
  founded: Schema.DateFromString
})

type Company = typeof Company.Type
// {
//   readonly name: string
//   readonly address: { readonly street: string; readonly city: string; readonly zip: string }
//   readonly founded: Date
// }

type CompanyEncoded = typeof Company.Encoded
// {
//   readonly name: string
//   readonly address: { readonly street: string; readonly city: string; readonly zip: string }
//   readonly founded: string   ← Date трансформировался в string!
// }

Optional поля

import { Schema } from "effect"

const Todo = Schema.Struct({
  id: Schema.String,
  title: Schema.String,

  // ═══════════════════════════════════════
  // Варианты optional
  // ═══════════════════════════════════════

  // 1. optional — поле может отсутствовать
  description: Schema.optional(Schema.String),
  // Type: string | undefined, поле может отсутствовать в объекте

  // 2. optional с exact: true
  notes: Schema.optional(Schema.String, { exact: true }),
  // Строго: поле может быть undefined, но не missing

  // 3. optional с default
  completed: Schema.optional(Schema.Boolean, { default: () => false }),
  // Type: boolean (NOT optional!) — default делает поле обязательным в Type
  // Encoded: boolean | undefined — при decode missing/undefined → false

  // 4. optional с nullable
  dueDate: Schema.optional(Schema.DateFromString, { nullable: true }),
  // Type: Date | undefined
  // Encoded: string | null | undefined

  // 5. optionalWith as: "Option" — превращает в Option<T>
  assignee: Schema.optional(Schema.String, { as: "Option" }),
  // Type: Option<string> — Option.none() если отсутствует
  // Encoded: string | undefined
})

Разберём optional с default подробнее — это очень полезный паттерн:

import { Schema } from "effect"

const Config = Schema.Struct({
  host: Schema.optional(Schema.String, { default: () => "localhost" }),
  port: Schema.optional(Schema.Number, { default: () => 3000 }),
  debug: Schema.optional(Schema.Boolean, { default: () => false }),
})

type Config = typeof Config.Type
// { readonly host: string; readonly port: number; readonly debug: boolean }
// Все поля required! Default убирает optional на уровне Type

// Можно декодировать пустой объект — все дефолты подставятся
const config = Schema.decodeUnknownSync(Config)({})
// { host: "localhost", port: 3000, debug: false }

// Или переопределить часть
const customConfig = Schema.decodeUnknownSync(Config)({ port: 8080 })
// { host: "localhost", port: 8080, debug: false }

Struct с дополнительными полями

По умолчанию Schema.Struct отбрасывает лишние поля при декодировании (что безопасно):

import { Schema } from "effect"

const User = Schema.Struct({
  name: Schema.String,
  age: Schema.Number
})

// Лишние поля отбрасываются
const user = Schema.decodeUnknownSync(User)({
  name: "Alice",
  age: 30,
  extra: "ignored"  // это поле будет отброшено
})
// user = { name: "Alice", age: 30 }

Если нужно сохранить дополнительные поля:

// Record позволяет сохранить дополнительные поля
const Extensible = Schema.Struct(
  { name: Schema.String },             // обязательные поля
  Schema.Record({ key: Schema.String, value: Schema.Unknown })  // дополнительные
)

Schema.Class — классы с идентичностью

Основы Schema.Class

Schema.Class создаёт настоящий TypeScript class с конструктором, instanceof, и методами:

import { Schema } from "effect"

class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String,
  title: Schema.String.pipe(Schema.nonEmptyString()),
  completed: Schema.Boolean,
  createdAt: Schema.DateFromString
}) {}

// Todo — это класс
const todo = new Todo({
  id: "1",
  title: "Buy milk",
  completed: false,
  createdAt: new Date()
})

// instanceof работает
console.log(todo instanceof Todo) // true

// Поля readonly
todo.title     // "Buy milk"
// todo.title = "x" // ❌ Error: Cannot assign to read-only property

// Тип извлекается
type TodoType = typeof Todo.Type
// Todo (экземпляр класса)

// Encoded тип
type TodoEncoded = typeof Todo.Encoded
// { readonly id: string; readonly title: string; readonly completed: boolean; readonly createdAt: string }

Ключевое отличие: номинальная типизация

Schema.Struct создаёт структурный тип — два Struct с одинаковыми полями совместимы:

import { Schema } from "effect"

const PointA = Schema.Struct({ x: Schema.Number, y: Schema.Number })
const PointB = Schema.Struct({ x: Schema.Number, y: Schema.Number })

type A = typeof PointA.Type // { readonly x: number; readonly y: number }
type B = typeof PointB.Type // { readonly x: number; readonly y: number }
// A и B — ОДИН И ТОТ ЖЕ тип! TypeScript считает их совместимыми

Schema.Class создаёт номинальный тип — два класса с одинаковыми полями различны:

import { Schema } from "effect"

class CartesianPoint extends Schema.Class<CartesianPoint>("CartesianPoint")({
  x: Schema.Number,
  y: Schema.Number
}) {}

class PolarPoint extends Schema.Class<PolarPoint>("PolarPoint")({
  x: Schema.Number, // r (radius)
  y: Schema.Number   // θ (angle)
}) {}

// CartesianPoint и PolarPoint — РАЗНЫЕ типы!
const cartesian = new CartesianPoint({ x: 3, y: 4 })
const polar = new PolarPoint({ x: 5, y: 0.93 })

function distanceFromOrigin(p: CartesianPoint): number {
  return Math.sqrt(p.x ** 2 + p.y ** 2)
}

distanceFromOrigin(cartesian) // ✅ OK
// distanceFromOrigin(polar)  // ❌ Type error! PolarPoint не CartesianPoint

Это критически важно для доменного моделирования: UserId и OrderId — это разные сущности, даже если оба содержат string.

Методы в Schema.Class

Schema.Class позволяет добавлять методы — поведение сущности:

import { Schema, Option } from "effect"

class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String,
  title: Schema.String.pipe(Schema.nonEmptyString()),
  completed: Schema.Boolean,
  priority: Schema.Literal("low", "medium", "high"),
  dueDate: Schema.optional(Schema.DateFromString, { as: "Option" })
}) {
  // ═══════════════════════════════════════
  // Методы — поведение сущности
  // ═══════════════════════════════════════

  /** Завершить задачу */
  complete(): Todo {
    return new Todo({ ...this, completed: true })
  }

  /** Изменить приоритет */
  withPriority(priority: "low" | "medium" | "high"): Todo {
    return new Todo({ ...this, priority })
  }

  /** Изменить заголовок */
  withTitle(title: string): Todo {
    return new Todo({ ...this, title })
  }

  /** Просрочена ли задача */
  isOverdue(now: Date): boolean {
    return Option.match(this.dueDate, {
      onNone: () => false,
      onSome: (due) => !this.completed && due < now
    })
  }

  /** Является ли высокоприоритетной */
  get isHighPriority(): boolean {
    return this.priority === "high"
  }
}

// Использование
const todo = new Todo({
  id: "1",
  title: "Deploy to production",
  completed: false,
  priority: "high",
  dueDate: Option.some(new Date("2024-12-31"))
})

const completed = todo.complete()
// completed — новый Todo с completed: true
// todo — неизменён (immutable!)

console.log(completed.completed) // true
console.log(todo.completed)      // false — оригинал не изменился

Важно: методы не мутируют объект, а возвращают новый экземпляр. Это иммутабельный подход, полностью соответствующий функциональному стилю.

Статические методы и фабрики

import { Schema, Effect } from "effect"

class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String,
  title: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(255)),
  completed: Schema.Boolean,
  createdAt: Schema.DateFromString
}) {
  /** Фабричный метод: создать новую задачу */
  static create(params: {
    readonly id: string
    readonly title: string
  }): Todo {
    return new Todo({
      ...params,
      completed: false,
      createdAt: new Date()
    })
  }

  /** Безопасная фабрика через Effect */
  static createSafe(params: {
    readonly id: string
    readonly title: string
  }): Effect.Effect<Todo, Schema.ParseError> {
    return Schema.decode(Todo)({
      ...params,
      completed: false,
      createdAt: new Date()
    })
  }

  complete(): Todo {
    return new Todo({ ...this, completed: true })
  }
}

// Использование фабрики
const todo = Todo.create({ id: "1", title: "Buy milk" })
// todo.completed === false
// todo.createdAt === now

Когда Struct, а когда Class?

Это ключевой вопрос проектирования. Вот чёткие критерии:

Используйте Schema.Struct когда:

✅ Value Objects (без идентичности)
✅ DTO (Data Transfer Objects)
✅ Промежуточные структуры (маппинг БД)
✅ Конфигурация
✅ Параметры запросов / ответы
import { Schema } from "effect"

// Value Object — нет идентичности, только значение
const Money = Schema.Struct({
  amount: Schema.BigDecimalFromNumber,
  currency: Schema.Literal("USD", "EUR", "RUB")
})

// DTO — структура для передачи данных
const CreateTodoInput = Schema.Struct({
  title: Schema.String.pipe(Schema.nonEmptyString()),
  priority: Schema.optional(Schema.Literal("low", "medium", "high"), {
    default: () => "medium" as const
  }),
  dueDate: Schema.optional(Schema.DateFromString)
})

// Маппинг строки БД
const TodoRow = Schema.Struct({
  id: Schema.String,
  title: Schema.String,
  completed: Schema.Number,
  created_at: Schema.String,
  updated_at: Schema.NullOr(Schema.String)
})

// Конфигурация
const AppConfig = Schema.Struct({
  host: Schema.String,
  port: Schema.Number.pipe(Schema.int(), Schema.between(1, 65535)),
  debug: Schema.Boolean
})

Используйте Schema.Class когда:

✅ Entity (объект с идентичностью)
✅ Aggregate Root (корень агрегата)
✅ Объекты с поведением (методы)
✅ Объекты, требующие instanceof
✅ Tagged union members (через _tag)
import { Schema, Option } from "effect"

// Entity — объект с идентичностью (id)
class Todo extends Schema.Class<Todo>("Todo")({
  id: Schema.String,
  title: Schema.String.pipe(Schema.nonEmptyString()),
  status: Schema.Literal("draft", "active", "completed", "archived"),
  createdAt: Schema.DateFromString
}) {
  activate(): Todo {
    if (this.status !== "draft") {
      throw new Error(`Cannot activate todo in status: ${this.status}`)
    }
    return new Todo({ ...this, status: "active" as const })
  }

  complete(): Todo {
    if (this.status !== "active") {
      throw new Error(`Cannot complete todo in status: ${this.status}`)
    }
    return new Todo({ ...this, status: "completed" as const })
  }
}

// Domain Event — Tagged Class
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", {
  todoId: Schema.String,
  title: Schema.String,
  createdAt: Schema.DateFromString
}) {}

class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()("TodoCompleted", {
  todoId: Schema.String,
  completedAt: Schema.DateFromString
}) {}

Сводная таблица

КритерийSchema.StructSchema.Class
ТипизацияСтруктурнаяНоминальная (instanceof)
МетодыНетДа
НаследованиеНетЧерез extend
_tag (discriminator)ВручнуюЧерез TaggedClass
ИммутабельностьПо умолчанию readonlyПо умолчанию readonly
ИдентичностьНетДа
ИспользованиеVO, DTO, конфигEntity, Aggregate, Event
ВесЛегковесныйТяжелее (class)

Schema.TaggedStruct и Schema.TaggedClass

TaggedStruct — дискриминированный Struct

import { Schema } from "effect"

// TaggedStruct добавляет _tag автоматически
const Circle = Schema.TaggedStruct("Circle", {
  radius: Schema.Number.pipe(Schema.positive())
})

type Circle = typeof Circle.Type
// { readonly _tag: "Circle"; readonly radius: number }

const Rectangle = Schema.TaggedStruct("Rectangle", {
  width: Schema.Number.pipe(Schema.positive()),
  height: Schema.Number.pipe(Schema.positive())
})

// Удобно для discriminated unions
const Shape = Schema.Union(Circle, Rectangle)
type Shape = typeof Shape.Type
// { _tag: "Circle"; radius: number } | { _tag: "Rectangle"; width: number; height: number }

TaggedClass — дискриминированный Class

import { Schema } from "effect"

// TaggedClass — РЕКОМЕНДОВАННЫЙ подход для Domain Events
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", {
  todoId: Schema.String,
  title: Schema.String,
  createdAt: Schema.DateFromString
}) {}

class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()("TodoCompleted", {
  todoId: Schema.String,
  completedAt: Schema.DateFromString
}) {}

class TodoArchived extends Schema.TaggedClass<TodoArchived>()("TodoArchived", {
  todoId: Schema.String,
  archivedAt: Schema.DateFromString
}) {}

// Объединение в union событий
const TodoEvent = Schema.Union(TodoCreated, TodoCompleted, TodoArchived)
type TodoEvent = typeof TodoEvent.Type
// TodoCreated | TodoCompleted | TodoArchived

// Pattern matching по _tag
const describeEvent = (event: TodoEvent): string => {
  switch (event._tag) {
    case "TodoCreated":
      return `Todo "${event.title}" created`
    case "TodoCompleted":
      return `Todo ${event.todoId} completed`
    case "TodoArchived":
      return `Todo ${event.todoId} archived`
  }
}

// instanceof работает
const event: TodoEvent = new TodoCreated({
  todoId: "1",
  title: "Buy milk",
  createdAt: new Date()
})

if (event instanceof TodoCreated) {
  console.log(event.title) // TypeScript знает, что это TodoCreated
}

Наследование и extend

Extend для Struct

import { Schema } from "effect"

// Базовая структура
const BaseEntity = Schema.Struct({
  id: Schema.String,
  createdAt: Schema.DateFromString,
  updatedAt: Schema.DateFromString
})

// Расширение — добавление полей
const Todo = Schema.extend(
  BaseEntity,
  Schema.Struct({
    title: Schema.String.pipe(Schema.nonEmptyString()),
    completed: Schema.Boolean,
    priority: Schema.Literal("low", "medium", "high")
  })
)

type Todo = typeof Todo.Type
// {
//   readonly id: string
//   readonly createdAt: Date
//   readonly updatedAt: Date
//   readonly title: string
//   readonly completed: boolean
//   readonly priority: "low" | "medium" | "high"
// }

Extend для Class

import { Schema } from "effect"

// Базовый класс
class BaseEntity extends Schema.Class<BaseEntity>("BaseEntity")({
  id: Schema.String,
  createdAt: Schema.DateFromString,
  updatedAt: Schema.DateFromString
}) {
  get age(): number {
    return Date.now() - this.createdAt.getTime()
  }
}

// Наследование через extend
class Todo extends BaseEntity.extend<Todo>("Todo")({
  title: Schema.String.pipe(Schema.nonEmptyString()),
  completed: Schema.Boolean
}) {
  complete(): Todo {
    return new Todo({
      ...this,
      completed: true,
      updatedAt: new Date()
    })
  }
}

const todo = new Todo({
  id: "1",
  title: "Buy milk",
  completed: false,
  createdAt: new Date(),
  updatedAt: new Date()
})

// Методы базового класса доступны
console.log(todo.age)

// И методы наследника
const done = todo.complete()

Паттерн “Smart Constructor” через Schema.Class

“Smart Constructor” — это фабрика, которая гарантирует валидность создаваемого объекта. Schema.Class идеально подходит для этого:

import { Schema, Effect } from "effect"

// ═══════════════════════════════════════
// Доменные типы-значения
// ═══════════════════════════════════════
const TodoTitle = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255),
  Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type

const TodoId = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.brand("TodoId")
)
type TodoId = typeof TodoId.Type

// ═══════════════════════════════════════
// Entity через Schema.Class
// ═══════════════════════════════════════
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  completed: Schema.Boolean,
  createdAt: Schema.DateFromString
}) {
  // Smart Constructor — единственный способ создать Todo
  static create(
    id: TodoId,
    title: TodoTitle
  ): Todo {
    return new Todo({
      id,
      title,
      completed: false,
      createdAt: new Date()
    })
  }

  // Безопасный Smart Constructor (с валидацией через Effect)
  static fromRaw(raw: {
    readonly id: string
    readonly title: string
  }): Effect.Effect<Todo, Schema.ParseError> {
    return Effect.gen(function* () {
      const id = yield* Schema.decode(TodoId)(raw.id)
      const title = yield* Schema.decode(TodoTitle)(raw.title)
      return Todo.create(id, title)
    })
  }

  complete(): Todo {
    return new Todo({ ...this, completed: true })
  }

  rename(newTitle: TodoTitle): Todo {
    return new Todo({ ...this, title: newTitle })
  }
}

Рекурсивные типы

Для моделирования деревьев, вложенных комментариев, каталогов:

import { Schema } from "effect"

// Рекурсивная структура: категория с подкатегориями
interface Category {
  readonly name: string
  readonly children: ReadonlyArray<Category>
}

const Category: Schema.Schema<Category> = Schema.suspend(() =>
  Schema.Struct({
    name: Schema.String,
    children: Schema.Array(Category)
  })
)

// Использование
const decoded = Schema.decodeUnknownSync(Category)({
  name: "Root",
  children: [
    {
      name: "Work",
      children: [
        { name: "Urgent", children: [] },
        { name: "Backlog", children: [] }
      ]
    },
    {
      name: "Personal",
      children: []
    }
  ]
})

Практический пример: полная доменная модель Todo

Соберём все знания вместе:

import { Schema, Option } from "effect"

// ═══════════════════════════════════════
// 1. Branded Value Types (Struct-based)
// ═══════════════════════════════════════
const TodoId = Schema.String.pipe(Schema.nonEmptyString(), Schema.brand("TodoId"))
type TodoId = typeof TodoId.Type

const TodoTitle = Schema.Trim.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255),
  Schema.brand("TodoTitle")
)
type TodoTitle = typeof TodoTitle.Type

// ═══════════════════════════════════════
// 2. Enumerations (Literal-based)
// ═══════════════════════════════════════
const TodoStatus = Schema.Literal("draft", "active", "completed", "archived")
type TodoStatus = typeof TodoStatus.Type

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

// ═══════════════════════════════════════
// 3. Value Objects (Struct-based)
// ═══════════════════════════════════════
const TodoMetadata = Schema.Struct({
  tags: Schema.Array(Schema.String.pipe(Schema.nonEmptyString())).pipe(
    Schema.maxItems(20)
  ),
  source: Schema.optional(Schema.Literal("web", "api", "import"), {
    default: () => "web" as const
  })
})
type TodoMetadata = typeof TodoMetadata.Type

// ═══════════════════════════════════════
// 4. Entity (Class-based)
// ═══════════════════════════════════════
class Todo extends Schema.Class<Todo>("Todo")({
  id: TodoId,
  title: TodoTitle,
  status: TodoStatus,
  priority: Priority,
  dueDate: Schema.optional(Schema.DateFromString, { as: "Option" }),
  metadata: TodoMetadata,
  createdAt: Schema.DateFromString,
  updatedAt: Schema.DateFromString
}) {
  static create(params: {
    readonly id: TodoId
    readonly title: TodoTitle
    readonly priority?: Priority
  }): Todo {
    const now = new Date()
    return new Todo({
      id: params.id,
      title: params.title,
      status: "draft",
      priority: params.priority ?? "medium",
      dueDate: Option.none(),
      metadata: { tags: [], source: "web" },
      createdAt: now,
      updatedAt: now
    })
  }

  activate(): Todo {
    return new Todo({ ...this, status: "active", updatedAt: new Date() })
  }

  complete(): Todo {
    return new Todo({ ...this, status: "completed", updatedAt: new Date() })
  }

  archive(): Todo {
    return new Todo({ ...this, status: "archived", updatedAt: new Date() })
  }

  withPriority(priority: Priority): Todo {
    return new Todo({ ...this, priority, updatedAt: new Date() })
  }

  withDueDate(date: Date): Todo {
    return new Todo({ ...this, dueDate: Option.some(date), updatedAt: new Date() })
  }

  isOverdue(now: Date): boolean {
    return Option.match(this.dueDate, {
      onNone: () => false,
      onSome: (due) => this.status === "active" && due < now
    })
  }
}

// ═══════════════════════════════════════
// 5. Domain Events (TaggedClass-based)
// ═══════════════════════════════════════
class TodoCreated extends Schema.TaggedClass<TodoCreated>()("TodoCreated", {
  todoId: TodoId,
  title: TodoTitle,
  priority: Priority,
  occurredAt: Schema.DateFromString
}) {}

class TodoStatusChanged extends Schema.TaggedClass<TodoStatusChanged>()("TodoStatusChanged", {
  todoId: TodoId,
  from: TodoStatus,
  to: TodoStatus,
  occurredAt: Schema.DateFromString
}) {}

const TodoEvent = Schema.Union(TodoCreated, TodoStatusChanged)
type TodoEvent = typeof TodoEvent.Type

// ═══════════════════════════════════════
// 6. DTOs (Struct-based)
// ═══════════════════════════════════════
const CreateTodoInput = Schema.Struct({
  title: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(255)),
  priority: Schema.optional(Priority, { default: () => "medium" as const }),
  dueDate: Schema.optional(Schema.DateFromString),
  tags: Schema.optional(Schema.Array(Schema.String), { default: () => [] as ReadonlyArray<string> })
})
type CreateTodoInput = typeof CreateTodoInput.Type

const TodoOutput = Schema.Struct({
  id: Schema.String,
  title: Schema.String,
  status: TodoStatus,
  priority: Priority,
  dueDate: Schema.NullOr(Schema.String),
  tags: Schema.Array(Schema.String),
  createdAt: Schema.String,
  updatedAt: Schema.String
})
type TodoOutput = typeof TodoOutput.Type

Итоги главы

ИнструментТипКогда использоватьПример
Schema.StructСтруктурныйVO, DTO, конфиг, маппингMoney, CreateTodoInput, TodoRow
Schema.ClassНоминальныйEntity с поведениемTodo, User, Order
Schema.TaggedStructСтруктурный + _tagЧлены union без поведенияПростые events
Schema.TaggedClassНоминальный + _tagDomain Events, CommandsTodoCreated, CreateTodo

Ключевые принципы:

  1. Struct для данных, Class для поведения: если объект только хранит данные — Struct; если имеет методы и идентичность — Class
  2. Иммутабельность везде: методы Class возвращают новый экземпляр, а не мутируют текущий
  3. Smart Constructors: используйте статические фабричные методы для гарантии инвариантов
  4. TaggedClass для событий: _tag автоматически добавляется, что идеально для pattern matching
  5. Extend для наследования: и Struct, и Class поддерживают расширение

В следующей главе мы подробно разберём Branded Types — механизм, превращающий обычные примитивы в типобезопасные доменные идентификаторы.