How to buildOctober 12, 20267 min read
Software architectureStep 9 of 9

Recipe: a port and an adapter for an external service

In short

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.

Code recipeLevel: AdvancedReusable Architecture PatternsPractice: ~40–90 min

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

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
  1. 01The task: an external API leaks into all the code
  2. 02A port in your domain's language
  3. 03Adapters: HTTP and fake
  4. 04Configuration and wiring
  5. 05How to check it: one contract for both adapters
  6. 06Limitations and an adapter checklist
  7. 07Related materials and sources

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.

  1. 01Use case: booking confirmation
  2. 02SmsSender port
  3. 03AcmeSMS adapter
  4. 04Provider API
The use case only knows the port; the provider's format and errors live in the adapter

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
What the recipe consists of

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

bash
npm init -y
npm pkg set type=module
npm install -D typescript @types/node@24 vitest
No runtime dependencies: the HTTP adapter uses the built-in fetch of 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"]
}
Strict TypeScript; .ts files run with the built-in type stripping of 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);
The port: what the application needs, without HTTP and without the provider's name
  • The port has no HTTP status codes, no client_ref and 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. isE164 is a simplified format check, not a check that the number exists.

Adapters: HTTP and 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}`);
  }
}
The production adapter: the provider's format and the translation of its answers into the port's result and errors

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_ref is safe

429, 5xx

Port result
SmsUnavailableError with retryAfterMs
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
How the adapter translates the provider's answers

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.

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;
  }
}
The fake adapter: a working in-memory implementation with the same behaviour

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

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"}`);
  }
}
The only place that knows the concrete adapters
.env.example
SMS_PROVIDER=acme
SMS_API_URL=https://api.acme-sms.example
SMS_API_KEY=YOUR_API_KEY
SMS_SENDER=InterLAB
Variables for the production adapter; the real key lives only in the environment's secrets
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;
  }
}
The use case depends on the port only
  1. 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.

  2. Pass it to use cases

    Use cases receive an SmsSender as a parameter or through a constructor. Business code must not import an adapter directly — otherwise the port loses its point.

  3. 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.

  4. Switch providers

    A new class implementing SmsSender, one more branch in createSmsSender and one more smsSenderContract call 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.

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) });
    });
  });
}
The port contract: sending, idempotency, an invalid number, an outage and recovery
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());
      }),
  };
}
A local stub of the provider's API on node:http, written from its documentation
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;
    }
  });
});
The contract for both adapters plus what only the HTTP adapter has
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"');
  });
});
The use case on the fake adapter and adapter selection by configuration
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)
Real output: the type check passes, 17 tests, the contract runs for both adapters

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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.