Skip to content

05 — Design Patterns for LLD Interviews

Which Patterns Actually Matter

The Gang of Four catalog has 23 patterns. In a typical 45-minute LLD interview you will use maybe three. Over a career's worth of interviews you will see eight or nine recur. The rest — Visitor, Flyweight, Chain of Responsibility, Interpreter, Memento, Mediator, Prototype, Bridge, Proxy, Facade (as a conscious pattern choice, not a natural layering decision) — are either too narrow to surface or so general they stop carrying information.

This document covers eleven patterns that do surface: the four creational ones that matter, two structural, four behavioral, and an honest discussion of Singleton that separates "I know the pattern" from "I know when to run away from it." If you can't name the pressure each one resolves and recite its common misuse, you are memorizing trivia instead of building judgment.

The senior framing up front

A strong candidate does not announce patterns. They announce pressures — "these pricing rules change independently of who calls them," "this object has too many optional fields to pass positionally," "I need to replay these operations in reverse." The pattern is the consequence of the pressure, not the starting point. Leading with the pattern name reads as bottom-up memorization. Leading with the pressure reads as top-down design, and lets the interviewer agree or push back on the framing before you've committed code.

A useful mental model: every pattern exists to localize a change. Strategy localizes "the algorithm changes." Observer localizes "the set of reactions changes." Factory localizes "the concrete type changes." If you can't name the axis of change a pattern resolves, you don't understand it yet.


Creational

Strategy

What it is. Strategy extracts a family of interchangeable algorithms behind a common interface, and lets the owning object pick one at runtime. The owner no longer knows which algorithm runs — just that something implementing the contract runs.

Canonical structure.

typescript
interface RateLimitStrategy {
  allow(userId: string, now: number): boolean;
}

class TokenBucket implements RateLimitStrategy {
  constructor(private readonly capacity: number, private readonly refillPerSec: number) {}
  allow(userId: string, now: number): boolean { /* ... */ return true; }
}

class FixedWindow implements RateLimitStrategy {
  constructor(private readonly limit: number, private readonly windowMs: number) {}
  allow(userId: string, now: number): boolean { /* ... */ return true; }
}

class RateLimiter {
  constructor(private readonly strategy: RateLimitStrategy) {}
  check(userId: string): boolean {
    return this.strategy.allow(userId, Date.now());
  }
}

When to use.

  • You have two or more algorithms doing the same kind of work, and the caller shouldn't care which one runs.
  • The set of algorithms can grow after the initial design without forcing changes to the caller.
  • You want to unit-test each algorithm in isolation from the orchestration around it.

When NOT to use.

  • You only have two variants and they will never grow. A private method with a kind parameter or a plain if/else is shorter and clearer.
  • The "strategies" share more state than behavior — you're probably reaching for inheritance when composition would be cleaner, or vice versa.
  • Each "strategy" is a single function. An injected function ((userId, now) => boolean) is the same pattern with no ceremony. Interfaces earn their keep when strategies hold state or have more than one method.

LLD problems where it fits. Rate limiter (token bucket / fixed window / sliding log), parking lot pricing (hourly / flat / tiered), ride-share surge pricing, payment retry (exponential / linear / jittered backoff), load balancer (round-robin / least-connections / hash-based), cache eviction (LRU / LFU / TTL).

Alternatives. Plain if/else on an enum (fine for two variants, stops scaling around four). Function injection (fine when the strategy is stateless and has one method). Subclassing (if the "strategy" needs to override multiple methods and share state with the host — but then you've probably made the object too smart and should extract a collaborator anyway).

Real-world comparison. java.util.Comparator<T>. Collections.sort(list, comparator) doesn't know or care how the comparator orders — that's pure Strategy. Similarly, Node's stream.pipeline accepts transform strategies, and every HTTP client library takes a retry strategy as a config object.

Senior tell. "The algorithms need to vary independently of the caller and of each other, so I'm making each a class behind a RateLimitStrategy interface. If there were only two and they'd never grow, I'd skip the interface and just branch — the pattern isn't free, it adds a class per variant."


Factory / Abstract Factory

What it is. A Factory is a function or class whose job is to construct objects whose exact type the caller shouldn't choose. The pain it solves is concrete: scattering new PaymentProcessor(...) across a codebase couples every caller to PaymentProcessor's constructor signature and to the decision of which implementation to use. A Factory lets the caller ask for "a payment processor for this order" and get back whichever concrete type the factory decides.

Abstract Factory is one level higher. Instead of producing one type, it produces a family of related types that need to be consistent with each other — e.g., "give me the Stripe family: Stripe charge, Stripe refund, Stripe webhook verifier." Mixing a Stripe charge with a PayPal refund verifier would be a bug; the abstract factory enforces the consistency.

Canonical structure.

typescript
interface PaymentProcessor {
  charge(amount: number, source: string): Promise<string>;
}

class StripeProcessor implements PaymentProcessor { /* ... */ charge = async () => "id"; }
class PayPalProcessor implements PaymentProcessor { /* ... */ charge = async () => "id"; }
class RazorpayProcessor implements PaymentProcessor { /* ... */ charge = async () => "id"; }

class PaymentProcessorFactory {
  static forRegion(region: string): PaymentProcessor {
    switch (region) {
      case "US": return new StripeProcessor();
      case "EU": return new StripeProcessor();
      case "IN": return new RazorpayProcessor();
      default:   return new PayPalProcessor();
    }
  }
}

// Abstract factory: related types stay consistent
interface PaymentFamily {
  processor(): PaymentProcessor;
  refunder(): Refunder;
  webhookVerifier(): WebhookVerifier;
}

class StripeFamily implements PaymentFamily { /* all three return Stripe variants */ }

When to use.

  • The caller doesn't know (and shouldn't know) which concrete type it needs.
  • Construction itself has logic — region lookups, feature-flag branches, caching of expensive instances.
  • Related objects need to be built together consistently (Abstract Factory).

