← назад к разделу

Когда сервис идёт за данными в базу или вызывает внешний 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>RepositoryOrderRepository
Внешняя HTTP-система<Y>PortPaymentPort, SmsPort
Прямая публикация событий<Z>EventPublisherOrderEventPublisher

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, &notFound) {
        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.