13. Hexagonal Architecture. Ports and Adapters

После того как мы научились очерчивать границы Bounded Context и проектировать транзакционные агрегаты в рамках Domain-Driven Design, перед инженером встаёт сугубо практическая задача: как организовать исходный код внутри одного сервиса или модуля?

В классических корпоративных приложениях доменная логика почти всегда оказывается намертво зацементирована между SQL-запросами и HTTP-роутерами. Стоит заказчику попросить добавить консольную CLI-утилиту, переключиться с PostgreSQL на MongoDB или покрыть сценарий быстрыми юнит-тестами без поднятия тяжеловесной базы данных в Docker, как проект превращается в кошмар рефакторинга.

В 2005 году Алистер Кокбёрн (Alistair Cockburn) сформулировал архитектурный паттерн Hexagonal Architecture (Шестигранная архитектура), также известный как Ports and Adapters (Порты и Адаптеры). Этот подход предлагает кристально ясный принцип изоляции ядра от внешнего шума, который удивительно органично сочетается со строгой типизацией и интерфейсной моделью языка Go.


Истоки и основная идея

Hexagonal Architecture возникла как радикальная альтернатива традиционной многослойной архитектуре (15. Layered Architecture. Классическая трехслойка). В классической трёхслойке поток управления и направление зависимостей совпадают:

DIAGRAM
  [HTTP Контроллер] ──> [Служба (Service)] ──> [Репозиторий (SQL)] ──> [PostgreSQL]

Главная уязвимость этой схемы — транзитивная зависимость от инфраструктуры. Сервисный слой, хранящий самое ценное — правила вашего бизнеса, напрямую импортирует типы базы данных, SQL-драйверы или HTTP-библиотеки.

Шестигранная архитектура совершает инверсию зависимостей (Dependency Inversion):

  1. Ядро приложения (Core) помещается в абсолютный геометрический центр. Оно содержит доменные сущности и чистые сценарии использования (Use Cases). Ядро ничего не знает о существовании HTTP, gRPC, PostgreSQL, Kafka, консоли или операционной системы Linux.
  2. Ядро объявляет Порты (Ports) — абстрактные спецификации того, что ему требуется для работы, и какие операции оно готово предоставить внешнему миру. В Go порт — это обычный интерфейс (type ... interface).
  3. Внешний мир подключается к ядру через Адаптеры (Adapters) — конкретные реализации, переводящие байты из сокетов или строки таблиц БД на язык доменных типов ядра.
flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    Core["Ядро приложения<br>Бизнес-логика<br>Доменные модели<br>Сервисы приложения"]:::system

    subgraph Primary["Первичные адаптеры - управляют приложением"]
        HTTP["HTTP Handler"]:::network
        GRPC["gRPC Handler"]:::network
        CLI["CLI команда"]:::process
    end

    subgraph Secondary["Вторичные адаптеры - управляются приложением"]
        DB["Репозиторий PostgreSQL"]:::process
        API["Клиент внешнего API"]:::entry
        Queue["Отправка в очередь"]:::data
    end

    Primary -->|"реализуют"| InPorts["Входные порты<br>интерфейсы Use Case"]
    Secondary -->|"реализуют"| OutPorts["Выходные порты<br>интерфейсы репозиториев и клиентов"]

    InPorts --> Core
    Core --> OutPorts
  

Почему именно шестигранник? Геометрическая фигура не накладывает ограничений на число граней (их может быть 4, 6 или 12). Шестигранник символизирует равноправие интерфейсов: ядру безразлично, кто его вызывает — веб-браузер по протоколу HTTP, фоновый воркер или автономный юнит-тест. Все они равноправно стыкуются через адаптеры.


Порты (Ports) в Go

В языке Go понятие Порта выражается максимально идиоматично. Порт — это интерфейс, определённый внутри ядра (core/ports), описывающий контракт взаимодействия.

