How to buildOctober 11, 20269 min read
JavaStep 4 of 6

Design patterns you actually need

In short

A design pattern is a named, reusable solution to a recurring design problem: Strategy swaps an algorithm, Adapter translates a foreign API into your interface, Factory creates an object from configuration, Observer delivers events to subscribers, Decorator adds behaviour around a call, Repository hides storage, Builder assembles a complex object step by step. A pattern's value lies in the team's shared vocabulary and in the problem it solves. Without the problem — say, a rule with a single implementation — a pattern only adds code.

FundamentalsLevel: IntermediateDesign Patterns

After this you will be able to

  • apply Strategy, Adapter, Factory, Observer, Decorator, Repository and Builder to payment, notification and integration tasks in TypeScript
  • plug in a third-party provider SDK through an adapter so its types, units and errors never leave one file
  • build a chain of decorators (retry, cache, middleware) and explain why only idempotent calls may be retried
  • tell a pattern that removes complexity from a pattern for the pattern's sake, and replace a needless hierarchy with a function

Verified with

TypeScript 7.0.2 (tsc --strict)Node.js 24 LTS (24.18), node:testJava 21 LTS (eclipse-temurin 21.0.12)

Checked against docs: October 11, 2026

Contents
  1. 01A pattern is shared vocabulary, not a recipe
  2. 02Strategy: delivery and pricing options
  3. 03Adapter: a foreign API behind your interface
  4. 04Factory: creating an object from configuration
  5. 05Observer and Pub-Sub: order events
  6. 06Decorator and Middleware: behaviour around a call
  7. 07Repository and Builder
  8. 08Anti-pattern: a pattern for the pattern's sake

A pattern is shared vocabulary, not a recipe

The idea of patterns came from building architecture: in "A Pattern Language", Christopher Alexander described recurring solutions for towns and houses as a "language". In software it was established by "Design Patterns: Elements of Reusable Object-Oriented Software" (1994) by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides — the Gang of Four, GoF — with a catalogue of 23 patterns. Later, Martin Fowler's "Patterns of Enterprise Application Architecture" described enterprise application patterns, including Repository and Gateway.

A pattern's main benefit is vocabulary. "Wrap the SDK in an adapter" replaces a paragraph of explanation and immediately says where format translation lives. A pattern is not a library and not code to copy: in TypeScript many solutions from the 1994 book look simpler because functions are ordinary values. Below are seven patterns that keep showing up in web and mobile development, applied to orders, payments, notifications and integrations.

Several ways to calculate or pay

Pattern
Strategy
Where it shows up
Delivery, discounts, tariffs, payment methods

A foreign API must fit your interface

Pattern
Adapter
Where it shows up
Payment providers, accounting systems, CRM

An object is created from configuration or environment

Pattern
Factory
Where it shows up
Notification channel, storage client

Several modules react to one event

Pattern
Observer / Pub-Sub
Where it shows up
"Order paid" → kitchen, SMS, loyalty points

Behaviour around a call without editing the call

Pattern
Decorator / Middleware
Where it shows up
Retries, cache, logs, request id

Data as a collection, no SQL in business logic

Pattern
Repository
Where it shows up
Services and their tests

A complex object assembled in steps with validation

Pattern
Builder
Where it shows up
Receipts, API requests, test data

The project is set up as in the SOLID lesson: TypeScript 7 in strict mode checks types, Node.js 24 runs .ts files and node:test tests without a build step.

bash
npm init -y
npm pkg set type=module
npm install --save-dev typescript@7.0.2 @types/node@24
npx tsc --noEmit
node --test
Project setup
tsconfig.json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "noImplicitOverride": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
The same strict tsconfig as in the SOLID lesson

Strategy: delivery and pricing options

The problem: the delivery fee depends on the method — pickup is free, a courier is charged per kilometre, and above a certain order total the courier is free. Marketing changes these rules, and often. If all of it lives in a switch inside checkout, every promotion means editing checkout. Strategy moves each algorithm into a separate, interchangeable object with a common interface.

In TypeScript a strategy is most often just a function type. The TypeScript handbook calls these function type expressions: (cart: Cart) => number means "a function that takes a cart and returns a number". Strategies compose: freeOver takes another strategy and returns a new one.

src/strategy.ts
// Amounts are in tenge, integers. Fees are illustrative.
export type Cart = { subtotal: number; distanceKm: number };

// A strategy is just a function type: one way to price delivery.
export type DeliveryPricing = (cart: Cart) => number;

