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

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

Классификация архитектурных решений из реального кода: определите стиль, найдите нарушения, предложите миграцию. Сравнительный анализ двух проектов с разными архитектурами. Проектирование Todo-приложения в каждом из четырёх стилей для наглядного сравнения.

Упражнение 1: Определи архитектурный стиль (анализ)

Для каждого фрагмента кода определите, какой архитектурный стиль используется (Layered, Clean, Onion, Hexagonal), и обоснуйте ответ.

Фрагмент A

// user-service.ts
import { PrismaClient } from "@prisma/client"

const prisma = new PrismaClient()

export class UserService {
  async createUser(name: string, email: string) {
    const user = await prisma.user.create({
      data: { name, email },
    })
    return user
  }

  async getUser(id: string) {
    return prisma.user.findUnique({ where: { id } })
  }
}

Фрагмент B

// create-user.ts
import { Effect, Context, Layer } from "effect"

class UserRepository extends Context.Tag("UserRepository")<
  UserRepository,
  {
    readonly save: (user: User) => Effect.Effect<void, RepositoryError>
    readonly findByEmail: (email: string) => Effect.Effect<User | null, RepositoryError>
  }
>() {}

const createUser = (name: string, email: string) =>
  Effect.gen(function* () {
    const repo = yield* UserRepository
    const existing = yield* repo.findByEmail(email)
    if (existing !== null) {
      return yield* Effect.fail(new DuplicateEmailError({ email }))
    }
    const user: User = { id: crypto.randomUUID(), name, email }
    yield* repo.save(user)
    return user
  })

Фрагмент C

// create-user-interactor.ts
interface CreateUserInputBoundary {
  execute(input: CreateUserInput): Promise<void>
}

interface CreateUserOutputBoundary {
  presentSuccess(output: CreateUserOutput): void
  presentError(error: ApplicationError): void
}

class CreateUserInteractor implements CreateUserInputBoundary {
  constructor(
    private readonly userRepo: UserRepository,
    private readonly presenter: CreateUserOutputBoundary
  ) {}

  async execute(input: CreateUserInput): Promise<void> {
    try {
      const user = new User(input.name, input.email)
      await this.userRepo.save(user)
      this.presenter.presentSuccess({ id: user.id, name: user.name })
    } catch (error) {
      this.presenter.presentError(error as ApplicationError)
    }
  }
}

Фрагмент D

// domain-model/user.ts
class User {
  constructor(
    readonly id: string,
    readonly name: string,
    readonly email: Email
  ) {}
}

// domain-services/user-validator.ts
const validateUniqueEmail = (
  users: ReadonlyArray<User>,
  email: Email
): boolean => !users.some((u) => u.email.value === email.value)

// application-services/ports.ts
interface UserRepository {
  save(user: User): Promise<void>
  findAll(): Promise<ReadonlyArray<User>>
}

// infrastructure/sqlite-user-repository.ts
import { Database } from "bun:sqlite"

class SqliteUserRepository implements UserRepository {
  constructor(private readonly db: Database) {}
  async save(user: User): Promise<void> { /* SQL */ }
  async findAll(): Promise<ReadonlyArray<User>> { /* SQL */ }
}

Ответы

Раскрыть ответы

Фрагмент A — Layered Architecture

Признаки:

  • Прямая зависимость от конкретной технологии (PrismaClient) в бизнес-сервисе
  • Нет интерфейсов/абстракций между слоями
  • Бизнес-логика (UserService) напрямую зависит от Data Access (Prisma)
  • Направление зависимостей: сверху вниз (Service → ORM → DB)

Фрагмент B — Hexagonal Architecture (с Effect)

Признаки:

  • Context.Tag = порт (типизированный контракт без реализации)
  • Бизнес-логика (createUser) зависит от интерфейса (UserRepository), а не от реализации
  • R-канал (UserRepository) = Dependency Rule, проверяемый компилятором
  • E-канал (DuplicateEmailError | RepositoryError) = контракт ошибок
  • Нет привязки к конкретной технологии хранения

