Design Patterns
Learning Corner
Advanced 18 min read

Common Design Patterns

Master the Gang of Four patterns — creational, structural, and behavioral solutions to recurring software problems

Think of design patterns as

Battle-Tested Blueprints

Proven solutions to problems that developers have solved thousands of times before

Why Learn Design Patterns?

Without Patterns
  • Reinvent solutions to common problems
  • Code becomes tangled and hard to change
  • Team communication suffers — no shared vocabulary
  • Refactoring is painful and risky
  • Technical debt accumulates faster
With Patterns
  • Apply proven solutions immediately
  • Code is modular and extensible
  • "Use a Factory here" — everyone understands
  • Changes are isolated and predictable
  • Architecture scales with complexity

The Three Categories

CREATIONAL How objects are created Factory Builder Singleton Prototype STRUCTURAL How objects compose Adapter Decorator Facade Proxy BEHAVIORAL How objects communicate Strategy Observer Command State
🏭

Creational Patterns

Control how objects are instantiated. Hide creation logic, reduce coupling to concrete classes.

1 Factory Pattern

What: Delegate object creation to a factory method instead of calling constructors directly.

When: You don't know the exact type until runtime, or you want to centralize creation logic.

Client create(type) Factory decides type StripeProcessor PayPalProcessor CryptoProcessor implements Interface
// Define a common interface
interface PaymentProcessor {
  process(amount: number): Promise<void>;
}

// Concrete implementations
class StripeProcessor implements PaymentProcessor {
  async process(amount: number) {
    // Stripe-specific logic
  }
}

class PayPalProcessor implements PaymentProcessor {
  async process(amount: number) {
    // PayPal-specific logic
  }
}

// Factory function
function createPaymentProcessor(type: 'stripe' | 'paypal'): PaymentProcessor {
  switch (type) {
    case 'stripe': return new StripeProcessor();
    case 'paypal': return new PayPalProcessor();
  }
}

// Usage — client doesn't know concrete class
const processor = createPaymentProcessor(config.paymentType);
await processor.process(99.99);

✓ Use when

  • Type is determined by config/user input
  • Creation logic is complex
  • You need to swap implementations easily

⚠️ Skip if

  • Only one implementation exists
  • Creation is trivial (new X())
  • Adds unnecessary abstraction

2 Builder Pattern

What: Construct complex objects step-by-step. Separate construction from representation.

When: Object has many optional parameters, or construction requires multiple steps.

Builder .setUrl() step 1 .addHeader() step 2 .setBody() step 3 .build() → Object
class HttpRequestBuilder {
  private method: string = 'GET';
  private url: string = '';
  private headers: Record<string, string> = {};
  private body?: unknown;
  private timeout: number = 30000;

  setMethod(method: string) {
    this.method = method;
    return this;  // Enable chaining
  }

  setUrl(url: string) {
    this.url = url;
    return this;
  }

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

  setBody(body: unknown) {
    this.body = body;
    return this;
  }

  setTimeout(ms: number) {
    this.timeout = ms;
    return this;
  }

  build(): HttpRequest {
    return new HttpRequest(this.method, this.url, this.headers, this.body, this.timeout);
  }
}

// Usage — readable, flexible construction
const request = new HttpRequestBuilder()
  .setMethod('POST')
  .setUrl('/api/users')
  .addHeader('Content-Type', 'application/json')
  .addHeader('Authorization', `Bearer ${token}`)
  .setBody({ name: 'Alice' })
  .setTimeout(5000)
  .build();

✓ Use when

  • Many optional parameters (5+)
  • Construction order matters
  • Same process, different representations

⚠️ Skip if

  • Object is simple (2-3 required params)
  • No optional configuration
  • Object is immutable and small

3 Singleton Pattern

What: Ensure only one instance exists. Provide global access point.

When: Shared resource (config, logging, connection pool). Use sparingly — often a code smell.

Module A Module B Module C getInstance() Singleton private constructor Single Instance shared state
// Modern TypeScript singleton
class Logger {
  private static instance: Logger;
  private logs: string[] = [];

  private constructor() {} // Prevent direct construction

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

  log(message: string) {
    this.logs.push(`[${new Date().toISOString()}] ${message}`);
  }
}

