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

Schema как инструмент доменного моделирования

Почему Schema — фундамент доменного моделирования. Проблема множественных определений. Schema как Single Source of Truth. Двойственная природа Type/Encoded. Schema в контексте гексагональной архитектуры. Parse don't validate. Schema как контракт порта.

Введение: почему Schema — фундамент доменного моделирования

В предыдущих модулях мы определили, что доменная модель — это сердце гексагональной архитектуры. Домен не зависит ни от инфраструктуры, ни от фреймворков. Но как именно мы описываем сущности, значения и правила домена в TypeScript?

Традиционный подход — использовать interface или type для описания структуры, а затем писать отдельные функции валидации, отдельные функции сериализации, отдельные функции для генерации тестовых данных. Это приводит к расщеплению знания о домене: информация о том, что такое Email, разбросана по десяткам файлов.

Effect Schema решает эту проблему радикально. Schema — это единый источник истины (Single Source of Truth), который одновременно описывает:

  1. Тип — TypeScript-тип на уровне компиляции
  2. Валидацию — правила проверки данных в рантайме
  3. Кодирование/Декодирование — трансформация между слоями (JSON ↔ Domain)
  4. Документацию — аннотации и описания для генерации документации
  5. Arbitrary — генерация случайных данных для property-based тестирования
  6. Equivalence — правила сравнения значений
  7. Pretty-printing — человекочитаемое представление

Всё это из одного определения. Это не просто удобство — это архитектурное решение, которое идеально ложится на гексагональную архитектуру.


Проблема множественных определений

Рассмотрим типичный подход без Schema. Допустим, у нас есть доменная сущность Todo:

// types.ts — определение типа
interface Todo {
  readonly id: string
  readonly title: string
  readonly completed: boolean
  readonly createdAt: Date
}

// validation.ts — валидация
function validateTodo(input: unknown): Todo {
  if (typeof input !== "object" || input === null) {
    throw new Error("Invalid todo")
  }
  const obj = input as Record<string, unknown>
  if (typeof obj.id !== "string" || obj.id.length === 0) {
    throw new Error("Invalid id")
  }
  if (typeof obj.title !== "string" || obj.title.length < 1 || obj.title.length > 255) {
    throw new Error("Invalid title")
  }
  // ... ещё 20 строк проверок
  return obj as unknown as Todo
}

// serialization.ts — сериализация для JSON
function todoToJson(todo: Todo): Record<string, unknown> {
  return {
    id: todo.id,
    title: todo.title,
    completed: todo.completed,
    createdAt: todo.createdAt.toISOString()
  }
}

// deserialization.ts — десериализация из JSON
function todoFromJson(json: Record<string, unknown>): Todo {
  return {
    id: json.id as string,
    title: json.title as string,
    completed: json.completed as boolean,
    createdAt: new Date(json.createdAt as string)
  }
}

// test-factory.ts — создание тестовых данных
function makeTodo(overrides?: Partial<Todo>): Todo {
  return {
    id: "test-id-1",
    title: "Test todo",
    completed: false,
    createdAt: new Date("2024-01-01"),
    ...overrides
  }
}

Проблемы этого подхода:

  • Рассинхронизация: добавил поле в interface — забыл обновить validateTodo, todoToJson, todoFromJson, makeTodo
  • Отсутствие композиции: каждая функция — отдельный мир, нет единого языка описания
  • Дублирование знаний: правило “title от 1 до 255 символов” записано в validateTodo, но не отражено в типе
  • Хрупкость: as unknown as Todo — это ложь компилятору, типы не гарантируют корректность
  • Нет типобезопасности: todoFromJson не проверяет данные, полагаясь на веру

Schema как Single Source of Truth

Теперь тот же Todo через Effect Schema:

import { Schema } from "effect"

const Todo = Schema.Struct({
  id: Schema.String.pipe(Schema.nonEmptyString()),
  title: Schema.String.pipe(
    Schema.minLength(1),
    Schema.maxLength(255)
  ),
  completed: Schema.Boolean,
  createdAt: Schema.DateFromString // Автоматически: string ↔ Date
})