When NOT to use.

  • You have one concrete class and might have more "someday." Add the factory when the second implementation shows up, not before.
  • The "factory" is a constructor in disguise — new Foo(a, b, c) wrapped in Foo.create(a, b, c) with no logic. That's ceremony; delete it.
  • Dependency injection is already threading the right instance through. A DI container is a factory; layering another factory on top is noise.

LLD problems where it fits. Notification system (Email / SMS / Push providers), payment gateway selection, database driver selection, shape/ticket/vehicle construction in parking lots, logger backends, cache client (Redis / Memcached / in-memory).

Alternatives. A constructor is already a factory (the new keyword). A static method on the class (PaymentProcessor.forRegion(region)) is usually enough — a dedicated *Factory class is over-engineering unless the factory itself has state or dependencies. DI containers do this automatically.

Real-world comparison. JDBC's DriverManager.getConnection(url) returns a concrete Connection you never named. Spring's BeanFactory. Node's http.createServer() — you never new Server() directly. Every ORM's Model.create(attrs) is a factory method.

Senior tell. "The caller's concern is what it wants to do with a payment, not which provider. I'll hide the branching behind a factory function so new providers are a one-line addition there — not a change scattered across every call site. If it were one provider, I'd just new it."


Singleton — and Why You Should Usually Avoid It

What it is. A Singleton guarantees a class has exactly one instance and provides global access to it. It's the most-taught and most-abused pattern in the catalog. In a senior interview, reaching for Singleton without a strong reason is a negative signal.

Canonical structure.

typescript
class ConfigRegistry {
  private static instance: ConfigRegistry | null = null;
  private config: Record<string, string> = {};

  private constructor() {}

  static getInstance(): ConfigRegistry {
    if (!ConfigRegistry.instance) ConfigRegistry.instance = new ConfigRegistry();
    return ConfigRegistry.instance;
  }

  get(key: string): string | undefined { return this.config[key]; }
  set(key: string, value: string): void { this.config[key] = value; }
}

Why it's usually a mistake. A Singleton is global mutable state dressed up as a class. Every problem you'd have with a global variable you still have with a Singleton:

  • Hidden dependencies. A method that calls ConfigRegistry.getInstance() has a dependency that doesn't appear in its signature. You can't tell what a function needs by reading its parameters. Code review becomes archaeology.
  • Test hostility. Unit tests that share a Singleton share state across tests. You'll write beforeEach hooks to reset it, forget one, and get flaky tests in CI. Parallel test runners make it worse.
  • Threading bugs. The double-checked locking pattern exists because naive getInstance() is racy. In JVM / .NET you need volatile, proper memory barriers, or a static initializer. In Node this is moot (single-threaded), but in multi-worker setups each worker gets its own "singleton" — so it isn't one anyway.
  • It lies about lifecycle. A Singleton is "one per process." If you deploy two processes, you have two. If you test in-process, you have one that persists across tests. Neither matches what most people mean when they say "there's only one."
  • Subclassing and mocking break. getInstance() returns a hard-coded concrete type. You can't substitute a mock without reaching into the static field and mutating it — which is exactly the coupling you wanted to avoid.

When it's actually okay.

  • Stateless registries and caches of immutable data where "more than one" would be wasteful but not incorrect. A compiled-regex cache, a loaded-config object that never mutates after startup.
  • Process-wide resources that are genuinely unique by nature — a thread pool, a connection pool, a logger sink. Even here, prefer passing the instance as a dependency rather than reaching for it globally; the uniqueness can be enforced at the DI container level.
  • Language-idiom singletons that don't use the pattern — e.g., module-level state in a Node module is effectively a singleton because the module system caches it. That's fine; it's not the Singleton pattern.

When NOT to use.

  • Anything stateful that test code needs to reset.
  • "Managers" and "services" whose only justification for being singletons is "we only need one." Pass one in. That's what DI is for.
  • To coordinate cross-instance state in a multi-process or distributed deployment — a Singleton only guarantees uniqueness within one process. You'd need a distributed lock or external coordinator anyway.

LLD problems where it might fit. A process-wide logger, a connection pool front-end, a feature-flag cache. Even in these, the pattern usually appears as "the DI container holds one instance" rather than "the class enforces uniqueness via a static field."

Alternatives. Dependency injection (pass the instance in). Module-level variables (Node / ES modules). A DI container configured with singleton scope. A static factory that doesn't enforce uniqueness but is always called the same way.

Real-world comparison. java.lang.Runtime.getRuntime(), System.out. Both are famously hard to test around. Spring beans default to singleton scope but are injected, not looked up — which removes most of the pain.

Senior tell. When the interviewer asks "is this a Singleton?" the correct answer is almost never "yes, I made it a Singleton." It's: "There's one instance per process, but I'm injecting it rather than using a static accessor — that keeps the class testable and makes the dependency visible in method signatures." If you truly need the static-accessor form, name the cost: "This is a Singleton, which means this class is now global mutable state; I'm accepting that tradeoff here because X, and we'll need a test-reset hook."


Builder

What it is. Builder separates the construction of a complex object from its representation. You chain configuration calls, then call build() to get the finished immutable object. The payoff is readability when a constructor would otherwise take many arguments — especially many optional arguments that don't have a sensible positional order.

The pressure Builder solves is telescoping constructors: new HttpRequest(url, "GET", headers, null, 30_000, true, null, 3, null). Nobody reading that can tell you what the third null means without checking the signature. Builder makes the same call self-documenting.

