Опирается на правила:
R-HEX-PORT-1…R-HEX-PORT-4иR-HEX-PORT-X1…R-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>Repository | CRUD агрегата (OrderRepository) |
| Внешняя HTTP-система | <Y>Port | PaymentPort, 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, ¬Found) {
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-X1 | Interface в core/<bc>/port/out/; адаптер — реализация |
Register(ctx, SberRegisterRequest) в сигнатуре port | R-HEX-PORT-X2 | Domain-тип RegisterPaymentCommand; маппинг — в адаптере |
FindByID возвращает (*Order, bool, error) | R-HEX-PORT-X3 | (*Order, error) + OrderNotFoundError с apperr.Kind |
type PaymentPort struct{ ... } (структура) | R-HEX-PORT-X4 | type PaymentPort interface{ ... } |
Импорт adapter/out/sber/ в core/ | R-HEX-CORE-X1 | core видит только 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-сервиса.