// Тип извлекается автоматически
type Todo = typeof Todo.Type
// { readonly id: string; readonly title: string; readonly completed: boolean; readonly createdAt: Date }

// Encoded-тип (для JSON) тоже извлекается
type TodoEncoded = typeof Todo.Encoded
// { readonly id: string; readonly title: string; readonly completed: boolean; readonly createdAt: string }

Из одного определения мы получаем:

import { Schema } from "effect"

// 1. Валидация (decode unknown → Todo)
const decodeTodo = Schema.decodeUnknownEither(Todo)
const result = decodeTodo({ id: "1", title: "Buy milk", completed: false, createdAt: "2024-01-01T00:00:00Z" })
// Either<Todo, ParseError>

// 2. Кодирование (Todo → JSON)
const encodeTodo = Schema.encodeEither(Todo)
const json = encodeTodo({ id: "1", title: "Buy milk", completed: false, createdAt: new Date() })
// Either<TodoEncoded, ParseError>

// 3. Type guard
const isTodo = Schema.is(Todo)
isTodo({ id: "1", title: "Buy milk", completed: false, createdAt: new Date() }) // true

// 4. Asserts
const assertTodo = Schema.asserts(Todo)
// throws ParseError if invalid

// 5. Arbitrary для тестов (через @effect/schema)
import { Arbitrary } from "effect"
const todoArbitrary = Arbitrary.make(Todo)
// Генерирует случайные валидные Todo

Двойственная природа Schema: Type и Encoded

Ключевая концепция Schema — два типа для каждой схемы:

Schema<Type, Encoded, Requirements>
  • Type (он же A) — тип в доменном слое. Это то, с чем работает бизнес-логика. Например, Date, BigDecimal, Brand<string, "TodoId">.
  • Encoded (он же I) — тип для сериализованного представления. Это то, что приходит из JSON, из БД, из HTTP-запроса. Например, string для даты, number для BigDecimal.
  • Requirements (он же R) — зависимости, необходимые для decode/encode (обычно never).

Эта двойственность — именно то, что нужно гексагональной архитектуре:

                    ┌─────────────────┐
   Encoded (I)      │   Schema<A,I,R> │      Type (A)
   ───────────────> │                 │ ──────────────>
   JSON, DB row,    │   decode ──>    │   Domain types
   HTTP params      │   <── encode    │   Date, Brand, VO
                    └─────────────────┘

Encoded — это мир адаптеров (HTTP, SQLite, файлы). Type — это мир домена. Schema стоит на границе между ними — это и есть контракт порта.

// Schema определяет контракт между слоями
const DateField = Schema.DateFromString
// Type = Date         (домен работает с Date)
// Encoded = string    (адаптер работает со строкой "2024-01-15T10:30:00Z")

const Amount = Schema.BigDecimalFromNumber
// Type = BigDecimal   (домен работает с точными числами)
// Encoded = number    (JSON содержит number)

Schema в контексте гексагональной архитектуры

Посмотрим, как Schema вписывается в архитектуру Ports & Adapters:

┌─────────────────────────────────────────────────────────┐
│                    Driving Adapter                       │
│         (HTTP Handler / CLI / GraphQL)                  │
│                                                         │
│   req.body ──> Schema.decodeUnknown(CreateTodoInput)    │
│                         │                               │
│                    Encoded → Type                        │
└─────────────────────────│───────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│                   Application Core                       │
│                                                         │
│   CreateTodoInput (Type) ──> UseCase ──> Todo (Type)    │
│                                                         │
│   Всё уже в доменных типах:                             │
│   Date, TodoId, Title, Priority                         │
└─────────────────────────│───────────────────────────────┘


┌─────────────────────────────────────────────────────────┐
│                    Driven Adapter                        │
│            (SQLite Repository / FileStorage)             │
│                                                         │
│   Todo (Type) ──> Schema.encode(TodoRow) ──> DB INSERT  │
│                                                         │
│                    Type → Encoded                        │
└─────────────────────────────────────────────────────────┘

