Как делать12 октября 2026 г.6 мин чтения
Архитектура ПОШаг 9 из 9

Рецепт: порт и адаптер для внешнего сервиса

Коротко

Порт — это интерфейс, которым приложение описывает, что ему нужно от внешнего сервиса, своими словами: «отправь SMS», а не «POST /v2/messages». Адаптер реализует порт для конкретного провайдера и переводит в обе стороны форматы и ошибки; fake-адаптер реализует тот же порт в памяти для тестов. Один контрактный тест прогоняется на обоих адаптерах и гарантирует, что подделка ведёт себя как настоящий адаптер.

Рецепт кодаУровень: ПродвинутыйПереиспользуемые архитектурные шаблоныПрактика: ~40–90 мин

После материала вы сможете

  • описать порт внешнего сервиса в терминах своего домена: данные, результат и две ошибки, на которые приложение умеет реагировать
  • реализовать HTTP-адаптер с переводом формата и ошибок провайдера и fake-адаптер, который ведёт себя так же
  • выбирать адаптер конфигурацией при старте и не пускать fake в продакшен
  • написать контрактный тест, который проходит любая реализация порта, и подключить к нему оба адаптера

Проверено на

TypeScript 7.0.2 (strict)Node.js 24 LTS (24.18), built-in fetchVitest 5.0.3

Сверено с документацией: 12 октября 2026 г.

Содержание
  1. 01Задача: внешний API протекает по всему коду
  2. 02Порт в языке своего домена
  3. 03Адаптеры: HTTP и fake
  4. 04Конфигурация и подключение
  5. 05Как проверить: один контракт для обоих адаптеров
  6. 06Ограничения и чек-лист адаптера
  7. 07Связанные материалы и источники

Задача: внешний API протекает по всему коду

Типичная картина: сервис подтверждения записи сам собирает JSON для SMS-провайдера, проверяет response.status === 202 и разбирает его коды ошибок. Через полгода то же делают регистрация, напоминания и сброс пароля. Тесты либо ходят к настоящему провайдеру, либо подменяют глобальный fetch в каждом файле, а смена провайдера означает правку десятка мест в бизнес-логике.

Кокберн в описании гексагональной архитектуры (ports and adapters) советует называть интерфейсы по назначению, а не по технологии, и делать технологии заменяемыми через адаптеры. SMS-провайдер для приложения — вторичный (driven) участник: приложение само его вызывает. Фаулер называет такую обёртку шлюзом (Gateway): объект, который инкапсулирует доступ к внешней системе и переводит её словарь в словарь приложения.

  1. 01Сценарий: подтверждение записи
  2. 02Порт SmsSender
  3. 03Адаптер AcmeSMS
  4. 04API провайдера
Сценарий знает только порт; формат и ошибки провайдера живут в адаптере

sms-sender.ts

Роль
Порт: тип сообщения, квитанция, две доменные ошибки

acme-sms-sender.ts

Роль
Боевой адаптер: HTTP, формат провайдера, перевод ошибок

fake-sms-sender.ts

Роль
Fake-адаптер: рабочая реализация в памяти для тестов и локальной разработки

create-sms-sender.ts

Роль
Выбор адаптера по переменным окружения при старте

sms-sender.contract.ts

Роль
Контракт порта: один набор тестов для всех адаптеров
Из чего состоит рецепт

В уроке о чистой архитектуре порты и адаптеры разобраны на примере целого приложения, а в материале о паттернах — Adapter над SDK платёжной системы. Здесь — готовый к переносу набор для одного внешнего сервиса: с ошибками, идемпотентностью, конфигурацией и контрактным тестом. Провайдер AcmeSMS вымышленный, его API описан в коде адаптера и в локальной заглушке.

Порт в языке своего домена