Кокбёрн разделил порты на две принципиальные категории:

TEXT
                                [ВХОДНОЙ ПОРТ]
   Внешний мир (HTTP/gRPC/CLI) ------------> [Ядро: Use Case]
                                (Driving / Primary)

                                [ВЫХОДНОЙ ПОРТ]
   [Ядро: Доменный сервис]    ------------> Внешний мир (PostgreSQL/Redis/Kafka)
                                (Driven / Secondary)

1. Входной порт (Primary / Driving Port)

Описывает пользовательские сценарии (Use Cases), которые инициирует внешний мир. Ядро декларирует: «Вот операции, которые я умею выполнять для внешних клиентов».

Go
// core/ports/order_use_cases.go
package ports

import (
    "context"
    "yourproject/core/domain"
)

type OrderItem struct {
    ProductID string
    Quantity  int
}

type CreateOrderInput struct {
    UserID string
    Items  []OrderItem
}

// OrderUseCases - входной порт приложения
type OrderUseCases interface {
    CreateOrder(ctx context.Context, input CreateOrderInput) (*domain.Order, error)
    GetOrder(ctx context.Context, orderID string) (*domain.Order, error)
}

2. Выходной порт (Secondary / Driven Port)

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

Go
// core/ports/order_repository.go
package ports

import (
    "context"
    "yourproject/core/domain"
)

// OrderRepository - выходной порт: ядро требует возможности персистентности,
// но оставляет реализацию внешним пакетам.
type OrderRepository interface {
    Save(ctx context.Context, order *domain.Order) error
    FindByID(ctx context.Context, id string) (*domain.Order, error)
}

Адаптеры (Adapters) в Go

Адаптер — это «переводчик-синхронист». Он транслирует низкоуровневые реалии конкретного сетевого протокола, операционной системы или СУБД в вызовы чистых портов ядра. Адаптеры всегда живут снаружи ядра, в директориях вроде adapters/http или adapters/postgres.

Первичный адаптер (HTTP Handler)

Первичный адаптер слушает сокет, парсит заголовки HTTP, декодирует тело JSON в доменный CreateOrderInput, вызывает метод входного порта useCases.CreateOrder и формирует HTTP-код ответа:

Go
// adapters/http/order_handler.go
package http

import (
    "encoding/json"
    "net/http"
    "yourproject/core/ports"
)

type OrderHandler struct {
    useCases ports.OrderUseCases
}

func NewOrderHandler(uc ports.OrderUseCases) *OrderHandler {
    return &OrderHandler{useCases: uc}
}