export const pickup: DeliveryPricing = () => 0;

export const courier =
  (baseFee: number, perKm: number): DeliveryPricing =>
  (cart) =>
    baseFee + Math.ceil(cart.distanceKm) * perKm;

// Strategies compose: free delivery over a threshold, otherwise another rule.
export const freeOver =
  (threshold: number, otherwise: DeliveryPricing): DeliveryPricing =>
  (cart) =>
    cart.subtotal >= threshold ? 0 : otherwise(cart);

export type DeliveryMethod = "pickup" | "courier";

// The choice is data, not a switch inside checkout.
export const pricingByMethod: Record<DeliveryMethod, DeliveryPricing> = {
  pickup,
  courier: freeOver(15_000, courier(700, 150)),
};

export function checkoutTotal(cart: Cart, method: DeliveryMethod): number {
  return cart.subtotal + pricingByMethod[method](cart);
}
Delivery strategies. Amounts in tenge, fees are illustrative. Choosing a strategy is data (a Record), not branching
test/strategy.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkoutTotal, courier, freeOver, pickup } from "../src/strategy.ts";

test("each strategy prices delivery on its own", () => {
  const cart = { subtotal: 8_000, distanceKm: 3.2 };
  assert.equal(pickup(cart), 0);
  assert.equal(courier(700, 150)(cart), 700 + 4 * 150);
  assert.equal(freeOver(5_000, courier(700, 150))(cart), 0);
});

test("checkout picks the strategy by the delivery method", () => {
  assert.equal(checkoutTotal({ subtotal: 8_000, distanceKm: 3.2 }, "courier"), 9_300);
  assert.equal(checkoutTotal({ subtotal: 16_000, distanceKm: 3.2 }, "courier"), 16_000);
  assert.equal(checkoutTotal({ subtotal: 8_000, distanceKm: 3.2 }, "pickup"), 8_000);
});
Each strategy is tested on its own, checkout with selection by delivery method

A class instead of a function is needed when a strategy has several methods or its own state — like the notification channels in the SOLID lesson, where a channel both answers "can you deliver?" and does the sending. In Java the same is expressed with a functional interface — an interface with a single abstract method, implemented by a lambda:

DeliveryPricingDemo.java
import java.util.Map;

public class DeliveryPricingDemo {
    record Cart(long subtotal, double distanceKm) {}

    // A functional interface: any lambda with this shape is a strategy.
    @FunctionalInterface
    interface DeliveryPricing {
        long price(Cart cart);
    }

    enum DeliveryMethod { PICKUP, COURIER }

    static DeliveryPricing courier(long baseFee, long perKm) {
        return cart -> baseFee + (long) Math.ceil(cart.distanceKm()) * perKm;
    }

    static DeliveryPricing freeOver(long threshold, DeliveryPricing otherwise) {
        return cart -> cart.subtotal() >= threshold ? 0 : otherwise.price(cart);
    }

    static final Map<DeliveryMethod, DeliveryPricing> PRICING = Map.of(
        DeliveryMethod.PICKUP, cart -> 0,
        DeliveryMethod.COURIER, freeOver(15_000, courier(700, 150))
    );

    static long checkoutTotal(Cart cart, DeliveryMethod method) {
        return cart.subtotal() + PRICING.get(method).price(cart);
    }

    public static void main(String[] args) {
        var cart = new Cart(8_000, 3.2);
        long courierTotal = checkoutTotal(cart, DeliveryMethod.COURIER);
        long pickupTotal = checkoutTotal(cart, DeliveryMethod.PICKUP);
        if (courierTotal != 9_300 || pickupTotal != 8_000) throw new AssertionError("wrong totals");
        System.out.println("courier: " + courierTotal + ", pickup: " + pickupTotal);
    }
}
Strategy in Java 21: a functional interface, lambdas and a lookup table built with Map.of
terminal
$ javac -encoding UTF-8 -d out DeliveryPricingDemo.java && java -cp out DeliveryPricingDemo
courier: 9300, pickup: 8000
Actual output in the eclipse-temurin:21 Docker image

Adapter: a foreign API behind your interface

Every payment provider has its own SDK: its own field names, amounts in minor units (tiyn), its own statuses and errors. If those types spread through checkout, switching providers turns into rewriting half the code. Adapter converts the interface of a foreign class into the one your code expects. Fowler describes a close pattern, Gateway — an object that encapsulates access to an external system; in practice a provider gateway is usually implemented as an adapter.

