Упражнения: нарисуй гексагон для своего проекта
Шесть практических упражнений: идентификация зон, определение портов, рефакторинг, построение диаграммы, классификация компонентов, проектирование приложения. Развёрнутые решения и чеклист самопроверки.
Цель упражнений
После изучения теоретической основы Hexagonal Architecture важно закрепить понимание на практике. В этих упражнениях вы будете анализировать, проектировать и рефакторить — не писать большие объёмы кода, а думать архитектурно.
Каждое упражнение направлено на проверку понимания конкретного аспекта гексагональной архитектуры.
Упражнение 1: Идентификация зон гексагона
Задача: Дан следующий код Todo-приложения. Определите, к какой зоне гексагона (Domain, Port, Adapter, Application) относится каждый фрагмент. Укажите нарушения Dependency Rule, если они есть.
// Фрагмент A
import { Database } from "bun:sqlite"
export const saveTodo = (db: Database, title: string, priority: number) => {
if (title.length === 0) throw new Error("Title is required")
if (priority < 1 || priority > 5) throw new Error("Invalid priority")
db.run("INSERT INTO todos (title, priority) VALUES (?, ?)", [title, priority])
}
// Фрагмент B
import { Schema } from "effect"
class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
value: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(200)),
}) {}
class Priority extends Schema.TaggedClass<Priority>()("Priority", {
value: Schema.Number.pipe(Schema.int(), Schema.between(1, 5)),
}) {}
// Фрагмент C
import { Context, Effect } from "effect"
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
}
>() {}
// Фрагмент D
import { HttpRouter, HttpServerResponse } from "@effect/platform"
const todoRoutes = HttpRouter.post("/todos",
Effect.gen(function* () {
const body = yield* parseBody()
const todo = yield* createTodo(body)
return HttpServerResponse.json(todo, { status: 201 })
})
)
// Фрагмент E
const createTodo = (input: CreateTodoInput): Effect.Effect<
Todo,
ValidationError | DuplicateTitle,
TodoRepository | Clock | IdGenerator
> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const clock = yield* Clock
const idGen = yield* IdGenerator
const title = yield* Schema.decode(TodoTitle)({ value: input.title })
const todo = new Todo({
id: yield* idGen.generate(),
title,
status: "active",
createdAt: yield* clock.now(),
})
yield* repo.save(todo)
return todo
})
Ответьте на вопросы:
- К какой зоне относится каждый фрагмент (A–E)?
- Какие нарушения Dependency Rule содержит Фрагмент A?
- Почему Фрагмент B принадлежит домену, а не порту?
- Что произойдёт, если мы попробуем запустить
createTodoбезEffect.provide?
Решение
-
Зоны:
- A — Это «антипаттерн-монолит»: содержит бизнес-логику (валидация), инфраструктуру (SQL) и не имеет чёткой зоны. Формально это адаптер с утечкой бизнес-логики.
- B — Domain (Value Objects). Чистые типы с бизнес-правилами, зависят только от
effect. - C — Port (Driven Port). Контракт хранилища, определённый через
Context.Tag. - D — Driving Adapter. HTTP-роутер, знает о HTTP, вызывает Use Case.
- E — Application (Use Case). Оркестрирует домен и порты, не содержит бизнес-логику.
-
Нарушения во фрагменте A:
- Прямой
import { Database } from "bun:sqlite"— зависимость от конкретной БД - Бизнес-правила (
title.length === 0,priority < 1 || priority > 5) смешаны с инфраструктурным кодом (db.run) - Нет типизированных ошибок —
throw new Errorвместо доменных ошибок - Нет разделения на слои — все три зоны в одной функции
- Прямой
-
Фрагмент B — домен, а не порт, потому что:
TodoTitleиPriority— это Value Objects с бизнес-правилами (минимальная длина, диапазон приоритета)- Они не описывают взаимодействие с внешним миром — они описывают что такое заголовок и приоритет
- Порт описывает операции (save, find), а Value Object описывает данные (что такое корректный заголовок)
-
Без
Effect.provide:- TypeScript выдаст ошибку компиляции:
Type 'Effect<Todo, ..., TodoRepository | Clock | IdGenerator>' is not assignable to type 'Effect<Todo, ..., never>' - R-канал содержит
TodoRepository | Clock | IdGenerator— это незакрытые зависимости - Код не скомпилируется, пока все зависимости не будут предоставлены через
Effect.provide
- TypeScript выдаст ошибку компиляции:
Упражнение 2: Определение портов для Todo-приложения
Задача: На основе следующих бизнес-требований определите полный набор Driven-портов для Todo-приложения. Для каждого порта напишите определение через Context.Tag с полной типизацией операций, ошибок и возвращаемых типов.
Бизнес-требования:
- Пользователь может создавать задачи с заголовком, приоритетом и необязательной датой
- Пользователь может завершать задачи
- Пользователь может просматривать список задач с фильтрацией по статусу
- При создании задачи с высоким приоритетом отправляется уведомление
- Каждой задаче присваивается уникальный идентификатор
- Задача получает timestamp создания
- История всех действий записывается для аудита
Задание: определите следующие порты:
TodoRepository(хранение)NotificationService(уведомления)IdGenerator(генерация ID)Clock(время)AuditLog(аудит)
Решение
import { Context, Effect, Option } from "effect"
// === DRIVEN PORTS ===
// Порт 1: Хранение задач
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (
id: TodoId
) => Effect.Effect<Todo, TodoNotFound>
readonly save: (
todo: Todo
) => Effect.Effect<void, RepositoryError>
readonly findAll: (
filter: TodoFilter
) => Effect.Effect<ReadonlyArray<Todo>>
readonly delete: (
id: TodoId
) => Effect.Effect<void, TodoNotFound>
readonly existsByTitle: (
title: TodoTitle
) => Effect.Effect<boolean>
}
>() {}
// Порт 2: Отправка уведомлений
class NotificationService extends Context.Tag("NotificationService")<
NotificationService,
{
readonly send: (
notification: Notification
) => Effect.Effect<void, NotificationError>
}
>() {}
// Порт 3: Генерация уникальных идентификаторов
class IdGenerator extends Context.Tag("IdGenerator")<
IdGenerator,
{
readonly generate: () => Effect.Effect<TodoId>
}
>() {}
// Порт 4: Текущее время
class Clock extends Context.Tag("Clock")<
Clock,
{
readonly now: () => Effect.Effect<Date>
}
>() {}
// Порт 5: Журнал аудита
class AuditLog extends Context.Tag("AuditLog")<
AuditLog,
{
readonly record: (
entry: AuditEntry
) => Effect.Effect<void, AuditError>
}
>() {}
// === ВСПОМОГАТЕЛЬНЫЕ ДОМЕННЫЕ ТИПЫ ===
interface TodoFilter {
readonly status?: TodoStatus
readonly priority?: Priority
}
interface Notification {
readonly type: "high_priority_created"
readonly todoId: TodoId
readonly title: TodoTitle
readonly priority: Priority
}
interface AuditEntry {
readonly action: "created" | "completed" | "deleted"
readonly todoId: TodoId
readonly timestamp: Date
readonly details: string
}
Обратите внимание:
- Все типы в портах — доменные (
TodoId,Todo,TodoTitle), не инфраструктурные - Каждый порт имеет типизированные ошибки в E-канале
TodoFilter,Notification,AuditEntry— доменные типы, не HTTP или SQL
Упражнение 3: Рефакторинг нарушений Dependency Rule
Задача: Следующий код содержит множественные нарушения Dependency Rule. Проведите рефакторинг, разделив его на правильные зоны гексагона.
// ИСХОДНЫЙ КОД (всё в одном файле!)
import { Database } from "bun:sqlite"
const db = new Database("todos.sqlite")
db.run(`
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
created_at TEXT NOT NULL
)
`)
export function createTodo(title: string) {
// Бизнес-правила
if (!title || title.trim().length === 0) {
throw new Error("Title is required")
}
if (title.length > 200) {
throw new Error("Title too long")
}
// Проверка уникальности
const existing = db.query("SELECT id FROM todos WHERE title = ?").get(title)
if (existing) {
throw new Error("Todo with this title already exists")
}
// Создание
const id = crypto.randomUUID()
const now = new Date().toISOString()
db.run("INSERT INTO todos (id, title, created_at) VALUES (?, ?, ?)", [id, title, now])
return { id, title, completed: false, createdAt: now }
}
export function completeTodo(id: string) {
const todo = db.query("SELECT * FROM todos WHERE id = ?").get(id)
if (!todo) {
throw new Error("Todo not found")
}
if (todo.completed === 1) {
throw new Error("Todo already completed")
}
db.run("UPDATE todos SET completed = 1 WHERE id = ?", [id])
return { ...todo, completed: true }
}
export function listTodos(onlyActive: boolean = false) {
const sql = onlyActive
? "SELECT * FROM todos WHERE completed = 0"
: "SELECT * FROM todos"
return db.query(sql).all()
}
Задание: разделите этот код на:
- Domain (Value Objects, Entity, Errors)
- Port (TodoRepository, Clock, IdGenerator)
- Application (Use Cases: createTodo, completeTodo, listTodos)
- Adapter (SQLite-реализация TodoRepository)
Решение
// ============================================
// 1. DOMAIN — domain/todo.ts
// ============================================
import { Schema, Data, Effect, Option } from "effect"
// Value Objects
class TodoId extends Schema.TaggedClass<TodoId>()("TodoId", {
value: Schema.UUID,
}) {}
class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
value: Schema.String.pipe(
Schema.trimmed(),
Schema.minLength(1, { message: () => "Title is required" }),
Schema.maxLength(200, { message: () => "Title too long" }),
),
}) {}
type TodoStatus = "active" | "completed"
// Entity
class Todo extends Schema.TaggedClass<Todo>()("Todo", {
id: TodoId,
title: TodoTitle,
status: Schema.Literal("active", "completed"),
createdAt: Schema.Date,
}) {
complete(): Effect.Effect<Todo, InvalidTransition> {
if (this.status === "completed") {
return Effect.fail(new InvalidTransition({
from: "completed",
to: "completed",
reason: "Todo already completed",
}))
}
return Effect.succeed(new Todo({ ...this, status: "completed" }))
}
}
// Domain Errors
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
readonly id: TodoId
}> {}
class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
readonly from: TodoStatus
readonly to: TodoStatus
readonly reason: string
}> {}
class DuplicateTitle extends Data.TaggedError("DuplicateTitle")<{
readonly title: TodoTitle
}> {}
class ValidationError extends Data.TaggedError("ValidationError")<{
readonly message: string
}> {}
class RepositoryError extends Data.TaggedError("RepositoryError")<{
readonly operation: string
readonly cause: unknown
}> {}
// ============================================
// 2. PORTS — ports/index.ts
// ============================================
import { Context, Effect } from "effect"
class TodoRepository extends Context.Tag("TodoRepository")<
TodoRepository,
{
readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
readonly save: (todo: Todo) => Effect.Effect<void, RepositoryError>
readonly findAll: (filter: { readonly onlyActive: boolean }) => Effect.Effect<ReadonlyArray<Todo>>
readonly existsByTitle: (title: TodoTitle) => Effect.Effect<boolean>
}
>() {}
class Clock extends Context.Tag("Clock")<
Clock,
{ readonly now: () => Effect.Effect<Date> }
>() {}
class IdGenerator extends Context.Tag("IdGenerator")<
IdGenerator,
{ readonly generate: () => Effect.Effect<TodoId> }
>() {}
// ============================================
// 3. APPLICATION — application/use-cases.ts
// ============================================
import { Effect, Schema } from "effect"
// Use Case: createTodo
const createTodo = (input: {
readonly title: string
}): Effect.Effect<
Todo,
ValidationError | DuplicateTitle | RepositoryError,
TodoRepository | Clock | IdGenerator
> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const clock = yield* Clock
const idGen = yield* IdGenerator
// Валидация через доменный тип
const title = yield* Schema.decode(TodoTitle)({ value: input.title }).pipe(
Effect.mapError((e) => new ValidationError({ message: String(e) }))
)
// Бизнес-правило: уникальность
const exists = yield* repo.existsByTitle(title)
if (exists) yield* Effect.fail(new DuplicateTitle({ title }))
// Создание Entity
const todo = new Todo({
id: yield* idGen.generate(),
title,
status: "active",
createdAt: yield* clock.now(),
})
yield* repo.save(todo)
return todo
})
// Use Case: completeTodo
const completeTodo = (
todoId: TodoId
): Effect.Effect<
Todo,
TodoNotFound | InvalidTransition | RepositoryError,
TodoRepository
> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
const todo = yield* repo.findById(todoId)
const completed = yield* todo.complete()
yield* repo.save(completed)
return completed
})
// Use Case: listTodos
const listTodos = (filter: {
readonly onlyActive: boolean
}): Effect.Effect<ReadonlyArray<Todo>, never, TodoRepository> =>
Effect.gen(function* () {
const repo = yield* TodoRepository
return yield* repo.findAll(filter)
})
// ============================================
// 4. ADAPTER — adapters/sqlite/todo-repository.ts
// ============================================
import { Layer, Effect } from "effect"
class SqliteClient extends Context.Tag("SqliteClient")<
SqliteClient,
{ readonly db: Database }
>() {}
interface TodoRow {
readonly id: string
readonly title: string
readonly completed: number
readonly created_at: string
}
const rowToTodo = (row: TodoRow): Effect.Effect<Todo, RepositoryError> =>
Schema.decode(Todo)({
id: { value: row.id },
title: { value: row.title },
status: row.completed === 1 ? "completed" as const : "active" as const,
createdAt: new Date(row.created_at),
}).pipe(
Effect.mapError((e) => new RepositoryError({ operation: "decode", cause: e }))
)
const SqliteTodoRepository = Layer.effect(
TodoRepository,
Effect.gen(function* () {
const { db } = yield* SqliteClient
return {
findById: (id) =>
Effect.gen(function* () {
const row = db.query("SELECT * FROM todos WHERE id = ?")
.get(id.value) as TodoRow | null
if (!row) return yield* Effect.fail(new TodoNotFound({ id }))
return yield* rowToTodo(row)
}),
save: (todo) =>
Effect.try({
try: () => {
db.run(
"INSERT OR REPLACE INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
[todo.id.value, todo.title.value, todo.status === "completed" ? 1 : 0, todo.createdAt.toISOString()]
)
},
catch: (e) => new RepositoryError({ operation: "save", cause: e }),
}),
findAll: (filter) =>
Effect.gen(function* () {
const sql = filter.onlyActive
? "SELECT * FROM todos WHERE completed = 0"
: "SELECT * FROM todos"
const rows = db.query(sql).all() as ReadonlyArray<TodoRow>
return yield* Effect.forEach(rows, rowToTodo)
}),
existsByTitle: (title) =>
Effect.sync(() => {
const row = db.query("SELECT 1 FROM todos WHERE title = ?")
.get(title.value)
return row !== null
}),
}
})
)
Что изменилось:
- Бизнес-правила теперь в Domain (Value Objects, Entity.complete())
- Контракт хранилища — в Port (Context.Tag)
- Оркестрация — в Application (Use Cases)
- SQL-запросы — в Adapter (SqliteTodoRepository)
- Зависимости от инфраструктуры — только в адаптере
- Dependency Rule соблюдён: стрелки зависимостей указывают внутрь
Упражнение 4: Нарисуй гексагон
Задача: На основе Todo-приложения из этого модуля нарисуйте полную гексагональную диаграмму. Используйте ASCII-art или текстовый формат.
Диаграмма должна содержать:
- Application Core в центре (Domain + Use Cases)
- Все Driving Ports (слева)
- Все Driven Ports (справа)
- Все адаптеры (снаружи)
- Стрелки направления зависимостей
Решение
DRIVING DRIVEN
(входящие) (исходящие)
┌────────────────┐ ┌──────────────────┐
│ HTTP Server │ │ SQLite DB │
│ (REST API) │ │ (bun:sqlite) │
└───────┬────────┘ └────────▲─────────┘
│ Driving │ Driven
│ Adapter │ Adapter
│ │
▼ │
┌───────────────┐ ╔═══════════════════════╗ ┌──────┴────────┐
│ HTTP Router + │ ║ ║ │ SQLite Todo │
│ Handler │────►║ APPLICATION CORE ║───►│ Repository │
└───────────────┘ ║ ║ └───────────────┘
║ ┌─────────────────┐ ║
┌───────────────┐ ║ │ USE CASES │ ║ ┌───────────────┐
│ CLI Parser + │ ║ │ createTodo() │ ║ │ Console │
│ Handler │────►║ │ completeTodo() │ ║───►│ Notification │
└───────┬────────┘ ║ │ listTodos() │ ║ └───────▲───────┘
│ ║ └────────┬────────┘ ║ │
│ ║ │ ║ ┌───────┴───────┐
┌───────┴────────┐ ║ ┌────────▼────────┐ ║ │ SMTP │
│ CLI Client │ ║ │ DOMAIN │ ║ │ Notification │
│ (терминал) │ ║ │ Todo Entity │ ║ └───────────────┘
└────────────────┘ ║ │ Value Objects │ ║
║ │ Errors │ ║ ┌───────────────┐
┌────────────────┐ ║ │ Events │ ║ │ System Clock │
│ Test Runner │ ║ └─────────────────┘ ║───►│ (Date) │
│ (bun:test) │───►║ ║ └───────▲───────┘
└────────────────┘ ║ PORTS: ║ │
║ • TodoRepository ◄───╫────────────┤
║ • NotificationSvc ◄──╫────────────┤
║ • Clock ◄────────────╫────────────┤
║ • IdGenerator ◄──────╫──┐ │
║ • AuditLog ◄─────────╫┐ │ ┌──────┴────────┐
╚═══════════════════════╝│ │ │ UUID Generator│
│ │ │ (crypto) │
│ │ └───────────────┘
│ │
│ └──┌───────────────┐
│ │ InMemory │
│ │ Audit Log │
│ └───────────────┘
│
└────┌───────────────┐
│ File Audit │
│ Log │
└───────────────┘
СТРЕЛКИ ЗАВИСИМОСТЕЙ:
──► = "зависит от" / "знает о"
Все стрелки от адаптеров указывают ВНУТРЬ (к Application Core)
Application Core НЕ ИМЕЕТ стрелок наружу
Упражнение 5: Определение Driving и Driven
Задача: Классифицируйте каждый из следующих компонентов как Driving Adapter, Driven Adapter, Driving Port или Driven Port:
| # | Компонент | Тип |
|---|---|---|
| 1 | HTTP-роутер, принимающий POST /todos | ? |
| 2 | TodoRepository (Context.Tag) | ? |
| 3 | SQLite-реализация TodoRepository | ? |
| 4 | InMemory-реализация TodoRepository | ? |
| 5 | CreateTodoUseCase (Context.Tag) | ? |
| 6 | CLI-парсер, обрабатывающий todo create "Buy milk" | ? |
| 7 | Clock (Context.Tag) | ? |
| 8 | Cron-задача, архивирующая старые задачи | ? |
| 9 | NotificationService (Context.Tag) | ? |
| 10 | Console-реализация NotificationService | ? |
Решение
| # | Компонент | Тип | Пояснение |
|---|---|---|---|
| 1 | HTTP-роутер | Driving Adapter | Преобразует HTTP-запрос в вызов Use Case |
| 2 | TodoRepository (Tag) | Driven Port | Контракт: что приложению нужно для хранения |
| 3 | SQLite-реализация | Driven Adapter | Реализует порт хранения через SQLite |
| 4 | InMemory-реализация | Driven Adapter | Реализует тот же порт через Map (для тестов) |
| 5 | CreateTodoUseCase (Tag) | Driving Port | Контракт: что внешний мир может попросить |
| 6 | CLI-парсер | Driving Adapter | Преобразует CLI-команду в вызов Use Case |
| 7 | Clock (Tag) | Driven Port | Контракт: приложению нужно текущее время |
| 8 | Cron-задача | Driving Adapter | Инициирует вызов Use Case по расписанию |
| 9 | NotificationService (Tag) | Driven Port | Контракт: приложению нужно отправлять уведомления |
| 10 | Console-реализация | Driven Adapter | Реализует порт уведомлений через console.log |
Упражнение 6: Проектирование собственного проекта
Задача открытая: Выберите одно из следующих приложений (или придумайте своё) и спроектируйте для него гексагональную архитектуру:
Варианты:
- A) Сервис заметок (Notes) — создание, редактирование, поиск, теги
- B) Сервис бронирования (Booking) — создание, подтверждение, отмена
- C) Сервис аналитики (Analytics) — сбор событий, агрегация, отчёты
Для выбранного приложения определите:
- 3–5 доменных сущностей (Entities / Value Objects)
- 2–3 доменные ошибки
- 3–5 Use Cases (Driving Ports)
- 3–5 Driven Ports
- По 2 адаптера для каждого Driven Port (production + test)
- Файловую структуру проекта
Формат ответа: текстовое описание или код с определениями Context.Tag.
Чеклист самопроверки
После выполнения упражнений убедитесь, что вы можете ответить «да» на каждый вопрос:
- Я могу объяснить разницу между портом и адаптером
- Я понимаю, почему Application Core не должен зависеть от инфраструктуры
- Я могу определить, является ли компонент Driving или Driven
- Я могу определить порт через
Context.Tagв Effect-ts - Я понимаю, как
Layerреализует концепцию адаптера - Я могу обнаружить нарушение Dependency Rule в коде
- Я понимаю, как R-канал
Effect<A, E, R>связан с портами - Я могу объяснить, почему
ClockиIdGenerator— это порты - Я могу нарисовать гексагональную диаграмму для приложения
- Я понимаю связь между Configurable Dependency и
Effect.provide(Layer)