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

Важно знать

  • cockatiel wrap(retry, circuitBreaker, bulkhead, timeout) — на public-методе out-adapter класса, который оборачивает generated client. Не на сгенерированном клиенте, не в generic executeCall<T>-helper.
  • Generated client перегенерируется на build-шаге — любые изменения в нём потеряются.
  • Для нового кодаopenapi-typescript (+ openapi-fetch) или openapi-generator typescript-axios. Спека хранится в src/adapters/out/<system>/openapi/<system>.openapi.yaml, codegen — в generated/ (.gitignore).
  • Mapper обязателен между generated DTO и port-интерфейсом из core/. Generated DTO — транспорт, не доменные типы.
  • Адаптер возвращает domain-типы, не generated DTO. Утечка DTO наверх нарушает границы порта.
  • Policy-инстанс — singleton в DI (@Injectable({ scope: Scope.DEFAULT })), не пересоздаётся на вызов.
  • BrokenCircuitError / BulkheadRejectedError из cockatiel адаптер перехватывает и маппит в port-исключение (...SystemUnavailable).

OpenAPI-first для outbound: внешний API описан YAML-ом, из YAML генерируется TypeScript-клиент, вокруг него пишется адаптер. Ключевой вопрос — где прикреплять resilience-policy, и почему именно там. Это раскрытие раздела 9 Node-биндинга.

Policy-обёртки на public-методе out-adapter

R-RES-OAS-1: cockatiel-policy — на нашем out-adapter методе, обёртывающем generated client.

// Generated openapi-typescript (НЕ модифицируем, перегенерируется):
// generated/sber/types.ts — paths, components, operations
// generated/sber/client.ts — createApiClient(agent, baseUrl)

// core/payment/port/payment.port.ts — domain port:
export interface PaymentPort {
  registerPayment(cmd: RegisterPaymentCommand): Promise<RegisterPaymentResult>;
  getPaymentStatus(ref: PaymentRef): Promise<PaymentStatus>;
}

// adapters/out/sber/sber-payment.adapter.ts:
@Injectable()
export class SberPaymentAdapter implements PaymentPort {
  private readonly policy = wrap(
    retry(handleType(SberTransientError), { maxAttempts: 3, backoff: new ExponentialBackoff() }),
    circuitBreaker(handleAll, { halfOpenAfter: 30_000, breaker: new CountBreaker({ threshold: 0.5, size: 50 }) }),
    bulkhead(8, 4),
    timeout(5_000, TimeoutStrategy.Aggressive),
  );

  constructor(
    @Inject(SBER_CLIENT) private readonly client: ReturnType<typeof createApiClient>,
    private readonly mapper: SberPaymentMapper,
  ) {}

  async registerPayment(cmd: RegisterPaymentCommand): Promise<RegisterPaymentResult> {
    try {
      const resp = await this.policy.execute(ctx =>
        this.client.POST('/v1/payments', {
          body: this.mapper.toRegisterRequest(cmd),
          signal: ctx.signal,
        }),
      );
      if (resp.error) throw SberTransientError.fromApiError(resp.error);
      return this.mapper.toDomain(resp.data);
    } catch (e) {
      if (e instanceof BrokenCircuitError) throw PaymentPortError.systemUnavailable('sber', e);
      if (e instanceof BulkheadRejectedError) throw PaymentPortError.systemUnavailable('sber', e);
      throw PaymentPortError.from(e);
    }
  }

  async getPaymentStatus(ref: PaymentRef): Promise<PaymentStatus> {
    try {
      const resp = await this.policy.execute(ctx =>
        this.client.GET('/v1/payments/{id}', {
          params: { path: { id: ref.id } },
          signal: ctx.signal,
        }),
      );
      if (resp.error) throw SberTransientError.fromApiError(resp.error);
      return this.mapper.toPaymentStatus(resp.data);
    } catch (e) {
      if (e instanceof BrokenCircuitError) throw PaymentPortError.systemUnavailable('sber', e);
      throw PaymentPortError.from(e);
    }
  }
}

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

  • Generated client будет перегенерирован на следующем build-шаге — встроенные обёртки потеряются.
  • executeCall<T>-helper — общая утилита; если повесить policy там со строковым именем системы как параметром, теряется compile-time связь policy↔система.
  • SberPaymentAdapter.registerPayment — это граница порта PaymentPort. На неё имеет смысл вешать resilience: «вызов к Sber» как бизнес-операция.

openapi-typescript — для нового кода

R-RES-OAS-2: новые out-adapter строятся из OpenAPI-спеки с openapi-typescript + openapi-fetch.

// package.json
{
  "scripts": {
    "generate:sber": "openapi-typescript src/adapters/out/sber/openapi/sber.openapi.yaml -o src/adapters/out/sber/generated/sber.d.ts",
    "generate:receipt": "openapi-typescript src/adapters/out/receipt/openapi/receipt.openapi.yaml -o src/adapters/out/receipt/generated/receipt.d.ts"
  }
}
// adapters/out/sber/sber-client.factory.ts
import createClient from 'openapi-fetch';
import type { paths } from './generated/sber';

