GAP·MAP

Design patterns (GoF)

[ junior depth ]

Name the problem before the pattern

The useful interview answer is not a definition recital. State what varies, what the caller should remain unaware of, and where the boundary sits. A pattern name then gives the design a shared label.

The seven patterns in this module solve different problems:

  • Singleton controls access to one instance.
  • Factory centralizes object creation.
  • Builder collects construction choices before producing an object.
  • Observer lets a subject notify subscribed dependents.
  • Strategy makes behavior selectable at runtime.
  • Adapter translates one interface into another.
  • Decorator wraps one object to add behavior without changing its peers.

Wrong "Patterns are class templates that should look the same in every language."

Right "The pattern identifies a recurring design problem and a relationship between responsibilities. The host language may need only a function or object literal to express it."

The GoF catalog names Factory Method and Abstract Factory separately. This module uses Factory as shorthand for putting creation behind an operation, and its TypeScript examples use a simple factory function. Name the precise variant when its structure matters.

Singleton and Factory both create things, for different reasons

Microsoft's definition of Singleton includes two properties: restrict a class to one instance and provide global access to it. TypeScript type checking prevents ordinary callers from invoking a private constructor, while a static method returns the retained instance. The private modifier is not a JavaScript runtime access control for this constructor.

class MetricsRegistry {
  private static instance: MetricsRegistry | undefined;

  private constructor() {}

  static getInstance(): MetricsRegistry {
    return (this.instance ??= new MetricsRegistry());
  }
}

This answers "how many and how is it reached?" It does not answer who owns the lifecycle across processes, workers, or browser realms. A class-level field exists in the JavaScript environment that loaded that class.

JavaScript modules offer a lighter option when module-scoped sharing is enough. MDN documents that modules are executed once even when referenced by multiple script tags, and module variables remain scoped to the module unless attached to the global object.

// metrics.ts
class MetricsRegistry {}
export const metrics = new MetricsRegistry();

A Factory answers a different question: "which concrete object should be created, and how?" Microsoft's Factory documentation distinguishes a simple factory, which puts creation in one place, from Factory Method and Abstract Factory. A small TypeScript factory often needs no factory class.

interface Store {
  get(key: string): Promise<string | undefined>;
}

class MemoryStore implements Store {
  async get(_key: string) {
    return undefined;
  }
}

class RedisStore implements Store {
  constructor(private readonly url: string) {}
  async get(_key: string) {
    return undefined;
  }
}

function createStore(config: { kind: "memory" } | { kind: "redis"; url: string }): Store {
  return config.kind === "memory" ? new MemoryStore() : new RedisStore(config.url);
}

Wrong "Factory means there must be a StoreFactory class."

Right "Factory means creation is behind an operation. A function expresses that operation directly when no factory state or family of factories is needed."

Builder earns its keep when construction has stages

A Builder lets code supply zero or more construction choices and then call a final build operation. Oracle's JShell.Builder is a concrete example: its setter methods can be chained, then build() creates the JShell instance.

type Request = {
  url: string;
  headers: Readonly<Record<string, string>>;
  timeoutMs: number;
};

class RequestBuilder {
  private headers: Record<string, string> = {};
  private timeoutMs = 5_000;

  constructor(private readonly url: string) {}

  withHeader(name: string, value: string) {
    this.headers[name] = value;
    return this;
  }

  withTimeout(timeoutMs: number) {
    this.timeoutMs = timeoutMs;
    return this;
  }

  build(): Request {
    return { url: this.url, headers: { ...this.headers }, timeoutMs: this.timeoutMs };
  }
}

Object initializer syntax already creates objects from named properties. Default parameters also initialize omitted or undefined arguments at call time. For a small value with no staged validation, those features are clearer than a mutable builder.

type RequestOptions = {
  headers?: Readonly<Record<string, string>>;
  timeoutMs?: number;
};

function makeRequest(url: string, options: RequestOptions = {}): Request {
  return {
    url,
    headers: options.headers ?? {},
    timeoutMs: options.timeoutMs ?? 5_000,
  };
}

