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), который одновременно описывает:
- Тип — TypeScript-тип на уровне компиляции
- Валидацию — правила проверки данных в рантайме
- Кодирование/Декодирование — трансформация между слоями (JSON ↔ Domain)
- Документацию — аннотации и описания для генерации документации
- Arbitrary — генерация случайных данных для property-based тестирования
- Equivalence — правила сравнения значений
- 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 выступает в двух ролях:
- На входящей границе (Driving Adapter → Core):
decodeпревращает сырые данные (JSON, form data) в доменные типы - На исходящей границе (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, type | Schema.Struct(...) |
| Валидация | Отдельные функции | Встроена в Schema |
| Сериализация | Отдельные функции | Schema.encode / Schema.decode |
| Тестовые данные | Ручные фабрики | Arbitrary.make(schema) |
| Сравнение | Ручная реализация | Schema.equivalence |
| Синхронизация | Ручная, хрупкая | Автоматическая, из одного определения |
| Двунаправленность | Нет | Type ↔ Encoded |
Schema — это не просто валидатор. Это язык описания домена, который одновременно:
- Описывает структуру (какие поля, какие типы)
- Описывает ограничения (минимальная длина, паттерн, диапазон)
- Описывает трансформации (Date ↔ string, BigDecimal ↔ number)
- Описывает идентичность (Brand, Class)
- Описывает отношения (extend, pick, omit)
В следующей главе мы подробно рассмотрим базовые схемы — строительные блоки, из которых собираются сложные доменные модели.