func (h *OrderHandler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    var req struct {
        UserID string `json:"user_id"`
        Items  []struct {
            ProductID string `json:"product_id"`
            Quantity  int    `json:"quantity"`
        } `json:"items"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // Адаптер преобразует transport DTO во входную структуру порта
    input := ports.CreateOrderInput{
        UserID: req.UserID,
        Items:  make([]ports.OrderItem, len(req.Items)),
    }
    for i, item := range req.Items {
        input.Items[i] = ports.OrderItem{
            ProductID: item.ProductID,
            Quantity:  item.Quantity,
        }
    }

    order, err := h.useCases.CreateOrder(r.Context(), input)
    if err != nil {
        // Обработка доменных ошибок и преобразование в HTTP статус-коды
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(order)
}

Вторичный адаптер (PostgreSQL Repository)

Вторичный адаптер берет на себя рутину составления SQL-запросов, сканирования строк sql.Rows и обработки ошибок драйвера СУБД:

Go
// adapters/postgres/order_repository.go
package postgres

import (
    "context"
    "database/sql"
    "yourproject/core/domain"
    "yourproject/core/ports"
)

type OrderRepository struct {
    db *sql.DB
}

func NewOrderRepository(db *sql.DB) ports.OrderRepository {
    return &OrderRepository{db: db}
}

func (r *OrderRepository) Save(ctx context.Context, order *domain.Order) error {
    _, err := r.db.ExecContext(ctx, `
        INSERT INTO orders (id, user_id, total_amount, status) 
        VALUES ($1, $2, $3, $4)`, 
        order.ID, order.UserID, order.TotalAmount, order.Status)
    return err
}

func (r *OrderRepository) FindByID(ctx context.Context, id string) (*domain.Order, error) {
    row := r.db.QueryRowContext(ctx, "SELECT id, user_id, total_amount, status FROM orders WHERE id = $1", id)
    var order domain.Order
    err := row.Scan(&order.ID, &order.UserID, &order.TotalAmount, &order.Status)
    if err != nil {
        return nil, err
    }
    return &order, nil
}

Обратите внимание на стрелки импортов: пакет postgres импортирует core/ports и core/domain. Но ядро (core) ни единой строчкой не ссылается на пакет postgres!


Сборка приложения: Dependency Injection

В гексагональной системе ни один модуль не должен инстанцировать свои зависимости самостоятельно. Сборка всего графа объектов происходит в единственной точке конфигурации — корневом пакете main (директория cmd/app):

Go
// cmd/app/main.go
package main

import (
    "database/sql"
    "log"
    "net/http"

    "yourproject/adapters/http"
    "yourproject/adapters/postgres"
    "yourproject/core/ports"
    "yourproject/core/service"

    _ "github.com/lib/pq"
)

func main() {
    db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/orders?sslmode=disable")
    if err != nil {
        log.Fatalf("failed to connect to database: %v", err)
    }
    defer db.Close()

    // 1. Создаем вторичный адаптер (Driven)
    orderRepo := postgres.NewOrderRepository(db)

    // 2. Создаем ядро приложения (Сервис реализует входной порт ports.OrderUseCases)
    orderService := service.NewOrderService(orderRepo)

    // 3. Создаем первичный адаптер (Driving), передавая в него ядро
    orderHandler := http.NewOrderHandler(orderService)

    // 4. Настраиваем маршрутизацию и запускаем сетевой слушатель
    http.HandleFunc("/orders", orderHandler.CreateOrder)
    log.Println("Server running on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Преимущества в Go

Безупречная тестируемость ядра

Поскольку ядро взаимодействует с внешним миром только через интерфейсы портов, юнит-тестирование бизнес-сценариев превращается в удовольствие:

Go
// core/service/order_service_test.go
package service_test

import (
    "context"
    "testing"
    "yourproject/core/domain"
    "yourproject/core/ports"
    "yourproject/core/service"
)

// mockOrderRepo реализует выходной порт ports.OrderRepository в памяти
type mockOrderRepo struct {
    saved *domain.Order
}

func (m *mockOrderRepo) Save(ctx context.Context, order *domain.Order) error {
    m.saved = order
    return nil
}

func (m *mockOrderRepo) FindByID(ctx context.Context, id string) (*domain.Order, error) {
    return nil, nil
}

func TestCreateOrder(t *testing.T) {
    repo := &mockOrderRepo{}
    svc := service.NewOrderService(repo)

    input := ports.CreateOrderInput{
        UserID: "usr-42",
        Items: []ports.OrderItem{
            {ProductID: "sku-1", Quantity: 2},
        },
    }

    order, err := svc.CreateOrder(context.Background(), input)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if repo.saved == nil || repo.saved.ID != order.ID {
        t.Errorf("expected order to be persisted in repository")
    }
}

Тест выполняется за микросекунды! Вам не нужны docker-compose, базы данных или локальные сетевые порты для проверки того, правильно ли ваш сервис считает налоги и применяет скидки.

Мгновенная замена технологий

Потребовалось временно сменить PostgreSQL на локальный SQLite для интеграционных тестов или перенести аналитику в ClickHouse?
Вы просто пишете новый вторичный адаптер в пакете adapters/clickhouse, реализующий метод Save(ctx context.Context, order *domain.Order) error. Ни единая строчка в core не сдвинется с места.

Параллельная работа

Разработчик первичного адаптера (например, gRPC API) и разработчик репозитория (Postgres) могут работать независимо друг от друга в параллельных ветках Git. Их разделяет незыблемый контракт — интерфейсы входных и выходных портов.


Mechanical Sympathy: цена интерфейсов и кэш-линий

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

  1. Косвенные вызовы через itab:
    Каждый вызов метода через Go-интерфейс компилируется в косвенный переход по таблице методов (itab). Для современного процессора с динамическим предсказанием ветвлений (Branch Target Buffer) этот оверхед составляет ничтожные 1–2 наносекунды. Если метод содержит реальную логику или выполняет I/O-операцию, плата за интерфейс математически неотличима от нуля.
  2. Аллокации и Inlining:
    Вызовы через интерфейс обычно не инлайнятся компилятором (escape analysis не всегда может доказать неизменность типа), из-за чего структуры данных, переданные в интерфейс, могут «убегать» в кучу (heap). В критических сетевых микросервисах с миллионным RPS рекомендуется следить за тем, чтобы DTO адаптера не порождали бесконечные аллокации при маппинге в доменные типы.
  3. Осознанный маппинг данных:
    Двойное копирование структур (HTTP Request -> Ports Input -> Domain Entity) требует тактов CPU. Для типичных CRUD-приложений эта плата ничтожна по сравнению с задержками диска и сети (миллисекунды против наносекунд), но даёт железную архитектурную стабильность.

Сравнение с Clean Architecture

Hexagonal Architecture часто упоминается рядом с Clean Architecture Роберта Мартина (Дядюшки Боба, см. 14. Clean Architecture и Dependency Rule). Эти концепции родственны по духу, но различаются в акцентах:

Характеристика Hexagonal Architecture (Алистер Кокбёрн) Clean Architecture (Роберт Мартин)
Главная метафора Порты и Адаптеры (симметрия входов и выходов) Концентрические круги (слои абстракции)
Детализация ядра Ядро едино: домен и сценарии живут в центре Ядро строго разделено: Entities (центр) и Use Cases (кольцо вокруг)
Внешние слои Адаптеры (Primary / Secondary) Interface Adapters, затем Frameworks & Drivers
Направление зависимостей Снаружи внутрь к портам ядра Строго внутрь: правило зависимости (Dependency Rule)

В практической разработке на Go эти два стиля почти всегда сливаются: ядро строится по правилам Clean Architecture, а изоляция ввода-вывода оформляется как порты и адаптеры.


Когда применять Hexagonal Architecture

Рекомендуется выбирать, когда:

  • Бизнес-логика системы нетривиальна, содержит строгие инварианты и должна быть долговечной.
  • Приложение имеет множество равноправных источников команд: REST API, gRPC-клиенты, консольный CLI, периодические задачи cron, события из брокеров Kafka/RabbitMQ.
  • Критически важна быстрая юнит-тестируемость без сетевых зависимостей.

Избыточна, когда:

  • Проект представляет собой примитивный прокси-сервис или типичную CRUD-обёртку над базой данных («прочитал из таблицы, вернул JSON»). Для таких задач классическая плоская архитектура или простая трёхслойка сэкономят время и избавят от написания сотен строк тривиальных адаптеров.


Итог

Hexagonal Architecture — это торжество принципа инверсии зависимостей, возведённое в ранг системного проектирования. Она превращает код сервиса в надежный часовой механизм: чистое сердцевина тикает по законам вашей предметной области, а внешние шестерёнки (сети, базы, брокеры) легко меняются на новые без остановки внутренних часов.

Разобравшись с портами и адаптерами, мы готовы взглянуть на родственную концепцию Роберта Мартина, которая детально препарирует внутренние слои самого доменного ядра: 14. Clean Architecture и Dependency Rule.