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.
Что почитать дальше
- Ports в Hexagonal на Go — как объявлять port-interface и port-ошибки в
core/. - In-адаптеры в Hexagonal на Go — симметричная сторона: chi-хендлер → mapper → UseCase.
- Core layer в Hexagonal на Go — что разрешено и запрещено импортировать в ядре.
- Bootstrap и composition root в Go — где создаются адаптеры и как передаются в хендлеры.