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

Каноническая диаграмма: порты, адаптеры, ядро

Анатомия канонической диаграммы гексагональной архитектуры — три концентрических зоны (Application Core, Ports, Adapters), направление зависимостей, симметрия левой и правой сторон, маппинг на Effect-ts концепции (Context.Tag = Port, Layer = Adapter, R-channel = Dependency Rule). Вложенные гексагоны для масштабирования.

Зачем нужна визуализация архитектуры

Прежде чем погружаться в диаграммы, ответим на ключевой вопрос: зачем вообще визуализировать архитектуру?

Архитектурные диаграммы решают три задачи:

  1. Коммуникация — общий язык между разработчиками, архитекторами, тимлидами и менеджерами. Когда вся команда видит одну и ту же картину, количество недоразумений резко сокращается.

  2. Навигация — диаграмма работает как карта. Новый разработчик, посмотрев на неё, сразу понимает: «вот бизнес-логика, вот HTTP-входы, вот база данных, вот как они связаны».

  3. Защита инвариантов — когда границы нарисованы явно, их сложнее случайно нарушить. Если на диаграмме стрелка всегда идёт от адаптера к порту (а не наоборот), любой pull request, нарушающий это правило, сразу виден на code review.


Почему именно «гексагон»?

Одно из самых частых заблуждений — что шестиугольная форма имеет глубокий смысл. Нет. Кокберн выбрал шестиугольник по трём прагматичным причинам:

  1. Достаточно граней — шестиугольник позволяет нарисовать 6 портов (сторон), что достаточно для большинства приложений. Квадрат давал бы только 4 стороны и вызывал бы ассоциации со слоистой архитектурой.

  2. Нет «верха» и «низа» — в отличие от слоистой архитектуры, где UI всегда сверху, а БД снизу, гексагон симметричен. Это подчёркивает, что все внешние взаимодействия (будь то HTTP, CLI, БД или файлы) — одинаковы по статусу.

  3. Визуальная новизна — шестиугольник сразу отличается от привычных квадратов и прямоугольников, что заставляет задуматься: «это что-то другое».

Количество сторон не важно. Можно рисовать пятиугольник, восьмиугольник или даже круг. Важна семантика: внутри — бизнес-логика, снаружи — внешний мир, между ними — порты.


Анатомия канонической диаграммы

Три концентрических зоны

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

┌─────────────────────────────────────────────────────────────┐
│                    ВНЕШНИЙ МИР                              │
│  ┌────────────┐                          ┌──────────────┐   │
│  │  HTTP       │                          │   SQLite     │   │
│  │  Client     │                          │   Database   │   │
│  └──────┬─────┘                          └──────┬───────┘   │
│         │                                       │           │
│    ┌────▼───────────────────────────────────────▼────┐      │
│    │              АДАПТЕРЫ (Adapters)                 │      │
│    │  ┌──────────┐                  ┌─────────────┐  │      │
│    │  │  HTTP     │                  │  SQLite     │  │      │
│    │  │  Adapter  │                  │  Adapter    │  │      │
│    │  └────┬─────┘                  └──────┬──────┘  │      │
│    │       │                               │         │      │
│    │  ┌────▼───────────────────────────────▼────┐    │      │
│    │  │           ПОРТЫ (Ports)                  │    │      │
│    │  │  ┌──────────┐          ┌─────────────┐  │    │      │
│    │  │  │ Driving   │          │  Driven     │  │    │      │
│    │  │  │ Port      │          │  Port       │  │    │      │
│    │  │  └────┬─────┘          └──────┬──────┘  │    │      │
│    │  │       │                       │         │    │      │
│    │  │  ┌────▼───────────────────────▼────┐    │    │      │
│    │  │  │     APPLICATION CORE            │    │    │      │
│    │  │  │                                 │    │    │      │
│    │  │  │   Domain Model                  │    │    │      │
│    │  │  │   Domain Services               │    │    │      │
│    │  │  │   Application Services          │    │    │      │
│    │  │  │   Use Cases                     │    │    │      │
│    │  │  │                                 │    │    │      │
│    │  │  └─────────────────────────────────┘    │    │      │
│    │  └─────────────────────────────────────────┘    │      │
│    └─────────────────────────────────────────────────┘      │
└─────────────────────────────────────────────────────────────┘