Use a builder when intermediate configuration, fluent discovery, or validation at build() has real value. Ten setters around a three-field data object are ceremony.

Strategy chooses behavior; Observer announces change

Microsoft defines Strategy as selecting an algorithm's behavior at runtime. JavaScript functions are first-class objects, so code can pass a function instead of constructing a class for every stateless strategy.

type ShippingPrice = (weightKg: number) => number;

const standard: ShippingPrice = (weightKg) => 5 + weightKg;
const express: ShippingPrice = (weightKg) => 12 + weightKg * 2;

function quote(weightKg: number, price: ShippingPrice) {
  return price(weightKg);
}

An Observer relationship has a subject that maintains dependents and notifies them about state changes. Subscription lifecycle matters. React's useSyncExternalStore contract, for example, requires subscribe to return a cleanup function.

function createCounter() {
  let value = 0;
  const listeners = new Set<() => void>();

  return {
    get: () => value,
    increment() {
      value += 1;
      for (const listener of listeners) listener();
    },
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

Wrong "Observer means events run asynchronously."

Right "Observer defines the subscription and notification relationship. The API defines timing. Node's EventEmitter, for example, calls attached listeners synchronously."

Adapter translates; Decorator wraps

An Adapter makes an existing interface usable as another interface. Put it at the integration boundary so provider-specific shapes do not spread through the domain.

interface PriceSource {
  price(): Promise<{ amount: number }>;
}

type VendorClient = {
  fetchPrice(): Promise<{ cents: number }>;
};

function adaptVendor(client: VendorClient): PriceSource {
  return {
    async price() {
      const result = await client.fetchPrice();
      return { amount: result.cents / 100 };
    },
  };
}

TypeScript checks compatibility structurally. The adapter object does not need to declare a nominal relationship with PriceSource; matching members are enough.

A Decorator adds behavior to an individual object without changing other objects from the same class. The wrapper preserves the interface and delegates to the wrapped value.

function withTiming(source: PriceSource, record: (ms: number) => void): PriceSource {
  return {
    async price() {
      const started = performance.now();
      try {
        return await source.price();
      } finally {
        record(performance.now() - started);
      }
    },
  };
}

Adapter changes the caller-facing interface. Decorator preserves it while adding behavior.

[ middle depth ]

The pattern map interviewers expect

Creational patterns separate callers from construction. Structural patterns change how existing pieces fit together. Behavioral patterns separate a changing behavior or notification relationship.

Here Factory is umbrella shorthand. The GoF catalog treats Factory Method and Abstract Factory as distinct patterns; the examples below use a simple factory function to isolate creation.

For this module, the practical grouping is:

  • Creational: Singleton, Factory, Builder
  • Structural: Adapter, Decorator
  • Behavioral: Observer, Strategy

The grouping is less important than the axis of change. If provider selection changes, a Factory can isolate construction. If the payment algorithm changes, Strategy isolates behavior. If a provider speaks a foreign shape, Adapter translates it.

Wrong "Choose the pattern from the number of classes in the diagram."

Right "Choose it from the responsibility that must vary independently. Similar-looking wrappers can have different intent."

Separate creation, selection, and translation

Consider two provider SDKs behind one domain port:

interface PaymentPort {
  charge(input: { amount: number }): Promise<{ transactionId: string }>;
}

type CardSdk = {
  debit(cents: number): Promise<{ id: string }>;
};

function adaptCard(sdk: CardSdk): PaymentPort {
  return {
    async charge({ amount }) {
      const result = await sdk.debit(Math.round(amount * 100));
      return { transactionId: result.id };
    },
  };
}

The adapter owns the conversion. TypeScript's structural compatibility allows the returned object to satisfy PaymentPort by having compatible members. No base class is required.

Creation belongs elsewhere:

type PaymentConfig =
  | { provider: "card"; sdk: CardSdk }
  | { provider: "bank"; endpoint: string };

declare function makeBankPort(endpoint: string): PaymentPort;

function createPaymentPort(config: PaymentConfig): PaymentPort {
  switch (config.provider) {
    case "card":
      return adaptCard(config.sdk);
    case "bank":
      return makeBankPort(config.endpoint);
  }
}

This Factory hides the concrete construction path. It does not need to choose the business algorithm on every call. Strategy covers that behavior choice:

type RoutePayment = (amount: number) => "card" | "bank";

const lowestFee: RoutePayment = (amount) => (amount < 100 ? "card" : "bank");

function checkout(amount: number, route: RoutePayment) {
  const provider = route(amount);
  return { amount, provider };
}

In JavaScript, a function can be passed, returned, and assigned like another value. A function type is therefore enough for a stateless Strategy. A class remains useful when each strategy carries state or several related operations must stay together.

interface PaymentPolicy {
  choose(amount: number): "card" | "bank";
  explain(amount: number): string;
}

Wrong "Factory and Strategy are interchangeable because both contain a switch."

Right "Factory returns a constructed dependency. Strategy is the selected behavior used to perform work. Syntax does not determine intent."

Builder versus a typed options object

A builder can accumulate choices across calls, expose a fluent chain, and delay object creation until build(). That shape helps when construction has an intermediate state.

class QueryBuilder {
  private filters: string[] = [];
  private limitValue: number | undefined;

  where(expression: string) {
    this.filters.push(expression);
    return this;
  }

  limit(count: number) {
    if (!Number.isInteger(count) || count < 1) {
      throw new RangeError("limit must be a positive integer");
    }
    this.limitValue = count;
    return this;
  }

  build() {
    return {
      filters: [...this.filters],
      limit: this.limitValue,
    };
  }
}

The builder above has a reason to exist: it collects an open-ended list and validates fluent input. For a fixed set of named fields, object initializer syntax and defaults already express construction.

type RetryPolicy = {
  attempts?: number;
  delayMs?: number;
};

function retryPolicy({ attempts = 3, delayMs = 100 }: RetryPolicy = {}) {
  return { attempts, delayMs };
}

Do not claim that Builder is required whenever a constructor has many parameters. Named options may solve readability. Builder becomes stronger when callers need staged assembly, reusable partial configuration, or a final validation boundary.

Observer is a lifecycle contract

Node's current EventEmitter documentation says attached listeners are called synchronously, and returned listener values are ignored. React's useSyncExternalStore requires a subscribe(callback) function that invokes the callback on change and returns cleanup. These are two concrete Observer-like APIs with explicit contracts.

type Listener = () => void;

function createStore<T>(initial: T) {
  let current = initial;
  const listeners = new Set<Listener>();

  return {
    getSnapshot: () => current,
    set(next: T) {
      if (Object.is(current, next)) return;
      current = next;
      for (const listener of [...listeners]) listener();
    },
    subscribe(listener: Listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

This implementation makes several choices that the word Observer does not settle:

  • Set deduplicates the same function reference.
  • Listeners run synchronously.
  • Copying the set before iteration means subscription changes affect the next notification.
  • Object.is suppresses notification when the stored value is identical by that comparison.

An interview answer should surface the choices relevant to the system. Without cleanup, a long-lived subject retains listener references. Without a timing contract, callers cannot reason about what has happened when set returns.

Wrong "Publish-subscribe and Observer are identical names for any event system."

Right "The documented Observer shape has the subject maintain its observers. A broker or event bus introduces another component, so discuss that topology instead of assuming the names are synonyms."

Decorator order changes behavior

Decorators share an interface with the wrapped object. That makes them composable and also makes order observable.

type Operation = () => Promise<string>;

function withTiming(operation: Operation, record: (ms: number) => void): Operation {
  return async () => {
    const started = performance.now();
    try {
      return await operation();
    } finally {
      record(performance.now() - started);
    }
  };
}

function withRetry(operation: Operation, attempts: number): Operation {
  return async () => {
    let lastError: unknown;
    for (let attempt = 0; attempt < attempts; attempt += 1) {
      try {
        return await operation();
      } catch (error) {
        lastError = error;
      }
    }
    throw lastError;
  };
}

These two compositions measure different things:

const wholeOperation = withTiming(withRetry(sendRequest, 3), record);
const everyAttempt = withRetry(withTiming(sendRequest, record), 3);

The first records once around all retry attempts. The second can record once per attempt. Wrapper order belongs in tests and review, especially for retry, caching, authorization, transactions, and metrics.

Decorator is excessive when the behavior has one call site and no independent composition requirement. A direct call is visible and easier to follow.

const started = performance.now();
try {
  await sendRequest();
} finally {
  record(performance.now() - started);
}

The pattern pays for itself when the same interface must support several independently reusable layers.

[ senior depth ]

Treat patterns as boundary decisions

At senior level, naming the pattern is the cheap part. The interview probes scope, ownership, failure behavior, and the cost of the abstraction.

Ask four questions:

  1. What is allowed to vary?
  2. Which code must stay unaware of that variation?
  3. What lifecycle or timing contract crosses the boundary?
  4. Which language feature expresses the same boundary with less machinery?

Wrong "Using more patterns makes the design more extensible."

Right "Each pattern introduces an indirection. Keep it when the isolated variation is real enough to justify navigation, testing, and lifecycle costs."

A Singleton's scope must be stated

Microsoft's definition combines one instance with global access. The hidden question is the meaning of one. A private constructor and static field can restrict ordinary construction under TypeScript's type checking:

class TokenCache {
  private static instance: TokenCache | undefined;

  private constructor() {}

  static getInstance() {
    return (this.instance ??= new TokenCache());
  }
}

It does not establish one instance across separate JavaScript environments. In a system with workers or multiple processes, each environment can load its own state. The safe statement is local: one retained instance for that loaded class definition.

ES modules can remove the class ceremony. MDN documents module-scoped variables and one-time module execution when a module is referenced multiple times in the documented browser loading model.

// token-cache.ts
class TokenCache {}
export const tokenCache = new TokenCache();

Neither form solves dependency visibility. Constructor injection makes sharing an application-composition decision and allows a test to provide a different instance.

interface Cache {
  get(key: string): string | undefined;
}

class CheckoutService {
  constructor(private readonly tokenCache: Cache) {}
}

declare const tokenCache: Cache;
const checkout = new CheckoutService(tokenCache);

If construction must remain restricted, expose a factory at the composition root rather than letting domain services call global accessors.

Wrong "Singleton and dependency injection are opposites."

Right "Singleton describes instance count and access. A dependency container may manage a single shared lifetime, but explicit injection avoids a hidden global lookup."

Factories should own policy only when policy belongs there

A Factory hides concrete creation. It often becomes a dumping ground for runtime routing, caching, validation, and business policy. Keep its contract narrow.

type RepositoryConfig =
  | { kind: "memory" }
  | { kind: "postgres"; connectionString: string };

interface UserRepository {
  find(id: string): Promise<{ id: string } | undefined>;
}

declare class MemoryUsers implements UserRepository {
  find(id: string): Promise<{ id: string } | undefined>;
}

declare class PostgresUsers implements UserRepository {
  constructor(connectionString: string);
  find(id: string): Promise<{ id: string } | undefined>;
}

function createUserRepository(config: RepositoryConfig): UserRepository {
  switch (config.kind) {
    case "memory":
      return new MemoryUsers();
    case "postgres":
      return new PostgresUsers(config.connectionString);
  }
}

If selection changes for every request based on business input, the varying piece is behavior. Model that as Strategy and let construction remain at the composition boundary.

type ChooseRepository = (tenantId: string) => UserRepository;

function loadUser(id: string, tenantId: string, choose: ChooseRepository) {
  return choose(tenantId).find(id);
}

The distinction improves tests. Factory tests cover configuration to construction. Strategy tests cover input to behavioral choice.

Structural typing shrinks Adapter and Strategy

TypeScript compatibility is based on structural subtyping. A value is accepted when the required members have compatible types, subject to documented rules such as special treatment for private and protected class members.

This lets an Adapter be a small boundary object:

interface Clock {
  now(): number;
}

type VendorClock = {
  currentTimeMillis(): number;
};

function adaptClock(vendor: VendorClock): Clock {
  return { now: () => vendor.currentTimeMillis() };
}

It also lets a Strategy be one function:

type Backoff = (attempt: number) => number;

const exponential: Backoff = (attempt) => 100 * 2 ** attempt;

Do not erase a meaningful protocol to save lines. If the strategy needs start, decide, and dispose, an interface documents that related lifecycle better than three unrelated callbacks.

interface RoutingStrategy {
  start(): Promise<void>;
  choose(request: Request): string;
  dispose(): Promise<void>;
}

The modern replacement is often less class ceremony, not less design. The boundary and its contract remain.

Observer needs reentrancy and identity rules

The pattern says a subject maintains observers and notifies them about changes. It does not decide delivery order, synchronous timing, duplicate registration, error handling, or what happens when a listener unsubscribes during notification.

Node's EventEmitter documents one concrete contract: attached listeners run synchronously, and their return values are discarded. React's external-store API documents another set of requirements: subscribe returns cleanup, and an unchanged store must yield the same snapshot value on repeated getSnapshot calls.

function createStore<T>(initial: T) {
  let value = initial;
  const listeners = new Set<() => void>();

  return {
    getSnapshot: () => value,
    subscribe(listener: () => void) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    set(next: T) {
      if (Object.is(value, next)) return;
      value = next;
      for (const listener of [...listeners]) listener();
    },
  };
}

The copied array freezes the listener list for that notification. A listener can unsubscribe itself, but it does not remove a later listener from the current copied list. A different implementation can choose live iteration; document and test the chosen semantics.

Errors need the same treatment. This loop stops if a listener throws. Catching errors and continuing would be a different contract.

for (const listener of [...listeners]) {
  try {
    listener();
  } catch (error) {
    reportListenerError(error);
  }
}

There is no universally correct choice. The senior answer identifies the consequence instead of claiming the pattern supplies one.

Decorator composition is an execution plan

Microsoft describes Decorator as adding behavior to one object without affecting other objects from the same class. In a typed functional style, preserving one function signature is sufficient.

type Handler = (request: Request) => Promise<Response>;

function withAuthorization(next: Handler): Handler {
  return async (request) => {
    if (!request.headers.get("authorization")) {
      return new Response("Unauthorized", { status: 401 });
    }
    return next(request);
  };
}

function withLogging(next: Handler, log: (status: number) => void): Handler {
  return async (request) => {
    const response = await next(request);
    log(response.status);
    return response;
  };
}

Composition order defines which behavior observes which result:

const logOnlyAuthorizedWork = withAuthorization(withLogging(handle, log));
const logEveryResponse = withLogging(withAuthorization(handle), log);

In the first composition, an unauthorized request returns before the logging wrapper runs. In the second, logging surrounds authorization and sees the 401 response.

Decorator and Adapter can both wrap. The stable question is interface intent. Adapter presents a different interface to its caller. Decorator presents the same interface and adds behavior. A wrapper that translates an SDK response and also retries it has two reasons to change; split the adapter from the retry decorator.

Builder is about valid construction, not fluent syntax

Chained setters alone do not justify Builder. The stronger use is delayed construction with a meaningful final boundary.

class ConnectionBuilder {
  private tlsConfig: { ca: string } | undefined;

  constructor(private readonly host: string) {}

  withTls(ca: string) {
    this.tlsConfig = { ca };
    return this;
  }

  build() {
    if (this.host.length === 0) {
      throw new Error("host is required");
    }
    return new Connection(this.host, this.tlsConfig);
  }
}

For immutable configuration with a small, fixed shape, a typed object and a factory function keep invalid states close to the call.

type ConnectionOptions = {
  host: string;
  tls?: { ca: string };
};

function createConnection(options: ConnectionOptions) {
  if (options.host.length === 0) throw new Error("host is required");
  return new Connection(options.host, options.tls);
}

Builder is overkill when its intermediate states have no value and build() performs no work that a normal creation function could express clearly.

dig deeper

Primary sources behind these notes - the specs and official docs worth reading in full.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

unlocks

more Architecture

was this useful?