Фрагмент C — Clean Architecture

Признаки:

  • Explicit Input/Output Boundaries (интерфейсы CreateUserInputBoundary и CreateUserOutputBoundary)
  • Паттерн Presenter (CreateUserOutputBoundary.presentSuccess/presentError)
  • Interactor class (реализация Use Case как класс с execute)
  • Dependency Inversion через конструктор
  • Типичная терминология Uncle Bob

Фрагмент D — Onion Architecture

Признаки:

  • Явное разделение Domain Model (user.ts) и Domain Services (user-validator.ts)
  • Интерфейсы портов в application-services/ports.ts (Application layer)
  • Реализация в infrastructure/ — зависит от Application layer (инвертированная зависимость)
  • Терминология DDD (Domain Model, Domain Services)
  • Структура папок соответствует концентрическим слоям Onion

Упражнение 2: Найди нарушение Dependency Rule

В каждом фрагменте найдите нарушение Dependency Rule (зависимость, направленную не в ту сторону) и предложите исправление.

Нарушение A

// domain/todo.ts
import { Schema } from "@effect/schema"

interface Todo {
  readonly id: string
  readonly title: string
  readonly status: TodoStatus
  readonly sqliteRowId?: number  // ← ???
}

Нарушение B

// domain/order.ts
import { sendEmail } from "../infrastructure/email-service"

const completeOrder = (order: Order): Order => {
  const completed = { ...order, status: "completed" as const }
  sendEmail(order.customerEmail, "Order completed!")  // ← ???
  return completed
}

Нарушение C

// use-cases/create-todo.ts
import { Effect } from "effect"
import { Database } from "bun:sqlite"  // ← ???

const createTodo = (title: string, db: Database) =>
  Effect.sync(() => {
    db.query("INSERT INTO todos (title) VALUES (?)").run(title)
  })

Ответы

Раскрыть ответы

Нарушение A: утечка инфраструктуры в домен через типы

sqliteRowId — это деталь хранения (SQLite). Доменная модель не должна знать о структуре таблицы.

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

// domain/todo.ts — чистый домен
interface Todo {
  readonly id: string
  readonly title: string
  readonly status: TodoStatus
}

// infrastructure/sqlite/types.ts — тип для SQL-слоя
interface TodoRow {
  readonly rowid: number
  readonly id: string
  readonly title: string
  readonly status: string
}

Нарушение B: домен вызывает инфраструктуру

sendEmail — инфраструктурная операция. Домен не должен вызывать инфраструктуру напрямую.

Исправление через Effect и порты:

// Порт: уведомления
class NotificationPort extends Context.Tag("NotificationPort")<
  NotificationPort,
  { readonly send: (to: string, message: string) => Effect.Effect<void> }
>() {}

// Доменная функция остаётся чистой
const completeOrder = (order: Order): Order => ({
  ...order,
  status: "completed" as const,
})

// Оркестрация — в Application layer
const completeOrderUseCase = (orderId: string) =>
  Effect.gen(function* () {
    const repo = yield* OrderRepository
    const notifications = yield* NotificationPort
    const order = yield* repo.findById(orderId)
    const completed = completeOrder(order!)
    yield* repo.save(completed)
    yield* notifications.send(completed.customerEmail, "Order completed!")
    return completed
  })

Нарушение C: Use Case зависит от конкретной технологии

Database из bun:sqlite — конкретная технология. Use Case должен зависеть от порта (абстракции), а не от реализации.

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

// Порт
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  { readonly save: (title: string) => Effect.Effect<void, RepositoryError> }
>() {}

// Use Case — зависит от порта
const createTodo = (title: string) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    yield* repo.save(title)
  })