bash
npm init -y
npm pkg set type=module
npm install -D typescript @types/node@24 vitest
Зависимостей во время выполнения нет: HTTP-адаптер использует встроенный fetch Node.js 24
tsconfig.json
{
  "compilerOptions": {
    "noEmit": true,
    "target": "esnext",
    "module": "nodenext",
    "allowImportingTsExtensions": true,
    "verbatimModuleSyntax": true,
    "erasableSyntaxOnly": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Строгий режим TypeScript; файлы .ts запускаются встроенным type stripping Node.js 24
src/notifications/sms-sender.ts
// Port: what the application needs from an SMS service, in its own words.
// No HTTP, no status codes, no provider names or field formats.
export type OutgoingSms = {
  to: string; // E.164, e.g. +77011234567
  text: string;
  idempotencyKey: string; // same key = same message: it is never sent twice
};

export type SmsReceipt = { messageId: string };

export interface SmsSender {
  send(sms: OutgoingSms): Promise<SmsReceipt>;
}

// This recipient cannot get the message: retrying will not help, the data must be fixed
export class RecipientRejectedError extends Error {
  readonly to: string;

  constructor(to: string, reason: string) {
    super(`SMS to ${to} rejected: ${reason}`);
    this.name = "RecipientRejectedError";
    this.to = to;
  }
}

// Temporary failure: safe to retry later with the same idempotency key
export class SmsUnavailableError extends Error {
  readonly retryAfterMs: number | undefined;

  constructor(message: string, options: { retryAfterMs?: number; cause?: unknown } = {}) {
    super(message, { cause: options.cause });
    this.name = "SmsUnavailableError";
    this.retryAfterMs = options.retryAfterMs;
  }
}

export const isE164 = (phone: string): boolean => /^\+[1-9]\d{6,14}$/.test(phone);
Порт: что нужно приложению, без HTTP и без имени провайдера
  • В порте нет кодов HTTP, client_ref и названия провайдера: если они появились в сигнатуре, адаптер протёк.
  • Ошибок ровно две, и каждая отвечает на вопрос «что делать приложению». RecipientRejectedError — повтор не поможет, нужно исправить номер. SmsUnavailableError — сбой временный, можно повторить позже с тем же ключом.
  • Ключ идемпотентности — часть порта, а не деталь адаптера: повтор после таймаута неизбежен, и порт обещает, что одно сообщение не уйдёт дважды. Как обеспечить это обещание, решает адаптер.
  • Номер в формате E.164 (+, код страны и номер без пробелов) — общий формат порта. isE164 — упрощённая проверка формата, а не проверка существования номера.

Адаптеры: HTTP и fake

src/notifications/acme-sms-sender.ts
import {
  isE164,
  RecipientRejectedError,
  SmsUnavailableError,
  type OutgoingSms,
  type SmsReceipt,
  type SmsSender,
} from "./sms-sender.ts";

// The wire format of a made-up provider "AcmeSMS" stays in this file
type AcmeSendRequest = { from: string; to: string; body: string; client_ref: string };
type AcmeErrorBody = { error?: { code?: string } };

export type AcmeSmsConfig = {
  baseUrl: string; // provider origin, e.g. https://api.acme-sms.example
  apiKey: string;
  sender: string; // registered sender name
  timeoutMs?: number;
  fetch?: typeof fetch;
};

// Provider error codes that mean "this recipient will never get it"
const RECIPIENT_ERRORS = new Set(["invalid_number", "blocked_number", "unsubscribed"]);

function retryAfterMs(header: string | null): number | undefined {
  return header !== null && /^\d+$/.test(header.trim()) ? Number(header) * 1_000 : undefined;
}

function hasMessageId(data: unknown): data is { message_id: string } {
  return typeof data === "object" && data !== null && typeof (data as { message_id?: unknown }).message_id === "string";
}

// Driven adapter: translates the port into AcmeSMS calls and AcmeSMS answers into the port's
// results and errors. It does not retry: retries live in one place, in the calling job.
export class AcmeSmsSender implements SmsSender {
  readonly #config: AcmeSmsConfig;

  constructor(config: AcmeSmsConfig) {
    this.#config = config;
  }

  async send(sms: OutgoingSms): Promise<SmsReceipt> {
    if (!isE164(sms.to)) throw new RecipientRejectedError(sms.to, "not an E.164 number");
    const { baseUrl, apiKey, sender, timeoutMs = 5_000, fetch: doFetch = fetch } = this.#config;
    const payload: AcmeSendRequest = {
      from: sender,
      to: sms.to.slice(1), // AcmeSMS wants digits without "+"
      body: sms.text,
      client_ref: sms.idempotencyKey, // AcmeSMS deduplicates by client_ref
    };

    let res: Response;
    try {
      res = await doFetch(new URL("/v2/messages", baseUrl), {
        method: "POST",
        headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(timeoutMs),
      });
    } catch (error) {
      // Timeout or network failure: the message may have been accepted or not.
      // A retry with the same key is safe because the provider deduplicates it.
      throw new SmsUnavailableError("SMS provider did not answer", { cause: error });
    }

    if (res.ok) {
      const data: unknown = await res.json();
      if (!hasMessageId(data)) throw new Error("Unexpected AcmeSMS response: no message_id");
      return { messageId: data.message_id };
    }
    if (res.status === 429 || res.status >= 500) {
      await res.body?.cancel();
      throw new SmsUnavailableError(`SMS provider answered ${res.status}`, {
        retryAfterMs: retryAfterMs(res.headers.get("retry-after")),
      });
    }
    const body = (await res.json().catch(() => null)) as AcmeErrorBody | null;
    const code = body?.error?.code ?? "unknown";
    if (res.status === 400 && RECIPIENT_ERRORS.has(code)) throw new RecipientRejectedError(sms.to, code);
    // 401, 403 and other 4xx: a wrong key or a bug on our side, not a business case
    throw new Error(`AcmeSMS rejected the request: ${res.status} ${code}`);
  }
}
Боевой адаптер: формат провайдера и перевод его ответов в результат и ошибки порта

202 с message_id

Результат порта
SmsReceipt
Почему
Сообщение принято

Таймаут, обрыв связи

Результат порта
SmsUnavailableError
Почему
Неизвестно, принято ли сообщение; повтор с тем же client_ref безопасен

429, 5xx

Результат порта
SmsUnavailableError с retryAfterMs
Почему
Временный сбой; пауза берётся из Retry-After

400 с invalid_number, blocked_number, unsubscribed

Результат порта
RecipientRejectedError
Почему
Получатель не сможет получить сообщение

401, 403, прочие 4xx

Результат порта
Обычная Error
Почему
Неверный ключ или ошибка в нашем коде — это не бизнес-сценарий, а повод для алерта
Как адаптер переводит ответы провайдера

Адаптер сам не повторяет запросы. Повторять стоит в одном месте стека — в нашем случае в фоновой задаче, которая отправляет подтверждение: она видит SmsUnavailableError, ждёт retryAfterMs и запускает шаг снова. Если повторять и в адаптере, и в задаче, число запросов к упавшему провайдеру перемножается; полная политика повторов разобрана в материале о внешних API.

src/notifications/fake-sms-sender.ts
import {
  isE164,
  RecipientRejectedError,
  SmsUnavailableError,
  type OutgoingSms,
  type SmsReceipt,
  type SmsSender,
} from "./sms-sender.ts";

// A working implementation for tests and local development: messages stay in memory.
// It follows the same contract as the real adapter, including idempotency.
export class FakeSmsSender implements SmsSender {
  readonly sent: (OutgoingSms & SmsReceipt)[] = [];
  readonly #receipts = new Map<string, SmsReceipt>();
  #outages = 0;

  // The next `times` calls fail as if the provider were down
  failNextWithOutage(times = 1): void {
    this.#outages += times;
  }

  async send(sms: OutgoingSms): Promise<SmsReceipt> {
    if (!isE164(sms.to)) throw new RecipientRejectedError(sms.to, "not an E.164 number");
    if (this.#outages > 0) {
      this.#outages--;
      throw new SmsUnavailableError("Fake SMS provider is down", { retryAfterMs: 1_000 });
    }
    const known = this.#receipts.get(sms.idempotencyKey);
    if (known) return known;

    const receipt = { messageId: `fake-${this.#receipts.size + 1}` };
    this.#receipts.set(sms.idempotencyKey, receipt);
    this.sent.push({ ...sms, ...receipt });
    return receipt;
  }
}
Fake-адаптер: рабочая реализация в памяти с тем же поведением

В классификации Фаулера это fake — объект с работающей реализацией, срезающей путь, который не годится для продакшена, а не mock с заранее запрограммированными ожиданиями. Он проверяет формат номера, помнит ключи идемпотентности и умеет изображать сбой. Поэтому тесты сценариев на нём проверяют поведение, а не то, какие методы и с какими аргументами вызывались.

Конфигурация и подключение

src/notifications/create-sms-sender.ts
import { AcmeSmsSender } from "./acme-sms-sender.ts";
import { FakeSmsSender } from "./fake-sms-sender.ts";
import type { SmsSender } from "./sms-sender.ts";

type Env = Record<string, string | undefined>;

// The only place that knows the concrete adapters. Called once at startup:
// a missing variable stops the app right away, not on the first SMS.
export function createSmsSender(env: Env): SmsSender {
  switch (env.SMS_PROVIDER) {
    case "acme": {
      const { SMS_API_URL, SMS_API_KEY, SMS_SENDER } = env;
      if (!SMS_API_URL || !SMS_API_KEY || !SMS_SENDER) {
        throw new Error("SMS_PROVIDER=acme needs SMS_API_URL, SMS_API_KEY and SMS_SENDER");
      }
      return new AcmeSmsSender({ baseUrl: SMS_API_URL, apiKey: SMS_API_KEY, sender: SMS_SENDER });
    }
    case "fake":
      if (env.NODE_ENV === "production") throw new Error("SMS_PROVIDER=fake is not allowed in production");
      return new FakeSmsSender();
    default:
      throw new Error(`SMS_PROVIDER must be "acme" or "fake", got: ${env.SMS_PROVIDER ?? "nothing"}`);
  }
}
Единственное место, которое знает конкретные адаптеры
.env.example
SMS_PROVIDER=acme
SMS_API_URL=https://api.acme-sms.example
SMS_API_KEY=YOUR_API_KEY
SMS_SENDER=InterLAB
Переменные для боевого адаптера; настоящий ключ — только в секретах окружения
src/booking/send-booking-confirmation.ts
import { RecipientRejectedError, type SmsSender } from "../notifications/sms-sender.ts";

export type Booking = { id: string; service: string; startsAt: Date; clientPhone: string };

const formatTime = new Intl.DateTimeFormat("ru-RU", {
  dateStyle: "short",
  timeStyle: "short",
  timeZone: "Asia/Almaty",
});

// Application logic depends on the port only: it does not know which provider is behind it.
// SmsUnavailableError is not caught here: the background job retries the whole step later.
export async function sendBookingConfirmation(
  sms: SmsSender,
  booking: Booking,
): Promise<"sent" | "invalid-phone"> {
  try {
    await sms.send({
      to: booking.clientPhone,
      text: `Booking confirmed: ${booking.service}, ${formatTime.format(booking.startsAt)}`,
      idempotencyKey: `booking-confirmed:${booking.id}`,
    });
    return "sent";
  } catch (error) {
    if (error instanceof RecipientRejectedError) return "invalid-phone"; // ask the client for another number
    throw error;
  }
}
Сценарий зависит только от порта
  1. Создать при старте

    const sms = createSmsSender(process.env) в точке сборки приложения. Нехватка переменной остановит запуск сразу, а не при первом SMS клиенту.

  2. Передать в сценарии

    Сценарии получают SmsSender параметром или через конструктор. Импортировать адаптер напрямую из бизнес-кода нельзя — иначе порт теряет смысл.

  3. Ключ из домена

    Ключ идемпотентности строится из идентификатора бизнес-события: booking-confirmed:<id>. Повтор шага задачей отправит то же сообщение, а не второе.

  4. Сменить провайдера

    Новый класс, реализующий SmsSender, ещё одна ветка в createSmsSender и ещё один вызов smsSenderContract в тестах. Сценарии и их тесты не меняются.

Как проверить: один контракт для обоих адаптеров

Главный риск fake-адаптера — он расходится с настоящим, и тесты сценариев зеленеют на поведении, которого в продакшене нет. Защита — контрактный тест: функция с набором проверок поведения порта, которую вызывают для каждой реализации. Тест получает «пульт» simulateOutage, потому что сбой у fake и у HTTP-адаптера вызывается по-разному.

test/sms-sender.contract.ts
import { describe, expect, it } from "vitest";
import { RecipientRejectedError, SmsUnavailableError, type SmsSender } from "../src/notifications/sms-sender.ts";

export type SmsHarness = {
  sender: SmsSender;
  simulateOutage: () => void; // the next send fails as a provider outage
};

// One behaviour spec for every adapter of the SmsSender port
export function smsSenderContract(name: string, makeHarness: () => Promise<SmsHarness>) {
  describe(`SmsSender contract: ${name}`, () => {
    const sms = (key: string, to = "+77011234567") => ({ to, text: "Your code: 1234", idempotencyKey: key });

    it("sends a message and returns a receipt", async () => {
      const { sender } = await makeHarness();
      const receipt = await sender.send(sms("k-1"));
      expect(receipt.messageId).toEqual(expect.any(String));
      expect(receipt.messageId.length).toBeGreaterThan(0);
    });

    it("returns the same receipt for the same idempotency key", async () => {
      const { sender } = await makeHarness();
      const first = await sender.send(sms("k-2"));
      const again = await sender.send(sms("k-2"));
      const other = await sender.send(sms("k-3"));
      expect(again).toEqual(first);
      expect(other.messageId).not.toBe(first.messageId);
    });

    it("rejects a number that is not E.164 with RecipientRejectedError", async () => {
      const { sender } = await makeHarness();
      await expect(sender.send(sms("k-4", "8 701 123 45 67"))).rejects.toBeInstanceOf(RecipientRejectedError);
    });

    it("reports an outage as SmsUnavailableError and works again with the same key", async () => {
      const { sender, simulateOutage } = await makeHarness();
      simulateOutage();
      const error = await sender.send(sms("k-5")).catch((e: unknown) => e);
      expect(error).toBeInstanceOf(SmsUnavailableError);
      expect((error as SmsUnavailableError).retryAfterMs).toBeGreaterThan(0);
      await expect(sender.send(sms("k-5"))).resolves.toEqual({ messageId: expect.any(String) });
    });
  });
}
Контракт порта: отправка, идемпотентность, неверный номер, сбой и восстановление
test/acme-stub.ts
import { createServer, type IncomingHttpHeaders, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";

// A local stand-in for the AcmeSMS API, written from its documentation:
// Bearer key, digits-only numbers, deduplication by client_ref, 503 with Retry-After
export async function startAcmeStub(apiKey: string) {
  const requests: { headers: IncomingHttpHeaders; body: Record<string, unknown> }[] = [];
  const byClientRef = new Map<string, string>();
  const state = { outages: 0, delayMs: 0, nextError: undefined as { status: number; code: string } | undefined };

  const reply = (res: ServerResponse, status: number, data: unknown, headers: Record<string, string> = {}) => {
    if (res.destroyed) return;
    res.writeHead(status, { "Content-Type": "application/json", ...headers }).end(JSON.stringify(data));
  };

  const server = createServer(async (req, res) => {
    let raw = "";
    for await (const chunk of req) raw += chunk;
    const body = JSON.parse(raw) as Record<string, unknown>;
    requests.push({ headers: req.headers, body });
    if (state.delayMs) await new Promise((resolve) => setTimeout(resolve, state.delayMs));

    if (req.headers.authorization !== `Bearer ${apiKey}`) {
      return reply(res, 401, { error: { code: "unauthorized" } });
    }
    if (state.outages > 0) {
      state.outages--;
      return reply(res, 503, { error: { code: "maintenance" } }, { "Retry-After": "2" });
    }
    if (state.nextError) {
      const { status, code } = state.nextError;
      state.nextError = undefined;
      return reply(res, status, { error: { code } });
    }
    if (typeof body.to !== "string" || !/^\d{7,15}$/.test(body.to)) {
      return reply(res, 400, { error: { code: "invalid_number" } });
    }
    const ref = String(body.client_ref);
    const id = byClientRef.get(ref) ?? `acme-${byClientRef.size + 1}`;
    byClientRef.set(ref, id);
    reply(res, 202, { message_id: id, status: "queued" });
  });

  await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
  const { port } = server.address() as AddressInfo;

  return {
    url: `http://127.0.0.1:${port}`,
    requests,
    state,
    close: () =>
      new Promise<void>((resolve) => {
        server.closeAllConnections();
        server.close(() => resolve());
      }),
  };
}
Локальная заглушка API провайдера на node:http, написанная по его документации
test/sms-senders.test.ts
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AcmeSmsSender } from "../src/notifications/acme-sms-sender.ts";
import { FakeSmsSender } from "../src/notifications/fake-sms-sender.ts";
import { RecipientRejectedError, SmsUnavailableError } from "../src/notifications/sms-sender.ts";
import { startAcmeStub } from "./acme-stub.ts";
import { smsSenderContract } from "./sms-sender.contract.ts";

const API_KEY = "test-key-not-a-secret";
let stub: Awaited<ReturnType<typeof startAcmeStub>>;
beforeAll(async () => {
  stub = await startAcmeStub(API_KEY);
});
afterAll(() => stub.close());

const acme = (overrides: { apiKey?: string; timeoutMs?: number } = {}) =>
  new AcmeSmsSender({ baseUrl: stub.url, apiKey: API_KEY, sender: "InterLAB", ...overrides });

// The same contract for both adapters
smsSenderContract("fake", async () => {
  const sender = new FakeSmsSender();
  return { sender, simulateOutage: () => sender.failNextWithOutage() };
});

smsSenderContract("AcmeSMS over HTTP", async () => ({
  sender: acme(),
  simulateOutage: () => {
    stub.state.outages = 1;
  },
}));

// What only the HTTP adapter has: the wire format and the provider's error codes
describe("AcmeSmsSender specifics", () => {
  const sms = { to: "+77011234567", text: "Hi", idempotencyKey: "order-17" };

  it("sends the provider's wire format with the API key", async () => {
    stub.requests.length = 0;
    await acme().send(sms);
    expect(stub.requests[0]!.headers.authorization).toBe(`Bearer ${API_KEY}`);
    expect(stub.requests[0]!.body).toEqual({ from: "InterLAB", to: "77011234567", body: "Hi", client_ref: "order-17" });
  });

  it("maps a provider recipient error to RecipientRejectedError", async () => {
    stub.state.nextError = { status: 400, code: "blocked_number" };
    await expect(acme().send(sms)).rejects.toThrow(new RecipientRejectedError(sms.to, "blocked_number"));
  });

  it("treats a wrong key as a configuration error, not as a domain case", async () => {
    const error = await acme({ apiKey: "wrong" }).send(sms).catch((e: unknown) => e);
    expect(error).not.toBeInstanceOf(RecipientRejectedError);
    expect(error).not.toBeInstanceOf(SmsUnavailableError);
    expect((error as Error).message).toBe("AcmeSMS rejected the request: 401 unauthorized");
  });

  it("passes Retry-After from 503 and treats a timeout as unavailability", async () => {
    stub.state.outages = 1;
    const outage = (await acme().send(sms).catch((e: unknown) => e)) as SmsUnavailableError;
    expect(outage.retryAfterMs).toBe(2_000);

    stub.state.delayMs = 300;
    try {
      const slow = await acme({ timeoutMs: 50 }).send(sms).catch((e: unknown) => e);
      expect(slow).toBeInstanceOf(SmsUnavailableError);
      expect((slow as SmsUnavailableError).retryAfterMs).toBeUndefined();
    } finally {
      stub.state.delayMs = 0;
    }
  });
});
Контракт для обоих адаптеров плюс то, что есть только у HTTP-адаптера
test/app.test.ts
import { describe, expect, it } from "vitest";
import { sendBookingConfirmation } from "../src/booking/send-booking-confirmation.ts";
import { AcmeSmsSender } from "../src/notifications/acme-sms-sender.ts";
import { createSmsSender } from "../src/notifications/create-sms-sender.ts";
import { FakeSmsSender } from "../src/notifications/fake-sms-sender.ts";
import { SmsUnavailableError } from "../src/notifications/sms-sender.ts";

const booking = { id: "b-42", service: "Haircut", startsAt: new Date("2026-10-20T10:00:00Z"), clientPhone: "+77011234567" };

describe("sendBookingConfirmation with the fake adapter", () => {
  it("sends one SMS per booking even if the step runs twice", async () => {
    const sms = new FakeSmsSender();
    expect(await sendBookingConfirmation(sms, booking)).toBe("sent");
    expect(await sendBookingConfirmation(sms, booking)).toBe("sent");
    expect(sms.sent).toHaveLength(1);
    expect(sms.sent[0]).toMatchObject({
      to: "+77011234567",
      text: "Booking confirmed: Haircut, 20.10.2026, 15:00",
      idempotencyKey: "booking-confirmed:b-42",
    });
  });

  it("reports a bad phone number instead of failing", async () => {
    const result = await sendBookingConfirmation(new FakeSmsSender(), { ...booking, clientPhone: "8701" });
    expect(result).toBe("invalid-phone");
  });

  it("lets an outage reach the job so it can retry", async () => {
    const sms = new FakeSmsSender();
    sms.failNextWithOutage();
    await expect(sendBookingConfirmation(sms, booking)).rejects.toBeInstanceOf(SmsUnavailableError);
    expect(await sendBookingConfirmation(sms, booking)).toBe("sent");
  });
});

describe("createSmsSender", () => {
  const acmeEnv = { SMS_PROVIDER: "acme", SMS_API_URL: "https://api.acme-sms.example", SMS_API_KEY: "YOUR_API_KEY", SMS_SENDER: "InterLAB" };

  it("picks the adapter from configuration", () => {
    expect(createSmsSender(acmeEnv)).toBeInstanceOf(AcmeSmsSender);
    expect(createSmsSender({ SMS_PROVIDER: "fake", NODE_ENV: "development" })).toBeInstanceOf(FakeSmsSender);
  });

  it("fails at startup on missing or unsafe configuration", () => {
    expect(() => createSmsSender({ ...acmeEnv, SMS_API_KEY: undefined })).toThrow("SMS_API_KEY");
    expect(() => createSmsSender({ SMS_PROVIDER: "fake", NODE_ENV: "production" })).toThrow("not allowed in production");
    expect(() => createSmsSender({})).toThrow('SMS_PROVIDER must be "acme" or "fake"');
  });
});
Сценарий на fake-адаптере и выбор адаптера конфигурацией
terminal
$ npx tsc -p .
$ npx vitest run --reporter=verbose

RUN  v5.0.3 …

 ✓ test/app.test.ts > sendBookingConfirmation with the fake adapter > sends one SMS per booking even if the step runs twice 3ms
 ✓ test/app.test.ts > sendBookingConfirmation with the fake adapter > reports a bad phone number instead of failing 0ms
 ✓ test/app.test.ts > sendBookingConfirmation with the fake adapter > lets an outage reach the job so it can retry 1ms
 ✓ test/app.test.ts > createSmsSender > picks the adapter from configuration 0ms
 ✓ test/app.test.ts > createSmsSender > fails at startup on missing or unsafe configuration 1ms
 ✓ test/sms-senders.test.ts > SmsSender contract: fake > sends a message and returns a receipt 1ms
 ✓ test/sms-senders.test.ts > SmsSender contract: fake > returns the same receipt for the same idempotency key 1ms
 ✓ test/sms-senders.test.ts > SmsSender contract: fake > rejects a number that is not E.164 with RecipientRejectedError 1ms
 ✓ test/sms-senders.test.ts > SmsSender contract: fake > reports an outage as SmsUnavailableError and works again with the same key 0ms
 ✓ test/sms-senders.test.ts > SmsSender contract: AcmeSMS over HTTP > sends a message and returns a receipt 18ms
 ✓ test/sms-senders.test.ts > SmsSender contract: AcmeSMS over HTTP > returns the same receipt for the same idempotency key 26ms
 ✓ test/sms-senders.test.ts > SmsSender contract: AcmeSMS over HTTP > rejects a number that is not E.164 with RecipientRejectedError 0ms
 ✓ test/sms-senders.test.ts > SmsSender contract: AcmeSMS over HTTP > reports an outage as SmsUnavailableError and works again with the same key 16ms
 ✓ test/sms-senders.test.ts > AcmeSmsSender specifics > sends the provider's wire format with the API key 16ms
 ✓ test/sms-senders.test.ts > AcmeSmsSender specifics > maps a provider recipient error to RecipientRejectedError 2ms
 ✓ test/sms-senders.test.ts > AcmeSmsSender specifics > treats a wrong key as a configuration error, not as a domain case 15ms
 ✓ test/sms-senders.test.ts > AcmeSmsSender specifics > passes Retry-After from 503 and treats a timeout as unavailability 78ms

 Test Files  2 passed (2)
      Tests  17 passed (17)
Реальный вывод: проверка типов без ошибок, 17 тестов, контракт выполнен для обоих адаптеров

При проверке рецепта тесты ломали «от обратного»: убирали из fake проверку номера или дедупликацию, из HTTP-адаптера — client_ref, перевод 5xx и таймаута, из фабрики — запрет fake в продакшене. Каждый раз падал профильный тест; расхождение fake с контрактом ловится контрактом, а не тестами сценариев.

Ограничения и чек-лист адаптера

  • Порт описан словами домена: в сигнатурах нет HTTP, имён полей и названия провайдера
  • Ошибки порта соответствуют решениям приложения: «исправить данные» и «повторить позже»; ошибки конфигурации не маскируются под доменные
  • Ключ идемпотентности передаётся провайдеру, если он это поддерживает. Если нет, адаптер не может обещать «не дважды»: нужен журнал отправок на своей стороне, а в контракте — честное ограничение
  • Таймаут на каждый вызов; повторы — в одном месте стека, не в адаптере
  • Ключи — только из переменных окружения или менеджера секретов; в тестах — заглушечные значения
  • Fake проходит тот же контракт, что и боевой адаптер, и запрещён в продакшене
  • Контракт запускается против тестового окружения провайдера по расписанию

Цена подхода — лишний слой и ещё один набор тестов. Для одного вызова в прототипе он избыточен; окупается, когда сервис вызывается из нескольких сценариев, когда тесты не должны зависеть от сети или когда провайдера реально могут сменить. Статусы доставки, которые провайдер присылает вебхуком, — это уже первичный (driving) адаптер на входе в приложение, и в этот рецепт он не входит.

Связанные материалы и источники

  • «Чистая архитектура: зависимости внутрь и границы модулей» — порты и адаптеры на уровне всего приложения
  • «Интеграция с CRM: адаптер, маппинг полей, синхронизация» — тот же подход для интеграции с CRM: маппинг полей, дубли, сверка
  • «Паттерны проектирования, которые реально нужны» — Adapter, Strategy и Factory, из которых собран рецепт
  • «Внешние API: таймауты, повторы, лимиты и деградация» — политика повторов для фоновой задачи
  • «Фоновые задачи и очереди: что выносить из запроса» — где живёт шаг отправки SMS и его повтор

Определения порта, адаптера, шлюза, fake и контрактного теста сверены с первоисточниками на дату в шапке; ссылки — ниже.

Как проверено2 · пройденосверено 12 октября 2026 г.
  • запущено, работает

    tsconfig.json, src/notifications/{sms-sender,acme-sms-sender,fake-sms-sender,create-sms-sender}.ts, src/booking/send-booking-confirmation.ts, test/{sms-sender.contract,acme-stub,sms-senders.test,app.test}.ts, .env.example

    временный проект scratchpad/kb/verify/recept-adapter-integracii: tsc -p . (TypeScript 7.0.2, strict, erasableSyntaxOnly) без ошибок; vitest run — 17/17 (контракт 4 теста × 2 адаптера, специфика HTTP-адаптера 4, сценарий 3, конфигурация 2), три прогона стабильно зелёные; внешний API — локальная заглушка node:http (вымышленный провайдер AcmeSMS); Node.js 24.18.0, Vitest 5.0.3, @types/node 24.19.2. Значения .env.example соответствуют переменным, проверенным в тесте createSmsSender

  • запущено, работает

    мутационная проверка тестов

    по одной мутации: fake без дедупликации (2 теста упали), fake без проверки E.164 (2), HTTP-адаптер без client_ref (2), 5xx как обычная ошибка (2), таймаут без перевода в SmsUnavailableError (1), фабрика без запрета fake в production (1), сценарий без обработки RecipientRejectedError (1); после отката 17/17

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.