Think of design patterns as
Battle-Tested Blueprints
Proven solutions to problems that developers have solved thousands of times before
Why Learn Design 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
- 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 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.
// 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.
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.
// 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 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.
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.
// 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.
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.
// 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.
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.
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.
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
Patterns are tools, not rules
Use them when they solve a real problem, not to tick a checkbox.
Start simple, refactor to patterns
Don't force patterns upfront. Let complexity emerge, then apply patterns.
Composition over inheritance
Most patterns favor object composition. Decorator, Strategy, Observer — all composable.
Modern TS has shortcuts
Generics, unions, and functions often replace class-heavy GoF patterns.
Patterns combine
Factory + Strategy, Decorator + Proxy — patterns work together in real systems.