Типобезопасный домен: Гексагональная архитектура на базе Effect Цели архитектуры: тестируемость, заменяемость, эволюционность
Глава

Цели архитектуры: тестируемость, заменяемость, эволюционность

Архитектура — не самоцель, а средство достижения конкретных эксплуатационных свойств. Мы определим три главные цели — тестируемость без инфраструктуры, заменяемость адаптеров одной строкой, эволюционность без каскадных изменений — и покажем, как защитить их автоматическими fitness functions в CI/CD.

Архитектура — это не самоцель

Архитектура не нужна «ради архитектуры». Она нужна для достижения конкретных эксплуатационных свойств системы. Если архитектура не улучшает ни одного из этих свойств — она лишний overhead.

Три главные цели:

  1. Тестируемость — возможность убедиться, что система работает правильно, быстро и надёжно.
  2. Заменяемость — возможность менять части системы (технологии, адаптеры, библиотеки) без переписывания всего остального.
  3. Эволюционность — возможность развивать систему в ответ на новые требования без экспоненциального роста сложности.

Есть и другие свойства (масштабируемость, безопасность, производительность), но эти три — фундамент, без которого остальные недостижимы.


Цель 1: Тестируемость

Что значит «тестируемая система»

Тестируемая система — это система, в которой:

  • Бизнес-правила можно проверить без поднятия инфраструктуры (базы данных, HTTP-сервера, файловой системы).
  • Тесты бизнес-логики выполняются за миллисекунды, а не секунды.
  • Адаптеры можно протестировать изолированно от бизнес-логики.
  • Полный прогон тестов занимает минуты, а не часы.
  • Тесты детерминированы: запускаются в любом порядке, не влияют друг на друга, дают одинаковый результат.

Почему плохая архитектура убивает тестируемость

В монолитном коде бизнес-правило переплетено с инфраструктурой, и протестировать одно без другого невозможно:

// ❌ Нетестируемый код: бизнес-правило вплетено в инфраструктуру
export async function completeTodo(id: string) {
  const db = new Database("app.db")                   // Реальная БД
  const todo = db.query("SELECT * FROM todos WHERE id = ?").get(id)
  if (!todo) throw new Error("Not found")
  if ((todo as any).done) throw new Error("Already done")
  db.run("UPDATE todos SET done = 1 WHERE id = ?", [id])
  await fetch("https://api.slack.com/notify", {       // Реальный HTTP
    method: "POST",
    body: JSON.stringify({ text: `Todo ${id} completed` })
  })
}

// Чтобы протестировать правило "нельзя завершить завершённую задачу":
// 1. Нужна реальная SQLite (или мок Database с 5+ методами)
// 2. Нужен mock/stub для fetch (или реальный Slack)
// 3. Тест зависит от порядка выполнения (нужно вставить данные ДО теста)
// 4. Тест медленный (I/O)
// 5. Тест недетерминирован (сеть может отказать)

Как Hexagonal делает код тестируемым

Разделение на домен, порты и адаптеры создаёт три уровня тестирования:

Уровень 1: Unit-тесты домена (мгновенные, чистые)

import { describe, it, expect } from "bun:test"
import { completeTodo, type Todo } from "../domain/todo"

describe("completeTodo", () => {
  const activeTodo: Todo = {
    id: "1" as TodoId,
    title: "Buy milk",
    status: "active",
    createdAt: new Date("2025-01-01"),
    completedAt: null,
  }

  it("should complete an active todo", () => {
    const result = completeTodo(activeTodo, new Date("2025-01-15"))
    expect(result.status).toBe("completed")
    expect(result.completedAt).toEqual(new Date("2025-01-15"))
  })

  it("should not complete an already completed todo", () => {
    const completed = { ...activeTodo, status: "completed" as const }
    expect(() => completeTodo(completed, new Date())).toThrow()
  })
})

// Время: < 1 мс на тест. Никакой инфраструктуры. Чистые функции.

Уровень 2: Тесты Use Case с тестовыми адаптерами (быстрые, изолированные)

