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