Schema выступает в двух ролях:

  1. На входящей границе (Driving Adapter → Core): decode превращает сырые данные (JSON, form data) в доменные типы
  2. На исходящей границе (Core → Driven Adapter): encode превращает доменные типы в формат хранилища (SQL row, JSON для внешнего API)

Schema vs Zod / io-ts / Yup: почему именно Effect Schema

Может возникнуть вопрос: чем Effect Schema лучше Zod, io-ts или Yup? Ключевые отличия:

1. Двойной тип (Type + Encoded)

// Zod — только один тип
const zodSchema = z.string().datetime()
type A = z.infer<typeof zodSchema> // string — просто string, не Date!

// Effect Schema — два типа
const effectSchema = Schema.DateFromString
type A = typeof effectSchema.Type    // Date — настоящий Date!
type I = typeof effectSchema.Encoded // string — для сериализации

У Zod нет концепции “encoded” типа. Если вы хотите работать с Date в домене, но с string в JSON, вам придётся писать .transform() вручную и терять информацию об исходном типе. Effect Schema хранит оба типа и может двунаправленно трансформировать.

2. Интеграция с Effect ecosystem

import { Schema, Effect } from "effect"

// Schema.decode возвращает Effect — встраивается в pipe
const program = Effect.gen(function* () {
  const input = yield* Schema.decodeUnknown(Todo)(rawData)
  // input уже типизирован как Todo
  // ошибка ParseError уже в E-канале
  return input
})

Ошибки декодирования автоматически попадают в E-канал Effect. Не нужно оборачивать в try/catch, не нужно конвертировать ошибки.

3. Composability

Schema из Effect — это обычные значения, которые можно композировать функционально:

const NonEmptyString = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255)
)

const TodoTitle = NonEmptyString.pipe(
  Schema.brand("TodoTitle")
)

// Переиспользуем в любом Struct
const Todo = Schema.Struct({
  title: TodoTitle,
  description: NonEmptyString.pipe(Schema.optional)
})

4. Поддержка Arbitrary

import { Arbitrary, FastCheck } from "effect"

// Из Schema автоматически генерируется Arbitrary
const arb = Arbitrary.make(Todo)

// Используем в property-based тестах
FastCheck.assert(
  FastCheck.property(arb, (todo) => {
    // todo — гарантированно валидный Todo
    return todo.title.length > 0
  })
)

Философия “Parse, don’t validate”

Effect Schema реализует подход “Parse, don’t validate” (Alexis King). Суть: вместо того чтобы проверить данные и вернуть boolean, мы парсим данные и возвращаем типизированный результат:

// ❌ Validate approach — знание теряется после проверки
function isValidEmail(s: string): boolean {
  return /^[^@]+@[^@]+\.[^@]+$/.test(s)
}

const input = "user@example.com"
if (isValidEmail(input)) {
  sendEmail(input) // input всё ещё string — компилятор не знает, что это Email
}

// ✅ Parse approach — знание кодируется в типе
const Email = Schema.String.pipe(
  Schema.pattern(/^[^@]+@[^@]+\.[^@]+$/),
  Schema.brand("Email")
)
type Email = typeof Email.Type // string & Brand<"Email">

const parsed = Schema.decodeUnknownSync(Email)("user@example.com")
// parsed: Email — компилятор ЗНАЕТ, что это валидный Email
sendEmail(parsed) // типобезопасно!

Этот подход гарантирует, что если значение имеет тип Email, оно уже валидно. Невозможно создать невалидный Email в обход Schema. Это называется “making illegal states unrepresentable” — один из ключевых принципов доменного моделирования.


Schema как контракт порта

В гексагональной архитектуре Schema естественно выступает контрактом порта. Вот как это выглядит:

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

// ═══════════════════════════════════════
// Доменный тип (Schema = контракт)
// ═══════════════════════════════════════
const TodoId = Schema.String.pipe(Schema.brand("TodoId"))
type TodoId = typeof TodoId.Type

