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

Упражнения Модуля 1

Практические задания на закрепление: идентификация видов coupling и cohesion по фрагментам кода, анализ графа зависимостей, вычисление цикломатической сложности и рефакторинг, проектирование файловой структуры Todo-приложения с нуля.

Упражнение 1: Идентификация видов coupling

Задание: Определите вид coupling (Content, Common, External, Control, Stamp, Data) в каждом из фрагментов и предложите, как понизить его до Data Coupling.

Фрагмент A

class UserService {
  private cache = new Map<string, User>()

  getUser(id: string): User | undefined {
    return this.cache.get(id)
  }
}

class TodoService {
  constructor(private userService: UserService) {}

  getUserTodos(userId: string) {
    // Обращение к приватному кешу через приведение типа
    const allUsers = (this.userService as any).cache
    const user = allUsers.get(userId)
    if (!user) throw new Error("User not found")
    return this.fetchTodos(userId)
  }

  private fetchTodos(userId: string) { /* ... */ }
}
Ответ

Content Coupling. TodoService обращается к приватному полю cache через as any. Это патологическая связанность — модуль ковыряется во внутренностях другого.

Исправление:

class TodoService {
  constructor(private userService: UserService) {}

  getUserTodos(userId: string) {
    const user = this.userService.getUser(userId)  // Публичный метод
    if (!user) throw new Error("User not found")
    return this.fetchTodos(userId)
  }
}

Ещё лучше — зависеть не от UserService, а от минимального интерфейса:

interface UserChecker {
  readonly exists: (id: string) => boolean
}

class TodoService {
  constructor(private users: UserChecker) {}

  getUserTodos(userId: string) {
    if (!this.users.exists(userId)) throw new Error("User not found")
    return this.fetchTodos(userId)
  }
}

Теперь — Data Coupling: TodoService зависит только от boolean, который возвращает exists.

Фрагмент B

// config.ts
export const APP_CONFIG = {
  dbPath: "app.db",
  port: 3000,
  maxTodos: 100,
  smtpHost: "mail.example.com",
  jwtSecret: "super-secret",
}

// todo-service.ts
import { APP_CONFIG } from "./config"

function createTodo(title: string) {
  if (countTodos() >= APP_CONFIG.maxTodos) {
    throw new Error("Limit reached")
  }
  // ...
}

// auth-service.ts
import { APP_CONFIG } from "./config"

function verifyToken(token: string) {
  return jwt.verify(token, APP_CONFIG.jwtSecret)
}
Ответ

Common Coupling. Оба модуля зависят от одного глобального объекта APP_CONFIG. Изменение структуры конфигурации (переименование поля, разделение на секции) затрагивает оба.

К тому же todo-service имеет доступ к jwtSecret, что ему совершенно не нужно (Stamp Coupling поверх Common).

Исправление — каждый модуль получает только то, что ему нужно:

// todo-service.ts
function createTodo(title: string, maxTodos: number) {
  if (countTodos() >= maxTodos) throw new Error("Limit reached")
}

// auth-service.ts
function verifyToken(token: string, secret: string) {
  return jwt.verify(token, secret)
}

С Effect:

const TodoConfig = Effect.Config.number("MAX_TODOS")
const AuthConfig = Effect.Config.string("JWT_SECRET").pipe(Config.map(Redacted.make))

Каждый модуль декларирует свою конфигурацию независимо — Data Coupling.

Фрагмент C

function processTodo(todo: Todo, action: "complete" | "archive" | "delete" | "reopen") {
  switch (action) {
    case "complete": return { ...todo, status: "completed" }
    case "archive":  return { ...todo, status: "archived" }
    case "delete":   return null
    case "reopen":   return { ...todo, status: "active" }
  }
}
Ответ

Control Coupling. Параметр action управляет внутренней логикой функции. Вызывающий код знает, какие действия поддерживаются, и управляет поведением через строковый флаг.

Исправление — отдельная функция для каждого действия:

const completeTodo = (todo: Todo): Todo =>
  ({ ...todo, status: "completed" as const })

const archiveTodo = (todo: Todo): Todo =>
  ({ ...todo, status: "archived" as const })

const reopenTodo = (todo: Todo): Todo =>
  ({ ...todo, status: "active" as const })

Каждая функция — чистая, с единственной ответственностью. Data Coupling: принимает Todo, возвращает Todo.


