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

Оригинальная статья Кокберна: разбор и интерпретация

Разбор статьи Алистера Кокберна 2005 года: исторический контекст, три ключевых тезиса, влияние на Clean/Onion Architecture и DDD. Пять распространённых заблуждений и соответствие концепций механизмам Effect-ts.

Введение: почему стоит вернуться к первоисточнику

Hexagonal Architecture — одна из самых влиятельных архитектурных идей последних двадцати лет. Однако большинство разработчиков знакомы с ней через пересказы, туториалы и интерпретации, которые нередко упрощают, искажают или дополняют оригинал собственными предположениями. Прежде чем строить систему на основе этих принципов, критически важно понять, что именно Алистер Кокберн (Alistair Cockburn) имел в виду, когда в 2005 году опубликовал свою статью «Hexagonal Architecture» (также известную как «Ports and Adapters Pattern»).

В этом уроке мы разберём оригинальную статью Кокберна строка за строкой, выделим ключевые идеи, проследим за эволюцией концепции и покажем, как каждая из этих идей проецируется на современную разработку с использованием Effect-ts и TypeScript.


Исторический контекст

Проблемное пространство середины 2000-х

Когда Кокберн формулировал свою идею, индустрия находилась в определённом состоянии:

Доминирование слоистой архитектуры. Большинство enterprise-приложений строились как «трёхслойки» — Presentation → Business Logic → Data Access. Эта модель была интуитивно понятной, но создавала серьёзные проблемы:

  • Бизнес-логика оказывалась «зажатой» между слоем представления и слоем данных
  • Тестирование бизнес-правил требовало запуска всей инфраструктуры
  • Замена базы данных или UI-фреймворка превращалась в полное переписывание

Расцвет J2EE и тяжёлых фреймворков. Enterprise Java Beans, контейнеры приложений и сложные деплоймент-дескрипторы создавали ощущение, что инфраструктура — это центр системы, а бизнес-логика — лишь её начинка.

Движение за гибкость. Agile-манифест (2001) уже существовал, и идея адаптивности к изменениям требовала архитектурных решений, поддерживающих эту адаптивность на уровне кода.

Формулировка Кокберна

Кокберн начал работу над идеей ещё в начале 2000-х. Первоначально он использовал название «Ports and Adapters», а термин «Hexagonal Architecture» появился позже — как визуальная метафора. Кокберн неоднократно подчёркивал, что предпочитает название «Ports and Adapters», потому что оно точнее передаёт суть паттерна.

«Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.»

— Alistair Cockburn, 2005

Эта цитата — квинтэссенция всей идеи. Разберём её по частям.


Разбор ключевых тезисов оригинальной статьи

Тезис 1: «Equally be driven by…»

Слово «equally» здесь принципиально. Кокберн не говорит «приложение можно адаптировать для разных источников». Он говорит, что приложение должно быть равнодушно к тому, кто его вызывает.

Это означает, что с точки зрения бизнес-логики нет никакой разницы между:

  • HTTP-запросом от пользователя
  • Вызовом из другого сервиса
  • Командой из автоматического теста
  • Триггером из batch-скрипта
  • Вызовом из CLI-интерфейса

Если ваша бизнес-логика «знает», что она вызывается из HTTP-контроллера — вы нарушили этот принцип.

Проекция на Effect-ts:

В Effect это достигается через R-канал типа Effect<A, E, R>. Бизнес-логика объявляет свои зависимости через типы, а не через конкретные реализации:

import { Effect, Context } from "effect"

// Бизнес-логика НЕ ЗНАЕТ откуда приходит запрос
// Она знает только ЧТО ей нужно для работы
const completeTodo = (
  todoId: TodoId
): Effect.Effect<Todo, TodoNotFound | InvalidTransition, 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
  })

// Этот код работает одинаково вне зависимости от того,
// вызван ли он из HTTP-хендлера, CLI, теста или cron-задачи

Тезис 2: «Developed and tested in isolation»

Второй ключевой элемент — изоляция разработки и тестирования от конечной runtime-среды. Кокберн указывает на фундаментальную проблему: если для запуска одного бизнес-теста нужна реальная база данных, реальный почтовый сервер и реальный файловый сервер — разработка становится невозможно медленной и хрупкой.

Решение: приложение должно работать с абстрактными контрактами (портами), а конкретные реализации (адаптеры) подключаются снаружи.

Проекция на Effect-ts:

// ПОРТ: абстрактный контракт
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFound>
    readonly save: (todo: Todo) => Effect.Effect<void>
    readonly findAll: () => Effect.Effect<ReadonlyArray<Todo>>
  }