// Usage — always same instance
Logger.getInstance().log('App started');
Logger.getInstance().log('User logged in');

// Or export a singleton instance (simpler)
export const logger = Logger.getInstance();

⚠️ Warning: Singleton Dangers

  • Global state — hidden dependencies, hard to test
  • Violates single responsibility (manages own lifecycle)
  • Complicates parallel testing
  • Often misused — consider dependency injection instead
🧱

Structural Patterns

Compose objects into larger structures while keeping them flexible and efficient.

📚

Looking for Repository Pattern? We have a dedicated beginner-friendly guide that covers it in depth with Rust examples.

1 Adapter Pattern

What: Convert one interface to another. Make incompatible things work together.

When: Integrating legacy code, third-party libs, or APIs with different shapes.

Your Code ADAPTER Translates Legacy API (different interface)
// Your expected interface
interface EmailService {
  send(to: string, subject: string, body: string): Promise<void>;
}

// Third-party SDK with different interface
class SendGridClient {
  sendEmail(params: { recipient: string; title: string; content: string }) {
    // SendGrid implementation
  }
}

// Adapter — wraps SendGrid to match your interface
class SendGridAdapter implements EmailService {
  constructor(private client: SendGridClient) {}

  async send(to: string, subject: string, body: string) {
    await this.client.sendEmail({
      recipient: to,
      title: subject,
      content: body,
    });
  }
}

// Usage — your code stays clean
const emailService: EmailService = new SendGridAdapter(new SendGridClient());
await emailService.send('user@example.com', 'Hello', 'Welcome!');

2 Decorator Pattern

What: Add behavior to objects dynamically by wrapping them. Like layering clothes.

When: Need to add responsibilities without subclassing. Compose behaviors flexibly.

FileDataSource core wrap Compressed decorator wraps ↑ wrap Encrypted decorator wraps ↑ Client uses outer write() → encrypt → compress → file
interface DataSource {
  read(): string;
  write(data: string): void;
}

// Base implementation
class FileDataSource implements DataSource {
  constructor(private filename: string) {}
  read() { return fs.readFileSync(this.filename, 'utf8'); }
  write(data: string) { fs.writeFileSync(this.filename, data); }
}

// Decorator — adds encryption
class EncryptedDataSource implements DataSource {
  constructor(private wrapped: DataSource) {}

  read() {
    return decrypt(this.wrapped.read());
  }

  write(data: string) {
    this.wrapped.write(encrypt(data));
  }
}

// Decorator — adds compression
class CompressedDataSource implements DataSource {
  constructor(private wrapped: DataSource) {}

  read() {
    return decompress(this.wrapped.read());
  }

  write(data: string) {
    this.wrapped.write(compress(data));
  }
}

// Compose behaviors — encryption + compression
const source = new EncryptedDataSource(
  new CompressedDataSource(
    new FileDataSource('data.txt')
  )
);
source.write('secret data'); // Encrypted → Compressed → File

3 Facade Pattern

What: Provide a simplified interface to a complex subsystem. Hide the mess.

When: Complex library with many classes, or you want to expose only what clients need.

Client play() VideoPlayer Facade simple interface Complex Subsystem VideoDecoder AudioDecoder SubtitleParser VideoRenderer SubtitleRenderer
// Complex subsystem classes
class VideoDecoder { decode(file: string) { /*...*/ } }
class AudioDecoder { decode(file: string) { /*...*/ } }
class SubtitleParser { parse(file: string) { /*...*/ } }
class VideoRenderer { render(video: any, audio: any) { /*...*/ } }
class SubtitleRenderer { overlay(subs: any) { /*...*/ } }

// Facade — simple interface to all of the above
class VideoPlayer {
  private videoDecoder = new VideoDecoder();
  private audioDecoder = new AudioDecoder();
  private subtitleParser = new SubtitleParser();
  private videoRenderer = new VideoRenderer();
  private subtitleRenderer = new SubtitleRenderer();

  play(videoFile: string, subtitleFile?: string) {
    const video = this.videoDecoder.decode(videoFile);
    const audio = this.audioDecoder.decode(videoFile);
    this.videoRenderer.render(video, audio);
    
    if (subtitleFile) {
      const subs = this.subtitleParser.parse(subtitleFile);
      this.subtitleRenderer.overlay(subs);
    }
  }
}

