Опирается на правила: R-RES-OAS-1R-RES-OAS-4 и R-RES-OAS-X1R-RES-OAS-X3 из Resilience Rules → раздел 9. Связка с OpenAPI generator.

Важно знать

  • gobreaker.CircuitBreaker, semaphore.Weighted, retry.Do — на public-методе структуры-адаптера, которая оборачивает generated client. Не на сгенерированном клиенте, не в shared helper-функции.
  • Generated клиент (ClientWithResponses, интерфейс ClientInterface) перегенерируется при следующей сборке — любые правки в нём потеряются.
  • oapi-codegen — стандартный генератор для Go-стека. Спека в adapters/out/<system>/openapi/<system>.openapi.yaml; generated sources в internal/generated/<system>/, не коммитятся.
  • Mapper обязателен между generated DTO и типами из core/. Generated DTO — детали транспорта, domain-порт работает только с domain-типами.
  • Адаптер возвращает domain-типы, не generated struct. PaymentPort.Register возвращает PaymentRef из core/, не generated.RegisterResponse.
  • gobreaker.ErrOpenState из CB адаптер маппит в port-specific ошибку (PaymentSystemUnavailableError); сигнатура ошибки — domain, не transport.
  • OpenAPI-спека внешнего API коммитится; build её регенерит. PR со spec-update показывает diff в generated client — совместимость проверяется в ревью.

OpenAPI-first для outbound: внешний API описан YAML-ом, из YAML генерируется Go-клиент, поверх него пишется адаптер. Главный вопрос — где именно размещать resilience-инструменты. Неправильное место даёт или потерю защиты при регенерации, или потерю domain-инкапсуляции. Раскрытие раздела 9 гайда в идиомах Go.

Resilience на public-методе адаптера

R-RES-OAS-1: gobreaker, semaphore.Weighted, retry.Do — на методе структуры-адаптера, которая реализует port-интерфейс из core/.

// internal/generated/sber/client.gen.go — НЕ редактируем, перегенерируется:
type ClientInterface interface {
    RegisterOrder(ctx context.Context, body RegisterOrderJSONRequestBody, ...) (*http.Response, error)
    GetOrderStatus(ctx context.Context, orderId string, ...) (*http.Response, error)
}

// core/payment/port/out.go — domain port:
type PaymentPort interface {
    Register(ctx context.Context, order Order) (PaymentRef, error)
    GetStatus(ctx context.Context, ref PaymentRef) (PaymentStatus, error)
}

// adapters/out/sber/sber_adapter.go — наш адаптер:
type SberAdapter struct {
    client  ClientInterface          // generated
    breaker *gobreaker.CircuitBreaker
    sem     *semaphore.Weighted
    cfg     SberClientConfig
}

func (a *SberAdapter) Register(ctx context.Context, order Order) (PaymentRef, error) {
    if err := a.sem.Acquire(ctx, 1); err != nil {
        return PaymentRef{}, &PaymentSystemUnavailableError{System: "sber", Cause: err}
    }
    defer a.sem.Release(1)

    raw, err := a.breaker.Execute(func() (any, error) {
        callCtx, cancel := capTimeout(ctx, a.cfg.CallTimeout)
        defer cancel()
        return a.doRegister(callCtx, order)
    })
    if err != nil {
        if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
            return PaymentRef{}, &PaymentSystemUnavailableError{System: "sber", Cause: err}
        }
        return PaymentRef{}, fmt.Errorf("sber register: %w", err)
    }
    return raw.(PaymentRef), nil
}

Почему именно здесь:

  • Generated ClientInterface перегенерируется при следующем go generate — любые обёртки в нём исчезнут.
  • Shared helper с systemName string как параметром теряет возможность статического анализа: опечатка "sbr" вместо "sber" обнаруживается только в runtime.
  • SberAdapter.Register — это публичная граница port-интерфейса PaymentPort. Именно здесь «вызов к Sber» приобретает смысл как бизнес-операция; здесь уместна защита.

oapi-codegen — генерация клиента

R-RES-OAS-2 / R-RES-OAS-3: для нового сервиса клиент генерируется из OpenAPI-спеки внешней системы через oapi-codegen.

# adapters/out/sber/openapi/.oapi-codegen.yaml
package: sber
output: ../../../internal/generated/sber/client.gen.go
generate:
  client: true
  models: true
  strict-server: false
//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen \
//   --config=adapters/out/sber/openapi/.oapi-codegen.yaml \
//   adapters/out/sber/openapi/sber.openapi.yaml

Структура модуля:

adapters/
└── out/
    └── sber/
        ├── openapi/
        │   ├── sber.openapi.yaml          # ← спека внешнего API (коммитится)
        │   └── .oapi-codegen.yaml         # ← конфиг генератора (коммитится)
        ├── sber_adapter.go                # ← наш адаптер (реализует PaymentPort)
        ├── mapper.go                      # ← generated DTO → domain
        ├── config.go                      # ← SberClientConfig
        └── health.go                      # ← SberHealthChecker
internal/
└── generated/
    └── sber/
        └── client.gen.go                  # ← в .gitignore, регенерируется

Что это даёт:

  • Типобезопасный клиент с method-per-endpoint — не raw http.Do со строками.
  • Spec как source of truth: PR с обновлением sber.openapi.yaml показывает diff в generated client — разрывающие изменения видны в ревью до деплоя.
  • ClientInterface из oapi-codegen — удобная точка для mock в тестах адаптера: mockgen -source=internal/generated/sber/client.gen.go.

Mapper между generated DTO и domain

R-RES-OAS-4: между generated client и port из core/ — явный mapper. Generated DTO — транспорт, domain-порт работает только с domain-типами.

// adapters/out/sber/mapper.go

func toRegisterRequest(order Order) generated.RegisterOrderJSONRequestBody {
    return generated.RegisterOrderJSONRequestBody{
        OrderId:  order.ID.String(),
        Amount:   order.Amount.Value().InexactFloat64(),
        Currency: order.Amount.Currency().String(),
    }
}

func toPaymentRef(resp *generated.RegisterOrderResponse) (PaymentRef, error) {
    if resp.JSON200 == nil {
        return PaymentRef{}, fmt.Errorf("sber register: empty 200 body")
    }
    return PaymentRef{
        OrderID:    order.ID(resp.JSON200.OrderId),
        FormURL:    resp.JSON200.FormUrl,
        ExternalID: resp.JSON200.MdOrder,
    }, nil
}

func toPaymentStatus(resp *generated.GetOrderStatusResponse) (PaymentStatus, error) {
    if resp.JSON200 == nil {
        return PaymentStatus{}, fmt.Errorf("sber get status: empty 200 body")
    }
    return PaymentStatus{
        State:       mapSberState(resp.JSON200.OrderStatus),
        ConfirmedAt: resp.JSON200.ActionCodeDescription,
    }, nil
}

func mapSberState(s generated.SberOrderStatus) PaymentState {
    switch s {
    case generated.SberOrderStatusCONFIRMED:
        return PaymentStateConfirmed
    case generated.SberOrderStatusPENDING:
        return PaymentStatePending
    case generated.SberOrderStatusDECLINED:
        return PaymentStateDeclined
    default:
        return PaymentStateUnknown
    }
}

Зачем mapper:

  • Generated DTO меняются вместе с внешним API. Внешняя система переименовала sberOrderIdorderId — меняется один mapper, остальной код нетронут.
  • Domain-типы стабильны. PaymentRef в core/ не знает про Sber; завтра можно добавить YoomoneyAdapter с тем же PaymentPort, маппя в те же domain-типы.
  • Тесты упрощаются. handler_test.go мокирует PaymentPort.Register(Order) → PaymentRef — без зависимости на generated.RegisterOrderResponse с десятками полей.

Пример полного doRegister — внутренний метод, который вызывает generated client и запускает mapper:

// adapters/out/sber/sber_adapter.go

func (a *SberAdapter) doRegister(ctx context.Context, order Order) (PaymentRef, error) {
    body := toRegisterRequest(order)
    resp, err := a.client.RegisterOrderWithResponse(ctx, body)
    if err != nil {
        return PaymentRef{}, fmt.Errorf("sber http: %w", err)
    }
    if resp.StatusCode() >= 500 {
        return PaymentRef{}, &SberHTTPError{Code: resp.StatusCode()}
    }
    return toPaymentRef(resp)
}

Register (public, с CB + bulkhead) вызывает doRegister (private, только HTTP + mapper). Resilience — снаружи, транспорт — внутри.

Конфигурация под codegen-workflow

R-RES-CFG-1 / R-RES-CFG-3: имя системы в gobreaker.Settings.Name совпадает с префиксом конфига и меткой в метриках.

// adapters/out/sber/config.go
type SberClientConfig struct {
    ConnectTimeout time.Duration `envconfig:"SBER_CONNECT_TIMEOUT" default:"2s"`
    ReadTimeout    time.Duration `envconfig:"SBER_READ_TIMEOUT"    default:"10s"`
    CallTimeout    time.Duration `envconfig:"SBER_CALL_TIMEOUT"    default:"15s"`
    MaxConcurrent  int           `envconfig:"SBER_MAX_CONCURRENT"  default:"20"`
    BaseURL        string        `envconfig:"SBER_BASE_URL"        required:"true"`
}