>() {}

// АДАПТЕР ДЛЯ ТЕСТОВ: работает в памяти, мгновенный
const TestTodoRepository = Layer.succeed(TodoRepository, {
  findById: (id) => /* in-memory lookup */,
  save: (todo) => /* in-memory save */,
  findAll: () => /* return from Map */,
})

// АДАПТЕР ДЛЯ PRODUCTION: работает с SQLite
const SqliteTodoRepository = Layer.scoped(TodoRepository, 
  Effect.gen(function* () {
    const db = yield* SqliteClient
    return {
      findById: (id) => /* SQL query */,
      save: (todo) => /* SQL insert/update */,
      findAll: () => /* SQL select all */,
    }
  })
)

Бизнес-логика разрабатывается и тестируется с TestTodoRepository, а в production подключается SqliteTodoRepositoryбез единого изменения в бизнес-коде.

Тезис 3: «Run-time devices and databases»

Кокберн намеренно использует общий термин «devices» (устройства). Он не ограничивается базами данных. Под «устройством» понимается любой внешний компонент, с которым взаимодействует приложение:

  • База данных — устройство для хранения
  • Файловая система — устройство для файлов
  • Почтовый сервер — устройство для отправки писем
  • Часы (системное время) — устройство для получения времени
  • Генератор случайных чисел — устройство для получения случайности
  • Внешний API — удалённое устройство

Каждое такое «устройство» в терминологии Кокберна подключается через порт. Это ключевой момент, который часто упускают: даже системное время и генератор случайных чисел должны быть «устройствами», подключенными через порт, если вы хотите полной тестируемости.

// Время — это тоже "устройство" с портом
class Clock extends Context.Tag("Clock")<
  Clock,
  {
    readonly now: () => Effect.Effect<Date>
  }
>() {}

// В тестах: детерминированное время
const TestClock = Layer.succeed(Clock, {
  now: () => Effect.succeed(new Date("2025-01-15T10:00:00Z"))
})

// В production: реальное время
const SystemClock = Layer.succeed(Clock, {
  now: () => Effect.sync(() => new Date())
})

Центральная метафора: почему «гексагон»

Визуальный выбор

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

1. Достаточно граней для визуализации разных портов. Квадрат или круг не дают возможности визуально разместить разные порты на разных сторонах. Шестиугольник обеспечивает достаточно «стен» для визуального разделения:

        ╔══════════════╗
       ╱   HTTP API     ╲
      ╱   (Driving)      ╲
     ╱                     ╲
    ║                       ║
CLI ║   APPLICATION CORE    ║ Database
    ║                       ║
     ╲                     ╱
      ╲   (Driven)       ╱
       ╲   Events       ╱
        ╚══════════════╝

2. Разрыв с привычкой «сверху вниз». Слоистая архитектура рисуется как стопка — сверху UI, снизу база данных. Это создаёт ментальную модель «верх важнее» или «данные внизу, как фундамент». Гексагон разрушает эту иерархию: все стороны равны, центр — бизнес-логика.

3. Симметрия driving/driven. Левая сторона гексагона — driving-порты (кто вызывает), правая — driven-порты (кого вызывает приложение). Эта симметрия подчёркивает, что оба вида взаимодействия архитектурно эквивалентны.

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

Кокберн неоднократно подчёркивал: число «6» в «hexagonal» не несёт смысловой нагрузки. Не существует ровно шести портов. Шестиугольник — это визуальная метафора, не предписание. Реальное приложение может иметь два порта или двадцать два.

Именно поэтому Кокберн предпочитает название «Ports and Adapters» — оно описывает механизм, а не форму диаграммы.


Три ключевые правила Кокберна

Правило 1: Application Core ничего не знает о внешнем мире

Это самое фундаментальное правило. Ядро приложения (Application Core) содержит бизнес-логику и бизнес-правила. Оно не должно содержать:

  • Import-ов инфраструктурных библиотек
  • Ссылок на конкретные технологии (SQL, HTTP, файловые пути)
  • Знания о формате входных данных (JSON, XML, form-data)
  • Знания о способе хранения (таблицы, документы, файлы)
// ✅ ПРАВИЛЬНО: домен знает только о бизнес-концепциях
// domain/todo.ts
import { Schema } from "effect"

class TodoId extends Schema.TaggedClass<TodoId>()("TodoId", {
  value: Schema.UUID,
}) {}

class TodoTitle extends Schema.TaggedClass<TodoTitle>()("TodoTitle", {
  value: Schema.String.pipe(
    Schema.minLength(1),
    Schema.maxLength(200)
  ),
}) {}

