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.
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
- A pattern is shared vocabulary, not a recipe
- Strategy: delivery and pricing options
- Adapter: a foreign API behind your interface
- Factory: creating an object from configuration
- Observer and Pub-Sub: order events
- Decorator and Middleware: behaviour around a call
- Repository and Builder
- Anti-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.
npm init -y
npm pkg set type=module
npm install --save-dev typescript@7.0.2 @types/node@24
npx tsc --noEmit
node --test{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"strict": true,
"noEmit": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noImplicitOverride": true,
"types": ["node"]
},
"include": ["src", "test"]
}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.
// 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);
}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);
});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:
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);
}
}$ javac -encoding UTF-8 -d out DeliveryPricingDemo.java && java -cp out DeliveryPricingDemo
courier: 9300, pickup: 8000Adapter: 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.
// 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 {}// 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>;
}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 };
}
}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);
});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.
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`);
}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" });
});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.
// 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 };
};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] : []));
}
}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", () => {});
});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.
// 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;
},
};
}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.
// 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);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" });
});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.
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 });
}
}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.
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 } : {}),
};
}
}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/);
});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.
// 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);
}
}// After: a function. When a second rule appears, its type becomes the strategy.
export const applyDiscount = (total: number): number => Math.round(total * 0.9);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));
}
});- A second variant already exists, or change along this axis has already happened
- The pattern removes duplication or a growing
switchrather 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:
$ 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 0How 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.