src/payments.ts
// Our interface: what checkout needs, in our terms (amounts in tenge).
export type PaymentLink = { paymentId: string; payUrl: string };

export interface PaymentGateway {
  createPayment(orderId: string, amount: number): Promise<PaymentLink>;
}

export class PaymentDeclinedError extends Error {}
Our interface: what checkout needs, in our units
src/acme-pay-client.ts
// The shape of a third-party SDK (a made-up "AcmePay"): its names, its units.
export type AcmeInvoice = {
  invoice: { id: string; pay_url: string; state: "created" | "rejected" };
  reject_reason?: string;
};

export interface AcmePayClient {
  createInvoice(request: {
    merchant_order_id: string;
    amount_minor: number; // tiyn: 1 tenge = 100 tiyn
    currency: "KZT";
  }): Promise<AcmeInvoice>;
}
The shape of a made-up provider's SDK, AcmePay — any third-party client looks like this
src/acme-pay-adapter.ts
import type { AcmePayClient } from "./acme-pay-client.ts";
import { PaymentDeclinedError, type PaymentGateway, type PaymentLink } from "./payments.ts";

// Adapter: our PaymentGateway on top of the AcmePay SDK.
// Units, field names and errors are translated here and nowhere else.
export class AcmePayAdapter implements PaymentGateway {
  readonly #client: AcmePayClient;

  constructor(client: AcmePayClient) {
    this.#client = client;
  }

  async createPayment(orderId: string, amount: number): Promise<PaymentLink> {
    const response = await this.#client.createInvoice({
      merchant_order_id: orderId,
      amount_minor: Math.round(amount * 100),
      currency: "KZT",
    });
    if (response.invoice.state === "rejected") {
      throw new PaymentDeclinedError(response.reject_reason ?? "Payment rejected");
    }
    return { paymentId: response.invoice.id, payUrl: response.invoice.pay_url };
  }
}
The adapter: translation of units, field names and errors in one file
test/adapter.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import type { AcmePayClient } from "../src/acme-pay-client.ts";
import { AcmePayAdapter } from "../src/acme-pay-adapter.ts";
import { PaymentDeclinedError } from "../src/payments.ts";

test("translates our call into the SDK request and back", async () => {
  const requests: unknown[] = [];
  const client: AcmePayClient = {
    createInvoice: async (request) => {
      requests.push(request);
      return { invoice: { id: "inv_1", pay_url: "https://pay.example.com/inv_1", state: "created" } };
    },
  };

  const link = await new AcmePayAdapter(client).createPayment("order-17", 9_300);

  assert.deepEqual(requests, [{ merchant_order_id: "order-17", amount_minor: 930_000, currency: "KZT" }]);
  assert.deepEqual(link, { paymentId: "inv_1", payUrl: "https://pay.example.com/inv_1" });
});

test("a rejected invoice becomes our domain error", async () => {
  const client: AcmePayClient = {
    createInvoice: async () => ({
      invoice: { id: "inv_2", pay_url: "", state: "rejected" },
      reject_reason: "limit exceeded",
    }),
  };
  await assert.rejects(new AcmePayAdapter(client).createPayment("order-18", 100), PaymentDeclinedError);
});
Adapter tests with a fake SDK: translation both ways and turning a rejection into a domain error

A second provider is a second adapter with the same interface, and checkout does not change. Checkout tests use a fake PaymentGateway and know nothing about providers.

Factory: creating an object from configuration

Locally, notifications should print to the console; on staging they go to Telegram; in production, by SMS. If new TelegramNotifier(...) and environment checks are scattered across the code, switching channels touches many files. Factory puts the decision about which class to create in one place. In the GoF book this is Factory Method — a subclass method; in TypeScript a factory function is usually enough.

src/notifier-factory.ts
export interface Notifier {
  notify(to: string, text: string): Promise<void>;
}

// Configuration usually comes from the environment and differs per stage.
export type NotifierConfig =
  | { channel: "telegram"; token: string }
  | { channel: "sms"; endpoint: string; apiKey: string }
  | { channel: "console" };

class TelegramNotifier implements Notifier {
  readonly #token: string;
  constructor(token: string) {
    this.#token = token;
  }
  async notify(chatId: string, text: string): Promise<void> {
    const response = await fetch(`https://api.telegram.org/bot${this.#token}/sendMessage`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ chat_id: chatId, text }),
    });
    if (!response.ok) throw new Error(`Telegram responded ${response.status}`);
  }
}

