Опирается на правила:
R-RES-OAS-1…R-RES-OAS-4иR-RES-OAS-X1…R-RES-OAS-X3из Resilience Style Guide → раздел 9. Связка с OpenAPI generator.
Важно знать
- cockatiel
wrap(retry, circuitBreaker, bulkhead, timeout)— на public-методе out-adapter класса, который оборачивает generated client. Не на сгенерированном клиенте, не в genericexecuteCall<T>-helper.- Generated client перегенерируется на build-шаге — любые изменения в нём потеряются.
- Для нового кода —
openapi-typescript(+openapi-fetch) или openapi-generatortypescript-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-X1 | wrap(...) на public-методе out-adapter |
executeCall<T>(systemName: string) helper с policy по строковому ключу | R-RES-OAS-X2 | private readonly policy = wrap(...) в теле адаптера |
PaymentPort.registerPayment возвращает SberRegisterResponse | R-RES-OAS-X3 | Domain RegisterPaymentResult + mapper |
| Generated client без mapper'а в адаптере | R-RES-OAS-4 | SberPaymentMapper с явным переводом всех полей |
| Ручной HTTP-клиент вместо codegen для нового адаптера | R-RES-OAS-2 | openapi-typescript + openapi-fetch из спеки |
generated/ коммитится в git | R-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, cockatieltimeout()как total. - Где какая защита — outbound, internal s2s, scheduler, inbound.