Canonical structure.

typescript
class HttpRequest {
  readonly url: string;
  readonly method: "GET" | "POST" | "PUT" | "DELETE";
  readonly headers: ReadonlyMap<string, string>;
  readonly body: string | null;
  readonly timeoutMs: number;
  readonly retries: number;
  readonly followRedirects: boolean;

  private constructor(b: HttpRequestBuilder) {
    this.url = b._url;
    this.method = b._method;
    this.headers = new Map(b._headers);
    this.body = b._body;
    this.timeoutMs = b._timeoutMs;
    this.retries = b._retries;
    this.followRedirects = b._followRedirects;
  }

  static builder(url: string): HttpRequestBuilder {
    return new HttpRequestBuilder(url);
  }
}

class HttpRequestBuilder {
  _url: string;
  _method: "GET" | "POST" | "PUT" | "DELETE" = "GET";
  _headers = new Map<string, string>();
  _body: string | null = null;
  _timeoutMs = 30_000;
  _retries = 0;
  _followRedirects = true;

  constructor(url: string) { this._url = url; }

  method(m: HttpRequest["method"]): this { this._method = m; return this; }
  header(k: string, v: string): this   { this._headers.set(k, v); return this; }
  body(b: string): this                { this._body = b; return this; }
  timeout(ms: number): this            { this._timeoutMs = ms; return this; }
  retries(n: number): this             { this._retries = n; return this; }
  noRedirects(): this                  { this._followRedirects = false; return this; }

  build(): HttpRequest {
    if (this._method === "GET" && this._body !== null) {
      throw new Error("GET requests cannot have a body");
    }
    // `HttpRequest`'s constructor is private; same-file access is allowed in TS.
    return new (HttpRequest as any)(this);
  }
}

// Usage:
const req = HttpRequest.builder("https://api.example.com/orders")
  .method("POST")
  .header("Content-Type", "application/json")
  .body(JSON.stringify({ id: 42 }))
  .timeout(5_000)
  .retries(3)
  .build();

When to use.

  • The target object has roughly five or more fields and many are optional. Positional arguments stop being readable around four.
  • Construction has validation that spans multiple fields ("GET with a body is invalid," "retries > 0 requires timeout > 0"). A builder's build() is the natural place.
  • The object should be immutable after construction, but you need staged configuration to get there.
  • You want sensible defaults without forcing every caller to pass them.

When NOT to use.

  • Two or three fields, none optional. new Point(x, y, z) needs no builder. Even new HttpRequest(url, method, body) is fine if that's all there is.
  • A plain object literal with TypeScript works. const req: HttpRequest = { url, method: "POST", body: ... } gives you named fields, optional fields, and compile-time checking — with zero ceremony. Builder pays off when you need validation in build() or when you're in Java/C# where object literals don't exist.
  • The builder is a mutable container that never gets build()-called — then it is the object, and you've just renamed your class.

LLD problems where it fits. HTTP request/response construction, SQL query construction (QueryBuilder), parking-lot vehicle with many attributes, order checkout object with items + discounts + shipping + payment, test fixture construction (UserBuilder().withEmail(...).withRole(...).build() in test code).

Alternatives. TypeScript object literals with optional fields. Kotlin/Python named arguments with defaults. Factory methods for common combinations (HttpRequest.get(url), HttpRequest.post(url, body)). For JavaScript specifically, a single-argument constructor taking an options object is the idiomatic equivalent.

Real-world comparison. StringBuilder in Java (the name, not the exact pattern). java.net.http.HttpRequest.newBuilder(). Lombok's @Builder. SQL query builders like Knex or jOOQ. Protobuf generated message builders.

Senior tell. "This object has around ten fields, most optional, with cross-field invariants. A positional constructor is unreadable and can't enforce 'GET with body is invalid' at compile time. Builder gives me a single validation seam and keeps the final object immutable — the tradeoff is one extra class per target type."


Structural

Adapter

What it is. Adapter translates between two interfaces that were designed apart and need to interoperate. The shape of the adapter is almost boring: it implements the interface your code expects and delegates to the interface the other side provides, translating as needed. The value isn't the code — it's that the translation lives in one place.

The pressure Adapter solves is: "I have a clean abstraction in my domain, and I need to use a third-party library that doesn't quite fit it." Without the adapter, the third-party shape leaks everywhere. With it, there's one file to change when the vendor rev's their API.

Canonical structure.

typescript
// Our domain's expected interface
interface NotificationSender {
  send(to: string, subject: string, body: string): Promise<void>;
}

// Third-party SDK — different shape, not ours to change
class TwilioSdkClient {
  async sendMessage(opts: {
    toNumber: string;
    fromNumber: string;
    text: string;
    mediaUrls?: string[];
  }): Promise<{ sid: string }> { /* ... */ return { sid: "x" }; }
}

// Adapter — implements our interface, wraps theirs
class TwilioSmsAdapter implements NotificationSender {
  constructor(
    private readonly client: TwilioSdkClient,
    private readonly fromNumber: string,
  ) {}

  async send(to: string, subject: string, body: string): Promise<void> {
    // SMS has no subject — fold it into the body.
    const text = subject ? `${subject}\n\n${body}` : body;
    await this.client.sendMessage({ toNumber: to, fromNumber: this.fromNumber, text });
  }
}

When to use.

  • You own the consuming interface and can't (or shouldn't) change the provider's.
  • You want to swap providers without ripple-changing the codebase.
  • You need to shim an older API into a newer interface during a migration.

When NOT to use.

  • The vendor's interface is already what you want. Then NotificationSender = TwilioSdkClient via a type alias is enough — don't write a delegating class that adds nothing.
  • You're "adapting" from one of your own interfaces to another of your own interfaces. That's a design smell: unify them instead of papering over the mismatch.
  • You're adapting once, in one file. The translation is just a function; don't promote it to a class.