// The request format depends on the SMS provider; this one is illustrative.
class SmsNotifier implements Notifier {
  readonly #endpoint: string;
  readonly #apiKey: string;
  constructor(endpoint: string, apiKey: string) {
    this.#endpoint = endpoint;
    this.#apiKey = apiKey;
  }
  async notify(phone: string, text: string): Promise<void> {
    const response = await fetch(this.#endpoint, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: `Bearer ${this.#apiKey}` },
      body: JSON.stringify({ to: phone, text }),
    });
    if (!response.ok) throw new Error(`SMS provider responded ${response.status}`);
  }
}

class ConsoleNotifier implements Notifier {
  async notify(to: string, text: string): Promise<void> {
    console.log(`[notify] ${to}: ${text}`);
  }
}

// Factory: the only place that knows which class matches which config.
export function createNotifier(config: NotifierConfig): Notifier {
  switch (config.channel) {
    case "telegram":
      return new TelegramNotifier(config.token);
    case "sms":
      return new SmsNotifier(config.endpoint, config.apiKey);
    case "console":
      return new ConsoleNotifier();
    default: {
      const unknown: never = config;
      throw new Error(`Unknown notifier config: ${JSON.stringify(unknown)}`);
    }
  }
}

// Reads process.env-like input and fails fast on missing settings.
export function notifierConfigFromEnv(env: Record<string, string | undefined>): NotifierConfig {
  const channel = env.NOTIFY_CHANNEL ?? "console";
  if (channel === "console") return { channel };
  if (channel === "telegram" && env.TELEGRAM_TOKEN) return { channel, token: env.TELEGRAM_TOKEN };
  if (channel === "sms" && env.SMS_ENDPOINT && env.SMS_API_KEY) {
    return { channel, endpoint: env.SMS_ENDPOINT, apiKey: env.SMS_API_KEY };
  }
  throw new Error(`Notifier "${channel}" is not configured`);
}
The notifier factory. The config is a discriminated union; the never exhaustiveness check will not let you forget a new channel
test/factory.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { createNotifier, notifierConfigFromEnv } from "../src/notifier-factory.ts";

test("without settings the factory gives a console notifier for local runs", async (t) => {
  const log = t.mock.method(console, "log", () => {});
  const notifier = createNotifier(notifierConfigFromEnv({}));
  await notifier.notify("+77010000000", "Order 17 is ready");
  assert.equal(log.mock.calls[0]?.arguments[0], "[notify] +77010000000: Order 17 is ready");
});

test("the telegram config produces a notifier that calls the Bot API", async (t) => {
  const fetchMock = t.mock.method(globalThis, "fetch", async () => new Response("{}"));
  const notifier = createNotifier(notifierConfigFromEnv({ NOTIFY_CHANNEL: "telegram", TELEGRAM_TOKEN: "TEST_TOKEN" }));
  await notifier.notify("42", "Order 17 is ready");
  assert.equal(String(fetchMock.mock.calls[0]?.arguments[0]), "https://api.telegram.org/botTEST_TOKEN/sendMessage");
});

test("a missing secret fails at startup, not on the first message", () => {
  assert.throws(() => notifierConfigFromEnv({ NOTIFY_CHANNEL: "sms", SMS_API_KEY: "YOUR_API_KEY" }), /"sms" is not configured/);
});

test("the sms config posts to the provider with the key in a header", async (t) => {
  const fetchMock = t.mock.method(globalThis, "fetch", async () => new Response("{}"));
  const notifier = createNotifier(
    notifierConfigFromEnv({ NOTIFY_CHANNEL: "sms", SMS_ENDPOINT: "https://sms.example.com/send", SMS_API_KEY: "YOUR_API_KEY" }),
  );
  await notifier.notify("+77010000000", "Order 17 is ready");
  const [url, init] = fetchMock.mock.calls[0]?.arguments ?? [];
  assert.equal(String(url), "https://sms.example.com/send");
  assert.deepEqual(init?.headers, { "content-type": "application/json", authorization: "Bearer YOUR_API_KEY" });
});
Tests: console by default, Telegram and SMS by configuration, a missing key fails at startup rather than on the first message. The network is faked

Note notifierConfigFromEnv: the factory fails when the application starts if a secret is missing. A configuration error is visible right after deployment, not an hour later when the first client does not get an SMS.

Observer and Pub-Sub: order events