// Адаптер (Layer) — зависит от конкретной технологии
const SqliteTodoRepo = Layer.effect(
  TodoRepository,
  Effect.gen(function* () {
    const db = yield* SqliteClient
    return {
      save: (title) => Effect.try({
        try: () => { db.query("INSERT INTO todos (title) VALUES (?)").run(title) },
        catch: (e) => new RepositoryError({ cause: e }),
      }),
    }
  })
)

Упражнение 3: Рефакторинг из Layered в Hexagonal

Дан код в стиле Layered Architecture. Перепишите его в стиле Hexagonal с использованием Effect-ts.

Исходный код (Layered)

import { Database } from "bun:sqlite"

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

interface TodoRow {
  id: string
  title: string
  completed: number
  created_at: string
}

class TodoService {
  listPending(): ReadonlyArray<{ id: string; title: string; createdAt: Date }> {
    const rows = db
      .query("SELECT * FROM todos WHERE completed = 0 ORDER BY created_at ASC")
      .all() as ReadonlyArray<TodoRow>

    return rows.map((row) => ({
      id: row.id,
      title: row.title,
      createdAt: new Date(row.created_at),
    }))
  }

  markCompleted(id: string): void {
    const row = db
      .query("SELECT * FROM todos WHERE id = ?")
      .get(id) as TodoRow | null

    if (!row) {
      throw new Error(`Todo ${id} not found`)
    }

    if (row.completed === 1) {
      throw new Error(`Todo ${id} is already completed`)
    }

    db.query("UPDATE todos SET completed = 1 WHERE id = ?").run(id)
  }
}

Решение

Раскрыть решение
import { Effect, Context, Layer, Data } from "effect"

// ═══════════════════════════════════════════════════
// Слой 1: Domain Model
// ═══════════════════════════════════════════════════

type TodoStatus = "pending" | "completed"

interface Todo {
  readonly id: string
  readonly title: string
  readonly status: TodoStatus
  readonly createdAt: Date
}

// Доменные ошибки
class TodoNotFound extends Data.TaggedError("TodoNotFound")<{
  readonly id: string
}> {}

class AlreadyCompleted extends Data.TaggedError("AlreadyCompleted")<{
  readonly id: string
}> {}

// Доменная логика: чистая функция
const markAsCompleted = (todo: Todo): Todo =>
  ({ ...todo, status: "completed" as const })

// ═══════════════════════════════════════════════════
// Слой 2: Ports (Context.Tag)
// ═══════════════════════════════════════════════════

class TodoRepository extends Context.Tag("@app/TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: string) => Effect.Effect<Todo | null>
    readonly findByStatus: (status: TodoStatus) => Effect.Effect<ReadonlyArray<Todo>>
    readonly save: (todo: Todo) => Effect.Effect<void>
  }
>() {}

// ═══════════════════════════════════════════════════
// Слой 3: Use Cases (Application Core)
// ═══════════════════════════════════════════════════

/** Use Case: получить незавершённые задачи */
const listPending = Effect.gen(function* () {
  const repo = yield* TodoRepository
  return yield* repo.findByStatus("pending")
})

/** Use Case: завершить задачу */
const completeTodo = (id: string) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const todo = yield* repo.findById(id)

    if (todo === null) {
      return yield* Effect.fail(new TodoNotFound({ id }))
    }

    if (todo.status === "completed") {
      return yield* Effect.fail(new AlreadyCompleted({ id }))
    }

    const completed = markAsCompleted(todo)
    yield* repo.save(completed)
    return completed
  })

// ═══════════════════════════════════════════════════
// Слой 4: Adapter (Layer) — InMemory для тестов
// ═══════════════════════════════════════════════════

const InMemoryTodoRepo = Layer.sync(TodoRepository, () => {
  const store = new Map<string, Todo>()

  return {
    findById: (id) => Effect.sync(() => store.get(id) ?? null),
    findByStatus: (status) =>
      Effect.sync(() =>
        [...store.values()]
          .filter((t) => t.status === status)
          .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
      ),
    save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
  }
})

