caduh

Common Design Patterns — The 5‑Minute Version

4 min read

A whirlwind tour of widely used patterns—Singleton, Factory, Observer, Strategy, Adapter/Decorator—with tiny examples, when to use them, and common pitfalls.

TL;DR

  • Singleton: one instance, global access — use sparingly; prefer dependency injection.
  • Factory (Method/Abstract): centralize object creation and choose implementations at runtime.
  • Strategy: swap algorithms/behaviors behind the same interface (no if/else jungles).
  • Observer (Pub/Sub): producers emit events; subscribers react, decoupled in time.
  • Adapter converts one interface to another; Decorator wraps to add behavior without subclassing.
  • Rule of thumb: reach for patterns when you feel duplication, tight coupling, or branching exploding.

Mental model

Patterns are named solutions to recurring design problems. They help communication (“let’s use Strategy here”), improve testability, and reduce coupling — when applied to the right pressure points.


1) Singleton (careful with this one)

Guarantee exactly one instance and provide a global access point.

TypeScript

class Config {
  private static _inst: Config | null = null;
  private constructor(public readonly env: string) {}
  static get instance() {
    return this._inst ??= new Config(process.env.NODE_ENV ?? "dev");
  }
}
const cfg = Config.instance;

Use when: a truly unique resource (process-wide cache, connection pool) must be coordinated.
Pitfalls: hidden global state, hard to test/mock, tricky in concurrent or serverless contexts. Prefer DI containers and pass dependencies explicitly.


2) Factory (Factory Method & Abstract Factory)

Centralize creation logic, hide complexity, and pick implementations at runtime.

Factory Method (function returning an implementation)

interface Payment {
  charge(cents: number): Promise<void>;
}
class StripePayment implements Payment { async charge(c){ /* ... */ } }
class PayPalPayment implements Payment { async charge(c){ /* ... */ } }

function makePayment(gateway: "stripe" | "paypal"): Payment {
  return gateway === "stripe" ? new StripePayment() : new PayPalPayment();
}

Abstract Factory (family of related objects)

interface UIThemeFactory {
  button(): Button;
  modal(): Modal;
}
// DarkThemeFactory / LightThemeFactory each build matching components

Use when: creation is complex, varies by environment, or you must produce coherent families.
Pitfalls: over-engineering — sometimes a simple constructor or function is enough.


3) Strategy (swap algorithms)

Define a family of algorithms, encapsulate each, and make them interchangeable.

TS example

interface PriceStrategy { total(base: number): number; }
class Retail implements PriceStrategy { total(b){ return b * 1.15; } }
class Wholesale implements PriceStrategy { total(b){ return b * 1.05; } }

class Cart {
  constructor(private strat: PriceStrategy) {}
  checkout(subtotal: number){ return this.strat.total(subtotal); }
}

const retailCart = new Cart(new Retail());
const wholesaleCart = new Cart(new Wholesale());

Use when: lots of conditional logic chooses among similar behaviors (pricing, caching, retries).
Pitfalls: too many tiny classes; consider function strategies or data-driven configs.


4) Observer (a.k.a. Pub/Sub)

Objects subscribe to events and get notified when something happens.

Node-style EventEmitter

import { EventEmitter } from "events";
const bus = new EventEmitter();
bus.on("order.created", (o) => { /* send email */ });
bus.emit("order.created", { id: 123 });

Use when: you need loose coupling across features or async reactions (UI updates, domain events).
Pitfalls: memory leaks (unsubscribe!), ordering, and backpressure; for distributed systems use durable queues (Kafka, SQS) instead.


5) Adapter vs Decorator (structural)

Adapter: make an incompatible API look like one you expect.

class LegacyLogger { write(msg: string){ /* ... */ } }
interface Logger { info(msg: string): void; }
class LoggerAdapter implements Logger {
  constructor(private legacy: LegacyLogger) {}
  info(msg: string){ this.legacy.write(`[INFO] ${msg}`); }
}

Decorator: wrap an object to add behavior without modifying it.

interface Repo { find(id: string): Promise<any>; }
class DbRepo implements Repo { async find(id){ /* query DB */ } }

class CachingRepo implements Repo {
  private cache = new Map<string, any>();
  constructor(private inner: Repo) {}
  async find(id: string){
    if (this.cache.has(id)) return this.cache.get(id);
    const val = await this.inner.find(id);
    this.cache.set(id, val);
    return val;
  }
}

Use when:

  • Adapter: integrating with legacy/third-party libs.
  • Decorator: layering cross‑cutting concerns (caching, logging, auth) per instance.

Honorable mentions (1‑liners)

  • Builder: step‑by‑step construction for complex objects (fluent APIs).
  • Repository: data access abstraction to decouple domain logic from storage.
  • Command: encapsulate actions for undo/redo or queueing.
  • State: like Strategy but internal to the object; behavior changes with state.
  • Facade: provide a simplified API over a complex subsystem.

Quick decision table

| Problem | Pattern to try | |---|---| | Many if/else choosing behaviors | Strategy | | Need one well-known instance | Singleton (but prefer DI) | | Choose implementation at runtime | Factory | | Need to react to events from afar | Observer / Pub/Sub | | Incompatible interfaces | Adapter | | Add cross-cutting behavior per instance | Decorator |


Anti‑patterns to avoid

  • Pattern for pattern’s sake” — abstractions should pay rent.
  • God Singletons holding app-wide mutable state.
  • Deep inheritance trees; prefer composition.
  • Leaky Observers causing memory spikes (always unsubscribe / weak refs).

One‑minute starter kit

  1. Identify the hot spot (duplication, branching, tight coupling).
  2. Pick the simplest pattern that removes it.
  3. Write tests for the interface, not the implementation.
  4. Keep classes tiny; consider functions for strategies.
  5. Document with a short “Why this pattern” note for future readers.