// Client code — blissfully simple
const player = new VideoPlayer();
player.play('movie.mp4', 'subs.srt');

4 Proxy Pattern

What: Provide a surrogate or placeholder. Control access to the real object.

When: Lazy loading, access control, logging, caching, remote objects.

Client query() CachingProxy • check cache • log queries delegate RealDatabase actual work same interface
interface Database {
  query(sql: string): Promise<any[]>;
}

class RealDatabase implements Database {
  async query(sql: string) {
    // Expensive database call
    return db.execute(sql);
  }
}

// Caching proxy
class CachingDatabaseProxy implements Database {
  private cache = new Map<string, any[]>();
  
  constructor(private db: Database) {}

  async query(sql: string) {
    if (this.cache.has(sql)) {
      console.log('Cache hit');
      return this.cache.get(sql)!;
    }
    
    const result = await this.db.query(sql);
    this.cache.set(sql, result);
    return result;
  }
}

// Logging proxy
class LoggingDatabaseProxy implements Database {
  constructor(private db: Database) {}

  async query(sql: string) {
    console.log(`[SQL] ${sql}`);
    const start = Date.now();
    const result = await this.db.query(sql);
    console.log(`[SQL] Completed in ${Date.now() - start}ms`);
    return result;
  }
}

// Stack proxies
const db = new LoggingDatabaseProxy(
  new CachingDatabaseProxy(
    new RealDatabase()
  )
);
🔄

Behavioral Patterns

Define how objects interact and distribute responsibility.

1 Strategy Pattern

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

When: Multiple ways to do something, selected at runtime. Avoid switch/if-else chains.

FileUploader context uses «interface» CompressionStrategy compress(data) GzipStrategy BrotliStrategy NoCompression swap at runtime
// Strategy interface
interface CompressionStrategy {
  compress(data: Buffer): Buffer;
}

// Concrete strategies
class GzipStrategy implements CompressionStrategy {
  compress(data: Buffer) { return zlib.gzipSync(data); }
}

class BrotliStrategy implements CompressionStrategy {
  compress(data: Buffer) { return zlib.brotliCompressSync(data); }
}

class NoCompressionStrategy implements CompressionStrategy {
  compress(data: Buffer) { return data; }
}

// Context — uses strategy
class FileUploader {
  constructor(private strategy: CompressionStrategy) {}

  setStrategy(strategy: CompressionStrategy) {
    this.strategy = strategy;
  }

  upload(file: Buffer) {
    const compressed = this.strategy.compress(file);
    // Upload compressed data
  }
}

// Usage — swap algorithms
const uploader = new FileUploader(new GzipStrategy());
uploader.upload(smallFile);

uploader.setStrategy(new BrotliStrategy()); // Switch to better compression
uploader.upload(largeFile);

2 Observer Pattern

What: One-to-many dependency. When one object changes, all dependents are notified.

When: Event systems, pub/sub, reactive updates. Think: EventEmitter, RxJS, React state.

Subject notify() Observer A Observer B Observer C
type Listener<T> = (data: T) => void;

class EventEmitter<T> {
  private listeners: Set<Listener<T>> = new Set();

  subscribe(listener: Listener<T>) {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener); // Unsubscribe fn
  }

  emit(data: T) {
    this.listeners.forEach(listener => listener(data));
  }
}

// Usage
const priceUpdates = new EventEmitter<{ symbol: string; price: number }>();

// Observer A — UI update
const unsubUI = priceUpdates.subscribe(({ symbol, price }) => {
  updatePriceDisplay(symbol, price);
});

// Observer B — Alert system
priceUpdates.subscribe(({ symbol, price }) => {
  if (price > threshold) sendAlert(symbol);
});

// Subject emits — all observers notified
priceUpdates.emit({ symbol: 'BTC', price: 50000 });

unsubUI(); // Cleanup

3 Command Pattern

What: Encapsulate a request as an object. Queue, log, or undo operations.

When: Undo/redo, transaction logs, macro recording, queued operations.

History invoker Command Queue AddText execute() Delete undo() ... TextDocument receiver undo()
interface Command {
  execute(): void;
  undo(): void;
}