// ═══════════════════════════════════════════════════
// Слой 4: Adapter (Layer) — SQLite для продакшена
// ═══════════════════════════════════════════════════

// SQLite-специфичный тип (только внутри адаптера!)
interface TodoRow {
  readonly id: string
  readonly title: string
  readonly completed: number
  readonly created_at: string
}

const mapRowToTodo = (row: TodoRow): Todo => ({
  id: row.id,
  title: row.title,
  status: row.completed === 1 ? "completed" : "pending",
  createdAt: new Date(row.created_at),
})

const SqliteTodoRepo = Layer.scoped(
  TodoRepository,
  Effect.gen(function* () {
    const db = yield* SqliteClient

    return {
      findById: (id) =>
        Effect.sync(() => {
          const row = db.query("SELECT * FROM todos WHERE id = ?").get(id) as TodoRow | null
          return row ? mapRowToTodo(row) : null
        }),

      findByStatus: (status) =>
        Effect.sync(() => {
          const completed = status === "completed" ? 1 : 0
          const rows = db
            .query("SELECT * FROM todos WHERE completed = ? ORDER BY created_at ASC")
            .all(completed) as ReadonlyArray<TodoRow>
          return rows.map(mapRowToTodo)
        }),

      save: (todo) =>
        Effect.sync(() => {
          db.query(
            "INSERT OR REPLACE INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)"
          ).run(todo.id, todo.title, todo.status === "completed" ? 1 : 0, todo.createdAt.toISOString())
        }),
    }
  })
)

// ═══════════════════════════════════════════════════
// Запуск
// ═══════════════════════════════════════════════════

// Для тестов:
const testProgram = completeTodo("123").pipe(Effect.provide(InMemoryTodoRepo))

// Для продакшена:
// const prodProgram = completeTodo("123").pipe(Effect.provide(SqliteTodoRepo))

Ключевые улучшения после рефакторинга:

  • Домен чист: Todo, TodoStatus, markAsCompleted — ноль зависимостей
  • Ошибки типизированы: TodoNotFound, AlreadyCompleted вместо throw new Error(...)
  • Инфраструктура изолирована: TodoRow и SQL живут только внутри адаптера
  • Тестируемость: InMemoryTodoRepo для тестов, SqliteTodoRepo для продакшена
  • Dependency Rule enforced: R-канал гарантирует, что все порты предоставлены

Упражнение 4: Спроектируй порты для Todo-приложения

Определите все Context.Tag (порты) для Todo-приложения со следующими требованиями:

  1. CRUD операции для задач (создать, прочитать, обновить, удалить)
  2. Фильтрация задач по статусу и приоритету
  3. Прикрепление файлов к задаче
  4. Отправка уведомлений при завершении задачи
  5. Получение текущего времени (для тестируемости)
  6. Генерация уникальных идентификаторов (для тестируемости)

Для каждого порта определите:

  • Имя (Tag)
  • Тип: Driving или Driven
  • Методы с полной типизацией (включая Error и Requirements)

Решение

Раскрыть решение
import { Effect, Context } from "effect"

// ═══════════════════════════════════════════════════
// Доменные типы (используются в портах)
// ═══════════════════════════════════════════════════

type TodoId = string & { readonly _brand: unique symbol }
type TodoStatus = "pending" | "in_progress" | "completed" | "archived"
type Priority = "low" | "medium" | "high" | "critical"

interface Todo {
  readonly id: TodoId
  readonly title: string
  readonly description: string
  readonly status: TodoStatus
  readonly priority: Priority
  readonly createdAt: Date
  readonly completedAt: Date | null
}

interface TodoFilter {
  readonly status?: TodoStatus
  readonly priority?: Priority
}

