Доменная модель: что в ней живёт, а что — нет
Определение доменной модели, её место в Hexagonal Architecture, полный разбор элементов домена (Entity, Value Object, Aggregate, Event, Error, Service), таблица принадлежности элементов к слоям, анемичная vs богатая модель, эвристики определения границ домена
Введение: зачем нам доменная модель
В предыдущих модулях мы построили архитектурный фундамент: разобрали принципы Hexagonal Architecture, научились видеть порты и адаптеры, поняли, как Effect.Service становится портом, а Layer — адаптером. Теперь пора заглянуть внутрь гексагона — в его самое сердце.
Доменная модель — это представление бизнес-реальности в коде. Не технической реальности (базы данных, HTTP-запросы, файловая система), а именно бизнес-реальности — тех правил, процессов и ограничений, которые существуют в предметной области вашего приложения независимо от того, написано ли оно на TypeScript, Java или вовсе на бумаге.
Представьте, что вы разрабатываете систему управления задачами. Задача может быть создана, у неё есть заголовок, приоритет, срок. Задачу можно завершить, но нельзя завершить уже завершённую задачу. Эти правила существуют до того, как вы напишете первую строку кода — они диктуются предметной областью. Именно эти правила и составляют домен.
┌─────────────────────────────────────────────────────────────┐
│ Внешний мир │
│ HTTP, CLI, WebSocket, gRPC, Message Queue │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Application Layer │ │
│ │ Use Cases, Orchestration, Command/Query │ │
│ │ │ │
│ │ ┌─────────────────────────────────────┐ │ │
│ │ │ DOMAIN MODEL │ │ │
│ │ │ │ │ │
│ │ │ Entities, Value Objects, │ │ │
│ │ │ Aggregates, Domain Events, │ │ │
│ │ │ Domain Services, Business Rules │ │ │
│ │ │ │ │ │
│ │ │ ★ ZERO infrastructure dependencies │ │ │
│ │ │ ★ Pure business logic │ │ │
│ │ │ ★ Self-validating types │ │ │
│ │ └─────────────────────────────────────┘ │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ SQLite, FileSystem, Email, External APIs │
└─────────────────────────────────────────────────────────────┘
Что такое «домен» в контексте разработки
Слово «домен» (domain) пришло из Domain-Driven Design (DDD) Эрика Эванса. В контексте разработки домен — это предметная область, которую автоматизирует ваше приложение.
Для интернет-магазина домен — это товары, заказы, корзины, оплата, доставка. Для банковского приложения — счета, транзакции, лимиты, курсы валют. Для нашего Todo-приложения — задачи, их статусы, приоритеты, категории, правила перехода между состояниями.
Ключевое свойство домена: он существует независимо от технологий. Если завтра вы перепишете приложение с TypeScript на Rust, замените SQLite на PostgreSQL, а REST на GraphQL — бизнес-правила останутся теми же. Задача по-прежнему не может быть завершена дважды. Приоритет по-прежнему бывает Low, Medium, High. Заголовок задачи по-прежнему не может быть пустым.
Домен vs Технология: фундаментальное разделение
// ❌ ЭТО НЕ ДОМЕН — это технология
// SQL-запрос, HTTP-ответ, конфигурация базы данных
const query = "SELECT * FROM todos WHERE status = 'active'"
const response = new Response(JSON.stringify(todos), { status: 200 })
const dbConfig = { host: "localhost", port: 5432 }
// ✅ ЭТО ДОМЕН — это бизнес-правила
// Задача не может иметь пустой заголовок
// Завершённую задачу нельзя завершить повторно
// Приоритет задачи влияет на порядок отображения
type Priority = "Low" | "Medium" | "High" | "Critical"
type TodoStatus = "Active" | "Completed" | "Archived"
// Бизнес-правило: переход из Active → Completed разрешён,
// но из Completed → Active — нет (необходимо Reopen)
Доменная модель в Hexagonal Architecture
В Hexagonal Architecture доменная модель занимает центральное положение. Все зависимости направлены внутрь — к домену. Домен не знает и не должен знать ни о каких внешних технологиях.
Это не метафора и не рекомендация — в нашем стеке это гарантия типовой системы. Если доменный код попытается импортировать что-то из инфраструктурного слоя, компилятор TypeScript просто не скомпилирует проект (при правильной настройке путей и модулей).
Слои гексагона и их ответственности
┌──────────────────────────────────────────────────────┐
│ DRIVING ADAPTERS (Primary) │
│ HTTP Controller, CLI Handler, WebSocket Handler │
│ → Переводят внешние запросы в вызовы Application │
├──────────────────────────────────────────────────────┤
│ APPLICATION LAYER │
│ Use Cases, Command/Query Handlers │
│ → Оркестрирует доменную логику, НЕ содержит её │
├──────────────────────────────────────────────────────┤
│ ★ DOMAIN LAYER ★ │
│ Entities, Value Objects, Aggregates, Events │
│ → Содержит ВСЮ бизнес-логику │
│ → НУЛЕВЫЕ зависимости от внешних слоёв │
├──────────────────────────────────────────────────────┤
│ DRIVEN ADAPTERS (Secondary) │
│ SQLite Repository, File Storage, Email Sender │
│ → Реализуют порты, определённые доменом/приложением │
└──────────────────────────────────────────────────────┘
Что живёт в доменной модели
Давайте чётко определим, какие элементы принадлежат доменной модели. Каждый из этих элементов будет подробно разобран в последующих главах и модулях, но здесь важно увидеть полную картину.
1. Entities (Сущности)
Сущность — это объект с уникальной идентичностью, которая сохраняется на протяжении всего жизненного цикла. Два объекта-сущности равны тогда и только тогда, когда у них одинаковый идентификатор, независимо от значений остальных полей.
import { Schema } from "effect"
// TodoId — уникальный идентификатор задачи
class TodoId extends Schema.Class<TodoId>("TodoId")({
value: Schema.String.pipe(Schema.brand("TodoId"))
}) {}
// Todo Entity — объект с идентичностью
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"),
priority: Schema.Literal("Low", "Medium", "High", "Critical"),
createdAt: Schema.DateFromSelf,
completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {}
Ключевое свойство: два Todo с id = "abc" — это одна и та же задача, даже если у них разные заголовки (например, заголовок был изменён).
2. Value Objects (Объекты-значения)
Объект-значение не имеет идентичности. Два Value Object равны, если все их поля равны. Value Object неизменяем (immutable) и самовалидируется.
import { Schema } from "effect"
// Email — Value Object с встроенной валидацией
class Email extends Schema.Class<Email>("Email")({
value: Schema.String.pipe(
Schema.pattern(
/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
),
Schema.brand("Email")
)
}) {}
// Priority — Value Object как перечисление
class Priority extends Schema.Class<Priority>("Priority")({
value: Schema.Literal("Low", "Medium", "High", "Critical"),
}) {
// Бизнес-логика: сравнение приоритетов
static readonly order = {
Low: 0,
Medium: 1,
High: 2,
Critical: 3,
} as const
isHigherThan(other: Priority): boolean {
return Priority.order[this.value] > Priority.order[other.value]
}
}
3. Aggregates (Агрегаты)
Агрегат — это кластер связанных сущностей и объектов-значений, объединённых границей транзакционной согласованности. Агрегат гарантирует, что все его инварианты соблюдаются при любом изменении.
// TodoList — агрегат, содержащий список задач
// Бизнес-правило: в списке не может быть двух задач с одинаковым заголовком
class TodoList extends Schema.Class<TodoList>("TodoList")({
id: TodoListId,
name: Schema.String.pipe(Schema.minLength(1)),
todos: Schema.Array(Todo),
maxTodos: Schema.Number.pipe(Schema.int(), Schema.positive()),
}) {
// Инвариант: не превышен лимит задач
get isFull(): boolean {
return this.todos.length >= this.maxTodos
}
// Инвариант: нет дубликатов заголовков
hasDuplicateTitle(title: string): boolean {
return this.todos.some(
(todo) => todo.title.toLowerCase() === title.toLowerCase()
)
}
}
4. Domain Events (Доменные события)
Доменное событие — это факт, произошедший в домене. Событие неизменяемо, произошло в прошлом и несёт информацию о том, что случилось.
import { Schema } from "effect"
class TodoCreated extends Schema.TaggedClass<TodoCreated>()(
"TodoCreated",
{
todoId: TodoId,
title: Schema.String,
priority: Schema.Literal("Low", "Medium", "High", "Critical"),
occurredAt: Schema.DateFromSelf,
}
) {}
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
"TodoCompleted",
{
todoId: TodoId,
completedAt: Schema.DateFromSelf,
occurredAt: Schema.DateFromSelf,
}
) {}
// Union всех доменных событий
type TodoEvent = TodoCreated | TodoCompleted
5. Domain Services (Доменные сервисы)
Доменный сервис содержит бизнес-логику, которая не принадлежит ни одной конкретной сущности. Это операции, затрагивающие несколько агрегатов или требующие знаний, выходящих за рамки одной сущности.
import { Effect } from "effect"
// Доменный сервис: проверка уникальности заголовка
// Это чистая бизнес-логика, НЕ обращение к БД
const checkTitleUniqueness = (
existingTodos: ReadonlyArray<Todo>,
newTitle: string
): Effect.Effect<void, DuplicateTitleError> =>
existingTodos.some(
(t) => t.title.toLowerCase() === newTitle.toLowerCase()
)
? Effect.fail(new DuplicateTitleError({ title: newTitle }))
: Effect.void
6. Domain Errors (Доменные ошибки)
Ошибки — это полноценная часть домена. Они описывают ситуации, когда бизнес-правило нарушено. Доменная ошибка — это не 500 Internal Server Error, а осмысленное бизнес-исключение.
import { Data } from "effect"
class TodoNotFoundError extends Data.TaggedError("TodoNotFoundError")<{
readonly todoId: string
}> {}
class InvalidStatusTransitionError extends Data.TaggedError(
"InvalidStatusTransitionError"
)<{
readonly from: string
readonly to: string
}> {}
class DuplicateTitleError extends Data.TaggedError("DuplicateTitleError")<{
readonly title: string
}> {}
class TodoListFullError extends Data.TaggedError("TodoListFullError")<{
readonly maxTodos: number
readonly currentCount: number
}> {}
7. Business Rules (Бизнес-правила)
Бизнес-правила — это ограничения, которые домен накладывает на данные и операции. Они могут быть выражены явно через типы, валидацию или функции.
// Бизнес-правило: допустимые переходы между статусами
const VALID_TRANSITIONS: ReadonlyMap<TodoStatus, ReadonlySet<TodoStatus>> =
new Map([
["Active", new Set(["Completed", "Archived"])],
["Completed", new Set(["Archived"])],
["Archived", new Set<TodoStatus>()], // Архив — конечное состояние
])
const canTransition = (
from: TodoStatus,
to: TodoStatus
): boolean => {
const allowed = VALID_TRANSITIONS.get(from)
return allowed !== undefined && allowed.has(to)
}
Что НЕ живёт в доменной модели
Столь же важно понимать, что не должно находиться в доменном слое. Любая утечка инфраструктурных деталей разрушает изоляцию и подрывает ценность всей архитектуры.
1. Детали персистентности
// ❌ НАРУШЕНИЕ: SQL в домене
class TodoRepository {
async findById(id: string): Promise<Todo> {
const row = await db.query("SELECT * FROM todos WHERE id = ?", [id])
return mapRowToTodo(row)
}
}
// ❌ НАРУШЕНИЕ: ORM-декораторы в доменной сущности
@Entity()
class Todo {
@PrimaryGeneratedColumn()
id!: string
@Column()
title!: string
}
// ✅ ПРАВИЛЬНО: домен определяет только контракт (порт)
// Реализация (SQL, ORM) живёт в адаптере
class Todo extends Schema.Class<Todo>("Todo")({
id: TodoId,
title: Schema.String.pipe(Schema.minLength(1)),
// ...чистые доменные поля, никаких @Column
}) {}
2. Сетевые вызовы и HTTP
// ❌ НАРУШЕНИЕ: fetch в доменной логике
const notifyUser = async (todo: Todo) => {
await fetch("https://api.notifications.com/send", {
method: "POST",
body: JSON.stringify({ message: `Todo ${todo.title} completed` })
})
}
// ✅ ПРАВИЛЬНО: домен генерирует событие,
// а адаптер решает, как уведомить
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()(
"TodoCompleted",
{ todoId: TodoId, completedAt: Schema.DateFromSelf }
) {}
// Уведомление — это реакция на событие в Application/Infrastructure слое
3. Фреймворк-специфичный код
// ❌ НАРУШЕНИЕ: Express/Hono/Koa в домене
import { Router } from "express"
const router = Router()
router.post("/todos", (req, res) => { /* ... */ })
// ❌ НАРУШЕНИЕ: React-компоненты в домене
const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => (
<div>{todo.title}</div>
)
// ✅ ПРАВИЛЬНО: домен — чистый TypeScript + Effect
// Никаких импортов express, react, hono, etc.
4. Конфигурация и переменные окружения
// ❌ НАРУШЕНИЕ: чтение env в домене
const MAX_TODOS = parseInt(process.env.MAX_TODOS ?? "100")
// ✅ ПРАВИЛЬНО: лимит — часть доменной модели как параметр
class TodoList extends Schema.Class<TodoList>("TodoList")({
maxTodos: Schema.Number.pipe(Schema.int(), Schema.positive()),
// maxTodos задаётся при создании, а не читается из env
}) {}
5. Логирование и мониторинг
// ❌ НАРУШЕНИЕ: console.log/logger в домене
const completeTodo = (todo: Todo): Todo => {
console.log(`Completing todo: ${todo.id}`) // ❌
logger.info("Todo completed", { id: todo.id }) // ❌
return { ...todo, status: "Completed" }
}
// ✅ ПРАВИЛЬНО: домен возвращает результат,
// логирование — ответственность Application Layer или Middleware
const completeTodo = (
todo: Todo
): Effect.Effect<Todo, InvalidStatusTransitionError> =>
canTransition(todo.status, "Completed")
? Effect.succeed({ ...todo, status: "Completed" as const })
: Effect.fail(
new InvalidStatusTransitionError({
from: todo.status,
to: "Completed",
})
)
6. Сериализация и форматы передачи
// ❌ НАРУШЕНИЕ: JSON.stringify/parse в домене
class Todo {
toJSON(): string {
return JSON.stringify({ id: this.id, title: this.title })
}
static fromJSON(json: string): Todo {
return new Todo(JSON.parse(json))
}
}
// ✅ ПРАВИЛЬНО: Schema сама определяет encode/decode,
// но это используется на ГРАНИЦАХ, а не внутри домена
// Внутри домена мы работаем с типизированными объектами
7. Тайминги, retry, rate limiting
// ❌ НАРУШЕНИЕ: инфраструктурные паттерны в домене
const createTodo = async (data: CreateTodoInput) => {
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await saveTodo(data)
} catch {
await sleep(1000 * attempt)
}
}
}
// ✅ ПРАВИЛЬНО: retry — это concern инфраструктуры/application
// Доменная функция просто выполняет бизнес-логику
Таблица принадлежности: что где живёт
| Элемент | Domain | Application | Infrastructure |
|---|---|---|---|
| Бизнес-правила | ✅ | — | — |
| Entities, Value Objects | ✅ | — | — |
| Domain Events | ✅ | — | — |
| Domain Errors | ✅ | — | — |
| Валидация бизнес-правил | ✅ | — | — |
| Use Case оркестрация | — | ✅ | — |
| Авторизация | — | ✅ | — |
| Логирование | — | ✅ | ✅ |
| SQL-запросы | — | — | ✅ |
| HTTP-маршруты | — | — | ✅ |
| Сериализация JSON | — | — | ✅ |
| Конфигурация | — | ✅ | ✅ |
| Retry/Circuit Breaker | — | — | ✅ |
| Кеширование | — | — | ✅ |
| Отправка email | — | — | ✅ |
Доменная модель как чистые функции
В функциональном стиле с Effect-ts доменная модель выражается через чистые функции и неизменяемые данные. Это даёт фундаментальные преимущества:
Предсказуемость
Чистая функция при одних и тех же входных данных всегда возвращает одинаковый результат. Нет скрытых зависимостей, нет побочных эффектов.
// Чистая доменная функция: одни и те же входные данные → один и тот же результат
const calculatePriorityScore = (
priority: Priority,
isOverdue: boolean,
daysSinceCreation: number
): number => {
const baseScore = Priority.order[priority]
const overdueBonus = isOverdue ? 10 : 0
const ageBonus = Math.min(daysSinceCreation, 30) * 0.1
return baseScore + overdueBonus + ageBonus
}
Тестируемость
Чистые функции тестируются тривиально — не нужны моки, стабы, тестовые базы данных. Просто вызови функцию и проверь результат.
import { describe, it, expect } from "bun:test"
describe("calculatePriorityScore", () => {
it("should give overdue bonus", () => {
const score = calculatePriorityScore("Medium", true, 5)
expect(score).toBeGreaterThan(
calculatePriorityScore("Medium", false, 5)
)
})
})
Композируемость
Маленькие чистые функции легко комбинируются в более сложные операции через Effect pipe.
import { Effect, pipe } from "effect"
const createTodo = (
input: CreateTodoInput,
existingTodos: ReadonlyArray<Todo>
): Effect.Effect<readonly [Todo, TodoCreated], CreateTodoError> =>
pipe(
// Шаг 1: Валидировать заголовок
validateTitle(input.title),
// Шаг 2: Проверить уникальность
Effect.flatMap(() =>
checkTitleUniqueness(existingTodos, input.title)
),
// Шаг 3: Создать сущность
Effect.flatMap(() => buildTodo(input)),
// Шаг 4: Создать доменное событие
Effect.map((todo) => [
todo,
new TodoCreated({
todoId: todo.id,
title: todo.title,
priority: todo.priority,
occurredAt: new Date(),
}),
] as const)
)
Анемичная модель vs Богатая модель
Это одна из ключевых дилемм доменного моделирования, и понимание разницы критически важно.
Анемичная модель (Anti-pattern)
В анемичной модели сущности — это просто контейнеры данных (DTO) без поведения. Вся логика вынесена в отдельные «сервисы».
// ❌ Анемичная модель — Entity без поведения
interface Todo {
id: string
title: string
status: "Active" | "Completed" | "Archived"
priority: "Low" | "Medium" | "High"
}
// Вся логика — в «сервисах»
class TodoService {
complete(todo: Todo): Todo {
if (todo.status !== "Active") {
throw new Error("Cannot complete")
}
return { ...todo, status: "Completed" }
}
changeTitle(todo: Todo, newTitle: string): Todo {
if (newTitle.length === 0) {
throw new Error("Title cannot be empty")
}
return { ...todo, title: newTitle }
}
archive(todo: Todo): Todo {
if (todo.status === "Archived") {
throw new Error("Already archived")
}
return { ...todo, status: "Archived" }
}
}
Проблемы анемичной модели:
- Бизнес-правила рассеяны по разным сервисам
- Легко обойти валидацию, изменив поля напрямую
- Нарушается инкапсуляция — логика отделена от данных
- Сложно найти все правила для конкретной сущности
Богатая модель (Рекомендуемый подход)
В богатой модели сущность сама содержит свои правила и поведение. В функциональном стиле это выражается через функции, привязанные к типу.
import { Effect, Data, Schema, pipe } from "effect"
// ✅ Богатая модель — поведение рядом с данными
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"),
priority: Schema.Literal("Low", "Medium", "High", "Critical"),
createdAt: Schema.DateFromSelf,
completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {
// Поведение: завершение задачи
complete(): Effect.Effect<Todo, InvalidStatusTransitionError> {
return this.status === "Active"
? Effect.succeed(
new Todo({
...this,
status: "Completed",
completedAt: new Date(),
})
)
: Effect.fail(
new InvalidStatusTransitionError({
from: this.status,
to: "Completed",
})
)
}
// Поведение: архивирование задачи
archive(): Effect.Effect<Todo, InvalidStatusTransitionError> {
return this.status === "Archived"
? Effect.fail(
new InvalidStatusTransitionError({
from: "Archived",
to: "Archived",
})
)
: Effect.succeed(
new Todo({
...this,
status: "Archived",
})
)
}
// Поведение: изменение заголовка
changeTitle(
newTitle: string
): Effect.Effect<Todo, EmptyTitleError> {
return newTitle.trim().length === 0
? Effect.fail(new EmptyTitleError())
: Effect.succeed(
new Todo({
...this,
title: newTitle.trim(),
})
)
}
// Запрос: просрочена ли задача
isOverdue(now: Date, dueDate: Date): boolean {
return this.status === "Active" && now > dueDate
}
}
Преимущества богатой модели:
- Бизнес-правила локализованы — всё в одном месте
- Невозможно обойти валидацию — каждое изменение проходит через метод
- Самодокументируемость — тип показывает, что можно делать с сущностью
- Типовая безопасность — E-канал Effect показывает, какие ошибки возможны
Функциональный подход: модули функций
В строго функциональном стиле вместо методов класса можно использовать модуль функций, привязанный к типу. Это популярный подход в Effect-ts:
// Тип данных — чистая структура
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"),
priority: Schema.Literal("Low", "Medium", "High", "Critical"),
createdAt: Schema.DateFromSelf,
completedAt: Schema.OptionFromNullOr(Schema.DateFromSelf),
}) {}
// Модуль функций — поведение для типа
const TodoOps = {
complete: (
self: Todo
): Effect.Effect<Todo, InvalidStatusTransitionError> =>
self.status === "Active"
? Effect.succeed(
new Todo({
...self,
status: "Completed",
completedAt: new Date(),
})
)
: Effect.fail(
new InvalidStatusTransitionError({
from: self.status,
to: "Completed",
})
),
archive: (
self: Todo
): Effect.Effect<Todo, InvalidStatusTransitionError> =>
self.status !== "Archived"
? Effect.succeed(new Todo({ ...self, status: "Archived" }))
: Effect.fail(
new InvalidStatusTransitionError({
from: "Archived",
to: "Archived",
})
),
isActive: (self: Todo): boolean =>
self.status === "Active",
isOverdue: (self: Todo, now: Date, dueDate: Date): boolean =>
self.status === "Active" && now > dueDate,
} as const
Оба подхода (методы на Schema.Class и отдельный модуль функций) валидны. Выбирайте тот, который лучше подходит вашей команде. В курсе мы будем использовать оба, показывая их взаимозаменяемость.
Границы доменной модели
Определение границ — одна из самых сложных задач в проектировании. Вот набор эвристик, которые помогают:
Эвристика 1: «Тест бумаги и ручки»
Если бизнес-процесс можно описать на бумаге без упоминания компьютера — это домен. Если для описания нужен компьютер — это инфраструктура.
- «Задача создаётся с заголовком и приоритетом» → Домен ✅
- «Задача сохраняется в таблицу todos» → Инфраструктура ❌
- «Нельзя создать задачу с пустым заголовком» → Домен ✅
- «При создании задачи отправляется HTTP 201» → Инфраструктура ❌
Эвристика 2: «Тест замены технологии»
Если правило останется при замене технологии — это домен. Если нет — инфраструктура.
- Заменили SQLite на PostgreSQL: «Задача не может быть завершена дважды» — осталось → Домен ✅
- Заменили REST на GraphQL: «Ответ 200 OK» — исчезло → Инфраструктура ❌
Эвристика 3: «Тест эксперта предметной области»
Если бизнес-эксперт (не программист) понимает правило — это домен. Если нужен программист — скорее всего, инфраструктура.
- «Задачи с критическим приоритетом отображаются первыми» → Бизнес-эксперт понимает → Домен ✅
- «Используем индекс B-Tree для ускорения поиска» → Только программист → Инфраструктура ❌
Эвристика 4: «Тест стабильности»
Домен меняется медленно — бизнес-правила стабильны. Инфраструктура меняется часто — фреймворки, базы данных, API обновляются постоянно.
Скорость изменений:
МЕДЛЕННО ◄────────────────────────────────────► БЫСТРО
Бизнес-правила Application Инфраструктура
"Задача имеет Use Cases REST → GraphQL
приоритет" SQLite → Postgres
Express → Hono
Доменная модель и Effect-ts: естественное соответствие
Effect-ts предоставляет идеальный инструментарий для моделирования домена:
| Концепция домена | Инструмент Effect |
|---|---|
| Типизированные данные | Schema.Class, Schema.Struct |
| Валидация | Schema.filter, Schema.brand |
| Идентичность | Schema.brand("TodoId") |
| Неизменяемость | Schema.Class создаёт immutable объекты |
| Доменные ошибки | Data.TaggedError |
| Бизнес-операции | Effect<Success, Error> |
| Чистые вычисления | Effect.succeed, Effect.fail |
| Доменные события | Schema.TaggedClass |
| Перечисления | Schema.Literal, Schema.Union |
Эта таблица — не натяжка и не метафора. Effect-ts спроектирован так, что его конструкции напрямую отображаются на концепции доменного моделирования. В следующих главах мы увидим это на практике.
Резюме
Доменная модель — это ядро вашего приложения, содержащее всю бизнес-логику в чистом, типобезопасном виде без каких-либо внешних зависимостей.
В домене живут:
- Entities (сущности с идентичностью)
- Value Objects (неизменяемые значения с валидацией)
- Aggregates (кластеры с границей согласованности)
- Domain Events (факты о произошедшем)
- Domain Services (межагрегатная логика)
- Domain Errors (бизнес-исключения)
- Business Rules (ограничения и инварианты)
В домене НЕ живут:
- SQL-запросы и ORM-маппинг
- HTTP-обработчики и маршруты
- Сериализация и десериализация
- Логирование и мониторинг
- Конфигурация и переменные окружения
- Retry, caching, rate limiting
- Фреймворк-специфичный код
В следующей главе мы подробно разберём чистоту домена — почему нулевые зависимости от инфраструктуры так важны и как Effect-ts помогает гарантировать эту чистоту на уровне типовой системы.