import { Effect } from "effect"
import { completeTodoUseCase } from "../app/complete-todo-use-case"
import { TodoRepositoryTest } from "../adapters/test/todo-repo-test"
import { NotificationServiceTest } from "../adapters/test/notification-test"

describe("completeTodo use case", () => {
  const testLayer = Layer.mergeAll(
    TodoRepositoryTest,      // In-memory Map вместо SQLite
    NotificationServiceTest, // Запись в массив вместо Slack
  )

  it("should save completed todo and notify", async () => {
    const result = await Effect.runPromise(
      completeTodoUseCase("todo-1", new Date()).pipe(
        Effect.provide(testLayer)
      )
    )
    expect(result.status).toBe("completed")
  })
})

// Время: < 5 мс на тест. In-memory, без I/O.

Уровень 3: Integration-тесты адаптеров (с реальной инфраструктурой)

import { Database } from "bun:sqlite"
import { createSqliteTodoRepo } from "../adapters/sqlite/todo-repo-sqlite"

describe("TodoRepositorySqlite", () => {
  let db: Database

  beforeEach(() => {
    db = new Database(":memory:")  // In-memory SQLite — быстро, изолированно
    db.run("CREATE TABLE todos (...)")
  })

  afterEach(() => db.close())

  it("should save and retrieve a todo", async () => {
    const repo = createSqliteTodoRepo(db)
    const todo = makeTodo({ id: "1", title: "Test" })
    await repo.save(todo)
    const found = await repo.findById("1")
    expect(found).toEqual(todo)
  })
})

// Время: ~10 мс. Реальный SQLite, но in-memory.

Метрика тестируемости

Практическая формула:

Testability Score = (число Unit-тестов домена × 1 мс + число Integration-тестов × 50 мс)
                    / общее время прогона тестов

Цель: > 80% тестов — быстрые unit-тесты домена

Цель 2: Заменяемость

Что значит «заменяемая система»

Заменяемая система — это система, в которой:

  • Замена базы данных (SQLite → PostgreSQL) затрагивает один файл-адаптер.
  • Замена HTTP-фреймворка не требует изменений в бизнес-логике.
  • Замена провайдера email-уведомлений — один новый адаптер + одна строка в конфигурации.
  • Вся система может работать без любого конкретного адаптера (с заглушкой).

Заменяемость в действии

Представим: Todo-приложение начало жизнь с SQLite, но выросло и нуждается в PostgreSQL. В хорошей архитектуре это выглядит так:

Изменения при переходе SQLite → PostgreSQL:

❌ БЕЗ архитектуры:
  ├── domain/todo.ts                    (изменён — типы зависят от ORM)
  ├── domain/todo-service.ts            (изменён — SQL в бизнес-логике)
  ├── routes/todos.ts                   (изменён — SQL в обработчиках)
  ├── routes/stats.ts                   (изменён — SQL для аналитики)
  ├── utils/db.ts                       (изменён — подключение)
  ├── test/todo.test.ts                 (изменён — тесты завязаны на SQLite)
  └── test/stats.test.ts               (изменён)
  Итого: 7 файлов, все слои затронуты

✅ С Hexagonal Architecture:
  ├── adapters/postgres/todo-repo-pg.ts (добавлен — новый адаптер)
  └── main.ts                           (изменён — одна строка: Layer)
  Итого: 1 новый файл, 1 строка изменена

Механизм заменяемости в Effect

Заменяемость обеспечивается тем, что Layer — это «провод», соединяющий порт с адаптером. Замена провода не затрагивает ни порт, ни потребителей порта:

// main.ts — единственное место, где выбирается адаптер

// Вариант 1: SQLite (разработка)
const AppLayer = Layer.mergeAll(
  TodoRepositorySqlite,
  NotificationServiceConsole,
  AuthServiceMock,
).pipe(
  Layer.provide(SqliteClientLive)
)

// Вариант 2: PostgreSQL + real services (production)
const AppLayer = Layer.mergeAll(
  TodoRepositoryPostgres,
  NotificationServiceSlack,
  AuthServiceJWT,
).pipe(
  Layer.provide(PgClientLive),
  Layer.provide(SlackClientLive),
)

