Чистота домена: нулевые зависимости от инфраструктуры
Принцип нулевых зависимостей, паттерны загрязнения домена (прямой импорт, утечка типов, глобальное состояние, побочные эффекты), как Effect-ts обеспечивает чистоту через R-канал и E-канал, допустимые зависимости, структурное обеспечение чистоты (tsconfig, ESLint, архитектурные тесты, отдельный package.json), Dependency Inversion в домене, обработка времени и случайности, чеклист чистоты
Принцип нулевых зависимостей
Чистота домена — это не эстетический выбор и не перфекционизм. Это архитектурный принцип, от соблюдения которого зависит долгосрочная жизнеспособность системы. Принцип формулируется просто:
Доменный слой не должен иметь ни одного импорта из инфраструктурного слоя.
Ни одного. Ноль. Это абсолютное правило, не имеющее исключений.
В терминах модулей TypeScript это означает: файлы в директории domain/ никогда не содержат import из adapters/, infrastructure/, и не используют библиотеки, привязанные к конкретной технологии (express, sqlite, node:fs, и т.д.).
domain/
├── entities/
│ └── todo.ts ← импортирует ТОЛЬКО из domain/ и effect
├── value-objects/
│ └── priority.ts ← импортирует ТОЛЬКО из domain/ и effect
├── errors/
│ └── todo-errors.ts ← импортирует ТОЛЬКО из domain/ и effect
└── events/
└── todo-events.ts ← импортирует ТОЛЬКО из domain/ и effect
❌ ЗАПРЕЩЕНО в любом файле domain/:
import { Database } from "bun:sqlite"
import { HttpServer } from "@effect/platform"
import { Router } from "express"
import { readFile } from "node:fs/promises"
Зачем нужна чистота домена
1. Долговечность бизнес-логики
Технологии устаревают быстро. Express, Koa, Hono, Fastify — каждые несколько лет появляется новый «лучший» HTTP-фреймворк. Базы данных, облачные провайдеры, протоколы обмена — всё это меняется.
Бизнес-правила меняются медленно. «Задача имеет приоритет» — это правило, которое будет актуально через 10 лет. Если бизнес-логика перемешана с инфраструктурой, то при замене фреймворка придётся переписывать и бизнес-правила. Это дорого, рискованно и абсолютно не нужно.
Стоимость замены технологии:
Чистый домен: Загрязнённый домен:
┌─────────────┐ ┌─────────────────────┐
│ Заменить │ │ Переписать ВСЁ: │
│ ТОЛЬКО │ │ бизнес-логику + │
│ адаптер │ │ инфраструктуру + │
│ │ │ тесты + │
│ Стоимость: │ │ перетестировать │
│ НИЗКАЯ │ │ │
│ Риск: НИЗКИЙ│ │ Стоимость: ВЫСОКАЯ │
└─────────────┘ │ Риск: ВЫСОКИЙ │
└─────────────────────┘
2. Тестируемость без инфраструктуры
Чистый домен тестируется мгновенно. Не нужно поднимать базу данных, запускать HTTP-сервер, мокать файловую систему. Чистые функции + чистые данные = простейшие тесты.
import { describe, it, expect } from "bun:test"
import { Effect } from "effect"
describe("Todo.complete", () => {
it("should complete an active todo", async () => {
const todo = new Todo({
id: new TodoId({ value: "1" as TodoId["value"] }),
title: "Write tests",
status: "Active",
priority: "High",
createdAt: new Date(),
completedAt: null,
})
const result = await Effect.runPromise(todo.complete())
expect(result.status).toBe("Completed")
expect(result.completedAt).toBeDefined()
})
it("should reject completing a completed todo", async () => {
const todo = new Todo({
id: new TodoId({ value: "1" as TodoId["value"] }),
title: "Done task",
status: "Completed",
priority: "Low",
createdAt: new Date(),
completedAt: new Date(),
})
const result = await Effect.runPromiseExit(todo.complete())
expect(result._tag).toBe("Failure")
})
})
// Этот тест выполняется за <1 мс — нет IO, нет сети, нет БД
3. Переносимость между контекстами
Чистый домен можно использовать в любом контексте: на сервере, в CLI, в WebWorker, даже в браузере. Он не привязан к конкретной среде выполнения.
// Один и тот же доменный код работает:
// 1. В HTTP-сервере (Bun + Effect HttpServer)
// 2. В CLI-утилите (Effect CLI)
// 3. В тестах (bun:test)
// 4. В seed-скриптах (генерация тестовых данных)
// 5. В Worker потоках
// 6. В браузере (если нужно)
// Потому что домен — это ЧИСТЫЙ TypeScript + Effect
// Без привязки к runtime-среде
4. Понятность для новых разработчиков
Когда новый разработчик открывает domain/entities/todo.ts, он видит только бизнес-логику. Не SQL, не HTTP, не конфигурацию сервера. Только правила предметной области. Это радикально снижает когнитивную нагрузку.
Как нарушается чистота: паттерны загрязнения
Паттерн 1: Прямой импорт инфраструктуры
Самый очевидный вид нарушения — прямой импорт инфраструктурной библиотеки в доменном файле.
// domain/entities/todo.ts
// ❌ НАРУШЕНИЕ: прямой импорт инфраструктуры
import { Database } from "bun:sqlite"
class Todo {
async save(): Promise<void> {
const db = new Database("app.db")
db.run("INSERT INTO todos VALUES (?)", [this.id])
}
}
Почему это плохо: Теперь Todo намертво привязан к SQLite. Нельзя протестировать без базы, нельзя заменить на PostgreSQL, нельзя использовать в браузере.
Паттерн 2: Утечка типов инфраструктуры
Более коварный вид нарушения — когда доменная модель использует типы из инфраструктурных библиотек.
// ❌ НАРУШЕНИЕ: инфраструктурный тип в доменной модели
import { Statement } from "bun:sqlite"
interface TodoRepository {
// Statement — это тип SQLite, он протекает в домен
prepareFind(): Statement
}
// ❌ НАРУШЕНИЕ: тип Express Request в домене
import { Request } from "express"
const createTodoFromRequest = (req: Request): Todo => {
return new Todo(req.body)
}
Почему это плохо: Даже если вы не вызываете инфраструктурный код напрямую, зависимость от его типов привязывает домен к конкретной технологии.
Паттерн 3: Неявная зависимость через глобальное состояние
// ❌ НАРУШЕНИЕ: обращение к глобальному состоянию
class Todo {
isOverdue(): boolean {
// process.env — это инфраструктурная зависимость
const gracePeriod = parseInt(process.env.GRACE_PERIOD_DAYS ?? "0")
const deadline = new Date(this.dueDate)
deadline.setDate(deadline.getDate() + gracePeriod)
return new Date() > deadline
}
}
// ✅ ПРАВИЛЬНО: все зависимости — явные параметры
const isOverdue = (
todo: Todo,
now: Date,
gracePeriodDays: number
): boolean => {
const deadline = new Date(todo.dueDate)
deadline.setDate(deadline.getDate() + gracePeriodDays)
return now > deadline
}
Паттерн 4: Доменные события с инфраструктурными деталями
// ❌ НАРУШЕНИЕ: событие содержит инфраструктурные детали
class TodoCreated {
constructor(
public readonly todoId: string,
public readonly title: string,
public readonly sqlRowId: number, // ❌ деталь SQLite
public readonly httpRequestId: string, // ❌ деталь HTTP
public readonly userAgent: string, // ❌ деталь HTTP
) {}
}
// ✅ ПРАВИЛЬНО: событие содержит ТОЛЬКО доменные данные
class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
"TodoCreated",
{
todoId: TodoId,
title: Schema.String,
priority: Schema.Literal("Low", "Medium", "High", "Critical"),
occurredAt: Schema.DateFromSelf,
}
) {}
Паттерн 5: Валидация с побочными эффектами
// ❌ НАРУШЕНИЕ: валидация обращается к БД
class Todo {
static async create(title: string): Promise<Todo> {
// Проверка уникальности через запрос к БД
const existing = await db.query(
"SELECT COUNT(*) FROM todos WHERE title = ?", [title]
)
if (existing > 0) throw new Error("Duplicate title")
return new Todo({ title })
}
}
// ✅ ПРАВИЛЬНО: домен проверяет уникальность
// на переданных данных, а не через запрос к БД
const checkTitleUniqueness = (
existingTitles: ReadonlyArray<string>,
newTitle: string
): Effect.Effect<void, DuplicateTitleError> =>
existingTitles.some(
(t) => t.toLowerCase() === newTitle.toLowerCase()
)
? Effect.fail(new DuplicateTitleError({ title: newTitle }))
: Effect.void
// Application Layer отвечает за получение existingTitles из репозитория
// и передачу их в доменную функцию
Как Effect-ts обеспечивает чистоту
Effect-ts предоставляет мощные механизмы для структурного обеспечения чистоты домена. Не через конвенции и дисциплину, а через типовую систему.
R-канал как гарантия зависимостей
В Effect Effect<A, E, R> тип R (Requirements) явно показывает все зависимости функции. Если доменная функция имеет R = never — она не требует внешних зависимостей.
// Чистая доменная функция: R = never
// Нет внешних зависимостей — это видно из типа!
const completeTodo: (
todo: Todo
) => Effect.Effect<Todo, InvalidStatusTransitionError>
// ^ R = never (опущен)
// Application функция: R = TodoRepository
// Есть зависимость от репозитория — это видно из типа!
const completeTodoUseCase: (
todoId: string
) => Effect.Effect<Todo, TodoNotFoundError | InvalidStatusTransitionError, TodoRepository>
// ^ R = TodoRepository
Правило: Все функции в доменном слое должны иметь R = never. Если функция требует R ≠ never — она принадлежит Application Layer или инфраструктуре.
// Доменный слой — R всегда never:
declare namespace DomainLayer {
// ✅ R = never — чистая доменная логика
const create: (input: CreateInput) => Effect.Effect<Todo, ValidationError>
const complete: (todo: Todo) => Effect.Effect<Todo, TransitionError>
const calculateScore: (todo: Todo) => Effect.Effect<number>
// ❌ R ≠ never — это НЕ доменная функция
const save: (todo: Todo) => Effect.Effect<void, DbError, TodoRepository>
}
E-канал как доменный контракт ошибок
Канал ошибок E в доменных функциях содержит только доменные ошибки. Никаких SqliteError, HttpError, TimeoutError.
// ✅ Доменные ошибки — часть домена
type CreateTodoDomainError =
| EmptyTitleError
| TitleTooLongError
| DuplicateTitleError
| TodoListFullError
// ❌ Инфраструктурные ошибки — НЕ часть домена
type InfrastructureError =
| SqliteError
| ConnectionTimeoutError
| FileNotFoundError
| HttpError
Schema как граница валидации
Schema из Effect обеспечивает валидацию на уровне типов, без обращения к внешним системам.
import { Schema } from "effect"
// Доменная валидация через Schema — чистая, без IO
const TodoTitle = Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1, {
message: () => "Title cannot be empty"
}),
Schema.maxLength(255, {
message: () => "Title cannot exceed 255 characters"
}),
Schema.brand("TodoTitle")
)
type TodoTitle = Schema.Schema.Type<typeof TodoTitle>
// Валидация — это чистая функция decode
const validateTitle = Schema.decodeUnknown(TodoTitle)
// Effect<TodoTitle, ParseError> ← R = never!
Допустимые зависимости доменного слоя
Не все внешние зависимости запрещены. Вот что допустимо импортировать в доменном коде:
1. Effect core (effect)
// ✅ Основная библиотека Effect — это фундамент
import { Effect, Data, Schema, Option, Either, pipe, Array } from "effect"
Effect — это не инфраструктурная библиотека, а язык выражения вычислений. Он не привязывает вас к конкретной технологии. Effect<A, E, R> — это описание вычисления, а не его выполнение.
2. Другие доменные модули
// ✅ Импорт из других частей домена
import { TodoId } from "../value-objects/todo-id.js"
import { Priority } from "../value-objects/priority.js"
import { InvalidStatusTransitionError } from "../errors/todo-errors.js"
3. Стандартные типы TypeScript
// ✅ Стандартные типы — Date, Map, Set, RegExp, etc.
const isOverdue = (dueDate: Date, now: Date): boolean =>
now > dueDate
// ✅ ReadonlyArray, ReadonlyMap, ReadonlySet
const activeTodos = (todos: ReadonlyArray<Todo>): ReadonlyArray<Todo> =>
todos.filter((t) => t.status === "Active")
4. Чистые утилитарные библиотеки (с осторожностью)
// ⚠️ Допустимо, но с осторожностью — только чистые утилиты
// Убедитесь, что библиотека не тянет IO-зависимости
import { v4 as uuid } from "uuid" // ⚠️ генерация ID — граничный случай
Примечание о генерации ID: Генерация UUID технически является побочным эффектом (использует источник энтропии). В строго чистом подходе ID генерируется вне домена (в Application Layer) и передаётся в доменную функцию как параметр:
// Строго чистый подход: ID приходит извне
const createTodo = (
id: TodoId, // ← ID сгенерирован в Application Layer
title: string,
priority: Priority
): Effect.Effect<Todo, ValidationError> => { /* ... */ }
// Application Layer генерирует ID через Effect.sync
const createTodoUseCase = (input: CreateInput) =>
pipe(
Effect.sync(() => crypto.randomUUID()),
Effect.map((id) => new TodoId({ value: id as TodoId["value"] })),
Effect.flatMap((id) => createTodo(id, input.title, input.priority))
)
Структурное обеспечение чистоты
Конвенции — это хорошо, но они нарушаются. Как структурно гарантировать чистоту домена?
Подход 1: Строгие пути в tsconfig
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@domain/*": ["./src/domain/*"],
"@app/*": ["./src/app/*"],
"@infra/*": ["./src/infra/*"],
"@adapters/*": ["./src/adapters/*"]
}
}
}
При code review проверяйте: файлы в @domain/ не импортируют @infra/ и @adapters/.
Подход 2: ESLint правило import/no-restricted-paths
// .eslintrc.json
{
"rules": {
"import/no-restricted-paths": ["error", {
"zones": [
{
"target": "./src/domain",
"from": "./src/adapters",
"message": "Domain cannot import from adapters"
},
{
"target": "./src/domain",
"from": "./src/infrastructure",
"message": "Domain cannot import from infrastructure"
},
{
"target": "./src/domain",
"from": "./src/app",
"message": "Domain cannot import from application layer"
}
]
}]
}
}
Подход 3: Отдельный package.json для домена
Наиболее строгий подход — вынести домен в отдельный пакет с собственным package.json, где явно указаны только разрешённые зависимости:
packages/
domain/
package.json ← только "effect" в dependencies
src/
entities/
value-objects/
errors/
events/
app/
package.json ← зависит от @myapp/domain
src/
infrastructure/
package.json ← зависит от @myapp/domain, bun:sqlite, etc.
src/
// packages/domain/package.json
{
"name": "@myapp/domain",
"dependencies": {
"effect": "^3.x"
// И ВСЁ. Больше ничего.
}
}
Теперь если кто-то попытается import { Database } from "bun:sqlite" в доменном коде, пакет просто не скомпилируется — bun:sqlite нет в зависимостях.
Подход 4: Архитектурные тесты
Можно написать тест, который автоматически проверяет чистоту импортов:
import { describe, it, expect } from "bun:test"
import { readdir, readFile } from "node:fs/promises"
import { join } from "node:path"
const DOMAIN_DIR = join(import.meta.dir, "../src/domain")
const FORBIDDEN_IMPORTS = [
"bun:sqlite",
"@effect/platform",
"express",
"node:fs",
"node:http",
"node:net",
] as const
const getAllTsFiles = async (dir: string): Promise<ReadonlyArray<string>> => {
const entries = await readdir(dir, { recursive: true })
return entries.filter((f) => f.endsWith(".ts"))
}
describe("Domain Purity", () => {
it("should not import infrastructure modules", async () => {
const files = await getAllTsFiles(DOMAIN_DIR)
const violations: Array<string> = []
for (const file of files) {
const content = await readFile(join(DOMAIN_DIR, file), "utf-8")
for (const forbidden of FORBIDDEN_IMPORTS) {
if (content.includes(`from "${forbidden}"`)) {
violations.push(`${file} imports "${forbidden}"`)
}
if (content.includes(`from '${forbidden}'`)) {
violations.push(`${file} imports '${forbidden}'`)
}
}
}
expect(violations).toEqual([])
})
it("domain functions should have R = never", async () => {
// Этот тест проверяется компилятором TypeScript
// Если доменная функция требует R ≠ never,
// она не скомпилируется при использовании без provide
})
})
Паттерн: Dependency Inversion в домене
Иногда доменная логика действительно нуждается в информации из внешнего мира. Например, проверка уникальности заголовка требует знания о существующих задачах. Как быть?
Ответ: Dependency Inversion. Домен определяет порт (контракт), а не реализацию. Данные приходят извне через параметры.
Стратегия 1: Передача данных как параметров
Самая простая стратегия — доменная функция принимает уже загруженные данные:
// Домен: чистая функция, принимающая данные
const checkTitleUniqueness = (
existingTitles: ReadonlyArray<string>,
newTitle: string
): Effect.Effect<void, DuplicateTitleError> =>
existingTitles.some(
(t) => t.toLowerCase() === newTitle.toLowerCase()
)
? Effect.fail(new DuplicateTitleError({ title: newTitle }))
: Effect.void
// Application Layer: загружает данные и передаёт в домен
const createTodoUseCase = (input: CreateInput) =>
pipe(
// 1. Загрузить существующие задачи (через порт)
TodoRepository.findAllTitles(),
// 2. Передать в доменную функцию
Effect.flatMap((titles) =>
checkTitleUniqueness(titles, input.title)
),
// 3. Если уникален — создать
Effect.flatMap(() => buildTodo(input))
)
Стратегия 2: Доменный порт как параметр
Для более сложных случаев домен может определить интерфейс (порт), который передаётся как параметр:
// Домен определяет контракт — что ему нужно
interface TodoTitleChecker {
readonly isUnique: (title: string) => Effect.Effect<boolean>
}
// Доменная функция принимает контракт как параметр
const createTodo = (
checker: TodoTitleChecker,
input: CreateInput
): Effect.Effect<Todo, DuplicateTitleError | ValidationError> =>
pipe(
checker.isUnique(input.title),
Effect.flatMap((unique) =>
unique
? buildTodo(input)
: Effect.fail(new DuplicateTitleError({ title: input.title }))
)
)
Стратегия 3: Effect Service (для Application Layer)
В Application Layer используется полноценный Effect.Service. Но в самом домене мы стараемся обходиться стратегиями 1 и 2, чтобы сохранить R = never.
// Это уже Application Layer, не домен!
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
readonly save: (todo: Todo) => Effect.Effect<void>
}
>() {}
// Use Case в Application Layer — R = TodoRepository
const completeTodoUseCase = (
todoId: string
): Effect.Effect<Todo, TodoNotFoundError | InvalidStatusTransitionError, TodoRepository> =>
pipe(
TodoRepository,
Effect.flatMap((repo) => repo.findById(new TodoId({ value: todoId }))),
Effect.flatMap((todo) => todo.complete()), // ← чистая доменная логика
Effect.tap((updated) =>
pipe(
TodoRepository,
Effect.flatMap((repo) => repo.save(updated))
)
)
)
Чистота домена и время/случайность
Особый случай — операции, зависящие от текущего времени или случайных значений. Формально new Date() и Math.random() — побочные эффекты. Как с этим быть?
Подход: Время и случайность как параметры
// ❌ Нечистая функция — зависит от глобального состояния
const isOverdue = (todo: Todo): boolean =>
new Date() > todo.dueDate // new Date() — побочный эффект!
// ✅ Чистая функция — время передаётся как параметр
const isOverdue = (todo: Todo, now: Date): boolean =>
now > todo.dueDate
// ✅ Чистая функция — ID передаётся как параметр
const createTodo = (
id: TodoId, // ← сгенерирован снаружи
title: string,
now: Date // ← текущее время передано снаружи
): Effect.Effect<Todo, ValidationError> =>
Effect.succeed(new Todo({
id,
title,
status: "Active",
priority: "Medium",
createdAt: now,
completedAt: null,
}))
Effect-ts предоставляет сервисы Clock и Random для детерминированного контроля над временем и случайностью:
import { Effect, Clock } from "effect"
// Application Layer: использует Clock сервис
const createTodoWithTimestamp = (id: TodoId, title: string) =>
pipe(
Clock.currentTimeMillis,
Effect.map((millis) => new Date(Number(millis))),
Effect.flatMap((now) => createTodo(id, title, now))
)
// R = never для самой доменной функции createTodo
// R = Clock для обёртки в Application Layer (Clock предоставляется Effect runtime)
Чистота домена: чеклист
Используйте этот чеклист при code review доменного кода:
Импорты
- Нет импортов из
adapters/,infrastructure/,app/ - Нет импортов
bun:sqlite,node:fs,node:http,node:net - Нет импортов HTTP-фреймворков (express, hono, koa, fastify)
- Нет импортов ORM (prisma, drizzle, typeorm)
- Единственная внешняя зависимость —
effect
Типы
- Нет типов из инфраструктурных библиотек в сигнатурах
- Доменные функции имеют
R = never(нет зависимостей в R-канале) - Ошибки в E-канале — только доменные (
TaggedError) - Нет
Promise<T>— толькоEffect<A, E>
Поведение
- Нет прямых обращений к
process.env - Нет
console.log/console.error - Нет
new Date()внутри функций (время — параметр) - Нет
Math.random()/crypto.randomUUID()(случайность — параметр) - Нет
fetch,XMLHttpRequest, сетевых вызовов - Нет чтения/записи файлов
Данные
- Все структуры данных неизменяемы (
readonly,ReadonlyArray) - Нет
let, толькоconst - Нет мутаций массивов (
.push,.splice,.sortна месте) - Используются
Schema.Classдля сущностей
Практический пример: рефакторинг загрязнённого домена
Рассмотрим реальный пример — доменный код, который нарушает чистоту, и шаг за шагом очистим его.
До рефакторинга (загрязнённый домен)
// ❌ domain/todo.ts — загрязнённый домен
import { Database } from "bun:sqlite"
import { randomUUID } from "crypto"
const db = new Database("todos.db")
export class Todo {
id: string
title: string
status: string
createdAt: Date
constructor(title: string) {
this.id = randomUUID() // ❌ побочный эффект
this.title = title
this.status = "active"
this.createdAt = new Date() // ❌ побочный эффект
}
async save(): Promise<void> { // ❌ IO в домене
db.run(
"INSERT INTO todos (id, title, status) VALUES (?, ?, ?)",
[this.id, this.title, this.status]
)
console.log(`Saved todo: ${this.id}`) // ❌ логирование
}
async complete(): Promise<void> {
if (this.status === "completed") {
throw new Error("Already completed") // ❌ нетипизированная ошибка
}
this.status = "completed" // ❌ мутация
await this.save() // ❌ IO в домене
}
}
После рефакторинга (чистый домен)
// ✅ domain/value-objects/todo-id.ts
import { Schema } from "effect"
export class TodoId extends Schema.Class<TodoId>("TodoId")({
value: Schema.String.pipe(Schema.brand("TodoId"))
}) {}
// ✅ domain/errors/todo-errors.ts
import { Data } from "effect"
export class TodoAlreadyCompletedError extends Data.TaggedError(
"TodoAlreadyCompletedError"
)<{
readonly todoId: string
}> {}
// ✅ domain/entities/todo.ts — чистый домен
import { Schema, Effect } from "effect"
import { TodoId } from "../value-objects/todo-id.js"
import { TodoAlreadyCompletedError } from "../errors/todo-errors.js"
export class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(255)),
status: Schema.Literal("Active", "Completed", "Archived"),
createdAt: Schema.DateFromSelf,
}) {
complete(): Effect.Effect<Todo, TodoAlreadyCompletedError> {
return this.status === "Active"
? Effect.succeed(new Todo({ ...this, status: "Completed" }))
: Effect.fail(
new TodoAlreadyCompletedError({ todoId: this.id.value })
)
}
}
// ✅ domain/events/todo-events.ts
import { Schema } from "effect"
import { TodoId } from "../value-objects/todo-id.js"
export class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
"TodoCreated",
{
todoId: TodoId,
title: Schema.String,
occurredAt: Schema.DateFromSelf,
}
) {}
Вся инфраструктурная логика (сохранение в БД, генерация ID, логирование) переместилась в Application Layer и адаптеры, а домен стал чистым, тестируемым и переносимым.
Резюме
Чистота домена — это фундаментальный принцип Hexagonal Architecture:
- Доменный слой имеет нулевые зависимости от инфраструктуры
- Все доменные функции имеют
R = never— нет зависимостей в R-канале Effect - Время, случайность и ID передаются как параметры, а не генерируются внутри
- Чистота обеспечивается структурно: через tsconfig paths, ESLint rules, архитектурные тесты
- Допустимые зависимости:
effect, другие доменные модули, стандартные типы TypeScript - Результат: мгновенные тесты, лёгкая замена технологий, понятный код
В следующей главе мы разберём типы домена — Entity, Value Object, Aggregate и Event — и их точное определение в контексте TypeScript и Effect-ts.