Зона 1: Application Core (ядро)

Самая внутренняя зона. Здесь живёт бизнес-логика — то, ради чего вообще создаётся приложение. Ядро ничего не знает о внешнем мире. Оно не знает, что существуют HTTP, SQLite, файловые системы, email-серверы или очереди сообщений.

В контексте Effect-ts ядро — это:

import { Effect, Context } from "effect"

// Доменная модель — чистые типы и функции
// Никаких импортов из @effect/platform, bun:sqlite и т.д.

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

// Бизнес-правило — чистая функция
const completeTodo = (todo: Todo): Todo => ({
  ...todo,
  status: "completed" as TodoStatus,
})

Ключевой принцип: если вы удалите всю инфраструктуру (HTTP, БД, файлы), ядро должно продолжать компилироваться и проходить юнит-тесты.

Зона 2: Порты (Ports)

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

Два типа портов:

  • Driving (Primary) Ports — как внешний мир может обратиться к приложению. «Что приложение умеет делать».
  • Driven (Secondary) Ports — что приложению нужно от инфраструктуры. «Что приложение требует для работы».

В Effect-ts порт — это Context.Tag с определённым интерфейсом:

// Driving Port — что приложение умеет
class CreateTodoUseCase extends Context.Tag("CreateTodoUseCase")<
  CreateTodoUseCase,
  {
    readonly execute: (
      input: CreateTodoInput
    ) => Effect.Effect<Todo, TodoValidationError>
  }
>() {}

// Driven Port — что приложение требует
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, TodoPersistenceError>
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
    readonly findAll: Effect.Effect<ReadonlyArray<Todo>, TodoPersistenceError>
  }
>() {}

Зона 3: Адаптеры (Adapters)

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

Два типа адаптеров:

  • Driving (Primary) Adapters — «вызывают» приложение. Пример: HTTP-контроллер получает запрос и вызывает Use Case.
  • Driven (Secondary) Adapters — приложение «вызывает» их. Пример: SQLite-репозиторий сохраняет данные по запросу Use Case.

В Effect-ts адаптер — это Layer:

import { Layer, Effect } from "effect"

// Driven Adapter — реализация TodoRepository для SQLite
const TodoRepositorySqliteLive = Layer.succeed(
  TodoRepository,
  TodoRepository.of({
    save: (todo) =>
      Effect.gen(function* () {
        // SQL INSERT ... маппинг Todo -> SQL row
      }),
    findById: (id) =>
      Effect.gen(function* () {
        // SQL SELECT ... маппинг SQL row -> Todo
      }),
    findAll: Effect.gen(function* () {
      // SQL SELECT ALL ... маппинг SQL rows -> ReadonlyArray<Todo>
    }),
  })
)

За пределами гексагона: внешний мир

Всё, что находится за пределами адаптеров, — это внешний мир: браузеры, мобильные приложения, базы данных, файловые системы, внешние API, очереди сообщений, другие микросервисы. Приложение не контролирует внешний мир. Оно может только определить контракт (порт) и создать переводчик (адаптер).


Развёрнутая каноническая диаграмма

