Опирается на правила:
R-RES-OAS-1…R-RES-OAS-4иR-RES-OAS-X1…R-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. Внешняя система переименовала
sberOrderId→orderId— меняется один 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 ClientInterface | R-RES-OAS-X1 | На public-методе SberAdapter |
Shared helper withCB(systemName string, fn func()) | R-RES-OAS-X2 | Inline a.breaker.Execute(...) в каждом адаптере |
PaymentPort.Register возвращает generated.RegisterOrderResponse | R-RES-OAS-X3 | PaymentRef из core/ + mapper |
| Generated источники закоммичены в git | R-RES-OAS-3 | .gitignore на internal/generated/ |
retry.Do на Register без Idempotency-Key | R-RES-RE-X1 | Только на read-методах или при наличии ключа идемпотентности |
retry.Do без retry.RetryIf | R-RES-RE-X2 / R-RES-RE-X3 | RetryIf по 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 репозиторий.