Упражнение 2: Измерение cohesion

Задание: Определите вид cohesion для каждого модуля и предложите рефакторинг для достижения Functional Cohesion.

Модуль A: helpers.ts

export function formatDate(date: Date): string {
  return date.toISOString().split("T")[0]
}

export function hashPassword(password: string): string {
  return Bun.password.hashSync(password)
}

export function generateSlug(text: string): string {
  return text.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
}

export function clamp(value: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, value))
}

export function sendEmail(to: string, subject: string, body: string): void {
  // SMTP logic
}
Ответ

Coincidental Cohesion. Пять функций, не связанных друг с другом: форматирование дат, хеширование паролей, генерация slug, математика, отправка email. Они оказались вместе случайно.

Рефакторинг — разместить каждую функцию рядом с её доменом:

domain/todo/todo.ts          → generateSlug (если slug — свойство Todo)
domain/auth/password.ts      → hashPassword
domain/shared/date-format.ts → formatDate
domain/shared/math.ts        → clamp (если вообще нужен)
adapters/email/mailer.ts     → sendEmail

Или, если использовать Effect-подход: hashPassword — эффект (Bun.password.hashSync), sendEmail — эффект. Они должны быть адаптерами, а не утилитами.

Модуль B: todo-module.ts

export interface Todo {
  readonly id: string
  readonly title: string
  readonly status: "active" | "completed"
}

export const createTodo = (id: string, title: string): Todo => ({
  id, title, status: "active"
})

export const completeTodo = (todo: Todo): Todo => ({
  ...todo, status: "completed"
})

export const isActive = (todo: Todo): boolean =>
  todo.status === "active"

export const getTitle = (todo: Todo): string =>
  todo.title
Ответ

Functional Cohesion. Все элементы работают с одной сущностью Todo и обслуживают её жизненный цикл: создание, завершение, проверки. Это целевое состояние — рефакторинг не требуется.


Упражнение 3: Анализ направления зависимостей

Задание: Нарисуйте граф зависимостей (кто кого импортирует) для приведённого кода. Найдите нарушения Dependency Rule. Предложите исправление.

// --- domain/todo.ts ---
import { Database } from "bun:sqlite"  // ???

export interface Todo { id: string; title: string; done: boolean }

export function completeTodo(db: Database, id: string): Todo {
  const todo = db.query("SELECT * FROM todos WHERE id = ?").get(id) as Todo
  if (todo.done) throw new Error("Already done")
  db.run("UPDATE todos SET done = 1 WHERE id = ?", [id])
  return { ...todo, done: true }
}

// --- adapters/http-server.ts ---
import { completeTodo } from "../domain/todo"
import { Database } from "bun:sqlite"

const db = new Database("app.db")

Bun.serve({
  fetch(req) {
    const url = new URL(req.url)
    if (url.pathname.startsWith("/todos/") && req.method === "PUT") {
      const id = url.pathname.split("/")[2]
      const result = completeTodo(db, id)
      return Response.json(result)
    }
    return new Response("Not found", { status: 404 })
  }
})
Ответ

Граф зависимостей:

domain/todo.ts → bun:sqlite        ← НАРУШЕНИЕ! Домен зависит от инфраструктуры
adapters/http-server.ts → domain/todo.ts  (допустимо)
adapters/http-server.ts → bun:sqlite      (допустимо для адаптера)

Нарушения:

  1. domain/todo.ts импортирует bun:sqlite — домен знает о базе данных.
  2. Функция completeTodo принимает Database — бизнес-правило связано с SQLite.
  3. SQL-запрос внутри доменной функции.

Исправление:

// domain/todo.ts — НОЛЬ импортов
export interface Todo { readonly id: string; readonly title: string; readonly done: boolean }

export const completeTodo = (todo: Todo): Todo => {
  if (todo.done) throw new Error("Already done")
  return { ...todo, done: true }
}

// ports/todo-repository.ts
import type { Todo } from "../domain/todo"
export interface TodoRepository {
  readonly findById: (id: string) => Promise<Todo | null>
  readonly save: (todo: Todo) => Promise<void>
}

// adapters/sqlite/todo-repo.ts
import type { TodoRepository } from "../../ports/todo-repository"
import { Database } from "bun:sqlite"
// ... реализация

// app/complete-todo.ts
import { completeTodo } from "../domain/todo"
import type { TodoRepository } from "../ports/todo-repository"