Теперь нарисуем полную каноническую диаграмму с конкретными компонентами Todo-приложения:

                    ┌──────────────┐
                    │   Browser /  │
                    │   Postman    │
                    └──────┬───────┘
                           │ HTTP Request

                 ┌─────────────────────┐
                 │   HTTP Adapter      │  ◄── Driving Adapter
                 │   (Effect HttpApp)  │
                 └─────────┬───────────┘
                           │ вызывает
          ╔════════════════╧══════════════════════╗
          ║        DRIVING PORTS                  ║
          ║  ┌──────────────────────────────────┐ ║
          ║  │ CreateTodoUseCase                │ ║
          ║  │ CompleteTodoUseCase              │ ║
          ║  │ GetTodoUseCase                   │ ║
          ║  │ ListTodosUseCase                 │ ║
          ║  │ DeleteTodoUseCase                │ ║
          ║  └──────────────────────────────────┘ ║
          ╠═══════════════════════════════════════╣
          ║         APPLICATION CORE              ║
          ║                                       ║
          ║  ┌───────────────────────────┐        ║
          ║  │   Application Services    │        ║
          ║  │   (Use Case Handlers)     │        ║
          ║  └─────────────┬─────────────┘        ║
          ║                │ использует            ║
          ║  ┌─────────────▼─────────────┐        ║
          ║  │     Domain Model          │        ║
          ║  │  ┌──────┐ ┌───────────┐   │        ║
          ║  │  │ Todo │ │ TodoList  │   │        ║
          ║  │  └──────┘ └───────────┘   │        ║
          ║  │  ┌──────────┐ ┌────────┐  │        ║
          ║  │  │ TodoTitle│ │Priority│  │        ║
          ║  │  └──────────┘ └────────┘  │        ║
          ║  └───────────────────────────┘        ║
          ║                                       ║
          ╠═══════════════════════════════════════╣
          ║        DRIVEN PORTS                   ║
          ║  ┌──────────────────────────────────┐ ║
          ║  │ TodoRepository                   │ ║
          ║  │ EventStore                       │ ║
          ║  │ NotificationService              │ ║
          ║  │ FileStorage                      │ ║
          ║  │ Clock / IdGenerator              │ ║
          ║  └──────────────────────────────────┘ ║
          ╚════════════════╤══════════════════════╝
                           │ реализуют
                 ┌─────────▼───────────┐
                 │  SQLite Adapter     │  ◄── Driven Adapter
                 │  InMemory Adapter   │
                 │  FileSystem Adapter │
                 └─────────┬───────────┘

                    ┌──────▼───────┐
                    │  SQLite DB / │
                    │  File System │
                    └──────────────┘

Направление зависимостей: правило стрелок

Самое важное в гексагональной диаграмме — это направление стрелок зависимостей. Правило простое и абсолютное:

Все зависимости направлены внутрь — к ядру.

Это означает:

Adapter  ───зависит-от───►  Port  ◄───определён-в───  Core

HTTP Adapter  ───знает-о───►  CreateTodoUseCase
SQLite Adapter  ───знает-о───►  TodoRepository

Core  ───НЕ знает-о───►  HTTP Adapter    ✗
Core  ───НЕ знает-о───►  SQLite Adapter  ✗

В Effect-ts это выражается через R-канал:

// Use Case handler зависит от ПОРТА (TodoRepository),
// а не от АДАПТЕРА (TodoRepositorySqlite)
const createTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository    // ← зависимость от ПОРТА
    const todo = Todo.create(input)
    yield* repo.save(todo)
    return todo
  })

// Тип: Effect<Todo, TodoValidationError | TodoPersistenceError, TodoRepository>
//                                                                ^^^^^^^^^^^^^^
//                                               R-канал содержит ПОРТ, не адаптер

Асимметрия Driving и Driven

Хотя правило зависимостей одно, механизм подключения отличается для Driving и Driven:

Driving (вход):

Внешний мир → Driving Adapter → Driving Port → Application Core
                   │                  ▲
                   └──зависит от──────┘

Driving Adapter вызывает порт. Адаптер знает о порте, порт не знает об адаптере.

Driven (выход):

Application Core → Driven Port ← Driven Adapter ← Внешний мир
                        ▲              │
                        └──реализует───┘

Application Core использует Driven Port. Driven Adapter реализует порт. Ядро не знает, кто реализует порт.

В Effect-ts обе стороны выглядят единообразно — через Service/Layer:

// Driving: HTTP Adapter вызывает Use Case
const httpRoutes = HttpRouter.empty.pipe(
  HttpRouter.post("/todos", 
    Effect.gen(function* () {
      const useCase = yield* CreateTodoUseCase  // ← берёт порт из контекста
      const body = yield* HttpServerRequest.schemaBodyJson(CreateTodoSchema)
      return yield* useCase.execute(body)
    })
  )
)

// Driven: Use Case использует Repository
const createTodoHandler = Effect.gen(function* () {
  const repo = yield* TodoRepository  // ← берёт порт из контекста
  // ... бизнес-логика ...
  yield* repo.save(todo)
})

Симметрия гексагона

Одна из самых мощных идей Кокберна — симметрия. В отличие от слоистой архитектуры, где HTTP «сверху», а БД «снизу», в гексагоне:

  • HTTP-клиент и SQLite-база — одинаковые внешние акторы
  • HTTP Adapter и SQLite Adapter — одинаковые по статусу (оба — Layer)
  • Driving Port и Driven Port — одинаковые по механизму (оба — Context.Tag)

Эта симметрия имеет практическое значение:

// С точки зрения ядра, и HTTP, и CLI — одинаковы.
// Оба адаптера вызывают один и тот же Use Case:

// HTTP Adapter
HttpRouter.post("/todos", 
  Effect.gen(function* () {
    const useCase = yield* CreateTodoUseCase
    // ...
  })
)

// CLI Adapter
const cliHandler = (args: ReadonlyArray<string>) =>
  Effect.gen(function* () {
    const useCase = yield* CreateTodoUseCase  // ← тот же порт!
    // ...
  })

// Test Adapter
const testCreateTodo = (input: CreateTodoInput) =>
  Effect.gen(function* () {
    const useCase = yield* CreateTodoUseCase  // ← и снова тот же порт!
    return yield* useCase.execute(input)
  })

Левая и правая стороны гексагона

Кокберн в оригинальной статье делит гексагон на левую (driving) и правую (driven) стороны:

         DRIVING SIDE                           DRIVEN SIDE
         (Left/Primary)                         (Right/Secondary)
         
    Кто вызывает                            Что приложение
    приложение?                             вызывает?
    
┌──────────────┐                                    ┌──────────────┐
│  Browser     │                                    │  PostgreSQL  │
│  Mobile App  │──►  ┌───────────────────┐  ──►    │  Redis       │
│  CLI         │     │                   │          │  S3          │
│  Tests       │     │   APPLICATION     │          │  SMTP        │
│  Cron Job    │──►  │      CORE         │  ──►    │  RabbitMQ    │
│  WebSocket   │     │                   │          │  gRPC        │
│  gRPC Client │──►  └───────────────────┘  ──►    │  File System │
└──────────────┘                                    └──────────────┘

    Driving          Driving    Driven                Driven
    Actors           Ports      Ports                 Actors
                     
                     ▲          │
                     │          ▼
                     
                Driving         Driven
                Adapters        Adapters

Мнемоника:

  • Левая сторона = “Кто?” — кто инициирует взаимодействие с приложением
  • Правая сторона = “Куда?” — куда приложение обращается за ресурсами

Гексагон в контексте Effect-ts: маппинг концепций

Hexagonal ConceptEffect-ts ConceptОписание
PortContext.Tag<I>Типизированный контракт / идентификатор сервиса
AdapterLayer<I>Конкретная реализация контракта
Application CoreЧистые функции + Effect без внешних зависимостейБизнес-логика
Dependency RuleR-channel в Effect<A, E, R>Компилятор гарантирует, что зависимости предоставлены
WiringEffect.provide(layer)Подключение адаптеров к портам
Port ContractTypeScript interface в Context.TagФорма (Shape) сервиса
Error ContractE-channel в Effect<A, E, R>Типизированные ошибки порта

Визуально в Effect-ts:

// ════════════════════════════════════════════
//  APPLICATION CORE (ядро)
// ════════════════════════════════════════════

// Driven Port (правая сторона)
class TodoRepository extends Context.Tag("TodoRepository")<
  TodoRepository,
  {
    readonly save: (todo: Todo) => Effect.Effect<void, PersistenceError>
    readonly findById: (id: TodoId) => Effect.Effect<Todo, TodoNotFoundError>
  }