class Todo extends Schema.TaggedClass<Todo>()("Todo", {
  id: TodoId,
  title: TodoTitle,
  completed: Schema.Boolean,
  createdAt: Schema.Date,
}) {
  complete(): Todo {
    if (this.completed) {
      throw new InvalidTransition({ from: "completed", to: "completed" })
    }
    return new Todo({ ...this, completed: true })
  }
}

// ❌ НЕПРАВИЛЬНО: домен знает о SQL, HTTP, файловых путях
// domain/todo.ts — АНТИПАТТЕРН
import { Database } from "bun:sqlite"            // утечка инфраструктуры!
import { readFileSync } from "node:fs"            // утечка инфраструктуры!

class Todo {
  async save(db: Database) {                       // домен знает о БД!
    db.run("INSERT INTO todos ...", [this.title])
  }
  
  static fromRequest(req: Request) {               // домен знает о HTTP!
    const body = await req.json()
    return new Todo(body.title)
  }
}

Правило 2: Внешний мир подключается через порты

Порт — это контракт (интерфейс), определённый ядром приложения. Порт описывает:

  • Что ядру нужно от внешнего мира (driven ports)
  • Что внешний мир может попросить у ядра (driving ports)

Порт не содержит реализации. Он содержит только сигнатуры — типы входов, выходов и ошибок.

// ПОРТ: определён ядром, реализуется снаружи
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>>
  }
>() {}

Правило 3: Адаптеры реализуют порты для конкретных технологий

Адаптер — это конкретный код, который «переводит» между портом и технологией. Адаптер знает о технологии (SQL, HTTP, файловая система), но подчиняется контракту порта.

// АДАПТЕР: знает о SQLite, реализует контракт TodoRepository
const SqliteTodoRepository = Layer.scoped(
  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)
          if (!row) return yield* Effect.fail(new TodoNotFound({ id }))
          return yield* decodeTodoFromRow(row)
        }),

      save: (todo) =>
        Effect.gen(function* () {
          db.run(
            "INSERT OR REPLACE INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)",
            [todo.id.value, todo.title.value, todo.completed ? 1 : 0, todo.createdAt.toISOString()]
          )
        }),

      findAll: (filter) =>
        Effect.gen(function* () {
          const rows = db.query("SELECT * FROM todos").all()
          return yield* Effect.forEach(rows, decodeTodoFromRow)
        }),
    }
  })
)

Паттерн «Configurable Dependency»

Кокберн вводит фундаментальную идею, которую называет Configurable Dependency — настраиваемая зависимость. Суть: каждая зависимость приложения от внешнего компонента должна быть конфигурируемой, то есть заменяемой без изменения бизнес-кода.

Это не просто «хорошая практика» — это архитектурное требование. Если зависимость нельзя заменить, она не является «настраиваемой», а значит, она жёстко связана с ядром.

Примеры настраиваемых зависимостей

ЗависимостьProduction-адаптерТестовый адаптер
Хранение данныхSQLiteIn-Memory Map
Отправка emailSMTP-серверConsole Logger
Файловое хранилищеФайловая системаIn-Memory Buffer
Текущее времяSystem ClockFixed Clock
Случайные числаCrypto.randomSeed-based generator
Внешний APIHTTP ClientStub с фиксированными ответами

В Effect-ts: Layer как механизм конфигурации

Effect-ts предоставляет механизм Layer, который идеально реализует идею Configurable Dependency:

// Конфигурация для тестов
const TestConfig = Layer.mergeAll(
  InMemoryTodoRepository,
  ConsoleNotificationService,
  FixedClock,
  SeedRandom
)

// Конфигурация для production
const ProductionConfig = Layer.mergeAll(
  SqliteTodoRepository,
  SmtpNotificationService,
  SystemClock,
  CryptoRandom
)

// Одна и та же программа — разные конфигурации
const program = createTodo(input)

// Тест: мгновенный, детерминированный
await Effect.runPromise(program.pipe(Effect.provide(TestConfig)))

// Production: реальная инфраструктура
await Effect.runPromise(program.pipe(Effect.provide(ProductionConfig)))

Наследие и влияние

Кого вдохновил Кокберн

Идея Кокберна оказала огромное влияние на последующие архитектурные подходы:

Clean Architecture (Robert C. Martin, 2012) — дядюшка Боб взял идею изоляции бизнес-логики от инфраструктуры и добавил концепцию «кольцевых слоёв» с правилом зависимостей. По сути, Clean Architecture — это Hexagonal Architecture с более строгой визуализацией слоёв.

