Порт — это интерфейс, которым приложение описывает, что ему нужно от внешнего сервиса, своими словами: «отправь SMS», а не «POST /v2/messages». Адаптер реализует порт для конкретного провайдера и переводит в обе стороны форматы и ошибки; fake-адаптер реализует тот же порт в памяти для тестов. Один контрактный тест прогоняется на обоих адаптерах и гарантирует, что подделка ведёт себя как настоящий адаптер.
После материала вы сможете
- описать порт внешнего сервиса в терминах своего домена: данные, результат и две ошибки, на которые приложение умеет реагировать
- реализовать HTTP-адаптер с переводом формата и ошибок провайдера и fake-адаптер, который ведёт себя так же
- выбирать адаптер конфигурацией при старте и не пускать fake в продакшен
- написать контрактный тест, который проходит любая реализация порта, и подключить к нему оба адаптера
Что знать заранее
Проверено на
TypeScript 7.0.2 (strict)Node.js 24 LTS (24.18), built-in fetchVitest 5.0.3
Сверено с документацией: 12 октября 2026 г.
Содержание
Задача: внешний API протекает по всему коду
Типичная картина: сервис подтверждения записи сам собирает JSON для SMS-провайдера, проверяет response.status === 202 и разбирает его коды ошибок. Через полгода то же делают регистрация, напоминания и сброс пароля. Тесты либо ходят к настоящему провайдеру, либо подменяют глобальный fetch в каждом файле, а смена провайдера означает правку десятка мест в бизнес-логике.
Кокберн в описании гексагональной архитектуры (ports and adapters) советует называть интерфейсы по назначению, а не по технологии, и делать технологии заменяемыми через адаптеры. SMS-провайдер для приложения — вторичный (driven) участник: приложение само его вызывает. Фаулер называет такую обёртку шлюзом (Gateway): объект, который инкапсулирует доступ к внешней системе и переводит её словарь в словарь приложения.
- 01Сценарий: подтверждение записи
- 02Порт SmsSender
- 03Адаптер AcmeSMS
- 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 описан в коде адаптера и в локальной заглушке.
Порт в языке своего домена
npm init -y
npm pkg set type=module
npm install -D typescript @types/node@24 vitestfetch Node.js 24{
"compilerOptions": {
"noEmit": true,
"target": "esnext",
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"strict": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}.ts запускаются встроенным type stripping Node.js 24// 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,
client_refи названия провайдера: если они появились в сигнатуре, адаптер протёк. - Ошибок ровно две, и каждая отвечает на вопрос «что делать приложению».
RecipientRejectedError— повтор не поможет, нужно исправить номер.SmsUnavailableError— сбой временный, можно повторить позже с тем же ключом. - Ключ идемпотентности — часть порта, а не деталь адаптера: повтор после таймаута неизбежен, и порт обещает, что одно сообщение не уйдёт дважды. Как обеспечить это обещание, решает адаптер.
- Номер в формате E.164 (
+, код страны и номер без пробелов) — общий формат порта.isE164— упрощённая проверка формата, а не проверка существования номера.
Адаптеры: HTTP и fake
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.
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 — объект с работающей реализацией, срезающей путь, который не годится для продакшена, а не mock с заранее запрограммированными ожиданиями. Он проверяет формат номера, помнит ключи идемпотентности и умеет изображать сбой. Поэтому тесты сценариев на нём проверяют поведение, а не то, какие методы и с какими аргументами вызывались.
Конфигурация и подключение
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"}`);
}
}SMS_PROVIDER=acme
SMS_API_URL=https://api.acme-sms.example
SMS_API_KEY=YOUR_API_KEY
SMS_SENDER=InterLABimport { 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;
}
}Создать при старте
const sms = createSmsSender(process.env)в точке сборки приложения. Нехватка переменной остановит запуск сразу, а не при первом SMS клиенту.Передать в сценарии
Сценарии получают
SmsSenderпараметром или через конструктор. Импортировать адаптер напрямую из бизнес-кода нельзя — иначе порт теряет смысл.Ключ из домена
Ключ идемпотентности строится из идентификатора бизнес-события:
booking-confirmed:<id>. Повтор шага задачей отправит то же сообщение, а не второе.Сменить провайдера
Новый класс, реализующий
SmsSender, ещё одна ветка вcreateSmsSenderи ещё один вызовsmsSenderContractв тестах. Сценарии и их тесты не меняются.
Как проверить: один контракт для обоих адаптеров
Главный риск fake-адаптера — он расходится с настоящим, и тесты сценариев зеленеют на поведении, которого в продакшене нет. Защита — контрактный тест: функция с набором проверок поведения порта, которую вызывают для каждой реализации. Тест получает «пульт» simulateOutage, потому что сбой у fake и у HTTP-адаптера вызывается по-разному.
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) });
});
});
}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());
}),
};
}node:http, написанная по его документации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;
}
});
});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"');
});
});$ 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)При проверке рецепта тесты ломали «от обратного»: убирали из 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.