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

Когда сервис обращается к базе данных, платёжной системе или отправляет событие в Kafka — он делает это через out-адаптеры. Это «выходная» часть гексагональной архитектуры: ядро вызывает интерфейс (порт), а адаптер переводит этот вызов в конкретный технический протокол.

Разберём, как выглядит out-адаптер на Go, зачем разделять адаптеры по системам и как правильно обрабатывать ошибки.

Почему один большой «исходящий адаптер» — плохая идея

Если сложить всё в один пакет adapter/out/ — базу, платёжный шлюз, Kafka — получится каша зависимостей. Сбой одной системы начнёт влиять на другие. Тест базы данных потянет за собой мок платёжного API. Смена SMS-провайдера потребует лезть в общий файл.

Правило простое: на каждую внешнюю систему — отдельный пакет.

internal/
  adapter/out/
    persistence/       # база данных через sqlc + pgx
    sber/              # платёжный шлюз Sber REST API
    odna_kassa/        # альтернативный платёжный провайдер
    kafka/             # публикация событий
    s3/                # хранилище файлов

Что это даёт:

  • adapter/out/sber/ импортирует только net/http и Sber-DTO. adapter/out/persistence/ — только pgx и sqlc. Замена одного провайдера не затрагивает другие.
  • Для каждого адаптера поднимается свой тестовый сервер или контейнер — не один «мок на всё».
  • Метрики и логи пишутся с атрибутом системы (system=sber, system=persistence) — сразу видно, кто замедлился.

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

Ядро сервиса не знает, как именно работает платёжная система. Оно знает только интерфейс — что можно сделать и какие данные получить:

// 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/ реализует этот интерфейс:

// adapter/out/sber/payment_adapter.go
package sber

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

var _ out.PaymentPort = (*PaymentAdapter)(nil) // проверка на этапе компиляции

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-идиома. Если адаптер не реализует все методы интерфейса, компилятор сразу покажет ошибку. Не нужно ждать тестов.

Аналогично устроен адаптер к базе данных через sqlc:

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

type OrderRepository struct {
    q   *db.Queries   // сгенерировано sqlc
    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 nil, &out.OrderNotFoundError{OrderID: id}
        }
        return nil, fmt.Errorf("persistence: find order %s: %w", id, err)
    }
    o := OrderMapper{}.ToDomain(row)
    return &o, 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-сгенерированные типы. Они живут только внутри adapter/out/persistence/ и никогда не попадают в core/.

Mapper: перевод между доменом и внешним форматом

Адаптер получает доменные объекты — RegisterPaymentCommand с Money, OrderID, CustomerID. Внешняя система ожидает свой формат — SberRegisterRequest с суммой в копейках, числовыми кодами, специфичными полями.

Эту трансляцию делает mapper — отдельная структура в пакете адаптера:

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

Для адаптера к базе данных mapper переводит между domain-агрегатом и sqlc-структурами:

// 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(),
    }
}

Все особенности конкретной системы — копейки вместо рублей, числовые статусы, форматы дат — остаются внутри адаптера и не утекают в ядро.

Ошибки в out-адаптерах

Ошибки организованы в два слоя. В core/ объявлены доменные типы ошибок, которые знает ядро:

// core/order/port/out/errors.go
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 }

type OrderNotFoundError struct {
    OrderID OrderID
}

func (e *OrderNotFoundError) Error() string { return "order not found: " + string(e.OrderID) }

В адаптере объявлен свой системный тип ошибки:

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

Хендлер в core/ использует errors.As по типам из core/ — он не знает о SberError и не должен:

// core/order/usecase/confirm_order.go
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) error {
    order, err := h.orders.FindByID(ctx, cmd.OrderID)
    if err != nil {
        return fmt.Errorf("confirm order: load order %s: %w", cmd.OrderID, err)
    }
    result, err := h.payments.Register(ctx, out.RegisterPaymentCommand{
        OrderID: cmd.OrderID,
        Amount:  order.Total(),
    })
    if err != nil {
        return fmt.Errorf("confirm order: register payment: %w", err)
    }
    _ = result
    return nil
}

Если завтра Sber сменится на другого провайдера, хендлер не изменится — только адаптер.

Частая ошибка: бизнес-логика в адаптере

Адаптер транслирует и вызывает — не решает. Решения принимает хендлер в core/.

Плохой пример:

// так делать не нужно — бизнес-логика в адаптере
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
    if cmd.Amount.Kopecks() > 10_000_000 { // проверка суммы — это бизнес-правило
        return out.RegisterPaymentResult{}, 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) // вызов другого адаптера из адаптера
    }
    return a.mapper.ToDomainResult(resp), nil
}

Правильный вариант:

// адаптер просто транслирует
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
}

Проверки суммы и реакция на статус ответа — это ответственность хендлера. Адаптер возвращает результат или ошибку, хендлер решает, что с этим делать.

Ещё одна частая ошибка — один «универсальный» адаптер, который реализует несколько несвязанных портов: PaymentPort, SmsPort, StoragePort. Смешивать их нельзя — это три отдельных пакета с отдельными клиентами и своими тестами.

Если хендлеру нужно вызвать два адаптера в рамках одного use case — он инжектирует оба порта сам и координирует их. Адаптеры друг о друге не знают.

Коротко

  • На каждую внешнюю систему — отдельный пакет adapter/out/<system>/. Один пакет знает только свою инфраструктуру.
  • Адаптер реализует interface из core/<bc>/port/out/. Проверка на этапе компиляции: var _ out.PaymentPort = (*PaymentAdapter)(nil).
  • Mapper — отдельная структура в пакете адаптера. Переводит domain-объекты во внешний формат и обратно. Детали системы (копейки, коды статусов) не утекают в core/.
  • Ошибки в двух слоях: системный тип в адаптере, доменный тип в core/. Хендлер работает только с типами из core/.
  • Бизнес-логика в адаптере — ошибка проектирования. Адаптер транслирует, хендлер решает.
  • Координация нескольких адаптеров — ответственность хендлера, не адаптеров.

Что почитать дальше