A port is an interface in which the application describes what it needs from an external service in its own words: «send an SMS», not «POST /v2/messages». An adapter implements the port for a specific provider and translates formats and errors both ways; a fake adapter implements the same port in memory for tests. One contract test runs against both adapters and guarantees that the fake behaves like the real adapter.
After this you will be able to
- describe the port of an external service in your domain's terms: data, a result and two errors the application knows how to handle
- implement an HTTP adapter that translates the provider's format and errors, and a fake adapter that behaves the same way
- select the adapter by configuration at startup and keep the fake out of production
- write a contract test that any implementation of the port must pass and run it against both adapters
Know beforehand
Verified with
TypeScript 7.0.2 (strict)Node.js 24 LTS (24.18), built-in fetchVitest 5.0.3
Checked against docs: October 12, 2026
Contents
The task: an external API leaks into all the code
A typical picture: the booking confirmation service builds the JSON for the SMS provider itself, checks response.status === 202 and interprets the provider's error codes. Six months later sign-up, reminders and password reset do the same. Tests either call the real provider or replace the global fetch in every file, and switching providers means editing a dozen places in business logic.
In his description of the hexagonal architecture (ports and adapters), Cockburn advises naming interfaces by purpose rather than by technology and making technologies substitutable through adapters. For the application, an SMS provider is a secondary (driven) actor: the application calls it. Fowler calls such a wrapper a Gateway: an object that encapsulates access to an external system and translates its vocabulary into the application's.
- 01Use case: booking confirmation
- 02SmsSender port
- 03AcmeSMS adapter
- 04Provider API
sms-sender.ts
- Role
- The port: message type, receipt, two domain errors
acme-sms-sender.ts
- Role
- The production adapter: HTTP, the provider's format, error translation
fake-sms-sender.ts
- Role
- The fake adapter: a working in-memory implementation for tests and local development
create-sms-sender.ts
- Role
- Selects the adapter from environment variables at startup
sms-sender.contract.ts
- Role
- The port contract: one test suite for all adapters
The clean architecture lesson covers ports and adapters for a whole application, and the patterns article shows an Adapter over a payment SDK. This recipe is a portable kit for a single external service: errors, idempotency, configuration and a contract test included. The AcmeSMS provider is made up; its API is described in the adapter code and in the local stub.
A port in your domain's language
npm init -y
npm pkg set type=module
npm install -D typescript @types/node@24 vitestfetch of 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 files run with the built-in type stripping of 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);- The port has no HTTP status codes, no
client_refand no provider name: if they show up in a signature, the adapter has leaked. - There are exactly two errors, and each answers «what should the application do».
RecipientRejectedError— a retry will not help, the number must be fixed.SmsUnavailableError— the failure is temporary, retry later with the same key. - The idempotency key is part of the port, not an adapter detail: a retry after a timeout is inevitable, and the port promises that one message is never sent twice. How to keep that promise is the adapter's job.
- An E.164 number (
+, country code and number without spaces) is the port's common format.isE164is a simplified format check, not a check that the number exists.
Adapters: HTTP and 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 with message_id
- Port result
SmsReceipt- Why
- The message was accepted
Timeout, dropped connection
- Port result
SmsUnavailableError- Why
- Unknown whether the message was accepted; a retry with the same
client_refis safe
429, 5xx
- Port result
SmsUnavailableErrorwithretryAfterMs- Why
- A temporary failure; the pause comes from
Retry-After
400 with invalid_number, blocked_number, unsubscribed
- Port result
RecipientRejectedError- Why
- The recipient cannot get the message
401, 403, other 4xx
- Port result
- A plain
Error - Why
- A wrong key or a bug in our code — not a business case but a reason for an alert
The adapter does not retry by itself. Retries belong in one place of the stack — here, the background job that sends the confirmation: it sees SmsUnavailableError, waits retryAfterMs and runs the step again. If both the adapter and the job retry, the number of requests to a failing provider multiplies; the full retry policy is covered in the external API article.
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;
}
}In Fowler's classification this is a fake — an object with a working implementation that takes a shortcut unsuitable for production — not a mock pre-programmed with expectations. It checks the number format, remembers idempotency keys and can pretend to fail. So use case tests on top of it check behaviour, not which methods were called with which arguments.
Configuration and wiring
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;
}
}Create at startup
const sms = createSmsSender(process.env)at the application's composition point. A missing variable stops the start right away, not on the first SMS to a client.Pass it to use cases
Use cases receive an
SmsSenderas a parameter or through a constructor. Business code must not import an adapter directly — otherwise the port loses its point.A key from the domain
The idempotency key is built from the business event id:
booking-confirmed:<id>. When the job repeats the step, the same message is sent, not a second one.Switch providers
A new class implementing
SmsSender, one more branch increateSmsSenderand one moresmsSenderContractcall in the tests. Use cases and their tests stay unchanged.
How to check it: one contract for both adapters
The main risk of a fake adapter is that it drifts from the real one, and use case tests go green on behaviour that does not exist in production. The protection is a contract test: a function with a set of checks of the port's behaviour, called for every implementation. The test gets a «remote control», simulateOutage, because an outage is triggered differently for the fake and for the HTTP adapter.
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, written from its documentationimport { 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)While checking the recipe, the tests were verified the other way round: we removed the number check or deduplication from the fake, client_ref, the 5xx and timeout translation from the HTTP adapter, and the production guard from the factory. Each time a relevant test failed; the fake drifting from the contract is caught by the contract, not by use case tests.
Limitations and an adapter checklist
- The port speaks the domain's language: no HTTP, field names or provider name in the signatures
- The port's errors match the application's decisions: «fix the data» and «retry later»; configuration errors are not disguised as domain ones
- The idempotency key is passed to the provider if it supports one. If not, the adapter cannot promise «never twice»: you need a send log on your side and an honest limitation in the contract
- A timeout on every call; retries in one place of the stack, not in the adapter
- Keys come only from environment variables or a secrets manager; tests use placeholder values
- The fake passes the same contract as the production adapter and is forbidden in production
- The contract runs against the provider's test environment on a schedule
The price of the approach is an extra layer and one more test suite. For a single call in a prototype it is overkill; it pays off when the service is called from several use cases, when tests must not depend on the network, or when the provider may really be replaced. Delivery statuses that the provider sends via a webhook are a primary (driving) adapter at the application's entrance and are not part of this recipe.
Related materials and sources
- «Clean architecture: dependencies point inward, module boundaries» — ports and adapters at the level of a whole application
- «CRM integration: adapter, field mapping, synchronisation» — the same approach for a CRM integration: field mapping, duplicates, reconciliation
- «Design patterns you actually need» — Adapter, Strategy and Factory, the building blocks of this recipe
- «External APIs: timeouts, retries, rate limits and graceful degradation» — the retry policy for the background job
- «Background jobs and queues: what to move out of the request» — where the SMS step and its retries live
The definitions of port, adapter, gateway, fake and contract test were checked against the original sources on the date in the header; the links are below.
How this was verified2 · passedchecked 12 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.