Анатомия гексагона: Application Core как центр
Три зоны гексагона: Application Core, порты, адаптеры. Иерархия ядра: Domain Model → Domain Services → Application Services. Принцип нулевых зависимостей, R-канал как декларация зависимостей, файловая структура и метрики здоровья архитектуры.
Введение: три зоны гексагона
Если посмотреть на гексагональную архитектуру как на физическую структуру, она состоит из трёх чётко разграниченных зон:
- Application Core (внутри гексагона) — бизнес-логика, доменная модель, правила
- Порты (на границе гексагона) — контракты взаимодействия с внешним миром
- Адаптеры (снаружи гексагона) — конкретные технологические реализации
В этом уроке мы детально рассмотрим центральную зону — Application Core — и поймём, почему именно она является «сердцем» системы, от которого зависит всё остальное.
Что такое Application Core
Определение
Application Core — это совокупность всего кода, который описывает бизнес-логику приложения и правила предметной области, не содержа при этом никаких ссылок на конкретные технологии, фреймворки или инфраструктурные компоненты.
Application Core отвечает на вопросы:
- Что такое «задача» (Todo) в нашем приложении?
- Какие состояния может принимать задача?
- Какие переходы между состояниями допустимы?
- Какие бизнес-правила должны соблюдаться?
- Какие операции можно выполнять с задачами?
- Какие ошибки могут возникнуть при нарушении правил?
Application Core не отвечает на вопросы:
- Где хранятся задачи? (это знает адаптер базы данных)
- Как пользователь создаёт задачу? (это знает HTTP/CLI адаптер)
- В каком формате передаются данные? (это знает адаптер сериализации)
- Как отправить уведомление? (это знает адаптер уведомлений)
Визуализация зон
ВНЕШНИЙ МИР
┌─────────────────────────────┐
│ АДАПТЕРЫ │
│ ┌───────────────────────┐ │
│ │ ПОРТЫ │ │
│ │ ┌─────────────────┐ │ │
│ │ │ │ │ │
│ │ │ APPLICATION │ │ │
HTTP ──────────► │ │ │ CORE │ │ │ ◄────── SQLite
│ │ │ │ │ │
CLI ───────────► │ │ │ • Domain Model │ │ │ ◄────── FileSystem
│ │ │ • Rules │ │ │
Tests ─────────► │ │ │ • Services │ │ │ ◄────── Email SMTP
│ │ │ • Events │ │ │
│ │ │ │ │ │
│ │ └─────────────────┘ │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
Составные части Application Core
Application Core — не монолитный блок. Он имеет внутреннюю структуру, состоящую из нескольких концептуальных слоёв (не путать со слоями слоистой архитектуры):
1. Domain Model (Доменная модель)
Самый внутренний слой. Содержит:
- Entities — объекты с идентичностью и жизненным циклом (Todo, User)
- Value Objects — неизменяемые значения с бизнес-правилами (TodoTitle, Priority, Email)
- Aggregates — кластеры объектов с единой границей согласованности (TodoList)
- Domain Events — факты, произошедшие в домене (TodoCreated, TodoCompleted)
- Domain Errors — типизированные ошибки бизнес-логики (TodoNotFound, InvalidTransition)
import { Schema, Data } from "effect"
// VALUE OBJECT: неизменяемый, с бизнес-валидацией
class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
value: Schema.String.pipe(
Schema.minLength(1, {
message: () => "Заголовок задачи не может быть пустым"
}),
Schema.maxLength(200, {
message: () => "Заголовок задачи не может превышать 200 символов"
}),
Schema.trimmed()
),
}) {}
// DOMAIN ERROR: типизированная ошибка бизнес-логики
class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
readonly from: TodoStatus
readonly to: TodoStatus
readonly reason: string
}> {}
// DOMAIN EVENT: факт, произошедший в домене
class TodoCompleted extends Schema.TaggedClass<TodoCompleted>()("TodoCompleted", {
todoId: TodoId,
completedAt: Schema.Date,
}) {}
2. Domain Services (Доменные сервисы)
Бизнес-операции, которые не принадлежат ни одной конкретной сущности:
// Доменный сервис: проверка уникальности заголовка
// Обратите внимание: он ЗАВИСИТ от порта (TodoRepository),
// но это всё ещё ДОМЕН, потому что правило "заголовок уникален" — бизнес-правило
const checkTitleUniqueness = (
title: TodoTitle
): Effect.Effect<void, DuplicateTitle, TodoRepository> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const existing = yield* repo.findByTitle(title)
if (Option.isSome(existing)) {
yield* Effect.fail(new DuplicateTitle({ title }))
}
})
3. Application Services (Use Cases)
Оркестраторы бизнес-процессов. Они координируют вызовы к доменной модели, портам и доменным сервисам:
// Use Case: создание задачи
// Это ОРКЕСТРАТОР — он не содержит бизнес-логику,
// а координирует вызовы к домену и портам
const createTodo = (
input: CreateTodoInput
): Effect.Effect<
Todo,
ValidationError | DuplicateTitle | RepositoryError,
TodoRepository | Clock | IdGenerator
> =>
Effect.gen(function* () {
// 1. Валидация и создание Value Objects
const title = yield* Schema.decode(TodoTitle)({ value: input.title })
// 2. Вызов доменного сервиса (бизнес-правило)
yield* checkTitleUniqueness(title)
// 3. Получение зависимостей через порты
const clock = yield* Clock
const idGen = yield* IdGenerator
// 4. Создание доменной сущности
const todo = new Todo({
id: yield* idGen.generate(),
title,
completed: false,
createdAt: yield* clock.now(),
})
// 5. Персистентность через порт
const repo = yield* TodoRepository
yield* repo.save(todo)
return todo
})
Иерархия внутри Application Core
┌─────────────────────────────────────────────┐
│ APPLICATION CORE │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ Application Services │ │
│ │ (Use Cases / Orchestration) │ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │ │
│ │ │ Domain Services │ │ │
│ │ │ (Cross-entity business logic) │ │ │
│ │ │ │ │ │
│ │ │ ┌────────────────────────────┐ │ │ │
│ │ │ │ Domain Model │ │ │ │
│ │ │ │ Entities, Value Objects, │ │ │ │
│ │ │ │ Aggregates, Events, │ │ │ │
│ │ │ │ Errors │ │ │ │
│ │ │ └────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────┘ │ │
│ └────────────────────────────────────────┘ │
│ │
│ ○ Порты (определены ядром, но реализуются │
│ снаружи — описаны в следующих уроках) │
└─────────────────────────────────────────────┘
Правило зависимостей внутри Core:
- Domain Model не зависит ни от чего (нулевые зависимости)
- Domain Services могут зависеть от Domain Model и портов
- Application Services могут зависеть от всего вышеперечисленного
Принцип «нулевых зависимостей» Domain Model
Почему это критично
Domain Model — самая ценная часть системы. Это чистая бизнес-логика, не загрязнённая инфраструктурными деталями. Её ценность в том, что она:
- Легко тестируется — нет внешних зависимостей, только чистые функции и типы
- Легко читается — код говорит на языке бизнеса, не на языке фреймворков
- Легко переносится — можно взять домен и использовать его в другом проекте, с другой инфраструктурой
- Медленно устаревает — бизнес-правила меняются реже, чем технологии
Что означает «нулевые зависимости»
В контексте Domain Model «нулевые зависимости» означает:
Разрешено зависеть от:
- Стандартной библиотеки языка (TypeScript built-ins)
- Effect core (
effectпакет) — он является частью «языка», не инфраструктуры - Типов, определённых в самом домене
Запрещено зависеть от:
- Библиотек работы с БД (
bun:sqlite,pg,prisma) - HTTP-фреймворков (
express,@effect/platform/HttpServer) - Файловых систем (
node:fs,@effect/platform/FileSystem) - Внешних API-клиентов
- Конфигурационных библиотек (кроме Effect Config как порта)
- Любых других инфраструктурных зависимостей
// domain/todo.ts — ЧИСТЫЙ ДОМЕН
//
// Обратите внимание на imports:
import { Schema, Data, Option, Effect } from "effect"
// ☝️ Только Effect core — это часть "языка", не инфраструктуры
// Всё остальное — типы из самого домена:
import { TodoId } from "./todo-id.js"
import { TodoTitle } from "./todo-title.js"
import { TodoStatus } from "./todo-status.js"
import { InvalidTransition } from "./errors.js"
Проверка чистоты домена
Простой критерий: откройте файл доменной модели и посмотрите на import-ы. Если среди них есть что-либо кроме effect и внутренних доменных модулей — домен загрязнён.
В реальных проектах можно автоматизировать эту проверку с помощью ESLint-правил или скриптов:
// scripts/check-domain-purity.ts
// Проверяет, что файлы в domain/ не импортируют инфраструктурные модули
const FORBIDDEN_IMPORTS = [
"bun:sqlite",
"node:fs",
"node:http",
"@effect/platform/HttpServer",
"@effect/platform/FileSystem",
"pg",
"prisma",
] as const
const checkFile = (filePath: string): ReadonlyArray<string> => {
const content = Bun.file(filePath).text()
return FORBIDDEN_IMPORTS.filter(
(imp) => content.includes(`from "${imp}"`) || content.includes(`from '${imp}'`)
)
}
Application Core и Effect<A, E, R>
R-канал как декларация зависимостей
В Effect-ts тип Effect<A, E, R> явно описывает:
A— тип успешного результатаE— тип возможных ошибокR— тип требуемых зависимостей (Requirements)
R-канал — это ключевой механизм, обеспечивающий изоляцию Application Core. Когда бизнес-логика объявляет зависимость через R-канал, она не получает реализацию — она получает обещание, что реализация будет предоставлена позже.
// Тип этой функции ЯВНО ГОВОРИТ:
// - Она возвращает Todo
// - Она может завершиться ошибками TodoNotFound или InvalidTransition
// - Она ТРЕБУЕТ TodoRepository и Clock (но НЕ ЗНАЕТ их реализации)
const completeTodo = (
todoId: TodoId
): Effect.Effect<
Todo, // A: результат
TodoNotFound | InvalidTransition, // E: ошибки
TodoRepository | Clock // R: зависимости (ПОРТЫ)
> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const clock = yield* Clock
const todo = yield* repo.findById(todoId)
const now = yield* clock.now()
const completed = yield* todo.complete(now)
yield* repo.save(completed)
return completed
})
Компилятор как страж архитектуры
Когда вы пытаетесь запустить Effect без предоставления всех зависимостей, TypeScript не позволит скомпилировать код:
// ❌ Ошибка компиляции:
// Type 'Effect<Todo, TodoNotFound | InvalidTransition, TodoRepository | Clock>'
// is not assignable to type 'Effect<Todo, TodoNotFound | InvalidTransition, never>'
//
// TodoRepository и Clock не предоставлены!
await Effect.runPromise(completeTodo(todoId))
// ✅ Правильно: все зависимости предоставлены
await Effect.runPromise(
completeTodo(todoId).pipe(
Effect.provide(SqliteTodoRepository),
Effect.provide(SystemClock)
)
)
Это означает, что нарушение архитектурных границ обнаруживается на этапе компиляции, а не в runtime.
Внутренняя организация Application Core
Файловая структура
Application Core отражается в файловой системе как набор директорий, изолированных от инфраструктуры:
src/
├── domain/ # ← Доменная модель (чистый домен)
│ ├── todo/
│ │ ├── todo.ts # Entity: Todo
│ │ ├── todo-id.ts # Value Object: TodoId
│ │ ├── todo-title.ts # Value Object: TodoTitle
│ │ ├── todo-status.ts # Value Object: TodoStatus (enum)
│ │ ├── priority.ts # Value Object: Priority
│ │ ├── due-date.ts # Value Object: DueDate
│ │ └── index.ts # Barrel file: публичный API домена
│ ├── events/
│ │ ├── todo-created.ts # Domain Event
│ │ ├── todo-completed.ts # Domain Event
│ │ └── index.ts
│ ├── errors/
│ │ ├── todo-not-found.ts # Domain Error
│ │ ├── invalid-transition.ts # Domain Error
│ │ ├── duplicate-title.ts # Domain Error
│ │ └── index.ts
│ └── services/
│ ├── duplicate-checker.ts # Domain Service
│ └── index.ts
│
├── application/ # ← Application Services (Use Cases)
│ ├── commands/
│ │ ├── create-todo.ts # Use Case: создание задачи
│ │ ├── complete-todo.ts # Use Case: завершение задачи
│ │ ├── update-title.ts # Use Case: изменение заголовка
│ │ └── index.ts
│ ├── queries/
│ │ ├── get-todo.ts # Query: получение задачи
│ │ ├── list-todos.ts # Query: список задач
│ │ └── index.ts
│ └── index.ts
│
├── ports/ # ← Порты (контракты, определённые ядром)
│ ├── todo-repository.ts # Driven Port: хранение задач
│ ├── notification.ts # Driven Port: уведомления
│ ├── clock.ts # Driven Port: время
│ ├── id-generator.ts # Driven Port: генерация ID
│ └── index.ts
│
└── adapters/ # ← ВНЕ Application Core!
├── sqlite/ # Driven Adapter
├── http/ # Driving Adapter
└── in-memory/ # Test Adapter
Обратите внимание: директории domain/, application/ и ports/ — это Application Core. Директория adapters/ — это внешний мир, который подключается через порты.
Публичный API ядра
Application Core экспортирует для внешнего мира ограниченный набор элементов:
// src/domain/index.ts — публичный API домена
export { Todo, type TodoProps } from "./todo/todo.js"
export { TodoId } from "./todo/todo-id.js"
export { TodoTitle } from "./todo/todo-title.js"
export { TodoStatus } from "./todo/todo-status.js"
export { Priority } from "./todo/priority.js"
export { TodoCreated } from "./events/todo-created.js"
export { TodoCompleted } from "./events/todo-completed.js"
export { TodoNotFound } from "./errors/todo-not-found.js"
export { InvalidTransition } from "./errors/invalid-transition.js"
export { DuplicateTitle } from "./errors/duplicate-title.js"
// src/ports/index.ts — публичный API портов
export { TodoRepository } from "./todo-repository.js"
export { NotificationService } from "./notification.js"
export { Clock } from "./clock.js"
export { IdGenerator } from "./id-generator.js"
// src/application/index.ts — публичный API Use Cases
export { createTodo } from "./commands/create-todo.js"
export { completeTodo } from "./commands/complete-todo.js"
export { getTodo } from "./queries/get-todo.js"
export { listTodos } from "./queries/list-todos.js"
Чистота как архитектурное свойство
Чистые функции в домене
Domain Model в идеале состоит из чистых функций — функций без побочных эффектов. Каждая операция над сущностью возвращает новый экземпляр, а не мутирует существующий:
class Todo extends Schema.TaggedClass<Todo>()("Todo", {
id: TodoId,
title: TodoTitle,
status: TodoStatus,
priority: Priority,
createdAt: Schema.Date,
completedAt: Schema.OptionFromNullOr(Schema.Date),
}) {
// Чистая функция: возвращает НОВЫЙ Todo, не мутирует this
complete(now: Date): Effect.Effect<Todo, InvalidTransition> {
if (this.status === "completed") {
return Effect.fail(
new InvalidTransition({
from: this.status,
to: "completed",
reason: "Задача уже завершена",
})
)
}
if (this.status === "archived") {
return Effect.fail(
new InvalidTransition({
from: this.status,
to: "completed",
reason: "Нельзя завершить архивированную задачу",
})
)
}
return Effect.succeed(
new Todo({
...this,
status: "completed" as const,
completedAt: Option.some(now),
})
)
}
// Чистая функция: изменение приоритета
changePriority(
newPriority: Priority
): Effect.Effect<Todo, InvalidTransition> {
if (this.status === "completed" || this.status === "archived") {
return Effect.fail(
new InvalidTransition({
from: this.status,
to: this.status,
reason: "Нельзя изменить приоритет завершённой или архивированной задачи",
})
)
}
return Effect.succeed(
new Todo({ ...this, priority: newPriority })
)
}
}
Преимущества чистоты
Тестируемость. Чистая функция тестируется тривиально: подай вход, проверь выход:
import { describe, it, expect } from "bun:test"
import { Effect } from "effect"
describe("Todo.complete", () => {
it("завершает активную задачу", async () => {
const todo = makeTodo({ status: "active" })
const now = new Date("2025-01-15T10:00:00Z")
const result = await Effect.runPromise(todo.complete(now))
expect(result.status).toBe("completed")
expect(Option.getOrThrow(result.completedAt)).toEqual(now)
})
it("отклоняет повторное завершение", async () => {
const todo = makeTodo({ status: "completed" })
const now = new Date()
const result = await Effect.runPromise(
todo.complete(now).pipe(Effect.flip)
)
expect(result._tag).toBe("InvalidTransition")
})
})
Предсказуемость. Один и тот же вход всегда даёт один и тот же выход. Нет скрытых зависимостей от глобального состояния, времени, случайных чисел или внешних сервисов.
Параллелизм. Чистые функции безопасны для параллельного выполнения — нет shared state, нет race conditions.
Рефакторинг. Чистую функцию можно перемещать, переименовывать, декомпозировать без риска сломать побочные эффекты.
Границы Application Core
Где проходит граница
Граница Application Core проходит по портам. Порт — это «мембрана», через которую ядро взаимодействует с внешним миром. Всё внутри мембраны — Application Core. Всё снаружи — адаптеры и инфраструктура.
АДАПТЕР ПОРТ APPLICATION CORE
┌──────┐ ┌──────────┐ ┌─────────────────┐
│HTTP │────►│ Driving │────►│ Use Case │
│Server│ │ Port │ │ (createTodo) │
└──────┘ └──────────┘ │ │
│ ↓ │
│ Domain Model │
│ (Todo.create) │
│ │
┌──────┐ ┌──────────┐ │ ↓ │
│SQLite│◄────│ Driven │◄────│ repo.save(todo) │
│ DB │ │ Port │ │ │
└──────┘ └──────────┘ └─────────────────┘
Что определяет «внутри» vs «снаружи»
| Критерий | Внутри (Application Core) | Снаружи (Адаптеры) |
|---|---|---|
| Знание о технологиях | Нет | Да |
| Зависимости | Только Effect core + домен | Инфраструктурные библиотеки |
| Скорость изменений | Медленная (бизнес-правила стабильны) | Быстрая (технологии меняются) |
| Тестирование | Unit-тесты, чистые функции | Integration-тесты, инфраструктура |
| Переносимость | Высокая | Привязана к технологии |
| Кто владеет | Бизнес / доменные эксперты | Технические специалисты |
Application Core и Effect Runtime
Как Core запускается
Application Core сам по себе — это набор описаний (Effect), а не выполнимый код. Он «оживает» только когда к нему подключаются адаптеры через Effect.provide и запускается через Effect.runPromise или аналогичные функции:
import { Effect, Layer } from "effect"
// ШАГ 1: Application Core определяет бизнес-логику (описание)
const program = createTodo({ title: "Написать документацию" })
// Тип: Effect<Todo, ValidationError | ..., TodoRepository | Clock | IdGenerator>
// Это ОПИСАНИЕ, не выполнение. Ничего не происходит.
// ШАГ 2: Собираем адаптеры в единый Layer
const ProductionLayer = Layer.mergeAll(
SqliteTodoRepository, // Адаптер: TodoRepository → SQLite
SystemClock, // Адаптер: Clock → системное время
UuidGenerator, // Адаптер: IdGenerator → crypto.randomUUID
)
// ШАГ 3: Подключаем адаптеры к ядру
const runnable = program.pipe(Effect.provide(ProductionLayer))
// Тип: Effect<Todo, ValidationError | ..., never>
// R = never означает: все зависимости удовлетворены
// ШАГ 4: Запускаем
const todo = await Effect.runPromise(runnable)
Эта последовательность — описание → конфигурация → запуск — является каноническим паттерном использования Application Core в Effect-ts.
Антипаттерны: когда Core загрязнён
Антипаттерн 1: Прямой импорт инфраструктуры
// ❌ АНТИПАТТЕРН: домен импортирует инфраструктурную библиотеку
import { Database } from "bun:sqlite"
class TodoService {
constructor(private db: Database) {} // Прямая зависимость от SQLite
createTodo(title: string) {
this.db.run("INSERT INTO todos (title) VALUES (?)", [title])
}
}
Проблема: замена SQLite на PostgreSQL требует изменения доменного кода.
Антипаттерн 2: Бизнес-логика в адаптере
// ❌ АНТИПАТТЕРН: бизнес-правило живёт в HTTP-хендлере
const handler = HttpRouter.post("/todos", (req) =>
Effect.gen(function* () {
const body = yield* req.json
// Бизнес-правило НЕ ДОЛЖНО быть здесь!
if (body.title.length > 200) {
return HttpServerResponse.json({ error: "Title too long" }, { status: 400 })
}
// Ещё одно бизнес-правило в неправильном месте
if (body.priority < 1 || body.priority > 5) {
return HttpServerResponse.json({ error: "Invalid priority" }, { status: 400 })
}
// Прямой вызов БД — Application Core отсутствует!
const db = yield* SqliteClient
db.run("INSERT INTO todos ...", [body.title, body.priority])
return HttpServerResponse.json({ success: true })
})
)
Проблема: бизнес-правила размазаны по адаптерам. При добавлении CLI-интерфейса придётся дублировать все проверки.
Антипаттерн 3: Доменные типы = инфраструктурные типы
// ❌ АНТИПАТТЕРН: тип домена = строка SQL-таблицы
interface TodoRow {
id: number // autoincrement SQLite — это не доменный ID
title: string // без валидации — это не TodoTitle
is_completed: 0 | 1 // SQLite boolean — это не доменный Status
created_at: string // ISO string — это не доменная Date
}
// Этот тип используется повсюду: в домене, в Use Cases, в HTTP-ответах
// Результат: замена SQLite на MongoDB потребует переписывания ВСЕГО
Правильный подход: домен имеет свои типы, адаптер — свои, маппинг между ними происходит на границе:
// Доменный тип (Application Core)
class Todo extends Schema.TaggedClass<Todo>()("Todo", {
id: TodoId, // Value Object с бизнес-правилами
title: TodoTitle, // Value Object с валидацией
status: TodoStatus, // Enum доменных состояний
createdAt: Schema.Date,
}) {}
// Инфраструктурный тип (Адаптер SQLite)
interface TodoRow {
readonly id: string
readonly title: string
readonly is_completed: number
readonly created_at: string
}
// Маппинг: только в адаптере
const toDomain = (row: TodoRow): Effect.Effect<Todo, DecodeError> =>
Schema.decode(Todo)({
id: { value: row.id },
title: { value: row.title },
status: row.is_completed === 1 ? "completed" : "active",
createdAt: new Date(row.created_at),
})
const toRow = (todo: Todo): TodoRow => ({
id: todo.id.value,
title: todo.title.value,
is_completed: todo.status === "completed" ? 1 : 0,
created_at: todo.createdAt.toISOString(),
})
Метрика здоровья Application Core
Несколько количественных показателей, по которым можно оценить здоровье Application Core:
| Метрика | Хорошо | Плохо |
|---|---|---|
| Инфраструктурные импорты в domain/ | 0 | >0 |
| Покрытие домена unit-тестами | >90% | <50% |
| Время запуска unit-тестов домена | <1 секунда | >5 секунд |
| Количество конкретных типов в R-канале | 0 (только Tag-и) | >0 |
| Дублирование бизнес-правил в адаптерах | 0 | >0 |
Резюме
Application Core — это «чистая комната» вашего приложения:
- Домен в центре — Entity, Value Objects, Aggregates, Events, Errors с нулевыми внешними зависимостями
- Use Cases как оркестраторы — координируют домен и порты, не содержат бизнес-логику
- Порты как мембрана — контракты на границе, через которые Core общается с миром
- Чистота проверяема — imports, R-канал Effect, unit-тесты без инфраструктуры
- Компилятор как страж — TypeScript + Effect гарантируют, что все зависимости удовлетворены
Application Core — это единственная часть системы, которая не устаревает при смене технологий. SQLite заменится на PostgreSQL, Express на Fastify, REST на gRPC — Application Core останется неизменным.