Опирается на правила: R-HEX-AOUT-1R-HEX-AOUT-4 и R-HEX-AOUT-X1R-HEX-AOUT-X4 из Hexagonal Rules → раздел 6. Adapters out.

Важно знать

  • На каждую внешнюю систему — отдельный пакет: adapter/out/persistence/, adapter/out/sber/, adapter/out/odna_kassa/, adapter/out/kafka/.
  • Адаптер реализует interface из core/<bc>/port/out/. Compile-time assertion: var _ out.PaymentPort = (*PaymentAdapter)(nil).
  • В пакете адаптера живёт mapper — переводит RegisterPaymentCommand (domain) в SberRegisterRequest (system-DTO) и обратно.
  • Out-adapter знает свою инфраструктуру: adapter/out/sber/ знает net/http-клиент и Sber-DTO; adapter/out/persistence/ знает pgx и sqlc-generated structs. Адаптеры не видят друг друга.
  • Ошибки — значения: SberError в пакете адаптера оборачивает системную ошибку; handler в core ловит через errors.As по *out.PaymentPortError.
  • Бизнес-логика в out-adapter — запрещена. Адаптер транслирует и вызывает, handler решает.
  • Один пакет — один порт одного домена. Никаких «универсальных» адаптеров через несколько BC.
  • Координация двух адаптеров — это use case в core/: handler инжектит два порта через интерфейсы.

Out-adapter — это «выход» сервиса во внешний мир. Он принимает domain-вызов через port-interface, транслирует в вызов конкретной системы (HTTP к Sber, SQL через sqlc, запись в Kafka), получает ответ, возвращает domain-результат. На этом его ответственность заканчивается.

Per-system пакеты

R-HEX-AOUT-1 — на каждую внешнюю систему свой пакет, не один общий adapter/out/.

internal/
  adapter/out/
    persistence/       # OrderRepository через sqlc + pgx (R-HEX-AOUT)
    sber/              # PaymentPort через Sber REST API
    odna_kassa/        # PaymentPort через OdnaKassa (альтернатива)
    kafka/             # EventPublisher через kafka-go
    s3/                # StoragePort через S3-клиент

Почему важно разделять:

  • Изоляция зависимостей. adapter/out/sber/ импортирует только net/http + Sber-DTO; adapter/out/persistence/ — только pgx + sqlc. Смена SMS-провайдера затрагивает ровно один пакет.
  • Per-system circuit breaker. Resilience настраивается на клиент конкретной системы — сбой Sber не роняет весь исходящий трафик.
  • Тестируемость. httptest.NewServer для Sber поднимается в тестах adapter/out/sber/; контейнер PG — в тестах adapter/out/persistence/. Не один мок-сервер на всё.
  • Наблюдаемость. slog и метрики пишутся с атрибутом системы (system=sber, system=persistence) — гранулярные дашборды без смешивания.

Адаптер реализует port-interface

R-HEX-AOUT-2 — структура адаптера реализует interface, объявленный в core/<bc>/port/out/.

// core/order/port/out/payment_port.go
package out

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
}
// adapter/out/sber/payment_adapter.go
package sber

type PaymentAdapter struct {
    client *Client
    mapper PaymentMapper
    log    *slog.Logger
}

var _ out.PaymentPort = (*PaymentAdapter)(nil) // compile-time assertion

func NewPaymentAdapter(client *Client, log *slog.Logger) *PaymentAdapter {
    return &PaymentAdapter{client: client, log: log}
}

func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
    req := a.mapper.ToSberRequest(cmd)
    resp, err := a.client.RegisterPayment(ctx, req)
    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) — Go-идиома compile-time проверки: если адаптер не реализует все методы интерфейса, сборка упадёт с понятным сообщением.

Аналогично — persistence-адаптер через sqlc:

// adapter/out/persistence/order_repository.go
package persistence

type OrderRepository struct {
    q   *db.Queries   // sqlc-generated
    log *slog.Logger
}

var _ out.OrderRepository = (*OrderRepository)(nil)

func NewOrderRepository(pool *pgxpool.Pool, log *slog.Logger) *OrderRepository {
    return &OrderRepository{q: db.New(pool), log: log}
}

func (r *OrderRepository) FindByID(ctx context.Context, id out.OrderID) (aggregate.Order, error) {
    row, err := r.q.GetOrder(ctx, string(id))
    if err != nil {
        if errors.Is(err, pgx.ErrNoRows) {
            return aggregate.Order{}, &out.OrderNotFoundError{OrderID: id}
        }
        return aggregate.Order{}, fmt.Errorf("persistence: find order %s: %w", id, err)
    }
    return OrderMapper{}.ToDomain(row), nil
}

func (r *OrderRepository) Save(ctx context.Context, order aggregate.Order) error {
    params := OrderMapper{}.ToInsertParams(order)
    if err := r.q.UpsertOrder(ctx, params); err != nil {
        return fmt.Errorf("persistence: save order %s: %w", order.ID(), err)
    }
    return nil
}

db.Queries и db.GetOrderRow — это sqlc-generated типы, они существуют только в пакете adapter/out/persistence/. В core/ они не проникают — это enforced архитектурным тестом.

Mapper в пакете адаптера

R-HEX-AOUT-3 — mapper — отдельная структура в пакете адаптера, переводит между domain (port-сигнатура) и system-DTO.

// adapter/out/sber/payment_mapper.go
package sber

