Ports в Hexagonal Architecture — как ядро общается с внешним миром (Go)
Когда сервис идёт за данными в базу или вызывает внешний API, самое простое — написать этот вызов прямо в бизнес-логику. Поначалу удобно, но потом при тестировании приходится поднимать базу или мокировать HTTP-клиент, а замена одной платёжной системы на другую требует переписывать логику, которая вообще-то должна быть к этому безразлична.
Hexagonal Architecture решает это через port'ы — интерфейсы, которые описывают, что нужно ядру от внешнего мира, не говоря, как именно это получить. Реальные реализации — адаптеры — подключаются снаружи. Ядро ничего о них не знает.
Где живёт port и как он называется
Port — это interface в core/<bc>/port/out/. Именно в core/, не в адаптере. Ядро само объявляет контракт, который ему нужен, а адаптер приходит и говорит: «я умею это делать».
Структура выглядит так:
internal/core/order/
aggregate/ order.go
value_object/ money.go
event/ order_confirmed.go
port/out/
order_repo.go # OrderRepository — работа с агрегатом в БД
payment_port.go # PaymentPort — внешняя платёжная система
errors.go # ошибки port'ов
usecase/
confirm_order.go # ConfirmOrderCommand + ConfirmOrderHandler
Соглашения по именам:
| Тип | Имя | Пример |
|---|---|---|
| Работа с агрегатом в БД | <X>Repository | OrderRepository |
| Внешняя HTTP-система | <Y>Port | PaymentPort, SmsPort |
| Прямая публикация событий | <Z>EventPublisher | OrderEventPublisher |
Repository — историческое имя из DDD, оно само объясняет себя. Суффикс Port для внешних систем явно показывает: это граница к инфраструктуре.
Как объявить outbound port
Методы port'а принимают и возвращают domain-типы из core/. Никаких структур конкретных внешних систем в сигнатуре быть не должно — это детали адаптера.
// internal/core/order/port/out/payment_port.go
package out
import (
"context"
"time"
)
type PaymentPort interface {
Register(ctx context.Context, cmd RegisterPaymentCommand) (RegisterPaymentResult, error)
Cancel(ctx context.Context, paymentID PaymentID) error
}
type RegisterPaymentCommand struct {
OrderID OrderID
CustomerID CustomerID
Amount Money
}
type RegisterPaymentResult struct {
PaymentID PaymentID
ConfirmedAt time.Time
}
Money и OrderID — domain value object'ы. Структура SberRegisterRequest (конкретный формат запроса к Сберу) — деталь адаптера, в port она не появляется никогда.
Для работы с базой данных аналогично:
// internal/core/order/port/out/order_repo.go
package out
import (
"context"
"your-module/internal/core/order/aggregate"
)
type OrderRepository interface {
FindByID(ctx context.Context, id OrderID) (*aggregate.Order, error)
Save(ctx context.Context, order *aggregate.Order) error
}
Ошибки port'ов — типизированные структуры
Ошибки объявляются в core/, рядом с самим port'ом. Адаптеры могут добавлять свои типы ошибок, но ядро о них не знает.
// internal/core/order/port/out/errors.go
package out
import "your-module/internal/core/apperr"
type PaymentPortError struct {
Op string
Err error
}
func (e *PaymentPortError) Error() string { return "payment port: " + e.Op + ": " + e.Err.Error() }
func (e *PaymentPortError) Unwrap() error { return e.Err }
func (e *PaymentPortError) Kind() apperr.Kind { return apperr.Integration }
type OrderNotFoundError struct {
OrderID OrderID
}
func (e *OrderNotFoundError) Error() string { return "order not found: " + string(e.OrderID) }
func (e *OrderNotFoundError) Kind() apperr.Kind { return apperr.Domain }
Адаптер может добавить свой тип ошибки с деталями конкретной системы:
// internal/adapter/out/sber/errors.go
package sber
type SberError struct {
Op string
Err error
}
func (e *SberError) Error() string { return "sber: " + e.Op + ": " + e.Err.Error() }
func (e *SberError) Unwrap() error { return e.Err }
Адаптер заворачивает свою ошибку в порт-ошибку: снаружи *out.PaymentPortError, внутри через Unwrap — *sber.SberError с деталями. Handler в ядре ловит *out.PaymentPortError через errors.As, а не *sber.SberError. Это важно: если завтра Сбер заменится на другую систему, handler не меняется. Верни адаптер голый SberError — и errors.As в ядре не сработает никогда.
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
_, err := h.payments.Register(ctx, out.RegisterPaymentCommand{
OrderID: cmd.OrderID,
Amount: order.Total(),
})
if err != nil {
var portErr *out.PaymentPortError
if errors.As(err, &portErr) {
return fmt.Errorf("payment unavailable for order %s: %w", cmd.OrderID, err)
}
return err
}
return nil
}
«Не найдено» — это ошибка, не bool
Частая ошибка при проектировании port'ов — возвращать (value, bool, error), где false означает «не нашли»:
// не стоит так делать
FindByID(ctx context.Context, id OrderID) (*aggregate.Order, bool, error)
Лучше — возвращать error с типизированной ошибкой OrderNotFoundError:
// правильно
FindByID(ctx context.Context, id OrderID) (*aggregate.Order, error)
Handler сам решает, что делать с «не найдено»:
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
var notFound *out.OrderNotFoundError
if errors.As(err, ¬Found) {
return fmt.Errorf("confirm order: %w", err) // превратится в 404 на краю
}
return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
Пара (value, bool) уместна в редких случаях, когда false — нормальная ветка, не ошибка: например, опциональная настройка пользователя. В command-handler'е отсутствие агрегата — всегда ошибка.
Размер порта: потребность, а не возможности
Самая частая ошибка новичка это толстый порт, повторяющий возможности внешней системы: двадцать методов, потому что «у базы есть двадцать запросов» или «у платёжного шлюза двадцать ручек». Такой интерфейс ничего не изолирует: ядро зависит от всей чужой поверхности, и замена адаптера означает реализацию двадцати методов, из которых используются четыре.
Правило: порт описывает потребность ядра, а не возможности системы. Пишется он от сценариев: какие вопросы ядро задаёт и какие действия просит выполнить. Если метод не вызывается ни одним сценарием, его в порту нет.
// плохо: порт повторяет возможности платёжного шлюза
type PaymentPort interface {
Charge(ctx context.Context, req ChargeRequest) (PaymentResult, error)
Refund(ctx context.Context, req RefundRequest) (RefundResult, error)
Status(ctx context.Context, id PaymentID) (PaymentStatus, error)
Search(ctx context.Context, f PaymentFilter) ([]Payment, error) // нужен только отчёту
UpdateCustomerCard(ctx context.Context, c CustomerID, card Card) error // вообще другой сценарий
Receipt(ctx context.Context, id PaymentID) (Receipt, error) // нужен только уведомлениям
// ещё четырнадцать методов «на всякий случай»
}
Пример:
// хорошо: три порта по трём потребностям, у каждого свой вызывающий
type Charger interface { // нужен сценарию оплаты
Charge(ctx context.Context, req ChargeRequest) (PaymentResult, error)
}
type Refunder interface { // нужен сценарию возврата
Refund(ctx context.Context, req RefundRequest) (RefundResult, error)
}
type PaymentStatusReader interface { // нужен сверке и поддержке
Status(ctx context.Context, id PaymentID) (PaymentStatus, error)
}
Реализация у всех трёх может быть одна и та же: один адаптер реализует три интерфейса, если все три ходят в один шлюз. Разделение не про число адаптеров, а про то, что каждый сценарий видит только то, что ему нужно.
Признаки, что порт пора разрезать: методов больше пяти-шести и они относятся к разным сценариям; ни один вызывающий не использует больше двух методов; тест сценария вынужден заглушать методы, которые сценарий не вызывает; порт растёт при каждой новой задаче. Обратная крайность тоже встречается: порт на один метод для каждой мелочи это шум. Разумная гранулярность: порт на связную потребность, обычно два-четыре метода. Ориентир простой: если два метода почти всегда вызываются одним и тем же сценарием, им место в одном порту.
Inbound port — это UseCase Handler
В Hexagonal Architecture есть и «входные» point'ы — inbound ports. В Go отдельный InboundPort interface не нужен. UseCase Handler и есть вход в ядро. HTTP-обработчик получает *usecase.ConfirmOrderHandler через конструктор и вызывает .Handle() напрямую.
// internal/core/order/usecase/confirm_order.go
package usecase
type ConfirmOrderCommand struct {
OrderID aggregate.OrderID
PaymentRef string
}
type ConfirmOrderHandler struct {
orders out.OrderRepository
payments out.PaymentPort
}
func NewConfirmOrderHandler(orders out.OrderRepository, payments out.PaymentPort) *ConfirmOrderHandler {
return &ConfirmOrderHandler{orders: orders, payments: payments}
}
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
order, err := h.orders.FindByID(ctx, cmd.OrderID)
if err != nil {
return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}
result, err := h.payments.Register(ctx, out.RegisterPaymentCommand{
OrderID: cmd.OrderID,
CustomerID: order.CustomerID(),
Amount: order.Total(),
})
if err != nil {
return fmt.Errorf("register payment: %w", err)
}
if err := order.Confirm(aggregate.PaymentResult{
PaymentID: result.PaymentID,
Amount: order.Total(),
}); err != nil {
return err
}
return h.orders.Save(ctx, order)
}
Никакого дополнительного интерфейса между HTTP-обработчиком и Handler'ом нет — это лишний слой без пользы.
Проверка совместимости на этапе компиляции
Каждый out-адаптер должен явно объявить, что реализует нужный интерфейс:
// internal/adapter/out/sber/payment_adapter.go
package sber
var _ out.PaymentPort = (*PaymentAdapter)(nil)
type PaymentAdapter struct {
client *Client
mapper PaymentMapper
}
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
sberReq := a.mapper.ToSberRequest(cmd)
resp, err := a.client.RegisterPayment(ctx, sberReq)
if err != nil {
return out.RegisterPaymentResult{}, &out.PaymentPortError{Op: "register", Err: &SberError{Op: "register", Err: err}}
}
return a.mapper.ToDomainResult(resp), nil
}
func (a *PaymentAdapter) Cancel(ctx context.Context, paymentID out.PaymentID) error {
if err := a.client.CancelPayment(ctx, string(paymentID)); err != nil {
return &out.PaymentPortError{Op: "cancel", Err: &SberError{Op: "cancel", Err: err}}
}
return nil
}
Строка var _ out.PaymentPort = (*PaymentAdapter)(nil) — это compile-time проверка. Если добавить метод в port и забыть реализовать его в адаптере, компилятор сразу сообщит об ошибке. Без этой строки несоответствие можно не заметить до запуска.
Типичные ошибки
Port объявлен в адаптере, а не в core. Тогда ядро зависит от инфраструктуры — всё наоборот. Interface должен быть в core/<bc>/port/out/, адаптер — его реализация.
В сигнатуре port'а — тип конкретной системы. Register(ctx, SberRegisterRequest) делает port неотделимым от Сбера. Нужен domain-тип RegisterPaymentCommand; маппинг в формат Сбера — задача адаптера.
Port объявлен как структура, а не интерфейс. Структуру нельзя подменить в тесте без реального подключения. Только interface даёт возможность тестировать ядро изолированно.
Импорт адаптера из ядра. import "adapter/out/sber" в core/ — это нарушение изоляции. Ядро видит только port-интерфейс; адаптер подключается в bootstrap/.
Глубже: порт в тестах: поддельная реализация, заглушка или мокрасширенное
То, ради чего порт и вводят, и то, о чём чаще всего не договариваются. Три способа, и у каждого своё место.
Поддельная реализация в памяти это предпочтительный способ для репозиториев: маленький тип на карте, который честно реализует интерфейс, пишется один раз и переиспользуется во всех тестах ядра.
// internal/core/order/usecase/inmemory_test.go
type inMemoryOrders struct {
store map[OrderID]*aggregate.Order
}
func newInMemoryOrders() *inMemoryOrders { return &inMemoryOrders{store: map[OrderID]*aggregate.Order{}} }
func (r *inMemoryOrders) FindByID(ctx context.Context, id OrderID) (*aggregate.Order, error) {
order, ok := r.store[id]
if !ok {
return nil, &out.OrderNotFoundError{ID: id}
}
return order, nil
}
func (r *inMemoryOrders) Save(ctx context.Context, order *aggregate.Order) error {
r.store[order.ID] = order
return nil
}
Почему это лучше мока: тест читается как сценарий («сохранили заказ, выполнили команду, проверили состояние»), а не как список настроенных вызовов; тесты не ломаются при изменении способа сохранения; одна поддельная реализация обслуживает десятки тестов. Заглушка с заданным ответом подходит для внешних систем: порт платёжного шлюза в тесте отвечает «одобрено» или «отказано». Мок с проверкой вызова оставляют для случая, когда сам вызов и есть результат: письмо отправлено, событие опубликовано.
Поддельные реализации живут в тестовых файлах пакета ядра (_test.go), а не в основном коде и не в пакете адаптера. И один тест стоит написать на саму подделку: набор проверок контракта порта, который запускают и против подделки, и против настоящего адаптера с контейнером. Так тесты ядра не врут.
Глубже: когда входному порту всё же нужен интерфейсрасширенное
«Inbound port — это сам *usecase.ConfirmOrderHandler» — правило по умолчанию, но у него есть цена: HTTP-адаптер зависит от конкретного типа, и чтобы протестировать обработчик через httptest, приходится собирать настоящий use case с заглушками его портов. Когда это неудобно, интерфейс объявляют по общему правилу Go — на стороне потребителя: в пакете adapter/in/http/user заводят type orderConfirmer interface { Handle(ctx context.Context, cmd usecase.ConfirmOrderCommand) error } и принимают его в конструктор. Ядро об этом интерфейсе не знает, лишнего слоя в core/ не появляется, а тест адаптера подменяет use case одной структурой.
Коротко
- Port — это
interfaceвcore/<bc>/port/out/. Ядро объявляет контракт, адаптер его реализует. - Методы port'а работают с domain-типами (
Money,OrderID), а не с типами конкретных систем. - Ошибки port'ов объявлены в
core/. Handler ловит port-ошибку черезerrors.As, не системную. - «Не найдено» — это
errorс типизированной структурой, а не(value, bool, error). - Inbound port в Go — просто UseCase Handler. Отдельный интерфейс для него не нужен.
var _ out.PaymentPort = (*PaymentAdapter)(nil)в каждом адаптере — compile-time проверка совместимости.- Адаптер возвращает порт-ошибку из
core/, а системную (SberError) прячет внутрь черезUnwrap— иначеerrors.Asв ядре не сработает.
Что почитать дальше
- Adapters out — кто реализует port-интерфейс: структура адаптера, маппер, обработка ошибок.
- Adapters in — как HTTP-обработчик передаёт управление в ядро через UseCase Handler.
- Core Layer — структура ядра: агрегат, value object'ы, domain-события.
- Bootstrap / Composition Root — как
main.goсобирает адаптеры и Handler'ы вместе. - Module Structure — пакеты, стрелка зависимостей, как её проверить в CI.