Hexagonal Architecture (Ports & Adapters)
Суть
Бизнес-логика в центре. Снаружи — порты (интерфейсы) и адаптеры (реализации). Бизнес-логика не знает, с какой БД и через какой транспорт она работает. Автор — Alistair Cockburn (2005). Шестигранник нарисован, чтобы показать: сторон много, никакая не главная.
┌──────────────┐
HTTP ────►│ │◄──── CLI
│ Domain │
Kafka ────►│ + Use │◄──── gRPC
│ Cases │
│ │─────► Postgres
│ │─────► Redis
└──────────────┘─────► Kafka producer
▲ │
(driving) (driven)
Слева — кто дёргает систему (primary / driving). Справа — кого дёргает система (secondary / driven).
Задача и подход
Проблема, которую решает
// Layered: сервис напрямую зависит от postgres
type OrderService struct {
db *pgxpool.Pool
}
func (s *OrderService) Create(ctx context.Context, o Order) error {
_, err := s.db.Exec(ctx, "INSERT INTO orders ...") // привязан к postgres
return err
}
Хочешь тест — поднимай postgres. Хочешь mongo — переписывай сервис. Хочешь добавить вторую БД — копипаста. Бизнес-логика прибита к технологии.
Решение: Ports & Adapters
// 1. Сервис знает только интерфейс (порт)
type OrderService struct {
repo OrderRepo
}
// 2. Порт — интерфейс, объявленный в пакете сервиса
type OrderRepo interface {
Save(ctx context.Context, o Order) error
}
// 3. Postgres — один из адаптеров
type PostgresOrderRepo struct {
db *pgxpool.Pool
}
func (r *PostgresOrderRepo) Save(ctx context.Context, o Order) error {
_, err := r.db.Exec(ctx, "INSERT INTO orders ...")
return err
}
// 4. Сборка в main
func main() {
db := postgres.Connect(...)
repo := postgres.NewOrderRepo(db)
svc := domain.NewOrderService(repo) // инжектим адаптер в порт
}
Меняешь postgres на mongo — пишешь второй адаптер MongoOrderRepo, в main меняешь одну строку. Сервис не трогаешь. Тесты — без БД, через мок.
Порты и адаптеры
Primary vs Secondary порты
Primary (driving) — кто вызывает доменную логику снаружи.
- HTTP-handler вызывает
OrderService.Create() - gRPC-сервис, CLI, cron, Kafka-consumer
- Порт — интерфейс самого use case (
type OrderUseCase interface { Create(...) error }) - Адаптер — handler / consumer / cli-команда
Secondary (driven) — кого вызывает доменная логика наружу.
- Сервис зовёт
OrderRepo.Save(),EmailSender.Send(),EventBus.Publish() - Порт — интерфейс зависимости
- Адаптер — postgres-реализация, smtp-реализация, kafka-producer
Driving adapter ──► Driving port ──► Domain ──► Driven port ──► Driven adapter
(HTTP) (UseCase iface) (logic) (Repo iface) (Postgres)
Главное правило: зависимости направлены внутрь, к домену.
Структура проекта
internal/
domain/ ← ядро, не импортирует ничего извне
order.go ← Entity
order_service.go ← Use case
ports.go ← интерфейсы (driving + driven)
adapters/
primary/
http/
order_handler.go ← driving adapter
grpc/
order_server.go ← driving adapter
secondary/
postgres/
order_repo.go ← driven adapter (Postgres)
mongo/
order_repo.go ← driven adapter (Mongo)
smtp/
email_sender.go ← driven adapter
cmd/
server/
main.go ← composition root, сборка зависимостей
domain/ импортируется адаптерами. Адаптеры никогда не импортируются доменом.
Несколько адаптеров на один порт
Реальный кейс: сервис должен поддерживать и Postgres, и Mongo (миграция БД, multi-tenant, разные клиенты).
// domain/ports.go
type OrderRepo interface {
Save(ctx context.Context, o Order) error
FindByID(ctx context.Context, id string) (Order, error)
}
// adapters/secondary/postgres/order_repo.go
type PostgresOrderRepo struct{ db *pgxpool.Pool }
func (r *PostgresOrderRepo) Save(ctx context.Context, o domain.Order) error {
_, err := r.db.Exec(ctx, "INSERT INTO orders (id, total) VALUES ($1, $2)", o.ID, o.Total)
return err
}
func (r *PostgresOrderRepo) FindByID(ctx context.Context, id string) (domain.Order, error) {
row := r.db.QueryRow(ctx, "SELECT id, total FROM orders WHERE id = $1", id)
var o domain.Order
return o, row.Scan(&o.ID, &o.Total)
}
// adapters/secondary/mongo/order_repo.go
type MongoOrderRepo struct{ coll *mongo.Collection }
func (r *MongoOrderRepo) Save(ctx context.Context, o domain.Order) error {
_, err := r.coll.InsertOne(ctx, bson.M{"_id": o.ID, "total": o.Total})
return err
}
Выбор адаптера в main:
var repo domain.OrderRepo
switch cfg.Storage {
case "postgres":
repo = postgres.NewOrderRepo(db)
case "mongo":
repo = mongo.NewOrderRepo(coll)
}
svc := domain.NewOrderService(repo)
Domain не меняется ни на букву.
Практические решения
Тестируемость
Главный профит. Тесты пишутся без инфраструктуры.
// domain/order_service_test.go
type fakeRepo struct {
saved []Order
}
func (f *fakeRepo) Save(ctx context.Context, o Order) error {
f.saved = append(f.saved, o)
return nil
}
func (f *fakeRepo) FindByID(ctx context.Context, id string) (Order, error) {
return Order{}, nil
}
func TestOrderService_Create(t *testing.T) {
repo := &fakeRepo{}
svc := NewOrderService(repo)
err := svc.Create(context.Background(), Order{Total: 100})
require.NoError(t, err)
require.Len(t, repo.saved, 1)
require.Equal(t, 100.0, repo.saved[0].Total)
}
Без моков-генераторов, без testcontainers, без docker. Чистый Go.
Для интеграционных тестов адаптеров — отдельные *_test.go рядом с адаптером, с testcontainers / реальной БД.
Как правильно выделять порты
Главная ошибка: считать, что любой интерфейс — это порт. Это не так.
Порт — это бизнесовый запрос наружу, выраженный в терминах домена.
Плохо (порт-обёртка над технологией):
type SQLClient interface {
Exec(query string, args ...any) (Result, error)
Query(query string, args ...any) (Rows, error)
}
Это не порт, это обёртка над database/sql. Бизнес-логика не должна знать про SQL.
Хорошо (порт в терминах домена):
type OrderRepo interface {
Save(ctx context.Context, o Order) error
FindActiveByUser(ctx context.Context, userID string) ([]Order, error)
}
Это формулировка от лица бизнеса: "сохранить заказ", "найти активные заказы пользователя". Реализация — забота адаптера.
Правило большого пальца: имя метода порта понятно продукт-менеджеру, не девопсу.
Где объявлять интерфейс
В Go принято: интерфейс там, где его используют (consumer-side interface), не там где реализуют.
// domain/ports.go — интерфейс рядом с use case
package domain
type OrderRepo interface { ... }
// adapters/secondary/postgres/order_repo.go — реализация в адаптере
package postgres
type OrderRepo struct{ db *pgxpool.Pool } // не обязан явно "implements"
Это и есть инверсия зависимости: postgres импортирует domain (нужен тип Order), а не наоборот.
Связь с проектом RateDesk
В проектном этапе Architecture эта тема превращается в конкретную карту портов и адаптеров. В теории достаточно держать принцип:
- driving adapters инициируют сценарий: HTTP, gRPC, cron, consumer;
- use case не знает, кто его вызвал;
- driven adapters вызываются из use case через порты: repository, cache, provider, outbox, clock;
- порт называется по бизнес-роли (
RateReader,QuoteRepository,RateProvider), а не по технологии (SQLRepo,RedisCache,CBRClient).
Подробные интерфейсы, fake-адаптеры, acceptance criteria и MR-разбиение лучше держать в RateDesk Architecture, иначе урок про Hexagonal превращается в проектную спецификацию.
Pitfalls
Порт = технология. KafkaPublisher вместо EventBus. Имя порта тащит в себе технологию → утечка инфраструктуры в домен.
Анемичный домен. Сделали порты, но вся логика в сервисах, entities — DTO. Это Hexagonal по форме, но не по сути. Нужно собирать инварианты в методы entity.
Один гигантский порт. Repository с 30 методами — для каждой ситуации. Дроби по use case: OrderReader, OrderWriter, OrderArchiver. Interface segregation.
Адаптер в адаптере. Postgres-адаптер импортирует http-адаптер, чтобы вызвать внешний API. Нет — внешний API это driven port, ему нужен свой адаптер.
Утечка типов через порт. type Repo interface { Save(*sql.Tx, Order) error } — *sql.Tx тащит SQL в домен. Транзакции абстрагируй (UnitOfWork) или передавай контекстом.
Переусложнение для CRUD. Есть простой users сервис без логики — Hexagonal даст 5 пустых интерфейсов и 3 файла на CRUD. Layered подойдёт лучше.
Игнор primary портов. Часто делают только secondary (репозитории), а handler напрямую вызывает доменный сервис. Норм для маленького сервиса. Для большого — заведи UseCase интерфейсы (driving ports), их легче подменять и тестировать.
Сравнение
Без Hexagonal (Layered):
- Service импортирует postgres
- Тесты с testcontainers
- Замена БД = переписать сервис
Hexagonal:
- Service импортирует только свои порты
- Тесты с fake/mock
- Замена БД = новый адаптер, одна строка в main
Hexagonal + DDD:
- entities с инкапсулированной логикой
- value objects
- domain events
Onion / Clean:
- Hexagonal + явные слои внутри домена (см. след. темы)
Чек-лист ревью
-
domain/не импортирует ни одного внешнего пакета (postgres, http, kafka) - Интерфейсы (порты) объявлены в пакете, который их использует
- Имена портов в терминах бизнеса, не технологий (
OrderRepo, неSQLOrderTable) - Порт не тащит инфраструктурные типы (
*sql.Tx,*kafka.Message) - На каждый порт есть хотя бы один fake или mock для тестов
- Сборка зависимостей только в
main.go(composition root) - Адаптеры маппят инфраструктурные модели в доменные на границе
- Один use case = один публичный метод сервиса (или driving port)
Вопросы для интервью
Q: Что такое Hexagonal Architecture? A: Архитектурный стиль, где бизнес-логика в центре, а внешний мир (БД, HTTP, очереди) — через порты (интерфейсы) и адаптеры (реализации). Зависимости направлены внутрь, к домену. Автор — Alistair Cockburn, 2005.
Q: Почему "шестиугольник", а не круг? A: Чтобы показать симметрию сторон. Никакой источник вызова (UI, очередь, тест) не важнее другого. Шестиугольник — иллюстрация, не смысл. Главное — порты и адаптеры.
Q: Чем primary порт отличается от secondary? A: Primary (driving) — кто зовёт домен снаружи (HTTP, CLI). Secondary (driven) — кого зовёт домен наружу (БД, email). Адаптеры primary дёргают домен, адаптеры secondary вызываются доменом.
Q: Где в Go объявлять интерфейс — у consumer или у provider?
A: У consumer. Сервис объявляет, что ему нужно (OrderRepo). Адаптер просто реализует методы — без implements. Это инверсия зависимости: postgres знает про domain, а не наоборот.
Q: Любой интерфейс это порт?
A: Нет. Порт — бизнесовый интерфейс в терминах домена (OrderRepo.FindActiveByUser). Обёртка над технологией (SQLClient.Exec) — не порт, это утечка инфраструктуры в ядро.
Q: Как тестировать сервис в Hexagonal? A: Подменяя адаптеры fake-реализациями. Никакого docker, testcontainers, sqlmock — простая структура с памятью реализует порт. Проверяешь логику сервиса в изоляции от инфраструктуры.
Q: Что если нужно поддерживать и Postgres, и Mongo? A: Пишешь два адаптера на один порт. В main выбираешь нужный по конфигу. Domain не знает о различиях.
Q: Чем Hexagonal отличается от Layered? A: В Layered зависимости сверху вниз, бизнес-логика зависит от БД. В Hexagonal зависимости направлены внутрь, домен определяет интерфейсы — инфраструктура их реализует. Это инверсия (DIP).
Q: Чем Hexagonal отличается от Clean? A: Hexagonal фокусируется на портах и адаптерах (как общается наружу). Clean добавляет явные слои внутри (Entities, Use Cases, Interface Adapters, Frameworks) и Dependency Rule между ними. Это одна идея, разные акценты.
Q: Когда не надо Hexagonal? A: Простой CRUD без логики, прототип, маленький сервис на 1-2 эндпоинта. Цена структуры превысит профит. Layered будет проще.
Практика
Где в Go чаще всего объявляют интерфейс-порт для репозитория?
Что выведет этот код?
package main
import (
"fmt"
"strings"
)
func portNameKind(name string) string {
if strings.Contains(strings.ToLower(name), "postgres") {
return "technology"
}
return "business"
}
func main() {
fmt.Println(portNameKind("OrderRepository"))
fmt.Println(portNameKind("PostgresOrderTable"))
}
Реализуй CoreImportReview: ядро приложения не должно импортировать инфраструктурные пакеты.