const Todo = Schema.Struct({
  id: TodoId,
  title: Schema.String.pipe(Schema.nonEmptyString(), Schema.maxLength(255)),
  completed: Schema.Boolean,
  createdAt: Schema.DateFromString
})
type Todo = typeof Todo.Type

// ═══════════════════════════════════════
// Порт (Effect.Service)
// ═══════════════════════════════════════
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
    readonly save: (todo: Todo) => Effect.Effect<void>
  }
>() {}

// ═══════════════════════════════════════
// Адаптер (Layer) — использует Schema для маппинга
// ═══════════════════════════════════════

// Схема строки БД (Encoded-сторона)
const TodoRow = Schema.Struct({
  id: Schema.String,
  title: Schema.String,
  completed: Schema.Number, // SQLite хранит boolean как 0/1
  created_at: Schema.String  // SQLite хранит дату как строку
})

// Маппинг между доменом и БД
const TodoFromRow = Schema.transform(
  TodoRow,
  Todo,
  {
    strict: true,
    decode: (row) => ({
      id: row.id as TodoId,
      title: row.title,
      completed: row.completed === 1,
      createdAt: new Date(row.created_at)
    }),
    encode: (todo) => ({
      id: todo.id,
      title: todo.title,
      completed: todo.completed ? 1 : 0,
      created_at: todo.createdAt.toISOString()
    })
  }
)

Обратите внимание на разделение ответственности:

  • Domain определяет Todo через Schema с доменными типами (Date, TodoId)
  • Port (TodoRepository) оперирует доменными типами
  • Adapter (SQLite Layer) использует Schema.transform для маппинга между TodoRow (формат БД) и Todo (формат домена)

Schema стоит на каждой границе и обеспечивает типобезопасное преобразование.


Принципы использования Schema в домене

1. Schema определяется в домене, используется на границах

domain/
  schemas/
    todo.ts          ← Schema определяется здесь
    todo-id.ts
    priority.ts
ports/
  todo-repository.ts ← Порт использует доменные типы
adapters/
  sqlite/
    todo-mapper.ts   ← Адаптер использует Schema.transform
  http/
    todo-handler.ts  ← Хендлер использует Schema.decode

2. Доменные Schema не зависят от инфраструктуры

// ✅ Правильно — Schema описывает домен
const TodoTitle = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255),
  Schema.brand("TodoTitle")
)

// ❌ Неправильно — инфраструктурные детали в доменной Schema
const TodoTitle = Schema.String.pipe(
  Schema.nonEmptyString(),
  Schema.maxLength(255), // VARCHAR(255) в SQL ← это утечка инфраструктуры
  Schema.annotations({ sqlType: "VARCHAR(255)" }) // ← тем более
)

3. Schema объединяет валидацию с типом

// Вся бизнес-логика валидации — в Schema
const Priority = Schema.Literal("low", "medium", "high", "critical")
type Priority = typeof Priority.Type // "low" | "medium" | "high" | "critical"

// Невозможно создать Priority со значением "urgent" — тип не позволит
// Невозможно передать невалидную строку — Schema отвергнет при decode

Итоги главы

АспектБез SchemaС Effect Schema
Определение типаinterface, typeSchema.Struct(...)
ВалидацияОтдельные функцииВстроена в Schema
СериализацияОтдельные функцииSchema.encode / Schema.decode
Тестовые данныеРучные фабрикиArbitrary.make(schema)
СравнениеРучная реализацияSchema.equivalence
СинхронизацияРучная, хрупкаяАвтоматическая, из одного определения
ДвунаправленностьНетType ↔ Encoded

Schema — это не просто валидатор. Это язык описания домена, который одновременно:

  • Описывает структуру (какие поля, какие типы)
  • Описывает ограничения (минимальная длина, паттерн, диапазон)
  • Описывает трансформации (Date ↔ string, BigDecimal ↔ number)
  • Описывает идентичность (Brand, Class)
  • Описывает отношения (extend, pick, omit)

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