>() {}

// Бизнес-логика (использует порт, не адаптер)
const completeTodo = (id: TodoId) =>
  Effect.gen(function* () {
    const repo = yield* TodoRepository
    const todo = yield* repo.findById(id)
    const completed = Todo.complete(todo)
    yield* repo.save(completed)
    return completed
  })
// Тип: Effect<Todo, TodoNotFoundError | PersistenceError, TodoRepository>

// ════════════════════════════════════════════
//  ADAPTERS (адаптеры)
// ════════════════════════════════════════════

// Driven Adapter: SQLite
const TodoRepositorySqlite = Layer.effect(
  TodoRepository,
  Effect.gen(function* () {
    const db = yield* SqliteClient
    return TodoRepository.of({
      save: (todo) => /* SQL INSERT */,
      findById: (id) => /* SQL SELECT */,
    })
  })
)

// Driven Adapter: InMemory (для тестов)
const TodoRepositoryInMemory = Layer.sync(
  TodoRepository,
  () => {
    const store = new Map<string, Todo>()
    return TodoRepository.of({
      save: (todo) => Effect.sync(() => { store.set(todo.id, todo) }),
      findById: (id) => /* ... */,
    })
  }
)

// ════════════════════════════════════════════
//  WIRING (подключение)
// ════════════════════════════════════════════

// Production: SQLite
const prodProgram = completeTodo(todoId).pipe(
  Effect.provide(TodoRepositorySqlite)
)

// Test: InMemory
const testProgram = completeTodo(todoId).pipe(
  Effect.provide(TodoRepositoryInMemory)
)

Вложенные гексагоны: масштабирование

По мере роста системы один гексагон может стать слишком большим. В этом случае применяют вложенные гексагоны — каждый модуль (Bounded Context) становится самостоятельным гексагоном:

┌─────────────────────────────────────────────────────────────┐
│                      СИСТЕМА                                │
│                                                             │
│   ┌─────────────────┐        ┌─────────────────┐           │
│   │   Todo Module   │        │  User Module    │           │
│   │   ╔═══════════╗ │        │  ╔═══════════╗  │           │
│   │   ║           ║ │◄──────►│  ║           ║  │           │
│   │   ║   Core    ║ │  ACL   │  ║   Core    ║  │           │
│   │   ║           ║ │        │  ║           ║  │           │
│   │   ╚═══════════╝ │        │  ╚═══════════╝  │           │
│   └─────────────────┘        └─────────────────┘           │
│                                                             │
│   ┌─────────────────┐                                       │
│   │ Notification    │                                       │
│   │ Module          │                                       │
│   │ ╔═══════════╗   │                                       │
│   │ ║   Core    ║   │                                       │
│   │ ╚═══════════╝   │                                       │
│   └─────────────────┘                                       │
└─────────────────────────────────────────────────────────────┘

Связь между гексагонами идёт через Anti-Corruption Layer (ACL) — адаптер, защищающий один домен от терминов и моделей другого.

В Effect-ts:

// Todo Module определяет порт для пользователей
class UserInfoPort extends Context.Tag("UserInfoPort")<
  UserInfoPort,
  {
    readonly getOwner: (userId: UserId) => Effect.Effect<TodoOwner, UserNotFound>
  }
>() {}

// ACL-адаптер: переводит User Module → Todo Module
const UserInfoFromUserModule = Layer.effect(
  UserInfoPort,
  Effect.gen(function* () {
    const userService = yield* UserService // из User Module
    return UserInfoPort.of({
      getOwner: (userId) =>
        userService.findById(userId).pipe(
          // ACL: маппинг User → TodoOwner (доменный тип Todo Module)
          Effect.map((user) => ({
            id: userId,
            name: user.displayName,
          }))
        ),
    })
  })
)

Диаграмма с детализацией Effect-типов

Финальная диаграмма, показывающая как Effect-типы соответствуют элементам гексагона:

┌─────────────────────────────────────────────────────────────────┐
│                        ВНЕШНИЙ МИР                              │
│                                                                 │
│  Browser        SQLite DB      File System      Email Server    │
│     │               │               │                │          │
│     ▼               ▼               ▼                ▼          │
│ ┌──────────────────────────────────────────────────────────┐    │
│ │                  ADAPTERS = Layer<Port>                   │    │
│ │                                                          │    │
│ │  HttpAdapter     SqliteAdapter   FsAdapter   SmtpAdapter │    │
│ │  Layer<          Layer<          Layer<       Layer<      │    │
│ │   HttpRoutes>     TodoRepo>      FileStore>   Notifier>  │    │
│ │                                                          │    │
│ │ ┌──────────────────────────────────────────────────────┐ │    │
│ │ │              PORTS = Context.Tag<Shape>               │ │    │
│ │ │                                                      │ │    │
│ │ │  CreateTodoUseCase   TodoRepository    FileStorage   │ │    │
│ │ │  CompleteTodoUseCase EventStore        Notifier      │ │    │
│ │ │  ListTodosUseCase    Clock             Logger        │ │    │
│ │ │                                                      │ │    │
│ │ │ ┌──────────────────────────────────────────────────┐ │ │    │
│ │ │ │          APPLICATION CORE                        │ │ │    │
│ │ │ │                                                  │ │ │    │
│ │ │ │  Domain Model:     Effect<A, E, never>           │ │ │    │
│ │ │ │  ├── Todo Entity   (чистые, без зависимостей)    │ │ │    │
│ │ │ │  ├── TodoTitle VO                                │ │ │    │
│ │ │ │  └── TodoStatus                                  │ │ │    │
│ │ │ │                                                  │ │ │    │
│ │ │ │  Use Cases:        Effect<A, E, R>               │ │ │    │
│ │ │ │  ├── createTodo    (R = TodoRepository)          │ │ │    │
│ │ │ │  ├── completeTodo  (R = TodoRepository)          │ │ │    │
│ │ │ │  └── listTodos     (R = TodoRepository | Clock)  │ │ │    │
│ │ │ │                                                  │ │ │    │
│ │ │ └──────────────────────────────────────────────────┘ │ │    │
│ │ └──────────────────────────────────────────────────────┘ │    │
│ └──────────────────────────────────────────────────────────┘    │
│                                                                 │
│  WIRING = Effect.provide(Layer)                                 │
│  Подключение адаптеров к портам при старте приложения           │
└─────────────────────────────────────────────────────────────────┘

Чтение диаграммы: чеклист

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

  1. Стрелки зависимостей — все направлены внутрь? Адаптер зависит от порта, порт определён в ядре, ядро ни от чего внешнего не зависит.

  2. Симметрия — левая и правая стороны используют одинаковый механизм (Service/Layer)? Driving и Driven порты равноправны?

  3. Изоляция — типы из внешнего мира (HTTP Request, SQL Row) не проникают внутрь? Маппинг происходит в адаптерах?

  4. Полнота — все внешние взаимодействия проходят через порты? Нет «чёрных ходов» мимо портов?

  5. Заменяемость — можно ли заменить любой адаптер, не трогая ядро? Если заменить SQLite на PostgreSQL, изменится только один Layer?


Резюме

Каноническая диаграмма гексагональной архитектуры — это карта вашего приложения. Три зоны (ядро, порты, адаптеры) чётко разделяют ответственности. Правило зависимостей (всё указывает внутрь) защищает бизнес-логику от инфраструктурных деталей.

В Effect-ts эта диаграмма не просто визуальная метафора — она реализована в типовой системе:

  • Context.Tag = Порт
  • Layer = Адаптер
  • R-канал = Зависимость (всегда от порта, никогда от адаптера)
  • Effect.provide = Подключение адаптера к порту

Когда вы рисуете диаграмму своей системы, каждый элемент на ней должен иметь прямое соответствие в коде. Диаграмма — не украшение. Это исполняемая спецификация.