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. Классическая трехслойка). В классической трёхслойке поток управления и направление зависимостей совпадают:
[HTTP Контроллер] ──> [Служба (Service)] ──> [Репозиторий (SQL)] ──> [PostgreSQL]
Главная уязвимость этой схемы — транзитивная зависимость от инфраструктуры. Сервисный слой, хранящий самое ценное — правила вашего бизнеса, напрямую импортирует типы базы данных, SQL-драйверы или HTTP-библиотеки.
Шестигранная архитектура совершает инверсию зависимостей (Dependency Inversion):
- Ядро приложения (Core) помещается в абсолютный геометрический центр. Оно содержит доменные сущности и чистые сценарии использования (Use Cases). Ядро ничего не знает о существовании HTTP, gRPC, PostgreSQL, Kafka, консоли или операционной системы Linux.
- Ядро объявляет Порты (Ports) — абстрактные спецификации того, что ему требуется для работы, и какие операции оно готово предоставить внешнему миру. В Go порт — это обычный интерфейс (
type ... interface). - Внешний мир подключается к ядру через Адаптеры (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), описывающий контракт взаимодействия.
Кокбёрн разделил порты на две принципиальные категории:
[ВХОДНОЙ ПОРТ]
Внешний мир (HTTP/gRPC/CLI) ------------> [Ядро: Use Case]
(Driving / Primary)
[ВЫХОДНОЙ ПОРТ]
[Ядро: Доменный сервис] ------------> Внешний мир (PostgreSQL/Redis/Kafka)
(Driven / Secondary)
1. Входной порт (Primary / Driving Port)
Описывает пользовательские сценарии (Use Cases), которые инициирует внешний мир. Ядро декларирует: «Вот операции, которые я умею выполнять для внешних клиентов».
// 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. Классический пример — интерфейс репозитория:
// 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-код ответа:
// 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 и обработки ошибок драйвера СУБД:
// 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):
// 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
Безупречная тестируемость ядра
Поскольку ядро взаимодействует с внешним миром только через интерфейсы портов, юнит-тестирование бизнес-сценариев превращается в удовольствие:
// 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: цена интерфейсов и кэш-линий
Инженеры часто задаются вопросом: какова плата за такую гибкость на уровне машинных инструкций процессора?
- Косвенные вызовы через
itab:
Каждый вызов метода через Go-интерфейс компилируется в косвенный переход по таблице методов (itab). Для современного процессора с динамическим предсказанием ветвлений (Branch Target Buffer) этот оверхед составляет ничтожные 1–2 наносекунды. Если метод содержит реальную логику или выполняет I/O-операцию, плата за интерфейс математически неотличима от нуля. - Аллокации и Inlining:
Вызовы через интерфейс обычно не инлайнятся компилятором (escape analysis не всегда может доказать неизменность типа), из-за чего структуры данных, переданные в интерфейс, могут «убегать» в кучу (heap). В критических сетевых микросервисах с миллионным RPS рекомендуется следить за тем, чтобы DTO адаптера не порождали бесконечные аллокации при маппинге в доменные типы. - Осознанный маппинг данных:
Двойное копирование структур (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.