interface FileAttachment {
  readonly id: string
  readonly todoId: TodoId
  readonly filename: string
  readonly mimeType: string
  readonly size: number
}

// ═══════════════════════════════════════════════════
// Доменные ошибки
// ═══════════════════════════════════════════════════

class TodoNotFound extends Data.TaggedError("TodoNotFound")<{ readonly id: TodoId }> {}
class InvalidTransition extends Data.TaggedError("InvalidTransition")<{
  readonly from: TodoStatus
  readonly to: TodoStatus
}> {}
class DuplicateTitle extends Data.TaggedError("DuplicateTitle")<{ readonly title: string }> {}
class FileNotFound extends Data.TaggedError("FileNotFound")<{ readonly id: string }> {}
class StorageError extends Data.TaggedError("StorageError")<{ readonly cause: unknown }> {}
class NotificationError extends Data.TaggedError("NotificationError")<{ readonly cause: unknown }> {}

// ═══════════════════════════════════════════════════
// DRIVEN PORTS (что приложению нужно от инфраструктуры)
// ═══════════════════════════════════════════════════

/** Driven Port: персистентность задач */
class TodoRepository extends Context.Tag("@todo/TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo | null, StorageError>
    readonly findAll: (filter?: TodoFilter) => Effect.Effect<ReadonlyArray<Todo>, StorageError>
    readonly save: (todo: Todo) => Effect.Effect<void, StorageError>
    readonly delete: (id: TodoId) => Effect.Effect<void, StorageError>
    readonly existsByTitle: (title: string) => Effect.Effect<boolean, StorageError>
  }
>() {}

/** Driven Port: файловое хранилище */
class FileStorage extends Context.Tag("@todo/FileStorage")<
  FileStorage,
  {
    readonly upload: (todoId: TodoId, filename: string, data: Uint8Array) => Effect.Effect<FileAttachment, StorageError>
    readonly download: (attachmentId: string) => Effect.Effect<Uint8Array, FileNotFound | StorageError>
    readonly listByTodo: (todoId: TodoId) => Effect.Effect<ReadonlyArray<FileAttachment>, StorageError>
    readonly remove: (attachmentId: string) => Effect.Effect<void, StorageError>
  }
>() {}

/** Driven Port: уведомления */
class NotificationService extends Context.Tag("@todo/NotificationService")<
  NotificationService,
  {
    readonly notify: (message: string) => Effect.Effect<void, NotificationError>
  }
>() {}

/** Driven Port: генерация ID (детерминированность для тестов) */
class IdGenerator extends Context.Tag("@todo/IdGenerator")<
  IdGenerator,
  {
    readonly generate: () => Effect.Effect<TodoId>
  }
>() {}

/** Driven Port: часы (детерминированность для тестов) */
class Clock extends Context.Tag("@todo/Clock")<
  Clock,
  {
    readonly now: () => Effect.Effect<Date>
  }
>() {}

Обратите внимание:

  • Каждый порт — изолированная единица с минимальным API
  • Ошибки типизированы: StorageError для инфраструктурных сбоев, TodoNotFound/FileNotFound для доменных
  • IdGenerator и Clock — порты для детерминированности, что критично для тестирования
  • Все методы возвращают Effect, а не Promise — это обеспечивает единую модель обработки ошибок и зависимостей

Упражнение 5: Мини-эссе

Ответьте на один из вопросов в 5–10 предложений:

  1. Почему Layered Architecture провоцирует Database-Driven Design? Опишите механизм, по которому слоистая архитектура естественно приводит к проектированию «от таблиц БД».

  2. В чём принципиальная разница между «зависимости направлены вниз» (Layered) и «зависимости направлены внутрь» (Hexagonal/Clean/Onion)? Почему это не просто перестановка стрелок, а фундаментальное изменение?

  3. Как R-канал Effect<A, E, R> реализует Dependency Rule? Объясните, как компилятор TypeScript гарантирует, что все зависимости предоставлены.