Hexagonal Architecture (Ports & Adapters)

Суть

Бизнес-логика в центре. Снаружи — порты (интерфейсы) и адаптеры (реализации). Бизнес-логика не знает, с какой БД и через какой транспорт она работает. Автор — Alistair Cockburn (2005). Шестигранник нарисован, чтобы показать: сторон много, никакая не главная.

text
┌──────────────┐ HTTP ────►│ │◄──── CLI │ Domain │ Kafka ────►│ + Use │◄──── gRPC │ Cases │ │ │─────► Postgres │ │─────► Redis └──────────────┘─────► Kafka producer ▲ │ (driving) (driven)

Слева — кто дёргает систему (primary / driving). Справа — кого дёргает система (secondary / driven).

Задача и подход

Проблема, которую решает

go
// 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

go
// 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
text
Driving adapter ──► Driving port ──► Domain ──► Driven port ──► Driven adapter (HTTP) (UseCase iface) (logic) (Repo iface) (Postgres)

Главное правило: зависимости направлены внутрь, к домену.

Структура проекта

text
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, разные клиенты).

go
// domain/ports.go type OrderRepo interface { Save(ctx context.Context, o Order) error FindByID(ctx context.Context, id string) (Order, error) }
go
// 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:

go
var repo domain.OrderRepo switch cfg.Storage { case "postgres": repo = postgres.NewOrderRepo(db) case "mongo": repo = mongo.NewOrderRepo(coll) } svc := domain.NewOrderService(repo)

Domain не меняется ни на букву.

Практические решения

Тестируемость

Главный профит. Тесты пишутся без инфраструктуры.

go
// 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 / реальной БД.

Как правильно выделять порты

Главная ошибка: считать, что любой интерфейс — это порт. Это не так.

Порт — это бизнесовый запрос наружу, выраженный в терминах домена.

Плохо (порт-обёртка над технологией):

go
type SQLClient interface { Exec(query string, args ...any) (Result, error) Query(query string, args ...any) (Rows, error) }

Это не порт, это обёртка над database/sql. Бизнес-логика не должна знать про SQL.

Хорошо (порт в терминах домена):

go
type OrderRepo interface { Save(ctx context.Context, o Order) error FindActiveByUser(ctx context.Context, userID string) ([]Order, error) }

Это формулировка от лица бизнеса: "сохранить заказ", "найти активные заказы пользователя". Реализация — забота адаптера.

Правило большого пальца: имя метода порта понятно продукт-менеджеру, не девопсу.

Где объявлять интерфейс

В Go принято: интерфейс там, где его используют (consumer-side interface), не там где реализуют.

go
// domain/ports.go — интерфейс рядом с use case package domain type OrderRepo interface { ... }
go
// 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 будет проще.


Практика

Quiz+10 XP

Где в Go чаще всего объявляют интерфейс-порт для репозитория?

  • В adapter/postgres, потому что там реализация
  • В пакете consumer-а, который использует зависимость
  • В отдельном глобальном пакете interfaces
  • В main.go, чтобы все слои его видели
Predict+15 XP

Что выведет этот код?

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")) }
Задача+20 XP

Реализуй CoreImportReview: ядро приложения не должно импортировать инфраструктурные пакеты.