func NewSberAdapter(cfg SberClientConfig) *SberAdapter {
    httpClient := newSberHTTPClient(cfg)
    generated, _ := generated.NewClientWithResponses(cfg.BaseURL, generated.WithHTTPClient(httpClient))

    return &SberAdapter{
        client: generated,
        breaker: gobreaker.NewCircuitBreaker(gobreaker.Settings{
            Name:        "sber",
            MaxRequests: 3,
            Timeout:     30 * time.Second,
            ReadyToTrip: func(c gobreaker.Counts) bool {
                return c.Requests >= 10 && float64(c.TotalFailures)/float64(c.Requests) >= 0.50
            },
            OnStateChange: func(name string, from, to gobreaker.State) {
                slog.Warn("circuit breaker state changed",
                    "system", name,
                    "prev_state", from.String(),
                    "new_state", to.String(),
                )
                cbState.WithLabelValues(name).Set(float64(to))
            },
        }),
        sem: semaphore.NewWeighted(int64(cfg.MaxConcurrent)),
        cfg: cfg,
    }
}

"sber" — одна строка: в gobreaker.Settings.Name, в метрике circuit_breaker_state{system="sber"}, в логах "system": "sber". При добавлении receipt-адаптера — та же схема с RECEIPT_-префиксом и "receipt" в CB.

Retry на read-методах

R-RES-RE-1: retry.Do — только на read-методах (идемпотентны) или write с Idempotency-Key.

func (a *SberAdapter) GetStatus(ctx context.Context, ref PaymentRef) (PaymentStatus, error) {
    var result PaymentStatus
    err := retry.Do(
        func() error {
            var callErr error
            raw, err := a.breaker.Execute(func() (any, error) {
                callCtx, cancel := capTimeout(ctx, a.cfg.CallTimeout)
                defer cancel()
                return a.doGetStatus(callCtx, ref)
            })
            if err != nil {
                return err
            }
            result = raw.(PaymentStatus)
            return callErr
        },
        retry.Context(ctx),
        retry.Attempts(3),
        retry.DelayType(retry.BackOffDelay),
        retry.Delay(200*time.Millisecond),
        retry.RetryIf(func(err error) bool {
            if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
                return false
            }
            var netErr net.Error
            if errors.As(err, &netErr) && netErr.Timeout() {
                return true
            }
            return isRetriable5xx(err)
        }),
    )
    return result, err
}

На Register (write) — retry.Do нет, даже если Sber возвращает 500. Неизвестно, «дошло и упало при ответе» или «не дошло». Register должен вызываться с Idempotency-Key, и тогда внешняя система сама дедуплицирует.

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

АнтипаттернПравилоЧто взамен
gobreaker внутри generated ClientInterfaceR-RES-OAS-X1На public-методе SberAdapter
Shared helper withCB(systemName string, fn func())R-RES-OAS-X2Inline a.breaker.Execute(...) в каждом адаптере
PaymentPort.Register возвращает generated.RegisterOrderResponseR-RES-OAS-X3PaymentRef из core/ + mapper
Generated источники закоммичены в gitR-RES-OAS-3.gitignore на internal/generated/
retry.Do на Register без Idempotency-KeyR-RES-RE-X1Только на read-методах или при наличии ключа идемпотентности
retry.Do без retry.RetryIfR-RES-RE-X2 / R-RES-RE-X3RetryIf по errors.As; не ретраить ErrOpenState, не ретраить 4xx

Куда дальше

  • Per-system isolation — отдельный *http.Client + *http.Transport на каждую систему.
  • Circuit Breaker — gobreaker.CircuitBreaker: count-based окно, ReadyToTrip, OnStateChange.
  • Bulkhead — semaphore.NewWeighted рядом с CB.
  • Retry — retry.Do с RetryIf и BackOffDelay.
  • Timeouts — capTimeout, иерархия connect/read/call.
  • Configuration — envconfig-теги, per-system префиксы.
  • Observability — promauto, OTel-spans, circuit_breaker_state.
  • Health checks — TTL-кеш probe, readiness vs liveness.
  • Fallback — errors.As на port-specific ошибку, fallback к кешу.
  • Async and polling — task-queue вместо time.Sleep-цикла в handler.
  • Where protection goes — outbound vs inbound vs репозиторий.