Onion Architecture (Jeffrey Palermo, 2008) — аналогичная идея концентрических кругов, где ядро (домен) в центре, а инфраструктура на периферии.

Domain-Driven Design (Eric Evans, 2003) — хотя DDD вышла раньше статьи Кокберна, позже DDD и Hexagonal Architecture стали неразлучной парой. DDD говорит «как моделировать домен», Hexagonal говорит «как изолировать домен».

Эволюция концепции

С 2005 года идея эволюционировала:

  • 2008: Palermo формализует Onion Architecture
  • 2012: Martin публикует Clean Architecture
  • 2013–2015: микросервисная архитектура подхватывает идею портов/адаптеров на уровне сервисов
  • 2020-е: функциональные языки и Effect-ts показывают, что Ports & Adapters можно реализовать на уровне системы типов, а не только на уровне runtime-IoC-контейнеров

Что Кокберн НЕ говорил (распространённые заблуждения)

Заблуждение 1: «Hexagonal — это про REST API и базу данных»

Кокберн описывал любые внешние взаимодействия. REST API и SQL — лишь частные примеры. Консольный ввод, файловый обмен, межпроцессная коммуникация — всё это равноправные «порты».

Заблуждение 2: «Нужно создавать интерфейс для каждого класса»

Кокберн не говорил о классах и интерфейсах в ООП-смысле. Порт — это контракт, который может быть выражен любым способом: интерфейсом, типом, протоколом, абстрактным классом. В функциональном мире (Effect-ts) порт — это Context.Tag с описанием Shape.

Заблуждение 3: «Hexagonal Architecture означает много слоёв и абстракций»

Кокберн описывал минимальный набор концепций: ядро, порты, адаптеры. Три уровня, не больше. Чрезмерное количество промежуточных слоёв и абстракций — это антипаттерн, а не следствие Hexagonal Architecture.

Заблуждение 4: «Гексагон — это про шесть слоёв»

Как мы уже обсудили, число «шесть» не несёт смысла. Но заблуждение настолько распространено, что Кокберн несколько раз публично жалел о выборе названия «Hexagonal».

Заблуждение 5: «Hexagonal только для больших систем»

Кокберн не ставил ограничений по размеру. Даже маленькое приложение выигрывает от изоляции бизнес-логики. Особенно в контексте Effect-ts, где создание порта (Service) и адаптера (Layer) требует минимального boilerplate.


Связь с Effect-ts: почему соответствие не случайно

Effect-ts не создавался специально для Hexagonal Architecture. Но архитектурные решения Effect — система типов, Context.Tag, Layer, Effect<A, E, R> — органично реализуют все принципы Кокберна.

Принцип КокбернаМеханизм Effect-ts
Порт (контракт)Context.Tag с описанием Shape
Адаптер (реализация)Layer
Конфигурируемая зависимостьEffect.provide(layer)
Изоляция ядраR-канал Effect<A, E, R> — типы гарантируют отсутствие прямых зависимостей
Тестирование в изоляцииПодмена Layer без изменения бизнес-кода
Равноправие источниковОдин и тот же Effect-pipeline, разные driving-адаптеры

Это соответствие — одна из главных причин, по которой мы выбрали Hexagonal Architecture как архитектурный подход для Effect-ts приложений.


Оригинальная терминология Кокберна

Для точности приведём оригинальные термины, используемые Кокберном, и их соответствие в нашем курсе:

Оригинальный терминНаш терминОписание
ApplicationApplication CoreЯдро с бизнес-логикой
PortPort (Порт)Контракт взаимодействия
AdapterAdapter (Адаптер)Реализация порта для технологии
ActorDriving Actor / Driven ActorКто инициирует взаимодействие
Configurable DependencyLayer в EffectЗаменяемая зависимость
Driving (Primary)Driving Port/AdapterВходящее взаимодействие (кто вызывает)
Driven (Secondary)Driven Port/AdapterИсходящее взаимодействие (кого вызывает приложение)

Резюме

Оригинальная статья Кокберна формулирует три простые, но революционные идеи:

  1. Бизнес-логика не должна знать, кто её вызывает — будь то пользователь, тест или cron-задача
  2. Каждое внешнее взаимодействие оформляется как порт — контракт, определённый ядром
  3. Конкретные технологии подключаются через адаптеры — реализации портов, заменяемые без изменения ядра

Effect-ts делает эти принципы проверяемыми компилятором: если порт не подключён — код не скомпилируется. Это переводит архитектурные правила из категории «соглашений» в категорию «гарантий типов».


Дополнительные материалы