Опирается на правила: R-HEX-PORT-1R-HEX-PORT-4 и R-HEX-PORT-X1R-HEX-PORT-X4 из Hexagonal Style Guide → раздел 4. Ports.

Важно знать

  • Outbound port — interface в core/<bc>/port/out/. Описывает, что core нужно от внешнего мира.
  • Имена: <X>Repository (persistence), <Y>Port (внешние HTTP-системы), <Z>EventPublisher (события без outbox).
  • Методы port'а оперируют domain-типами (Money, OrderID), не DTO внешней системы (SberRegisterRequest).
  • Port-ошибки объявлены в core/ как типизированные структуры с apperr.Kind. Подтипы систем — в адаптерах.
  • Inbound port = UseCase Handler. Отдельный interface не нужен — handler вызывается напрямую из chi-хэндлера.
  • Port — всегда interface. Структура убивает testability: нечем подменить без поднятия базы или внешнего HTTP.
  • Compile-time assertion var _ out.PaymentPort = (*PaymentAdapter)(nil) обязательна в каждом out-adapter'е.
  • errors.As в handler'е ловит port-ошибку (*out.PaymentPortError), не системную (*SberError).

В Hexagonal архитектуре стрелка зависимостей: bootstrap → adapter/* → core. Core ходит в БД и во внешние системы через port-интерфейсы — описывает, что нужно, не как. Реализацию (адаптер) подкладывает bootstrap/main.go через конструкторы. В Go нет DI-контейнера и аннотаций — wiring явный и читаемый.

Где живёт port и как он называется

R-HEX-PORT-1: outbound port — interface в core/<bc>/port/out/.

internal/core/order/
  aggregate/      order.go
  value_object/   money.go
  event/          order_confirmed.go
  port/out/
    order_repo.go      # OrderRepository — persistence агрегата
    payment_port.go    # PaymentPort — внешняя платёжная система
    errors.go          # PaymentPortError — port-ошибки
  usecase/
    confirm_order.go   # ConfirmOrderCommand + ConfirmOrderHandler

Конвенция имён:

Тип port'аИмяНазначение
Persistence write<X>RepositoryCRUD агрегата (OrderRepository)
Внешняя HTTP-система<Y>PortPaymentPort, SmsPort, StoragePort
Исходящие события (без outbox)<Z>EventPublisherПрямая публикация, если нет outbox-relay

<X>Repository без суффикса Port — историческая конвенция DDD; «repository» самодокументирующееся имя. Для внешних систем суффикс Port явно сигнализирует: это контракт к инфраструктуре.

Объявление outbound port

R-HEX-PORT-1, R-HEX-PORT-2: методы принимают и возвращают domain-типы.

// 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'ы из core/. SberRegisterRequest — деталь адаптера, в port она не появляется.

Аналогично для persistence:

// 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-ошибки — типизированные структуры с apperr.Kind

R-HEX-PORT-3: ошибки объявлены в core/, подтипы систем — в адаптере.

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

import "your-module/internal/core/apperr"

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 }
func (e *SberError) Kind() apperr.Kind   { return apperr.Integration }

Handler в core ловит *out.PaymentPortError, не *sber.SberError:

// internal/core/order/usecase/confirm_order.go
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
}

Если завтра SberAdapter заменяется на OdnaKassaAdapter — handler не меняется. Меняется только конкретный тип ошибки в адаптере.

Отсутствие значения = ошибка, не bool

R-HEX-PORT-X3: возврат (Order, bool) из port-метода, где false = «не найдено» — антипаттерн.

// AVOID
func (r *OrderRepo) FindByID(ctx context.Context, id OrderID) (*aggregate.Order, bool, error)

// PREFER: отсутствие — domain-ошибка
func (r *OrderRepo) FindByID(ctx context.Context, id OrderID) (*aggregate.Order, error)

Handler сам решает, как трактовать OrderNotFoundError:

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 на edge
    }
    return fmt.Errorf("load order %s: %w", cmd.OrderID, err)
}

(value, bool) — допустим для query-кейсов, где false = нормальная ветка, не ошибка (например, опциональная настройка). В command-handler'е отсутствие агрегата — всегда ошибка.

Inbound port = UseCase Handler

R-HEX-PORT-4: отдельный «InboundPort» interface не нужен. UseCase Handler — это и есть вход в core/. Chi-хэндлер в adapter/in/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)
}

Chi-хэндлер принимает *ConfirmOrderHandler в конструкторе и зовёт .Handle(r.Context(), cmd). Никакого дополнительного интерфейса между ними нет.

Compile-time assertion в out-adapter

R-HEX-AOUT-2 (поддержка): каждый out-adapter обязан иметь compile-time assertion:

// 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: 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: err}
    }
    return nil
}

var _ out.PaymentPort = (*PaymentAdapter)(nil) — если PaymentAdapter перестаёт удовлетворять интерфейсу (добавили метод в port, забыли реализовать в адаптере), компилятор ловит это немедленно.

Что запрещено

АнтипаттернПравилоЧто взамен
PaymentPort объявлен в adapter/out/sber/R-HEX-PORT-X1Interface в core/<bc>/port/out/; адаптер — реализация
Register(ctx, SberRegisterRequest) в сигнатуре portR-HEX-PORT-X2Domain-тип RegisterPaymentCommand; маппинг — в адаптере
FindByID возвращает (*Order, bool, error)R-HEX-PORT-X3(*Order, error) + OrderNotFoundError с apperr.Kind
type PaymentPort struct{ ... } (структура)R-HEX-PORT-X4type PaymentPort interface{ ... }
Импорт adapter/out/sber/ в core/R-HEX-CORE-X1core видит только out.PaymentPort; адаптер — в bootstrap/
init() создаёт соединение в adapter/out/persistence/Конструктор NewOrderRepository(db *pgxpool.Pool); pool — в bootstrap/

Куда дальше

  • Adapters out — кто реализует port-интерфейс: структура PaymentAdapter, маппер, compile-time assertion.
  • Adapters in — как chi-хэндлер передаёт управление в core/ через UseCase Handler.
  • Architecture Tests — packages.Load + forbidden-imports тест, который ловит нарушения в CI.
  • Bootstrap / Composition Root — как bootstrap/main.go собирает адаптеры и UseCase Handler'ы вместе.
  • Core Layer — структура core/<bc>/, rich aggregate, domain-ошибки.
  • Module Structure — пакеты, стрелка зависимостей, enforcement через архитектурный тест.
  • When to Use Hexagonal — признаки «пора» и «рано» для Go-сервиса.