After an order is paid, it must go to the kitchen, the client must get an SMS, and loyalty points must be added. If checkout calls all three modules directly, it knows about all of them, and analytics or a receipt is added by editing checkout. Observer provides a subscription mechanism: the publisher announces an event, subscribers react, and the publisher does not know who they are. People often distinguish Observer, where subscribers subscribe to a specific object, from Pub-Sub, where publishers and subscribers communicate through an intermediary — a bus or a message broker. The bus below is in-process Pub-Sub.

src/order-events.ts
// Event name → payload. One place to see what the order module announces.
export type OrderEvents = {
  "order.paid": { orderId: string; amount: number; phone: string };
  "order.cancelled": { orderId: string; reason: string };
};
The order module's event catalogue: event name → payload
src/event-bus.ts
type Handler<T> = (payload: T) => void | Promise<void>;

// A typed in-process event bus. Events maps an event name to its payload.
export class EventBus<Events extends Record<string, unknown>> {
  readonly #handlers = new Map<keyof Events, Set<Handler<never>>>();

  on<K extends keyof Events>(type: K, handler: Handler<Events[K]>): () => void {
    const set = this.#handlers.get(type) ?? new Set();
    set.add(handler);
    this.#handlers.set(type, set);
    return () => set.delete(handler); // unsubscribe
  }

