Каноническая диаграмма: порты, адаптеры, ядро
Анатомия канонической диаграммы гексагональной архитектуры — три концентрических зоны (Application Core, Ports, Adapters), направление зависимостей, симметрия левой и правой сторон, маппинг на Effect-ts концепции (Context.Tag = Port, Layer = Adapter, R-channel = Dependency Rule). Вложенные гексагоны для масштабирования.
Зачем нужна визуализация архитектуры
Прежде чем погружаться в диаграммы, ответим на ключевой вопрос: зачем вообще визуализировать архитектуру?
Архитектурные диаграммы решают три задачи:
-
Коммуникация — общий язык между разработчиками, архитекторами, тимлидами и менеджерами. Когда вся команда видит одну и ту же картину, количество недоразумений резко сокращается.
-
Навигация — диаграмма работает как карта. Новый разработчик, посмотрев на неё, сразу понимает: «вот бизнес-логика, вот HTTP-входы, вот база данных, вот как они связаны».
-
Защита инвариантов — когда границы нарисованы явно, их сложнее случайно нарушить. Если на диаграмме стрелка всегда идёт от адаптера к порту (а не наоборот), любой pull request, нарушающий это правило, сразу виден на code review.
Почему именно «гексагон»?
Одно из самых частых заблуждений — что шестиугольная форма имеет глубокий смысл. Нет. Кокберн выбрал шестиугольник по трём прагматичным причинам:
-
Достаточно граней — шестиугольник позволяет нарисовать 6 портов (сторон), что достаточно для большинства приложений. Квадрат давал бы только 4 стороны и вызывал бы ассоциации со слоистой архитектурой.
-
Нет «верха» и «низа» — в отличие от слоистой архитектуры, где UI всегда сверху, а БД снизу, гексагон симметричен. Это подчёркивает, что все внешние взаимодействия (будь то HTTP, CLI, БД или файлы) — одинаковы по статусу.
-
Визуальная новизна — шестиугольник сразу отличается от привычных квадратов и прямоугольников, что заставляет задуматься: «это что-то другое».
Количество сторон не важно. Можно рисовать пятиугольник, восьмиугольник или даже круг. Важна семантика: внутри — бизнес-логика, снаружи — внешний мир, между ними — порты.
Анатомия канонической диаграммы
Три концентрических зоны
Каноническая диаграмма гексагональной архитектуры состоит из трёх зон, вложенных друг в друга:
┌─────────────────────────────────────────────────────────────┐
│ ВНЕШНИЙ МИР │
│ ┌────────────┐ ┌──────────────┐ │
│ │ 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 Concept | Effect-ts Concept | Описание |
|---|---|---|
| Port | Context.Tag<I> | Типизированный контракт / идентификатор сервиса |
| Adapter | Layer<I> | Конкретная реализация контракта |
| Application Core | Чистые функции + Effect без внешних зависимостей | Бизнес-логика |
| Dependency Rule | R-channel в Effect<A, E, R> | Компилятор гарантирует, что зависимости предоставлены |
| Wiring | Effect.provide(layer) | Подключение адаптеров к портам |
| Port Contract | TypeScript interface в Context.Tag | Форма (Shape) сервиса |
| Error Contract | E-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) │
│ Подключение адаптеров к портам при старте приложения │
└─────────────────────────────────────────────────────────────────┘
Чтение диаграммы: чеклист
Когда вы смотрите на гексагональную диаграмму, проверяйте:
-
Стрелки зависимостей — все направлены внутрь? Адаптер зависит от порта, порт определён в ядре, ядро ни от чего внешнего не зависит.
-
Симметрия — левая и правая стороны используют одинаковый механизм (Service/Layer)? Driving и Driven порты равноправны?
-
Изоляция — типы из внешнего мира (HTTP Request, SQL Row) не проникают внутрь? Маппинг происходит в адаптерах?
-
Полнота — все внешние взаимодействия проходят через порты? Нет «чёрных ходов» мимо портов?
-
Заменяемость — можно ли заменить любой адаптер, не трогая ядро? Если заменить SQLite на PostgreSQL, изменится только один Layer?
Резюме
Каноническая диаграмма гексагональной архитектуры — это карта вашего приложения. Три зоны (ядро, порты, адаптеры) чётко разделяют ответственности. Правило зависимостей (всё указывает внутрь) защищает бизнес-логику от инфраструктурных деталей.
В Effect-ts эта диаграмма не просто визуальная метафора — она реализована в типовой системе:
Context.Tag= ПортLayer= Адаптер- R-канал = Зависимость (всегда от порта, никогда от адаптера)
Effect.provide= Подключение адаптера к порту
Когда вы рисуете диаграмму своей системы, каждый элемент на ней должен иметь прямое соответствие в коде. Диаграмма — не украшение. Это исполняемая спецификация.