ФАЗА 26. АРХИТЕКТУРНЫЕ ПАТТЕРНЫ Исходящие адаптеры

SQL, gRPC и fake реализуют один порт

sqlc · External DTO · Error mapping

Адаптер скрывает внешнюю модель и возвращает доменные типы и ошибки.

1. Одна внешняя система - один пакет

1 / 5

PostgresRepository реализует OrderRepository через pgx и sqlc. Другой gateway получает отдельный пакет и не делится DTO с первым.

  • Реализация соответствует порту.
  • Внешняя модель локальна.
  • Ядро не импортирует пакет.
Repository portdomain interfacePostgresRepositorypgx + sqlcPostgreSQLexternal schema

2. Mapper восстанавливает доменную модель

2 / 5

sqlc.Row превращается в Order с value objects и инвариантом. При сохранении модель снова превращается в параметры SQL.

  • Преобразование двустороннее.
  • Доменные типы проверяются.
  • Маппер не меняет поведение.
sqlc.Rowstorage formatAdapter mapperrow <-> orderOrder aggregatedomain format

3. Ошибка драйвера получает доменный смысл

3 / 5

pgx.ErrNoRows становится ErrOrderNotFound, unique violation - Conflict, timeout - Timeout. Домен не видит SQLSTATE.

  • Класс ошибки сохраняется.
  • Причина оборачивается.
  • Контракт не раскрывает драйвер.
pgx errorError mapperclass + causeNotFoundConflictTimeout

4. gRPC adapter переводит protobuf

4 / 5

Внешний request и response маппируются в доменные операции и ошибки. Deadline и metadata пробрасываются без утечки деталей.

  • Protobuf остаётся снаружи.
  • Ошибки gRPC классифицируются.
  • Контракт порта не меняется.
Payment portdomain methodgRPC adapterprotobuf + deadlinePayment APIexternal contract

5. Fake следует контракту порта

5 / 5

In-memory repository возвращает доменные модели и ошибки. Он позволяет проверять use case без SQL и меняется вместе с интерфейсом.

  • Сигнатура совпадает.
  • Состояние контролируется тестом.
  • Нет скрытого production singleton.
Use case testcore onlyFake repositorySame portdomain types

Out-адаптеры в Hexagonal Architecture на Go

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

Когда сервис обращается к базе данных, платёжной системе или отправляет событие в 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{}, &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) — стандартная 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 }

Адаптер возвращает наружу порт-ошибку *out.PaymentPortError, а SberError кладёт внутрь неё через Err — так errors.As по типу из core/ находит её, а детали Сбера остаются доступны через Unwrap тому, кто их захочет. Хендлер в 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 сменится на другого провайдера, хендлер не изменится — только адаптер.

Таймауты, повторы и идемпотентность исходящего вызова

Адаптер это единственное место, которое знает, что за портом сеть. Поэтому именно здесь живут таймаут на вызов, решение о повторе и ключ идемпотентности, а ядро об этом не знает:

// adapter/out/sber/payment_adapter.go
func (a *PaymentAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) {
    ctx, cancel := context.WithTimeout(ctx, a.cfg.CallTimeout) // предел на вызов, не на соединение
    defer cancel()

    req := a.mapper.ToSberRequest(cmd)
    req.IdempotencyKey = cmd.IdempotencyKey.String() // один ключ на все попытки одной команды

    var resp sber.RegisterResponse
    err := a.retry.Do(ctx, func() error { // повторяем только сетевые ошибки и 5xx
        var err error
        resp, err = a.client.RegisterPayment(ctx, req)
        return err
    })
    if err != nil {
        return out.RegisterPaymentResult{}, a.mapError(err)
    }
    return a.mapper.ToResult(resp), nil
}

Три правила, которые здесь зашиты. Таймаут задаёт ядро через конфигурацию адаптера, а не адаптер «по умолчанию». Повтор допустим только с ключом идемпотентности: без него второй вызов после таймаута это второе списание. И повторяют сетевые ошибки и ответы 5xx, а 4xx возвращают сразу, они не пройдут и во второй раз.

Как адаптер тестируется

Адаптер проверяют против поддельного сервера, а не против настоящего Сбера: httptest.Server отвечает заготовленным JSON, тест проверяет, что адаптер собрал правильный запрос и перевёл ответ в доменный результат, включая ошибки:

func TestPaymentAdapter_Register_MapsDeclineToDomainError(t *testing.T) {
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("Idempotency-Key") == "" {
            t.Error("ключ идемпотентности не передан")
        }
        w.WriteHeader(http.StatusUnprocessableEntity)
        _, _ = w.Write([]byte(`{"errorCode":"DECLINED","errorMessage":"insufficient funds"}`))
    }))
    defer srv.Close()

    adapter := sber.NewPaymentAdapter(sber.Config{BaseURL: srv.URL, CallTimeout: time.Second})
    _, err := adapter.Register(context.Background(), registerCommand())

    var declined *out.PaymentDeclinedError
    if !errors.As(err, &declined) {
        t.Fatalf("ожидали PaymentDeclinedError, получили %v", err)
    }
}

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

Адаптер транслирует и вызывает — не решает. Решения принимает хендлер в 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{}, &out.PaymentPortError{Op: "register", Err: &SberError{Op: "register", Err: err}}
    }
    return a.mapper.ToDomainResult(resp), nil
}

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

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

Частая ошибка: один универсальный адаптер

// так делать не нужно: три несвязанные системы в одном типе
type UniversalIntegrationAdapter struct {
    sber *sber.Client
    sms  *smsc.Client
    s3   *s3.Client
}

func (a *UniversalIntegrationAdapter) Register(ctx context.Context, cmd out.RegisterPaymentCommand) (out.RegisterPaymentResult, error) { ... }
func (a *UniversalIntegrationAdapter) Send(ctx context.Context, msg out.SMS) error { ... }
func (a *UniversalIntegrationAdapter) Put(ctx context.Context, obj out.Object) error { ... }

Один тип реализует три порта к трём системам, и любая правка в одной тянет пересборку и перетест остальных, а в тесте сценария оплаты приходится поднимать заглушки СМС и хранилища. Пакет на внешнюю систему, адаптер на порт; общая у них только обвязка (клиент, таймауты, метрики), и её выносят в отдельный пакет, а не в общий тип.

Коротко

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

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