export async function handleCompleteTodo(repo: TodoRepository, id: string) {
  const todo = await repo.findById(id)
  if (!todo) throw new Error("Not found")
  const completed = completeTodo(todo)
  await repo.save(completed)
  return completed
}

Новый граф:

domain/todo.ts           → (ничего)           ✅
ports/todo-repository.ts → domain/todo.ts      ✅
app/complete-todo.ts     → domain/todo.ts      ✅
app/complete-todo.ts     → ports/todo-repo     ✅
adapters/sqlite/         → ports/todo-repo     ✅
adapters/sqlite/         → bun:sqlite          ✅ (адаптеру можно)
adapters/http/           → app/complete-todo   ✅

Все стрелки указывают внутрь, к домену. Ни одна стрелка не идёт наружу от домена.


Упражнение 4: Цикломатическая сложность

Задание: Вычислите цикломатическую сложность функции. Предложите рефакторинг, снижающий сложность до ≤ 5.

function processRequest(req: {
  method: string
  path: string
  body?: unknown
  headers: Record<string, string>
}) {
  if (req.method === "GET" && req.path === "/todos") {
    return getAllTodos()
  } else if (req.method === "GET" && req.path.startsWith("/todos/")) {
    const id = req.path.split("/")[2]
    if (!id) return { status: 400, body: "Missing id" }
    const todo = getTodoById(id)
    if (!todo) return { status: 404, body: "Not found" }
    return { status: 200, body: todo }
  } else if (req.method === "POST" && req.path === "/todos") {
    if (!req.headers["content-type"]?.includes("application/json")) {
      return { status: 415, body: "JSON required" }
    }
    if (!req.body || typeof req.body !== "object") {
      return { status: 400, body: "Invalid body" }
    }
    const body = req.body as { title?: string }
    if (!body.title || body.title.length === 0) {
      return { status: 400, body: "Title required" }
    }
    if (body.title.length > 200) {
      return { status: 400, body: "Title too long" }
    }
    return createTodo(body.title)
  } else if (req.method === "PUT" && req.path.startsWith("/todos/")) {
    const id = req.path.split("/")[2]
    if (!id) return { status: 400, body: "Missing id" }
    return completeTodo(id)
  } else if (req.method === "DELETE" && req.path.startsWith("/todos/")) {
    const id = req.path.split("/")[2]
    if (!id) return { status: 400, body: "Missing id" }
    return deleteTodo(id)
  }
  return { status: 405, body: "Method not allowed" }
}
Ответ

Подсчёт: Базовая сложность = 1. Считаем каждый if, else if, &&, ||, ?.:

  • if (req.method === "GET" && req.path === "/todos") → +2
  • else if (req.method === "GET" && req.path.startsWith(...)) → +2
  • if (!id) → +1
  • if (!todo) → +1
  • else if (req.method === "POST" && req.path === "/todos") → +2
  • if (!req.headers[...]?.includes(...)) → +1
  • if (!req.body || typeof req.body !== "object") → +2
  • if (!body.title || body.title.length === 0) → +2
  • if (body.title.length > 200) → +1
  • else if (req.method === "PUT" && ...) → +2
  • if (!id) → +1
  • else if (req.method === "DELETE" && ...) → +2
  • if (!id) → +1

Цикломатическая сложность ≈ 21. Это в два раза выше допустимого порога.

Рефакторинг — разделение по ответственностям:

// Роутер — O(n) по количеству маршрутов, сложность 1 на маршрут
type Handler = (params: RouteParams) => Response

const routes: ReadonlyArray<{ method: string; pattern: RegExp; handler: Handler }> = [
  { method: "GET",    pattern: /^\/todos$/,       handler: handleListTodos },
  { method: "GET",    pattern: /^\/todos\/(.+)$/,  handler: handleGetTodo },
  { method: "POST",   pattern: /^\/todos$/,       handler: handleCreateTodo },
  { method: "PUT",    pattern: /^\/todos\/(.+)$/,  handler: handleCompleteTodo },
  { method: "DELETE", pattern: /^\/todos\/(.+)$/,  handler: handleDeleteTodo },
] as const

// Каждый обработчик — сложность ≤ 3
function handleCreateTodo(params: RouteParams): Response {
  const parsed = parseTodoInput(params.body)    // Schema.decode
  if (Either.isLeft(parsed)) return badRequest(parsed.left)
  return created(createTodo(parsed.right.title))
}

