Типобезопасный домен: Гексагональная архитектура на базе Effect Упражнения: нарисуй гексагон для своего проекта
Глава

Упражнения: нарисуй гексагон для своего проекта

Шесть практических упражнений: идентификация зон, определение портов, рефакторинг, построение диаграммы, классификация компонентов, проектирование приложения. Развёрнутые решения и чеклист самопроверки.

Цель упражнений

После изучения теоретической основы 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
  })

Ответьте на вопросы:

  1. К какой зоне относится каждый фрагмент (A–E)?
  2. Какие нарушения Dependency Rule содержит Фрагмент A?
  3. Почему Фрагмент B принадлежит домену, а не порту?
  4. Что произойдёт, если мы попробуем запустить createTodo без Effect.provide?
Решение
  1. Зоны:

    • A — Это «антипаттерн-монолит»: содержит бизнес-логику (валидация), инфраструктуру (SQL) и не имеет чёткой зоны. Формально это адаптер с утечкой бизнес-логики.
    • BDomain (Value Objects). Чистые типы с бизнес-правилами, зависят только от effect.
    • CPort (Driven Port). Контракт хранилища, определённый через Context.Tag.
    • DDriving Adapter. HTTP-роутер, знает о HTTP, вызывает Use Case.
    • EApplication (Use Case). Оркестрирует домен и порты, не содержит бизнес-логику.
  2. Нарушения во фрагменте A:

    • Прямой import { Database } from "bun:sqlite" — зависимость от конкретной БД
    • Бизнес-правила (title.length === 0, priority < 1 || priority > 5) смешаны с инфраструктурным кодом (db.run)
    • Нет типизированных ошибок — throw new Error вместо доменных ошибок
    • Нет разделения на слои — все три зоны в одной функции
  3. Фрагмент B — домен, а не порт, потому что:

    • TodoTitle и Priority — это Value Objects с бизнес-правилами (минимальная длина, диапазон приоритета)
    • Они не описывают взаимодействие с внешним миром — они описывают что такое заголовок и приоритет
    • Порт описывает операции (save, find), а Value Object описывает данные (что такое корректный заголовок)
  4. Без Effect.provide:

    • TypeScript выдаст ошибку компиляции: Type 'Effect<Todo, ..., TodoRepository | Clock | IdGenerator>' is not assignable to type 'Effect<Todo, ..., never>'
    • R-канал содержит TodoRepository | Clock | IdGenerator — это незакрытые зависимости
    • Код не скомпилируется, пока все зависимости не будут предоставлены через Effect.provide

Упражнение 2: Определение портов для Todo-приложения

Задача: На основе следующих бизнес-требований определите полный набор Driven-портов для Todo-приложения. Для каждого порта напишите определение через Context.Tag с полной типизацией операций, ошибок и возвращаемых типов.

Бизнес-требования:

  1. Пользователь может создавать задачи с заголовком, приоритетом и необязательной датой
  2. Пользователь может завершать задачи
  3. Пользователь может просматривать список задач с фильтрацией по статусу
  4. При создании задачи с высоким приоритетом отправляется уведомление
  5. Каждой задаче присваивается уникальный идентификатор
  6. Задача получает timestamp создания
  7. История всех действий записывается для аудита

Задание: определите следующие порты:

  • 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()
}

Задание: разделите этот код на:

  1. Domain (Value Objects, Entity, Errors)
  2. Port (TodoRepository, Clock, IdGenerator)
  3. Application (Use Cases: createTodo, completeTodo, listTodos)
  4. 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 или текстовый формат.

Диаграмма должна содержать:

  1. Application Core в центре (Domain + Use Cases)
  2. Все Driving Ports (слева)
  3. Все Driven Ports (справа)
  4. Все адаптеры (снаружи)
  5. Стрелки направления зависимостей
Решение
             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:

#КомпонентТип
1HTTP-роутер, принимающий POST /todos?
2TodoRepository (Context.Tag)?
3SQLite-реализация TodoRepository?
4InMemory-реализация TodoRepository?
5CreateTodoUseCase (Context.Tag)?
6CLI-парсер, обрабатывающий todo create "Buy milk"?
7Clock (Context.Tag)?
8Cron-задача, архивирующая старые задачи?
9NotificationService (Context.Tag)?
10Console-реализация NotificationService?
Решение
#КомпонентТипПояснение
1HTTP-роутерDriving AdapterПреобразует HTTP-запрос в вызов Use Case
2TodoRepository (Tag)Driven PortКонтракт: что приложению нужно для хранения
3SQLite-реализацияDriven AdapterРеализует порт хранения через SQLite
4InMemory-реализацияDriven AdapterРеализует тот же порт через Map (для тестов)
5CreateTodoUseCase (Tag)Driving PortКонтракт: что внешний мир может попросить
6CLI-парсерDriving AdapterПреобразует CLI-команду в вызов Use Case
7Clock (Tag)Driven PortКонтракт: приложению нужно текущее время
8Cron-задачаDriving AdapterИнициирует вызов Use Case по расписанию
9NotificationService (Tag)Driven PortКонтракт: приложению нужно отправлять уведомления
10Console-реализацияDriven AdapterРеализует порт уведомлений через console.log

Упражнение 6: Проектирование собственного проекта

Задача открытая: Выберите одно из следующих приложений (или придумайте своё) и спроектируйте для него гексагональную архитектуру:

Варианты:

  • A) Сервис заметок (Notes) — создание, редактирование, поиск, теги
  • B) Сервис бронирования (Booking) — создание, подтверждение, отмена
  • C) Сервис аналитики (Analytics) — сбор событий, агрегация, отчёты

Для выбранного приложения определите:

  1. 3–5 доменных сущностей (Entities / Value Objects)
  2. 2–3 доменные ошибки
  3. 3–5 Use Cases (Driving Ports)
  4. 3–5 Driven Ports
  5. По 2 адаптера для каждого Driven Port (production + test)
  6. Файловую структуру проекта

Формат ответа: текстовое описание или код с определениями 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)