// Вариант 3: всё в памяти (тесты)
const AppLayer = Layer.mergeAll(
  TodoRepositoryInMemory,
  NotificationServiceNoop,
  AuthServiceAlwaysAllow,
)

// Бизнес-логика одна и та же во всех трёх случаях
const program = completeTodoUseCase("todo-1", new Date())
Effect.runPromise(program.pipe(Effect.provide(AppLayer)))

Матрица заменяемости

КомпонентЗаменяется наФайлы затронуты
SQLitePostgreSQL1 адаптер + 1 строка в main
REST APIGraphQL1 адаптер (новый роутер)
Email через SMTPEmail через SendGrid1 адаптер
JWT authOAuth21 адаптер
Console loggerStructured JSON logger1 Layer
Bun runtimeNode.js runtimeАдаптеры platform-specific

Цель 3: Эволюционность

Что значит «эволюционирующая система»

Эволюционная архитектура (термин Нила Форда и Ребекки Парсонс) — архитектура, которая поддерживает управляемые инкрементальные изменения в ответ на новые требования.

Признаки эволюционной системы:

  • Добавление новой фичи не требует изменения существующего кода (Open/Closed Principle).
  • Новая фича затрагивает предсказуемый набор файлов.
  • Время на добавление N-й фичи сопоставимо с временем на (N-1)-ю фичу.
  • Система может расти от 1 до 100 фич без архитектурного кризиса.

Эволюция в Hexagonal: добавление новой фичи

Допустим, в Todo-приложение нужно добавить теги (tags). В Hexagonal Architecture это означает:

Шаг 1: Расширить домен
  domain/tag.ts          — новый Value Object
  domain/todo.ts         — добавить поле tags: ReadonlyArray<Tag>

Шаг 2: Расширить порт (если нужно)
  ports/todo-repository.ts — добавить findByTag(tag: Tag)

Шаг 3: Обновить адаптеры
  adapters/sqlite/todo-repo-sqlite.ts — новый SQL-запрос

Шаг 4: Добавить Use Case
  app/add-tag-to-todo.ts — новый Use Case

Шаг 5: Добавить HTTP-маршрут
  adapters/http/todo-routes.ts — POST /todos/:id/tags

Каждый шаг затрагивает один слой. Изменения не каскадируют. Существующие тесты продолжают проходить (тег — опциональное поле).

Fitness Functions: автоматическая проверка архитектуры

Эволюционность можно защитить автоматическими проверками — fitness functions:

// test/architecture.test.ts — fitness functions
import { describe, it, expect } from "bun:test"
import { readdir } from "fs/promises"
import { join } from "path"

