Когда сервис идёт за данными в базу или вызывает внешний 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"
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 }
Handler в ядре ловит *out.PaymentPortError через errors.As, а не *sber.SberError. Это важно: если завтра Сбер заменится на другую систему, handler не меняется.
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'е отсутствие агрегата — всегда ошибка.
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{}, &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 &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/.
Коротко
- 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 проверка совместимости.
Что почитать дальше
- Adapters out — кто реализует port-интерфейс: структура адаптера, маппер, обработка ошибок.
- Adapters in — как HTTP-обработчик передаёт управление в ядро через UseCase Handler.
- Core Layer — структура ядра: агрегат, value object'ы, domain-события.
- Bootstrap / Composition Root — как
main.goсобирает адаптеры и Handler'ы вместе. - Module Structure — пакеты, стрелка зависимостей, как её проверить в CI.