LLD problems where it fits. Notification system wrapping multiple SMS/email SDKs behind one interface, payment gateway adapters (Stripe / PayPal / Razorpay → PaymentProcessor), storage adapters (S3 / GCS / local disk → BlobStore), logger adapters wrapping Winston / Pino / Bunyan behind a project-internal interface.

Alternatives. A free function that translates. A thin wrapper module. For purely structural mismatches with no logic, a TypeScript type alias plus a tiny helper is enough.

Real-world comparison. JDBC drivers adapt vendor-specific database protocols to the Connection/Statement interface. SLF4J adapters bind its API to underlying loggers (Log4j, Logback). React's event system wraps native DOM events into SyntheticEvent. Node's stream.Readable.from(iterable) adapts iterables to the stream interface.

Senior tell. "I'll define a NotificationSender interface that matches our domain — to, subject, body — and write one adapter per provider. New providers don't touch consumer code, and the per-provider weirdness is quarantined. If we only ever had one provider, this would be overkill — I'd use the SDK directly."


Decorator

What it is. Decorator wraps an object with another object that implements the same interface, adding behavior before or after the underlying call. Unlike inheritance, it composes at runtime and stacks cleanly. The classic win is that orthogonal concerns — metrics, caching, retries, logging — can each be their own decorator and be combined in any order without a combinatorial explosion of subclasses.

The pressure Decorator solves is "I want to add optional behavior without making the core class know about every possible behavior." A MetricsAwareRedisCache extends RedisCache works for one axis of variation; the second axis (TTL enforcement) forces MetricsAwareTtlEnforcingRedisCache; by the fourth axis you have 16 subclasses and no clear names.

Canonical structure.

typescript
interface Cache<V> {
  get(key: string): Promise<V | null>;
  set(key: string, value: V): Promise<void>;
}

class RedisCache<V> implements Cache<V> {
  async get(key: string): Promise<V | null> { /* actual Redis call */ return null; }
  async set(key: string, value: V): Promise<void> { /* actual Redis call */ }
}

// Decorator: adds metrics, delegates to inner
class MetricsCache<V> implements Cache<V> {
  constructor(private readonly inner: Cache<V>, private readonly metrics: Metrics) {}

  async get(key: string): Promise<V | null> {
    const start = Date.now();
    const value = await this.inner.get(key);
    this.metrics.record("cache.get", Date.now() - start, { hit: value !== null });
    return value;
  }
  async set(key: string, value: V): Promise<void> {
    const start = Date.now();
    await this.inner.set(key, value);
    this.metrics.record("cache.set", Date.now() - start);
  }
}

// Decorator: enforces a TTL ceiling, delegates to inner
class TtlCache<V> implements Cache<V> {
  private readonly expiries = new Map<string, number>();

  constructor(private readonly inner: Cache<V>, private readonly ttlMs: number) {}

  async get(key: string): Promise<V | null> {
    const exp = this.expiries.get(key);
    if (exp !== undefined && Date.now() > exp) {
      this.expiries.delete(key);
      return null;
    }
    return this.inner.get(key);
  }
  async set(key: string, value: V): Promise<void> {
    this.expiries.set(key, Date.now() + this.ttlMs);
    return this.inner.set(key, value);
  }
}

// Decorator: rate-limits expensive backends, delegates to inner
class RateLimitedCache<V> implements Cache<V> {
  constructor(private readonly inner: Cache<V>, private readonly limiter: RateLimiter) {}

  async get(key: string): Promise<V | null> {
    if (!this.limiter.check(key)) throw new Error("rate limited");
    return this.inner.get(key);
  }
  async set(key: string, value: V): Promise<void> {
    if (!this.limiter.check(key)) throw new Error("rate limited");
    return this.inner.set(key, value);
  }
}

// Composition — order matters and is explicit:
const cache: Cache<string> = new MetricsCache(
  new TtlCache(
    new RateLimitedCache(new RedisCache<string>(), rateLimiter),
    60_000,
  ),
  metrics,
);

The order of wrapping is a real design decision. Putting metrics outermost means you measure the full latency including rate-limit rejections and TTL cache hits. Putting it innermost measures only the Redis call. Neither is wrong, but each answers a different question — and the interviewer may ask which you want.

When to use.

  • Multiple orthogonal cross-cutting concerns (metrics, retry, cache, auth, logging) that should compose.
  • The set of concerns changes independently of the core object.
  • You want the composition chosen at configuration time, not compile time.

When NOT to use.

  • You only need one wrapping and it's never optional. A method-level boolean or a hook inside the core object is simpler.
  • The decorators need to share state or inspect each other. Decorator is strictly one-directional (outer delegates to inner); if your "decorators" are peeking at each other's state, this isn't the right shape.
  • Four-level-deep wrapping obscures what's actually running. At that depth, a pipeline object with an explicit ordered list of middlewares reads better than nested constructors.