describe("Architecture Fitness Functions", () => {

  it("domain/ should not import from adapters/", async () => {
    const domainFiles = await getTypeScriptFiles("src/domain")
    for (const file of domainFiles) {
      const content = await Bun.file(file).text()
      expect(content).not.toMatch(/from\s+["'].*adapters/)
      expect(content).not.toMatch(/from\s+["']bun:sqlite/)
      expect(content).not.toMatch(/from\s+["']express/)
    }
  })

  it("domain/ should not import from app/", async () => {
    const domainFiles = await getTypeScriptFiles("src/domain")
    for (const file of domainFiles) {
      const content = await Bun.file(file).text()
      expect(content).not.toMatch(/from\s+["'].*\/app\//)
    }
  })

  it("ports/ should only import from domain/", async () => {
    const portFiles = await getTypeScriptFiles("src/ports")
    for (const file of portFiles) {
      const content = await Bun.file(file).text()
      const imports = content.match(/from\s+["']([^"']+)["']/g) ?? []
      for (const imp of imports) {
        expect(imp).toMatch(/domain|effect|@effect/)
      }
    }
  })

  it("each module should have < 300 lines", async () => {
    const allFiles = await getTypeScriptFiles("src")
    for (const file of allFiles) {
      const content = await Bun.file(file).text()
      const lines = content.split("\n").length
      expect(lines).toBeLessThan(300)
    }
  })

  it("cyclomatic complexity of domain functions < 10", async () => {
    // Интеграция с ESLint или custom анализатор
    // ...
  })
})

Fitness functions запускаются в CI/CD и ловят нарушения архитектуры до того, как они попадут в main.


Дополнительные цели

Понятность (Comprehensibility)

Система понятна, если новый разработчик может за разумное время (1–3 дня) разобраться, как она устроена, и внести осмысленное изменение.

Hexagonal Architecture помогает понятности через:

  • Предсказуемую структуру папок: domain/, ports/, adapters/, app/ — каждый разработчик знает, где искать бизнес-логику, где контракты, где реализации.
  • Явные зависимости: R-канал Effect-а показывает все зависимости функции прямо в сигнатуре — не нужно читать тело функции.
  • Разделение ответственностей: каждый файл отвечает за одну вещь — не нужно держать в голове весь контекст.

Разворачиваемость (Deployability)

Система легко разворачивается, если:

  • Конфигурация отделена от кода (Effect Config).
  • Адаптеры выбираются через конфигурацию, а не через изменение кода.
  • Healthcheck и graceful shutdown встроены (Effect Runtime).
  • Миграции БД автоматизированы и идемпотентны.

Наблюдаемость (Observability)

В Hexagonal Architecture наблюдаемость — это ещё один адаптер:

// Наблюдаемость через Layer — подключается без изменения бизнес-логики
const ObservabilityLayer = Layer.mergeAll(
  LoggerLayer,     // Effect.Logger → JSON structured logs
  TracingLayer,    // Effect.Tracer → OpenTelemetry spans
  MetricsLayer,    // Effect.Metric → Prometheus counters
)

// Бизнес-логика не знает о логах, трейсах и метриках
// Они добавляются при сборке:
const ProductionApp = AppLayer.pipe(
  Layer.provide(ObservabilityLayer)
)

Компромиссы: когда архитектура избыточна

Честный разговор: архитектура — не серебряная пуля. Есть случаи, когда Hexagonal Architecture избыточна:

Одноразовые скрипты. Если вы пишете скрипт для однократной миграции данных — файловая структура из 10 папок не нужна.

Прототипы. Если цель — проверить гипотезу за 2 дня и выбросить код — архитектура замедлит вас.

Микроскопические сервисы. Если ваш сервис — 100 строк, один endpoint, один запрос к БД — структура «домен, порты, адаптеры» будет boilerplate без содержания.

Правило большого пальца:

Если проект будет жить > 3 месяцев
  И его будут поддерживать > 1 человек
  И он содержит > 1 бизнес-правило
  → Архитектура окупится

Иначе → начните просто, рефакторьте при росте

Но даже в простых проектах стоит соблюдать базовые принципы: иммутабельность, разделение чистых функций и побочных эффектов, явные зависимости. Effect-ts даёт их «бесплатно» — это не overhead, а стиль написания кода.


Связь целей с Hexagonal Architecture

ЦельКак достигается в HexagonalИнструмент Effect
ТестируемостьДомен без зависимостей → unit-тесты за мсEffect.runSync в тестах
ЗаменяемостьПорт = абстракция, адаптер = реализацияLayer для подмены
ЭволюционностьНовая фича = новый Use Case + адаптерыКомпозиция Layer.mergeAll
ПонятностьПредсказуемая структураR-канал как документация
РазворачиваемостьКонфигурация через портыEffect.Config
НаблюдаемостьЛоги/трейсы/метрики как адаптерыEffect.Logger, Tracer, Metric

Ключевые выводы

  1. Архитектура — средство, не цель. Она нужна для конкретных свойств: тестируемость, заменяемость, эволюционность.
  2. Тестируемость: домен тестируется за миллисекунды без инфраструктуры. 80%+ тестов — быстрые unit-тесты.
  3. Заменяемость: замена технологии затрагивает один адаптер и одну строку в точке сборки.
  4. Эволюционность: новая фича — предсказуемый набор изменений, не каскад по всей системе.
  5. Fitness functions защищают архитектуру автоматически, в CI/CD.
  6. Архитектура не всегда нужна — для скриптов и прототипов она избыточна. Но для долгоживущих систем она окупается за 2–4 месяца.
  7. Effect-ts даёт эти свойства на уровне типов: R-канал = явные зависимости, Layer = заменяемость, чистые функции = тестируемость.