export const SBER_CLIENT = Symbol('SBER_CLIENT');

export const sberClientProvider = {
  provide: SBER_CLIENT,
  useFactory: (cfg: SberClientConfig, agent: Agent) =>
    createClient<paths>({ baseUrl: cfg.baseUrl, dispatcher: agent }),
  inject: [SberClientConfig, SBER_AGENT],
};

Для legacy-кода с openapi-generator typescript-axios допустимо оставить как есть. Новые адаптеры — на openapi-typescript / openapi-fetch.

Альтернатива — openapi-generator typescript-axios:

# openapitools.json
{
  "generator-cli": {
    "generators": {
      "sber-client": {
        "generatorName": "typescript-axios",
        "inputSpec": "src/adapters/out/sber/openapi/sber.openapi.yaml",
        "output": "src/adapters/out/sber/generated",
        "additionalProperties": {
          "withSeparateModelsAndApi": true,
          "apiPackage": "api",
          "modelPackage": "models"
        }
      }
    }
  }
}

typescript-axios генерирует SberApi-класс, который адаптер принимает через DI — без каких-либо изменений в generated-коде.

Расположение OpenAPI-спеки и codegen

R-RES-OAS-3: спецификация внешнего API хранится рядом с адаптером.

src/
└── adapters/
    └── out/
        ├── sber/
        │   ├── openapi/
        │   │   └── sber.openapi.yaml     ← spec внешнего API (коммитится)
        │   ├── generated/                ← .gitignore (перегенерируется)
        │   │   └── sber.d.ts
        │   ├── sber-payment.adapter.ts
        │   ├── sber-payment.mapper.ts
        │   └── sber.module.ts
        └── receipt/
            ├── openapi/
            │   └── receipt.openapi.yaml
            ├── generated/
            │   └── receipt.d.ts
            ├── receipt.adapter.ts
            └── receipt.module.ts

Что важно:

  • sber.openapi.yaml — копия (или fork) внешнего OpenAPI. Если внешняя система не публикует спеку — пишется вручную по документации.
  • generated/ — в .gitignore. Регенерация на build-шаге через npm-скрипт в CI.
  • Версионирование — спека коммитится, PR со spec-update показывает diff в публичном контракте. Сломанные поля видны в review до мержа.

Mapper между generated DTO и domain

R-RES-OAS-4: между generated client и портом из core/ — обязательно mapper.

// core/payment/domain/register-payment.command.ts
export interface RegisterPaymentCommand {
  orderId: OrderId;
  amount: Money;
  idempotencyKey: string;
}

export interface RegisterPaymentResult {
  externalId: string;
  status: PaymentRegistrationStatus;
  confirmedAt: Date | null;
}

// adapters/out/sber/sber-payment.mapper.ts
@Injectable()
export class SberPaymentMapper {
  toRegisterRequest(cmd: RegisterPaymentCommand): SberRegisterRequest {
    return {
      orderId: cmd.orderId.value,
      amount: cmd.amount.valueInKopecks(),
      currency: cmd.amount.currency,
      idempotencyKey: cmd.idempotencyKey,
    };
  }

  toDomain(resp: SberRegisterResponse): RegisterPaymentResult {
    return {
      externalId: resp.sberOrderId,
      status: this.mapStatus(resp.status),
      confirmedAt: resp.confirmedAt ? new Date(resp.confirmedAt) : null,
    };
  }

  toPaymentStatus(resp: SberOrderStatusResponse): PaymentStatus {
    return {
      externalId: resp.sberOrderId,
      state: this.mapState(resp.orderState),
      updatedAt: new Date(resp.lastUpdated),
    };
  }

  private mapStatus(s: SberRegisterStatus): PaymentRegistrationStatus {
    switch (s) {
      case 'CONFIRMED': return PaymentRegistrationStatus.Confirmed;
      case 'PENDING':   return PaymentRegistrationStatus.Pending;
      case 'DECLINED':  return PaymentRegistrationStatus.Declined;
    }
  }

  private mapState(s: SberOrderState): PaymentState {
    switch (s) {
      case 'CREATED':   return PaymentState.Created;
      case 'PAID':      return PaymentState.Paid;
      case 'CANCELLED': return PaymentState.Cancelled;
      case 'REFUNDED':  return PaymentState.Refunded;
    }
  }
}

Зачем mapper:

  • Generated DTO — транспорт. Они меняются вместе с внешним API. Domain-типы стабильны.
  • Изоляция от breaking-changes. Внешняя система переименовала поле — меняется один mapper, остальной код не затронут.
  • Тип-безопасность. SberRegisterStatus и PaymentRegistrationStatus могут разойтись семантически. Mapper делает явный перевод через switch, без неявных приведений.

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