type PaymentMapper struct{}

func (PaymentMapper) ToSberRequest(cmd out.RegisterPaymentCommand) SberRegisterRequest {
    return SberRegisterRequest{
        OrderID:  string(cmd.OrderID),
        Amount:   cmd.Amount.Kopecks(), // Sber принимает копейки
        Currency: "RUB",
        Customer: SberCustomer{
            ID: string(cmd.CustomerID),
        },
    }
}

func (PaymentMapper) ToDomainResult(resp SberRegisterResponse) out.RegisterPaymentResult {
    return out.RegisterPaymentResult{
        PaymentID:   out.PaymentID(resp.PaymentID),
        ConfirmedAt: resp.CreatedAt,
    }
}
// adapter/out/persistence/order_mapper.go
package persistence

type OrderMapper struct{}

func (OrderMapper) ToDomain(row db.GetOrderRow) aggregate.Order {
    return aggregate.Restore(
        aggregate.OrderID(row.ID),
        aggregate.CustomerID(row.CustomerID),
        mapItems(row.Items),
        mapStatus(row.Status),
        value_object.NewMoney(row.TotalAmount, row.Currency),
    )
}

func (OrderMapper) ToInsertParams(o aggregate.Order) db.UpsertOrderParams {
    return db.UpsertOrderParams{
        ID:          string(o.ID()),
        CustomerID:  string(o.CustomerID()),
        Status:      string(o.Status()),
        TotalAmount: o.Total().Amount(),
        Currency:    o.Total().Currency(),
    }
}

Mapper знает специфику системы: копейки против рублей, числовые коды статусов, форматы дат. Всё это — деталь адаптера, не утекает в core/.

Ошибки-значения в out-adapter

Ошибки объявлены в двух слоях:

// core/order/port/out/errors.go — port-ошибка в core
package out

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

Handler в core/ ловит через errors.As по типу из core/, не по SberError:

// core/order/usecase/confirm_order.go
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
    result, 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) {
            slog.Error("payment registration failed", "op", portErr.Op, "err", portErr.Err)
        }
        return fmt.Errorf("confirm order: register payment: %w", err)
    }
    // ...
}

Handler не знает о SberError. Если завтра Sber сменится на OdnaKassa, handler не меняется — только адаптер.

Что adapter знает и не знает

R-HEX-AOUT-4:

ПакетЗнаетНе знает
adapter/out/persistence/pgxpool, pgx, sqlc-generated типы, slogchi, net/http, kafka-go, Sber-DTO
adapter/out/sber/net/http, Sber-DTO, encoding/jsonpgx, sqlc, kafka-go, OdnaKassa-DTO
adapter/out/odna_kassa/net/http, OdnaKassa-DTOpgx, Sber-DTO
adapter/out/kafka/kafka-go, Avro/JSON serializationpgx, net/http, Sber

Это соблюдается конвенцией и проверяется архитектурным тестом: adapter/out/sber/ не должен импортировать adapter/out/persistence/ — и наоборот.

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

АнтипаттернПравилоЧто взамен
Port-метод возвращает SberRegisterResponseR-HEX-AOUT-X1Mapper переводит в RegisterPaymentResult внутри адаптера, наружу — только domain-тип
if sberResp.Code == 4 { sendNotification(...) } в адаптереR-HEX-AOUT-X2Адаптер возвращает ошибку; handler в core интерпретирует через errors.As и сам вызывает нужный порт
UniversalAdapter implements PaymentPort + SmsPort + StoragePortR-HEX-AOUT-X3Три отдельных пакета с отдельными клиентами, circuit breaker'ами, тестами
SberAdapter инжектит OdnaKassaAdapterR-HEX-AOUT-X4Handler в core инжектит оба порта (PaymentPort + RefundPort), координирует через use case

Пример нарушения R-HEX-AOUT-X2 и правильная альтернатива:

// AVOID — бизнес-логика и координация в адаптере
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
    if cmd.Amount.Kopecks() > 10_000_000 { // бизнес-правило в адаптере
        return out.RegisterPaymentResult{}, &out.PaymentPortError{Op: "register", Err: errors.New("amount too large")}
    }
    resp, err := a.client.RegisterPayment(ctx, a.mapper.ToSberRequest(cmd))
    if err != nil {
        return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
    }
    if resp.Status == 4 { // интерпретация результата в адаптере
        a.notifier.SendAlert(ctx, cmd.OrderID) // side-effect через другой адаптер
    }
    return a.mapper.ToDomainResult(resp), nil
}

// PREFER — адаптер транслирует, handler решает
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
    resp, err := a.client.RegisterPayment(ctx, a.mapper.ToSberRequest(cmd))
    if err != nil {
        return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
    }
    return a.mapper.ToDomainResult(resp), nil
}

Куда дальше

  • Ports — что именно реализует out-adapter, как объявить interface и port-ошибки в core/.
  • Adapters in — симметричная сторона: chi-handler → mapper → UseCase.Handle.
  • Core layer — что разрешено и запрещено импортировать в core/<bc>/.
  • Module structure — package-конвенция и архитектурный тест на import-нарушения.
  • Bootstrap / composition root — где создаются адаптеры и как передаются в handler'ы.
  • Architecture tests — packages.Load + forbidden-imports как required CI-check.
  • When to use Hexagonal — когда per-system пакеты оправданы, а когда плоский internal/ достаточен.