ФАЗА 26. АРХИТЕКТУРНЫЕ ПАТТЕРНЫ Ports

Обязательно

Как ядро общается с внешним миром

Interfaces · Domain types · Compile-time checks

Порт принадлежит потребителю операции и описывает её без инфраструктурных типов.

1. Inbound port описывает use case

1 / 5

PayOrder принимает команду и возвращает результат или доменную ошибку. HTTP handler и тест используют один контракт.

  • Операция одна и явная.
  • Нет http.Request в сигнатуре.
  • Ответ имеет доменный тип.
type PayOrderUseCase interface {Execute(ctx, cmd PayOrderCommand) (Receipt, error)}Контракт не содержит HTTP или SQL

2. Outbound port описывает потребность

2 / 5

OrderRepository сохраняет и загружает Order, PaymentGateway проводит оплату. Интерфейс принадлежит use case.

  • Только необходимые методы.
  • Доменные аргументы и ошибки.
  • Нет зависимости от реализации.
type OrderRepository interface {ByID(ctx, id string) (Order, error)Save(ctx, order Order) error}Order из домена, не sqlc.Row

3. Доменный тип не протекает наружу

3 / 5

Порт не возвращает pgx.Row или JSON. Адаптер сам переводит хранимую структуру в модель и обратно.

  • DTO остаётся снаружи.
  • Изменение схемы локально.
  • Ядро не импортирует адаптер.
External rowsqlc / JSONAdapter mapperconvert formatDomain Ordercore type

4. Компилятор проверяет реализацию

4 / 5

var _ OrderRepository = (*PostgresRepository)(nil) фиксирует соответствие метода и типов. Несовместимость обнаруживается на сборке.

  • Сигнатура проверяется.
  • Нет ручного подтверждения.
  • Тестовый fake проверяется так же.
var _ OrderRepository = (*PostgresRepository)(nil)Compile-time contract checkimplementation matches port

5. Порт маленький и предметный

5 / 5

Интерфейс не копирует все методы pgxpool или внешнего SDK. Он содержит только действия, нужные этому use case.

  • Минимум методов.
  • Доменный vocabulary.
  • Новая операция добавляется по необходимости.
Useful portByID + Savedomain-specificWide portall pgxpool methodshidden dependency

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>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"

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