LLD problems where it fits. Cache with metrics + TTL + rate limiting (the canonical example), HTTP clients with retry + circuit breaker + logging, database repositories with caching + audit logging, coffee/pizza order builders (the textbook example that's also legitimate — toppings are decorators).

Alternatives. A middleware chain (Express / Koa / Axum interceptors) is the same idea with a functional signature: (req, next) => next(req).then(resp => ...). AOP / annotations (@Timed, @Retryable) in Java frameworks. Method-level flags for a single optional behavior.

Real-world comparison. java.io.BufferedInputStream(new GZIPInputStream(new FileInputStream(...))) — the canonical JDK example. Express middleware. HTTP interceptor chains (Axios). Spring AOP advice. Every "wrap a client with metrics / retry / auth" in production Go / Rust services.

Senior tell. "These concerns — metrics, TTL, rate limiting — are orthogonal to the cache itself and to each other. I'll make each a decorator implementing Cache<V> so the composition is explicit at the wiring layer. Order matters: metrics outermost measures end-to-end latency; if you want to measure only the backend, you move it inside the TTL decorator. That choice is a product question."


Behavioral

Observer

What it is. Observer defines a one-to-many dependency: when one object (the subject) changes state, all registered observers are notified. The subject doesn't know how many observers there are or what they do — only that it should call them on change. It's the pattern that decouples "the event happened" from "who cares about it."

The pressure Observer solves is: "new reasons to react to this event keep showing up, and I don't want to edit the code that raises the event every time." A user signs up → send welcome email → also log to analytics → also add to CRM → also trigger fraud check. Each of those is a separate concern; each wants to be added and removed independently.

Canonical structure.

typescript
interface OrderObserver {
  onOrderPlaced(order: Order): void;
}

class OrderService {
  private readonly observers: OrderObserver[] = [];

  subscribe(o: OrderObserver): () => void {
    this.observers.push(o);
    return () => {
      const i = this.observers.indexOf(o);
      if (i >= 0) this.observers.splice(i, 1);
    };
  }

  placeOrder(order: Order): void {
    // ... persist, charge, etc.
    for (const obs of this.observers) {
      try { obs.onOrderPlaced(order); }
      catch (err) { /* isolate: one observer's failure must not break the rest */ }
    }
  }
}

class EmailNotifier implements OrderObserver {
  onOrderPlaced(o: Order): void { /* send confirmation */ }
}
class InventoryUpdater implements OrderObserver {
  onOrderPlaced(o: Order): void { /* decrement stock */ }
}
class AnalyticsTracker implements OrderObserver {
  onOrderPlaced(o: Order): void { /* emit event */ }
}

The sharp edges you must name.

  • Error isolation. One observer's exception must not stop the others. Wrap each call in a try/catch. If you don't, you've built a system where any observer can brick the subject.
  • Ordering. Observers run in registration order. If InventoryUpdater must run before EmailNotifier, the subject's contract should say so — or you need a priority mechanism, which usually means the pattern is the wrong tool.
  • Sync vs async. Synchronous observers block the subject until they're done. For anything slow or failable, emit to a queue and let observers consume asynchronously.
  • Lifecycle leaks. Observers that forget to unsubscribe hold references to whatever they captured, and the subject holds a reference to them. In long-running processes this is a memory leak. Return an unsubscribe function.
  • Re-entrancy. If an observer's callback calls back into the subject and triggers another emission, you can iterate a mutating list. Either copy-on-emit or disallow re-entry.

When to use.

  • The set of reactions to an event changes independently of the code that raises it.
  • Multiple unrelated subsystems need to hear about the same domain event.
  • You want to test the subject and observers separately.

When NOT to use.

  • There's one consumer and there always will be. A direct method call is clearer.
  • The "observers" need a reply from the subject. Observer is fire-and-forget — if you need results, use Strategy (pluggable algorithm) or a return-value-carrying pipeline.
  • Cross-process events. The Observer pattern is an in-process construct. For cross-process, use a message bus (Kafka, SNS, Redis pub/sub); the pattern doesn't stretch across network boundaries.

LLD problems where it fits. Stock-price alerts, auction bids, chat message fan-out, order event pipelines, UI state synchronization (Redux / Zustand / MobX are all Observer variants), filesystem watchers, cache invalidation broadcasts within a process.

Alternatives. An event bus (same idea with a shared broker object). Node's EventEmitter. RxJS Subjects. A message queue for cross-process. A direct call for one consumer.

Real-world comparison. Node's EventEmitter is pure Observer. DOM event listeners. React's useEffect subscribe/unsubscribe lifecycle. Every Pub/Sub system is Observer scaled across processes.

Senior tell. "The set of reactions to 'order placed' will grow independently of how we place orders, so I'm using Observer. I'll register each reaction separately, isolate failures so one bad observer doesn't break the rest, and return unsubscribe handles so we don't leak listeners. If any observer takes non-trivial time, we push to a queue — synchronous observers are a latency grenade."


State

What it is. State lets an object change its behavior when its internal state changes — by delegating to a state-object whose type is the current state. Instead of the outer class carrying a big switch (this.state) in every method, each state is its own class with methods that describe what's legal in that state and what transition to make next.

The pressure State solves is the order-state machine from hell: if (status === 'PLACED') { ... } else if (status === 'PAID') { ... } else if (status === 'SHIPPED') { ... } in every method. Add a state, you edit every method. Miss one branch in one method, you have a bug that only fires in one transition.

Canonical structure.

typescript
interface OrderState {
  pay(order: OrderContext): void;
  ship(order: OrderContext): void;
  cancel(order: OrderContext): void;
  name(): string;
}

class PlacedState implements OrderState {
  pay(o: OrderContext): void   { o.setState(new PaidState()); }
  ship(o: OrderContext): void  { throw new Error("cannot ship before payment"); }
  cancel(o: OrderContext): void { o.setState(new CancelledState()); }
  name(): string { return "PLACED"; }
}
class PaidState implements OrderState {
  pay(o: OrderContext): void   { throw new Error("already paid"); }
  ship(o: OrderContext): void  { o.setState(new ShippedState()); }
  cancel(o: OrderContext): void { o.setState(new RefundingState()); }
  name(): string { return "PAID"; }
}
class ShippedState implements OrderState {
  pay(o: OrderContext): void   { throw new Error("already paid"); }
  ship(o: OrderContext): void  { throw new Error("already shipped"); }
  cancel(o: OrderContext): void { throw new Error("cannot cancel shipped order"); }
  name(): string { return "SHIPPED"; }
}
class CancelledState implements OrderState { /* all transitions throw */ name(): string { return "CANCELLED"; } }
class RefundingState implements OrderState { /* terminal once refund completes */ name(): string { return "REFUNDING"; } }

class OrderContext {
  private state: OrderState = new PlacedState();
  setState(s: OrderState): void { this.state = s; }
  pay(): void    { this.state.pay(this); }
  ship(): void   { this.state.ship(this); }
  cancel(): void { this.state.cancel(this); }
  currentState(): string { return this.state.name(); }
}

When to use.

  • The object has three or more distinct states with different allowed operations.
  • Transitions are non-trivial — some are illegal, some depend on conditions.
  • The state machine will grow (new states, new transitions) over time.

When NOT to use.

  • Two states. if (active) { ... } else { ... } is clearer.
  • The "states" are just flags that enable or disable one method each. A flag-check at the top of each method is fine.
  • You have a state machine that's truly declarative — a table of (state, event) → nextState. Encoding that as classes is ceremony; a lookup table is more readable and easier to visualize.

LLD problems where it fits. Order lifecycle (placed / paid / shipped / delivered / cancelled / refunded), vending machine (idle / coin-accepted / dispensing / out-of-stock), TCP connection state, document workflow (draft / review / approved / published), ride-share trip (requested / matched / in-progress / completed / cancelled), parking spot (free / reserved / occupied).

Alternatives. A state-transition table (Map<[State, Event], State>) — often cleaner for simple machines. An enum + switch statements (fine for small stable machines). A library (XState, Stateless in .NET) for complex machines where you also want visualization and time-travel.

Real-world comparison. TCP's state diagram (LISTEN, SYN-SENT, ESTABLISHED, FIN-WAIT, ...) is the canonical non-toy example. Kafka consumer state. Stripe PaymentIntent status transitions. React component lifecycle (historically — hooks now abstract it).

Senior tell. "The order has six states and the legal transitions between them form a graph, not a line. Putting a switch in every method duplicates the topology in six places. One class per state localizes each state's rules — adding a new state is one file, not six edits. If the machine were two states I'd use a boolean; if it were table-describable I'd use a transition table — State pays off at the 'graph with behavior' level."


Command

What it is. Command turns a request into a standalone object. The object holds the receiver, the arguments, and the code to execute later. Once a request is an object, you can queue it, log it, undo it, batch it, schedule it, or replay it. Every editor's undo/redo stack is a stack of Commands.

The pressure Command solves is "I need to treat operations as first-class values, not just as function calls that happen and are forgotten." Undo is the most famous consumer — you can't undo a function that's already returned, but you can call undo() on an object that remembers what it did.

Canonical structure.

typescript
interface Command {
  execute(): void;
  undo(): void;
}

// Concrete command: transfer funds
class TransferCommand implements Command {
  private applied = false;

  constructor(
    private readonly ledger: Ledger,
    private readonly from: string,
    private readonly to: string,
    private readonly amount: number,
  ) {}

  execute(): void {
    if (this.applied) throw new Error("already applied");
    this.ledger.debit(this.from, this.amount);
    this.ledger.credit(this.to, this.amount);
    this.applied = true;
  }

  undo(): void {
    if (!this.applied) throw new Error("not applied");
    this.ledger.debit(this.to, this.amount);
    this.ledger.credit(this.from, this.amount);
    this.applied = false;
  }
}

// The invoker — knows about Commands, not about what they do
class CommandHistory {
  private readonly done: Command[] = [];
  private readonly undone: Command[] = [];

  run(cmd: Command): void {
    cmd.execute();
    this.done.push(cmd);
    this.undone.length = 0; // new action clears redo
  }

  undo(): void {
    const cmd = this.done.pop();
    if (!cmd) return;
    cmd.undo();
    this.undone.push(cmd);
  }

  redo(): void {
    const cmd = this.undone.pop();
    if (!cmd) return;
    cmd.execute();
    this.done.push(cmd);
  }
}

The hard questions Command forces you to answer.

  • What state does undo() need? Enough to restore everything the user would observe. A delete command that only remembers "deleted 3 chars" can't restore them — it needs to capture the actual chars.
  • Grain. Is one user action one command? If typing "hello" pushes five single-char commands, undo takes five presses — usually not what users want. Coalesce at the invoker.
  • Composite commands. Some user actions are multiple commands in sequence (delete selection + insert replacement). Wrap them in a composite command that executes and undoes atomically.
  • Serialization. Once commands are objects, you can serialize them — enabling persistent undo, replay-based recovery, and event-sourced architectures.

When to use.

  • Undo/redo is a requirement.
  • Operations need to be queued, scheduled, logged, or replayed.
  • The invoker (button, keybinding, scheduler) should be decoupled from what the operation does.
  • You're building a macro system — recording and replaying sequences of actions.

When NOT to use.

  • There's no undo, no queueing, no replay — the operation runs synchronously and is forgotten. A function call is a function call.
  • "Command" means you wrap every method on a service in a FooCommand(service).execute(). That's not the pattern; that's a ceremonial rename.
  • The "command" has no state beyond a method pointer. A function reference / lambda is the same pattern with no boilerplate.

LLD problems where it fits. Text editor undo/redo (Word / VS Code / Google Docs), drawing tool undo (Figma / Photoshop), transactional DB operations, job queue tasks (Sidekiq / Celery jobs are Commands), macro recorders, CQRS command side, shell command history with replay.

Alternatives. A function reference for stateless one-shot operations. Event sourcing — conceptually Command scaled to a durable log. A transaction in a database for undo within a session.

Real-world comparison. The Command pattern is explicit in every editor. java.lang.Runnable is a degenerate Command with only execute(). Redux actions are Commands (execute via reducer, time-travel debugging is redo). Ansible playbook tasks. Every job queue.

Senior tell. "The editor needs undo, so every mutation is a command carrying enough state to reverse itself — not just 'what I did' but 'the cursor position to restore, and the text I removed.' The invoker — the key-binding handler — only knows how to push commands onto the history stack; it doesn't know what commands do. That separation is what lets me add macro recording in a single file later."


Template Method

What it is. Template Method defines the skeleton of an algorithm in a base class, with specific steps left as abstract methods for subclasses to fill in. The order of steps is fixed; the content of each step varies. It's inheritance used deliberately — the base class owns the workflow, the subclasses own the variations.

The pressure Template Method solves is: "three variants of this workflow share 80% of their structure. If I copy-paste them, any bug fix has to be applied three times, and they'll drift." Template Method keeps the shared workflow in one place and forces each variant to be just the differences.

Canonical structure.

typescript
abstract class DataImportJob {
  // The template method — final in Java, not-really-overridden-in-practice in TS.
  run(source: string): ImportResult {
    const raw = this.fetch(source);
    const parsed = this.parse(raw);
    const validated = this.validate(parsed);
    const persisted = this.persist(validated);
    this.notify(persisted);
    return persisted;
  }

  protected abstract fetch(source: string): string;
  protected abstract parse(raw: string): Record<string, unknown>[];
  protected abstract persist(rows: Record<string, unknown>[]): ImportResult;

  // Hook with a default — subclass may override if needed
  protected validate(rows: Record<string, unknown>[]): Record<string, unknown>[] {
    return rows.filter(r => r != null);
  }

  // Hook that's optional to override; default is no-op
  protected notify(_result: ImportResult): void { /* default: silent */ }
}

class CsvImportJob extends DataImportJob {
  protected fetch(source: string): string { /* read CSV file */ return ""; }
  protected parse(raw: string): Record<string, unknown>[] { /* csv parse */ return []; }
  protected persist(rows: Record<string, unknown>[]): ImportResult { /* bulk insert */ return { count: rows.length }; }
  protected notify(r: ImportResult): void { /* slack */ }
}

class S3JsonImportJob extends DataImportJob {
  protected fetch(source: string): string { /* S3 getObject */ return ""; }
  protected parse(raw: string): Record<string, unknown>[] { /* JSON.parse */ return []; }
  protected persist(rows: Record<string, unknown>[]): ImportResult { /* stream insert */ return { count: rows.length }; }
}

When to use.

  • The workflow has a fixed order of steps with a few points of variation.
  • You have three or more concrete variants — below that, duplication is cheaper than an abstraction.
  • Subclasses should not be able to reorder the steps. If they can, the fixed skeleton is a lie.

When NOT to use.

  • The variations don't share enough structure. If most steps vary, you're forcing inheritance to carry complexity that doesn't exist — Strategy (injecting each step as a separate collaborator) reads better.
  • You need multiple axes of variation (fetch strategy × parse strategy × persist strategy). Inheritance has one axis. You'll end up with CsvS3BulkInsertJob and a combinatorial explosion.
  • Strong composition-over-inheritance house style. In that case, the same shape is a pipeline of injected functions — which reads the same but is testable and composable.

LLD problems where it fits. Data import jobs (ETL pipelines with parseable variants), report generators (fetch → format → deliver), test frameworks (setUpruntearDown), HTTP request lifecycle (pre-filter → handle → post-filter), payment workflows (validate → authorize → capture → record), caching layers (check → miss-fetch → store).

Alternatives. Strategy for each step (composition-based). A pipeline / middleware stack (explicit ordered functions). A plain function with hooks as parameters. Inversion: instead of the base class calling abstract methods, the caller threads a config object through a function.

Real-world comparison. HttpServlet.service(req, resp) dispatching to doGet, doPost, etc. — classical Template Method. JUnit's setUp / test / tearDown. React class components' componentDidMount / render / componentWillUnmount (lifecycle as template). AWS Lambda's runtime calling your handler — a template whose only variable step is your function.

Senior tell. "Three import variants share the fetch → parse → validate → persist → notify shape. Rather than copy-paste, the base class owns the order and the variants override fetch, parse, persist. validate has a default because most variants share it; notify is a no-op hook for variants that don't care. If the variants needed to reorder steps, Template Method would be the wrong tool — that signals Strategy per step, or a pipeline."


Interview-Facing Playbook

Pattern-to-problem mapping

The fastest way to be useful under time pressure is to recognize which kind of pressure the problem is applying and pick the pattern that resolves it. The mapping below is what you should be able to recite cold:

Pressure in the problemMost useful patternWhy
Algorithm varies, caller shouldn't care whichStrategyInterchangeable behavior behind one contract
Object creation has logic or picks among variantsFactoryCaller asks "what I want," factory decides "which concrete"
Construction has many optional fields with validationBuilderReadable config + one validation seam
Operations must be undoable, queueable, or replayableCommandOperation-as-object carries state for reversal
One event → many independent reactionsObserverDecouples "it happened" from "who cares"
Object's legal operations change based on its modeStateEach state owns its rules; transitions are explicit
Fixed workflow, varying step implementationsTemplate MethodSkeleton in base, variants fill in
Orthogonal cross-cutting concerns that should composeDecoratorEach concern is its own wrapper, composition is explicit
Third-party interface doesn't match our domainAdapterTranslation lives in one place
One-of-a-kind process resource (maybe)SingletonUse with open eyes; prefer DI

A one-sentence diagnostic that disambiguates Strategy from State: Strategy is picked by the caller; State is picked by the object itself based on what's happened to it. If the caller says "use algorithm X," it's Strategy. If the object says "I'm in state S, so I won't accept this," it's State.

A one-sentence diagnostic that disambiguates Strategy from Template Method: Strategy swaps the whole algorithm; Template Method swaps steps inside a fixed algorithm. If the order of things is the same and only the contents vary, Template Method. If the whole thing changes, Strategy.

A one-sentence diagnostic that disambiguates Decorator from Adapter: Decorator keeps the interface the same and adds behavior; Adapter changes the interface and keeps the behavior. Both wrap. They solve opposite problems.

Anti-patterns in interviews

These are the moves that tank strong candidates. Most of them are overcorrections — someone learned the patterns and started applying them as a default instead of a response to pressure.

Dropping Singleton everywhere. "I'll make ParkingLotManager a Singleton because there's only one parking lot." You don't know that — there might be multiple lots per region, and even if there's one, enforcing that with static state is the wrong tool. A senior interviewer will follow up with "how do you test this?" and you'll learn why. Use DI with singleton scope, not the Singleton pattern.

Decorator when a method flag works. Three decorators deep to add one optional log line is parody. If the concern appears in one place and is simple, a flag or an inlined hook is clearer. Decorator earns its keep when concerns compose and are orthogonal.

Factory for one concrete type. PaymentProcessorFactory.create() that always returns new StripeProcessor() is a constructor with extra steps. Add the factory when the second implementation arrives, not in anticipation.

Strategy for two forever-stable variants. Two pricing rules that will never change don't need a strategy interface. An if (type === "hourly") ... else ... is four lines. Strategy is for "this will grow, and/or I need each variant testable in isolation."

Builder for three fields. new Point().setX(x).setY(y).setZ(z).build() is a positional constructor someone took on a ski trip. Builder is for five-plus fields, optional fields, or fields with cross-validation.

Observer when there's one listener. "I'll use Observer in case we add more listeners later." You'll rewrite it anyway. Use a direct call; refactor to Observer when the second listener shows up.

Naming classes after patterns. UserFactory, OrderCommand, ShippingStrategy, CartBuilder, PaymentObserver. The domain already has names: UserRegistration, PlaceOrder, ShippingRule, CartAssembly. Pattern-in-the-name leaks implementation; domain-in-the-name communicates intent. The one legitimate exception is *Builder in languages (Java, Kotlin) where the idiom is so deeply established that the name carries meaning.

Announcing the pattern instead of the pressure. "Here I'll use the Decorator pattern." The interviewer thinks: did they pick it because it fits, or because they learned it? Say instead: "These cross-cutting concerns need to compose independently, so I'll wrap each as its own object implementing Cache<V>." Same code, different signal.

Treating patterns as goals. Patterns are tools to localize change. If the code is already clear and change is already localized, the pattern is overhead. Delete the abstraction until deleting it hurts.

Stacking patterns before writing code. "I'll use Factory + Strategy + Decorator + Observer + Command here." You probably won't. Name the pressures first, introduce patterns as you need them. A design that uses one pattern cleanly beats a design that uses five ornamentally.

"I used Strategy here" — why that phrasing is weak

Three candidates walk into the same interview. The problem: a rate limiter that supports multiple algorithms.

  • Candidate A. "I'll use the Strategy pattern for the algorithms." Interviewer nods; class goes on the board. Signal: memorized the catalog.
  • Candidate B. "I'll make an interface with an allow method and write a token bucket, a sliding window, and a fixed window. Each implements the interface." Signal: builds the pattern but doesn't know why.
  • Candidate C. "The rate-limit algorithm changes independently of who calls the limiter — the checkout service shouldn't know whether it's token-bucket or sliding-window. So I'll put allow(userId, now) behind an interface and inject the concrete algorithm at construction time. If we later want per-endpoint algorithms, the injection point is the single thing that changes." Signal: led with pressure, arrived at the pattern, already anticipated the evolution.

All three wrote identical code. C got the senior signal because the reasoning — not the pattern name — was the artifact the interviewer was grading. This is the phrasing shift to internalize:

  • Not "I'll use Strategy." → "These algorithms vary independently of the caller, so I'll inject an interface."
  • Not "I'll use Observer." → "The reactions to this event change independently of the code that raises it, so I'll expose a subscribe hook."
  • Not "I'll use State." → "This object's legal operations differ by mode, and I don't want a switch in every method. Each mode owns its rules."
  • Not "I'll use Builder." → "This object has eight optional fields with cross-validation. A positional constructor isn't readable, and I want validation in one place."
  • Not "I'll use Command." → "We need undo, which means operations have to be objects that carry enough state to reverse themselves."
  • Not "I'll use Decorator." → "Metrics, TTL, and rate-limiting are orthogonal to the cache and to each other. Wrapping each as its own layer lets us compose them at wiring time."
  • Not "I'll use Template Method." → "These variants share a fixed workflow with varying step implementations. I'll lock the order in a base class and have variants fill in the steps."

Three rules that generalize:

  1. Name the pressure, not the pattern. The pattern is a consequence; the pressure is the reason. Interviewers grade reasoning, not vocabulary.
  2. Anticipate the evolution. "If we later want X, the change is localized to Y." Senior candidates see the next ask before the interviewer makes it.
  3. Know when not to use it. Any pattern you propose, be ready to say "I wouldn't use this if..." Without the "wouldn't," you sound like a pattern dispenser. With it, you sound like someone who's been burned.

The patterns are thirty years old and well-documented. What the interview measures isn't whether you know them — it's whether you know why they exist, when they help, and when to leave them on the shelf.

Frontend interview preparation reference.