Policy на generated client

R-RES-OAS-X1: попытка встроить cockatiel-policy непосредственно в generated client или обернуть его класс.

// ПЛОХО — generated код, будет перезаписан
// generated/sber.d.ts — read-only, пересоздаётся
export type paths = { ... };   // ← здесь ничего не добавить через wrapper

// ПЛОХО — обёртка на сгенерированном SberApi (typescript-axios):
class SberApiWithPolicy extends SberApi {
  override async registerPayment(req: SberRegisterRequest) {
    return this.policy.execute(() => super.registerPayment(req));  // ← регенерация затрёт SberApi
  }
}

Корректно: policy — на нашем SberPaymentAdapter.registerPayment, который принимает generated client через DI и оборачивает его.

Policy в executeCall helper со строковым именем

R-RES-OAS-X2: общий helper с именем системы строкой-параметром.

// ПЛОХО — строковое имя теряет compile-time связь
@Injectable()
export class ResilienceHelper {
  executeWithPolicy<T>(systemName: string, fn: () => Promise<T>): Promise<T> {
    const policy = this.policyRegistry.get(systemName);   // ← KeyError на runtime если 'sbr'
    return policy.execute(fn);
  }
}

@Injectable()
export class SberPaymentAdapter {
  async registerPayment(cmd: RegisterPaymentCommand) {
    return this.helper.executeWithPolicy('sber', () => this.client.POST('/v1/payments', ...));
    //                                    ^^^^^ опечатка 'sbr' → runtime error, не compile error
  }
}

Что не так:

  • Опечатка в имени (sbr вместо sber) всплывает на runtime, не на этапе сборки.
  • Policy-инстанс создаётся через registry — не инжектируется напрямую, не виден в DI-графе.
  • Стиль расходится между адаптерами, code review не ловит несоответствия.

Корректно: policy — поле класса адаптера (private readonly policy = wrap(...)), собранное один раз в конструкторе или через фабрику из конфига.

Возврат generated DTO из port-метода

R-RES-OAS-X3: PaymentPort.registerPayment возвращает SberRegisterResponse.

// ПЛОХО — generated DTO утекло в port
export interface PaymentPort {
  registerPayment(cmd: RegisterPaymentCommand): Promise<SberRegisterResponse>;
  // ← SberRegisterResponse — из generated/, деталь транспорта в domain
}

// ПЛОХО — адаптер пробрасывает DTO без маппинга
async registerPayment(cmd: RegisterPaymentCommand): Promise<SberRegisterResponse> {
  const resp = await this.policy.execute(() => this.client.POST('/v1/payments', ...));
  return resp.data;  // ← SberRegisterResponse попадает в core/, handler, тесты
}

Что не так:

  • core/ зависит от generated/. Это нарушение hexagonal — port должен быть в domain, generated client — деталь out-adapter.
  • Смена провайдера становится невозможной: YoomoneyAdapter.registerPayment возвращает YoomoneyResponse — handler не примет без переработки.
  • Тесты handler'а вынуждены конструировать SberRegisterResponse с десятками полей.

Корректно: RegisterPaymentResult — domain-интерфейс в core/. Все адаптеры (Sber, Yoomoney, Tinkoff) маппят свой ответ в один domain-тип.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Policy на generated client / его наследникеR-RES-OAS-X1wrap(...) на public-методе out-adapter
executeCall<T>(systemName: string) helper с policy по строковому ключуR-RES-OAS-X2private readonly policy = wrap(...) в теле адаптера
PaymentPort.registerPayment возвращает SberRegisterResponseR-RES-OAS-X3Domain RegisterPaymentResult + mapper
Generated client без mapper'а в адаптереR-RES-OAS-4SberPaymentMapper с явным переводом всех полей
Ручной HTTP-клиент вместо codegen для нового адаптераR-RES-OAS-2openapi-typescript + openapi-fetch из спеки
generated/ коммитится в gitR-RES-OAS-3.gitignore на generated/, регенерация на CI

Куда дальше

  • Async и polling — когда вместо синхронного вызова нужен task-queue.
  • Bulkhead — bulkhead(maxConcurrent, queueLimit) в cockatiel-композиции.
  • Circuit Breaker — CountBreaker, halfOpenAfter, маппинг BrokenCircuitError.
  • Конфигурация — типизированный per-system конфиг, policy из фабрики.
  • Fallback — когда fallback допустим и чего нельзя возвращать.
  • Health checks — @nestjs/terminus индикатор с TTL-кешем.
  • Observability — prom-client метрики, OTel-spans, WARN на CB-переходах.
  • Per-system isolation — отдельный undici Agent и DI-токен на каждую систему.
  • Retry — ExponentialBackoff, только идемпотентные методы, не на 4xx.
  • Timeouts — connect < headers < body, cockatiel timeout() как total.
  • Где какая защита — outbound, internal s2s, scheduler, inbound.