Цикломатическая сложность каждой отдельной функции: ≤ 5. Общая логика разложена на compose-способные части.


Упражнение 5: Спроектируй Todo-приложение

Задание (проектное): Это задание вы будете развивать на протяжении всего курса. Начните сейчас с высокоуровневого дизайна.

Для Todo-приложения со следующими требованиями:

  • Пользователь может создавать, завершать, архивировать и удалять задачи
  • У задачи есть заголовок, приоритет (low/medium/high), опциональный срок (due date)
  • Задачи хранятся в SQLite
  • API доступен через HTTP (REST)
  • При завершении задачи отправляется уведомление

Выполните:

  1. Нарисуйте (текстом) структуру папок проекта с разделением на domain/, ports/, adapters/, app/.
  2. Определите какие файлы будут в каждой папке (просто имена, без кода).
  3. Нарисуйте граф зависимостей: кто от кого зависит (стрелки).
  4. Убедитесь, что все стрелки указывают внутрь (к домену).
  5. Перечислите все порты (driving и driven), которые вам понадобятся.
Пример ответа

Структура папок:

src/
  domain/
    todo/
      todo.ts              — Entity: Todo, TodoId, TodoStatus, Priority
      todo-events.ts       — Events: TodoCreated, TodoCompleted, TodoArchived
      todo-errors.ts       — Errors: TodoNotFound, InvalidTransition, DuplicateTitle
  ports/
    todo-repository.ts     — Driven Port: CRUD для Todo
    notification.ts        — Driven Port: отправка уведомлений
    clock.ts               — Driven Port: текущее время (детерминизм)
  app/
    create-todo.ts         — Use Case: создание задачи
    complete-todo.ts       — Use Case: завершение задачи
    archive-todo.ts        — Use Case: архивация
    delete-todo.ts         — Use Case: удаление
    list-todos.ts          — Use Case: получение списка
    get-todo.ts            — Use Case: получение одной задачи
  adapters/
    sqlite/
      sqlite-client.ts     — Инфраструктура: подключение к SQLite
      todo-repo-sqlite.ts  — Adapter: реализация TodoRepository для SQLite
      migrations.ts        — Миграции схемы БД
    http/
      todo-routes.ts       — Adapter: HTTP-маршруты для Todo
      error-mapper.ts      — Маппинг доменных ошибок → HTTP-кодов
    notification/
      console-notifier.ts  — Adapter: вывод в консоль (dev)
      email-notifier.ts    — Adapter: email (production)
    test/
      todo-repo-memory.ts  — Adapter: in-memory (тесты)
      noop-notifier.ts     — Adapter: no-op (тесты)
  main.ts                  — Точка сборки: Layer composition

Граф зависимостей:

domain/todo/*           → (ничего)
ports/*                 → domain/todo/*
app/*                   → domain/todo/*, ports/*
adapters/sqlite/*       → ports/todo-repository, domain/todo/*
adapters/http/*         → app/*, domain/todo/* (для типов ошибок)
adapters/notification/* → ports/notification
adapters/test/*         → ports/*
main.ts                 → app/*, adapters/*

Все стрелки от внешних слоёв к внутренним. Домен ни от чего не зависит.

Порты:

Driving (входные):

  • Use Case интерфейсы в app/ — API для внешнего мира

Driven (выходные):

  • TodoRepository — персистентность
  • NotificationService — уведомления
  • Clock — текущее время (для детерминированных тестов)

Чеклист самопроверки по Модулю 1

После выполнения упражнений убедитесь, что вы можете ответить «да» на каждый вопрос:

  • Я понимаю разницу между essential и accidental complexity
  • Я могу назвать 5 корневых причин неуправляемой сложности
  • Я могу определить вид coupling по фрагменту кода
  • Я могу определить вид cohesion по структуре модуля
  • Я понимаю, почему зависимости должны указывать внутрь (к домену)
  • Я могу объяснить Dependency Inversion Principle своими словами
  • Я понимаю, как Effect.Service (R-канал) делает зависимости явными
  • Я могу назвать три главные цели архитектуры и объяснить, как их достичь
  • Я могу спроектировать структуру папок для Hexagonal-проекта
  • Я понимаю, почему «рефакторинг потом» — плохая стратегия