class AddTextCommand implements Command {
  constructor(
    private document: TextDocument,
    private position: number,
    private text: string
  ) {}

  execute() {
    this.document.insert(this.position, this.text);
  }

  undo() {
    this.document.delete(this.position, this.text.length);
  }
}

class CommandHistory {
  private history: Command[] = [];
  private position = -1;

  execute(command: Command) {
    // Remove any undone commands
    this.history = this.history.slice(0, this.position + 1);
    command.execute();
    this.history.push(command);
    this.position++;
  }

  undo() {
    if (this.position >= 0) {
      this.history[this.position].undo();
      this.position--;
    }
  }

  redo() {
    if (this.position < this.history.length - 1) {
      this.position++;
      this.history[this.position].execute();
    }
  }
}

// Usage
const history = new CommandHistory();
history.execute(new AddTextCommand(doc, 0, 'Hello'));
history.execute(new AddTextCommand(doc, 5, ' World'));
history.undo();  // Removes ' World'
history.redo();  // Adds ' World' back

4 State Pattern

What: Object behavior changes based on internal state. State machines without switch statements.

When: Complex state logic, order workflows, connection states, game states.

Pending can cancel ship() Shipped in transit deliver() Delivered complete ✓ Cancelled cancel() ✗ can't cancel ✗ can't cancel
interface OrderState {
  cancel(order: Order): void;
  ship(order: Order): void;
  deliver(order: Order): void;
}

class PendingState implements OrderState {
  cancel(order: Order) {
    order.setState(new CancelledState());
    refund(order);
  }
  ship(order: Order) {
    order.setState(new ShippedState());
    sendShippingNotification(order);
  }
  deliver(order: Order) {
    throw new Error('Cannot deliver pending order');
  }
}

class ShippedState implements OrderState {
  cancel(order: Order) {
    throw new Error('Cannot cancel shipped order');
  }
  ship(order: Order) {
    throw new Error('Already shipped');
  }
  deliver(order: Order) {
    order.setState(new DeliveredState());
    sendDeliveryConfirmation(order);
  }
}

class Order {
  private state: OrderState = new PendingState();

  setState(state: OrderState) { this.state = state; }
  cancel() { this.state.cancel(this); }
  ship() { this.state.ship(this); }
  deliver() { this.state.deliver(this); }
}

// State-specific behavior automatic
const order = new Order();
order.ship();     // ✓ Works — pending → shipped
order.deliver();  // ✓ Works — shipped → delivered
order.cancel();   // ✗ Throws — can't cancel delivered

Pattern Selection Cheatsheet

Need to create objects?

Factory if type determined at runtime

Builder if many optional params or step-by-step construction

Singleton if exactly one instance (use sparingly!)

Need to connect incompatible interfaces?

Adapter if wrapping external code to match your interface

Facade if simplifying a complex subsystem

Repository if abstracting data access behind a clean interface

Need to add behavior?

Decorator if composing behaviors dynamically

Proxy if controlling access (caching, logging, lazy load)

Need flexible algorithms?

Strategy if swapping algorithms at runtime

State if behavior changes based on internal state

Need event-driven communication?

Observer if one-to-many notifications

Command if encapsulating operations (undo/redo, queues)

Trade-offs

Pros

  • Proven solutions — don't reinvent the wheel
  • Common vocabulary — "use a Strategy here"
  • Flexible, maintainable code
  • Easier to extend without breaking existing code
  • Encapsulation of change

Cons

  • Over-engineering — pattern for pattern's sake
  • Added complexity — more classes, indirection
  • Learning curve for the team
  • Can obscure simple solutions
  • Premature abstraction risk

Key Takeaways

1

Patterns are tools, not rules

Use them when they solve a real problem, not to tick a checkbox.

2

Start simple, refactor to patterns

Don't force patterns upfront. Let complexity emerge, then apply patterns.

3

Composition over inheritance

Most patterns favor object composition. Decorator, Strategy, Observer — all composable.

4

Modern TS has shortcuts

Generics, unions, and functions often replace class-heavy GoF patterns.

5

Patterns combine

Factory + Strategy, Decorator + Proxy — patterns work together in real systems.