  // Runs every handler, even if some fail, and reports the failures.
  async emit<K extends keyof Events>(type: K, payload: Events[K]): Promise<unknown[]> {
    const handlers = [...(this.#handlers.get(type) ?? [])] as Handler<Events[K]>[];
    const results = await Promise.allSettled(handlers.map(async (handler) => handler(payload)));
    return results.flatMap((r) => (r.status === "rejected" ? [r.reason] : []));
  }
}
A typed event bus: TypeScript will not let you subscribe to a non-existent event or pass a payload of the wrong shape
test/observer.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { EventBus } from "../src/event-bus.ts";
import type { OrderEvents } from "../src/order-events.ts";

test("checkout publishes one event, subscribers react independently", async () => {
  const bus = new EventBus<OrderEvents>();
  const log: string[] = [];
  bus.on("order.paid", ({ orderId }) => void log.push(`kitchen: start ${orderId}`));
  bus.on("order.paid", async ({ phone, amount }) => void log.push(`sms ${phone}: paid ${amount}`));
  bus.on("order.paid", ({ amount }) => void log.push(`loyalty: +${Math.floor(amount / 100)} points`));

  const failures = await bus.emit("order.paid", { orderId: "17", amount: 9_300, phone: "+77010000000" });

  assert.deepEqual(failures, []);
  assert.deepEqual(log, ["kitchen: start 17", "sms +77010000000: paid 9300", "loyalty: +93 points"]);
});

test("a failing subscriber does not stop the others", async () => {
  const bus = new EventBus<OrderEvents>();
  const log: string[] = [];
  bus.on("order.paid", async () => {
    throw new Error("SMS provider is down");
  });
  bus.on("order.paid", ({ orderId }) => void log.push(`kitchen: start ${orderId}`));

  const failures = await bus.emit("order.paid", { orderId: "18", amount: 500, phone: "+77010000000" });

  assert.deepEqual(log, ["kitchen: start 18"]);
  assert.equal(failures.length, 1);
  assert.match(String(failures[0]), /SMS provider is down/);
});

test("unsubscribe removes the handler", async () => {
  const bus = new EventBus<OrderEvents>();
  let calls = 0;
  const off = bus.on("order.cancelled", () => void calls++);
  off();
  await bus.emit("order.cancelled", { orderId: "19", reason: "client" });
  assert.equal(calls, 0);
});

test("payload types are checked by the compiler", async () => {
  const bus = new EventBus<OrderEvents>();
  // @ts-expect-error amount must be a number
  await bus.emit("order.paid", { orderId: "20", amount: "9300", phone: "+77010000000" });
  // @ts-expect-error there is no such event
  bus.on("order.shipped", () => {});
});
Tests: subscribers work independently, one failure does not stop the others, unsubscribe works, type errors are caught by the compiler (@ts-expect-error)

Why not the built-in EventEmitter? Node.js has one, and it calls listeners synchronously in registration order. The Node.js docs warn that async handlers are problematic with it: an exception inside becomes an unhandled promise rejection unless captureRejections is enabled. Our bus awaits all handlers with Promise.allSettled and returns the errors to the caller. In the browser, EventTarget plays the same role.

Decorator and Middleware: behaviour around a call

The accounting system's stock API sometimes times out, and stock is requested on every product page. You need retries and a short cache without touching the warehouse client or the page code. Decorator wraps an object in another object with the same interface and adds behaviour before or after the call. Decorators stack into a chain, and the calling code sees no difference.

src/stock.ts
// Warehouse integration (an accounting system, an ERP): read-only stock lookup.
export interface StockApi {
  getStock(sku: string): Promise<number>;
}

// Decorator: same interface, plus retries. Only for idempotent reads.
export function withRetry(api: StockApi, attempts: number): StockApi {
  return {
    async getStock(sku) {
      let lastError: unknown;
      for (let i = 0; i < attempts; i++) {
        try {
          return await api.getStock(sku);
        } catch (error) {
          lastError = error;
        }
      }
      throw lastError;
    },
  };
}

// Decorator: same interface, plus a short-lived cache. `now` is injectable for tests.
export function withCache(api: StockApi, ttlMs: number, now: () => number = Date.now): StockApi {
  const cache = new Map<string, { value: number; expiresAt: number }>();
  return {
    async getStock(sku) {
      const hit = cache.get(sku);
      if (hit && hit.expiresAt > now()) return hit.value;
      const value = await api.getStock(sku);
      cache.set(sku, { value, expiresAt: now() + ttlMs });
      return value;
    },
  };
}
Retry and cache decorators for the stock API. Time is a parameter so the test does not wait a minute

Middleware in Express, Koa and other frameworks is the same technique for HTTP handlers. According to the Express docs, middleware receives the request, the response and next; it can run code, modify the request and response, end the cycle or pass control on — and if it does not end the cycle, it must call next(), otherwise the request is left hanging. Below is a functional version on the standard web Request and Response objects, which Next.js route handlers, for example, are built on.

src/middleware.ts
// The same idea for HTTP handlers: a middleware wraps the next handler.
export type Handler = (request: Request) => Promise<Response>;
export type Middleware = (next: Handler) => Handler;

export const withRequestId: Middleware = (next) => async (request) => {
  const id = request.headers.get("x-request-id") ?? crypto.randomUUID();
  const response = await next(request);
  response.headers.set("x-request-id", id);
  return response;
};

export const withErrorBoundary: Middleware = (next) => async (request) => {
  try {
    return await next(request);
  } catch {
    return Response.json({ error: "internal_error" }, { status: 500 });
  }
};

// compose(a, b)(handler) = a(b(handler)): the first middleware is the outermost.
export const compose =
  (...middlewares: Middleware[]): Middleware =>
  (handler) =>
    middlewares.reduceRight((next, middleware) => middleware(next), handler);
Middleware as a decorator of a handler function. Headers can be changed on a response created with Response or Response.json; on a response from fetch() they are immutable (MDN, Headers)
test/decorator.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { withCache, withRetry, type StockApi } from "../src/stock.ts";
import { compose, withErrorBoundary, withRequestId, type Handler } from "../src/middleware.ts";

// A flaky warehouse: fails the first `failures` calls, counts every call.
function flakyWarehouse(failures: number) {
  let calls = 0;
  const api: StockApi = {
    async getStock() {
      calls++;
      if (calls <= failures) throw new Error("timeout");
      return 12;
    },
  };
  return { api, calls: () => calls };
}

test("retry hides two timeouts", async () => {
  const warehouse = flakyWarehouse(2);
  assert.equal(await withRetry(warehouse.api, 3).getStock("latte-cup"), 12);
  assert.equal(warehouse.calls(), 3);
});

test("retry gives up after the last attempt", async () => {
  const warehouse = flakyWarehouse(5);
  await assert.rejects(withRetry(warehouse.api, 3).getStock("latte-cup"), /timeout/);
});

test("decorators stack: cache over retry over the real client", async () => {
  let time = 0;
  const warehouse = flakyWarehouse(1);
  const stock = withCache(withRetry(warehouse.api, 3), 60_000, () => time);

  assert.equal(await stock.getStock("latte-cup"), 12); // 1 failure + 1 success
  assert.equal(await stock.getStock("latte-cup"), 12); // from cache
  assert.equal(warehouse.calls(), 2);

  time = 61_000; // cache expired
  await stock.getStock("latte-cup");
  assert.equal(warehouse.calls(), 3);
});

test("middleware: request id and error boundary around a handler", async () => {
  const failing: Handler = async () => {
    throw new Error("db is down");
  };
  const handler = compose(withRequestId, withErrorBoundary)(failing);

  const response = await handler(new Request("https://shop.example.com/api/orders", { headers: { "x-request-id": "req-1" } }));

  assert.equal(response.status, 500);
  assert.equal(response.headers.get("x-request-id"), "req-1");
  assert.deepEqual(await response.json(), { error: "internal_error" });
});
Tests: retry hides two timeouts, cache over retry saves calls, middleware turns an exception into a 500 response with a request id

Repository and Builder

Repository, in Fowler's definition, mediates between the domain and data mapping layers using a collection-like interface. A service says "find the order" and "save the order" and does not know whether the data lives in PostgreSQL, an external API or memory. The pattern is covered in depth — with transactions, PostgreSQL and tests — in the lesson on backend application layers; here is just the essence.

src/order-repository.ts
export type Order = { id: string; status: "new" | "paid" | "cancelled"; total: number };

// Repository: a collection-like interface in domain terms. The service does
// not know whether orders live in PostgreSQL, an API or memory.
export interface OrderRepository {
  findById(id: string): Promise<Order | null>;
  save(order: Order): Promise<void>;
}

export class InMemoryOrderRepository implements OrderRepository {
  readonly #orders = new Map<string, Order>();

  async findById(id: string): Promise<Order | null> {
    const order = this.#orders.get(id);
    return order ? { ...order } : null; // a copy: callers cannot mutate storage
  }

  async save(order: Order): Promise<void> {
    this.#orders.set(order.id, { ...order });
  }
}
A repository contract and an in-memory implementation for tests. Returning copies protects storage from accidental changes

Builder assembles a complex object step by step and validates it once, at the end. Example: a receipt. Items are added one by one, payment and email are separate steps, and the rule "payment equals the item total" cannot be checked until all parts are known. Methods return this, so calls chain.

src/receipt-builder.ts
export type ReceiptItem = { name: string; quantity: number; price: number };
export type Receipt = {
  orderId: string;
  items: ReceiptItem[];
  total: number;
  payment: { method: "card" | "cash"; amount: number };
  email?: string;
};

// Builder: assemble a receipt step by step, validate once in build().
export class ReceiptBuilder {
  readonly #orderId: string;
  readonly #items: ReceiptItem[] = [];
  #payment?: Receipt["payment"];
  #email?: string;

  constructor(orderId: string) {
    this.#orderId = orderId;
  }

  item(name: string, quantity: number, price: number): this {
    if (quantity <= 0 || price < 0) throw new Error(`Bad item: ${name}`);
    this.#items.push({ name, quantity, price });
    return this;
  }

  paidBy(method: "card" | "cash", amount: number): this {
    this.#payment = { method, amount };
    return this;
  }

  sendTo(email: string): this {
    this.#email = email;
    return this;
  }

  build(): Receipt {
    if (this.#items.length === 0) throw new Error("Receipt has no items");
    if (!this.#payment) throw new Error("Receipt has no payment");
    const total = this.#items.reduce((sum, i) => sum + i.quantity * i.price, 0);
    if (this.#payment.amount !== total) {
      throw new Error(`Payment ${this.#payment.amount} does not match total ${total}`);
    }
    return {
      orderId: this.#orderId,
      items: [...this.#items],
      total,
      payment: this.#payment,
      ...(this.#email ? { email: this.#email } : {}),
    };
  }
}
A receipt builder: one step at a time, integrity check in build()
test/repository-builder.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { InMemoryOrderRepository } from "../src/order-repository.ts";
import { ReceiptBuilder } from "../src/receipt-builder.ts";

test("repository returns copies, not live objects", async () => {
  const orders = new InMemoryOrderRepository();
  await orders.save({ id: "17", status: "new", total: 9_300 });

  const order = await orders.findById("17");
  assert.ok(order);
  order.status = "paid"; // not saved yet
  assert.equal((await orders.findById("17"))?.status, "new");

  await orders.save(order);
  assert.equal((await orders.findById("17"))?.status, "paid");
  assert.equal(await orders.findById("404"), null);
});

test("builder assembles a valid receipt", () => {
  const receipt = new ReceiptBuilder("17")
    .item("Latte 0.4", 2, 1_800)
    .item("Croissant", 1, 1_200)
    .paidBy("card", 4_800)
    .sendTo("client@example.com")
    .build();

  assert.equal(receipt.total, 4_800);
  assert.equal(receipt.items.length, 2);
  assert.equal(receipt.email, "client@example.com");
});

test("builder refuses an inconsistent receipt", () => {
  const builder = new ReceiptBuilder("18").item("Latte 0.4", 1, 1_800).paidBy("cash", 2_000);
  assert.throws(() => builder.build(), /Payment 2000 does not match total 1800/);
  assert.throws(() => new ReceiptBuilder("19").paidBy("card", 0).build(), /no items/);
});
Repository and builder tests

Anti-pattern: a pattern for the pattern's sake

Refactoring.Guru, a reference many courses point to, lists three typical criticisms of patterns. First: patterns are sometimes kludges for a language that lacks expressiveness; in languages with first-class functions Strategy is just a lambda. Second: patterns get implemented "by the book" without adapting them to the project. Third: a novice who has just learned patterns applies them everywhere — "if all you have is a hammer, everything looks like a nail". Here is what that looks like in code.

src/overengineered.ts
// Before: an interface, a class, a factory interface, a factory and a service
// for one rule that has exactly one implementation.
interface DiscountStrategy {
  apply(total: number): number;
}

class TenPercentDiscountStrategy implements DiscountStrategy {
  apply(total: number): number {
    return Math.round(total * 0.9);
  }
}

interface DiscountStrategyFactory {
  create(): DiscountStrategy;
}

export class DefaultDiscountStrategyFactory implements DiscountStrategyFactory {
  create(): DiscountStrategy {
    return new TenPercentDiscountStrategy();
  }
}

export class DiscountService {
  readonly #factory: DiscountStrategyFactory;
  constructor(factory: DiscountStrategyFactory) {
    this.#factory = factory;
  }
  apply(total: number): number {
    return this.#factory.create().apply(total);
  }
}
Before: an interface, a class, a factory interface, a factory and a service for one rule with one implementation
src/simple.ts
// After: a function. When a second rule appears, its type becomes the strategy.
export const applyDiscount = (total: number): number => Math.round(total * 0.9);
After: a function. When a second rule appears, its type becomes the strategy
test/overengineered.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { DefaultDiscountStrategyFactory, DiscountService } from "../src/overengineered.ts";
import { applyDiscount } from "../src/simple.ts";

test("five constructs and one function give the same result", () => {
  const service = new DiscountService(new DefaultDiscountStrategyFactory());
  for (const total of [0, 1_800, 4_805, 9_300]) {
    assert.equal(service.apply(total), applyDiscount(total));
  }
});
The test confirms the result is identical
  • A second variant already exists, or change along this axis has already happened
  • The pattern removes duplication or a growing switch rather than adding files
  • Tests got simpler after introducing it, not harder
  • A new developer understands the code from the pattern name without a walkthrough
  • The language or framework does not already solve this with a built-in feature

The last point matters: some patterns have become part of languages and platforms. Iterator is built into JavaScript through the iteration protocols, for...of and generators. Observer is EventTarget in the browser and EventEmitter in Node.js. Strategy and Command are plain functions. Decorator for HTTP is middleware in any server framework. Before writing a pattern by hand, check whether you already have it. Checking the whole lesson project:

terminal
$ npx tsc --noEmit && node --test
✔ translates our call into the SDK request and back
✔ a rejected invoice becomes our domain error
✔ retry hides two timeouts
✔ retry gives up after the last attempt
✔ decorators stack: cache over retry over the real client
✔ middleware: request id and error boundary around a handler
✔ exercise 1: express has no free threshold
✔ exercise 3: a hanging warehouse call is cut off and retried
✔ without settings the factory gives a console notifier for local runs
✔ the telegram config produces a notifier that calls the Bot API
✔ a missing secret fails at startup, not on the first message
✔ the sms config posts to the provider with the key in a header
✔ checkout publishes one event, subscribers react independently
✔ a failing subscriber does not stop the others
✔ unsubscribe removes the handler
✔ payload types are checked by the compiler
✔ five constructs and one function give the same result
✔ repository returns copies, not live objects
✔ builder assembles a valid receipt
✔ builder refuses an inconsistent receipt
✔ each strategy prices delivery on its own
✔ checkout picks the strategy by the delivery method
ℹ tests 22
ℹ suites 0
ℹ pass 22
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
Actual output: 22 tests, including the solutions to exercises 1 and 3. Test timings removed
How this was verified2 · passed1 · not runchecked 11 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.