Practical Design Patterns Guide
A guide to selecting and applying the right design pattern for common software engineering problems.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Introduction
Design patterns are reusable solutions to common software design problems. Knowing when to apply a pattern is as important as knowing how. This guide helps you choose the right pattern for the right situation.
Creational Patterns
Creational patterns deal with object creation mechanisms.
Factory Method
Use when: You need to create objects without specifying the exact class.
from abc import ABC, abstractmethod
class Notification(ABC):
@abstractmethod
def send(self, message: str): pass
class EmailNotification(Notification):
def send(self, message: str):
print(f"Email: {message}")
class SMSNotification(Notification):
def send(self, message: str):
print(f"SMS: {message}")
class NotificationFactory:
@staticmethod
def create(type: str) -> Notification:
if type == "email": return EmailNotification()
if type == "sms": return SMSNotification()
raise ValueError(f"Unknown type: {type}")
# Usage
notifier = NotificationFactory.create("email")
notifier.send("Hello!")
When to use: Multiple implementations of an interface, chosen at runtime.
Builder
Use when: You need to construct complex objects step by step.
class QueryBuilder {
private parts: string[] = [];
select(columns: string[]): this {
this.parts.push(`SELECT ${columns.join(', ')}`);
return this;
}
from(table: string): this {
this.parts.push(`FROM ${table}`);
return this;
}
where(condition: string): this {
this.parts.push(`WHERE ${condition}`);
return this;
}
build(): string {
return this.parts.join(' ') + ';';
}
}
// Usage
const query = new QueryBuilder()
.select(['id', 'name', 'email'])
.from('users')
.where('active = true')
.build();
When to use: Objects with many optional parameters, or complex construction logic.
Structural Patterns
Structural patterns deal with object composition.
Adapter
Use when: You need to make incompatible interfaces work together.
class OldPrinter:
def old_print(self, text):
print(f"OLD: {text}")
class PrinterAdapter:
def __init__(self, old_printer):
self._printer = old_printer
def print(self, text):
self._printer.old_print(text)
# Usage
adapter = PrinterAdapter(OldPrinter())
adapter.print("Hello") # Works with new interface
When to use: Integrating legacy code, third-party libraries, or APIs with different interfaces.
Decorator
Use when: You need to add responsibilities to objects dynamically.
from functools import wraps
def timing(func):
@wraps(func)
def wrapper(*args, **kwargs):
import time
start = time.time()
result = func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.2f}s")
return result
return wrapper
@timing
def fetch_data():
# ... slow operation
return data
When to use: Extending functionality without subclassing (logging, caching, validation, retries).
Behavioral Patterns
Behavioral patterns focus on communication between objects.
Observer
Use when: You need a publish-subscribe mechanism.
class EventEmitter {
private listeners: Map<string, Function[]> = new Map();
on(event: string, callback: Function): void {
if (!this.listeners.has(event)) this.listeners.set(event, []);
this.listeners.get(event)!.push(callback);
}
emit(event: string, data: any): void {
this.listeners.get(event)?.forEach(cb => cb(data));
}
}
// Usage
const emitter = new EventEmitter();
emitter.on('user:login', (user) => console.log(`${user.name} logged in`));
emitter.emit('user:login', { name: 'Alice' });
When to use: Event-driven architectures, real-time updates, decoupled systems.
Strategy
Use when: You need interchangeable algorithms.
from abc import ABC, abstractmethod
class PaymentStrategy(ABC):
@abstractmethod
def pay(self, amount: float): pass
class CreditCardPayment(PaymentStrategy):
def pay(self, amount: float):
print(f"Paid ${amount} with credit card")
class PayPalPayment(PaymentStrategy):
def pay(self, amount: float):
print(f"Paid ${amount} with PayPal")
class ShoppingCart:
def __init__(self, strategy: PaymentStrategy):
self.strategy = strategy
def checkout(self, amount: float):
self.strategy.pay(amount)
# Usage
cart = ShoppingCart(PayPalPayment())
cart.checkout(99.99)
When to use: Different algorithms for the same task (sorting, payment, validation rules).
Pattern Selection Cheat Sheet
| Problem | Pattern |
|---|---|
| ”I need exactly one instance” | Singleton |
| ”I create objects based on a string/type” | Factory Method |
| ”This object has 10 optional parameters” | Builder |
| ”Legacy code doesn’t match my interface” | Adapter |
| ”I need to add logging to everything” | Decorator |
| ”Components need to react to events” | Observer |
| ”I want to swap algorithms at runtime” | Strategy |
| ”I need to abstract database access” | Repository |
| ”I need to track and undo changes” | Command + Memento |
What Works
- Don’t force patterns: Not every problem needs a pattern
- Start simple: Refactor into a pattern when duplication appears. See clean code.
- Name matters: Use pattern names in class names (
UserRepository,EmailStrategy) - Document intent: Explain why you chose a pattern, not just what it does
Common Mistakes
- Over-engineering: applying patterns to trivial problems
- Pattern explosion: using too many patterns in one module
- Ignoring language idioms: not all patterns fit all languages
Frequently Asked Questions
When should I use a design pattern?
Use a design pattern when you encounter a problem it solves, not before. Combine with SOLID principles. Start with simple code and refactor into a pattern when you see duplication, complexity, or coupling that a pattern would resolve.
Are design patterns still relevant in modern languages?
Yes, but modern languages often absorb patterns into their standard libraries. For example, JavaScript’s Promise is the Observer pattern, and Python’s decorators implement the Decorator pattern natively.
How many patterns should I use in one module?
Use as many as needed, but no more. Each pattern adds cognitive overhead. If a module uses more than 2-3 patterns, consider whether it is doing too much and should be split.
Advanced Topics
Scenario: Patterns in an Order Processing System
System: E-commerce order processing
Patterns applied: Strategy, Factory, Observer, Decorator, Command
1. Strategy - Shipping calculation:
interface ShippingStrategy {
calculate(weight: number): number;
}
class StandardShipping implements ShippingStrategy {
calculate(weight: number) { return weight * 0.5; }
}
class ExpressShipping implements ShippingStrategy {
calculate(weight: number) { return weight * 1.5; }
}
class SameDayShipping implements ShippingStrategy {
calculate(weight: number) { return weight * 3.0; }
}
// Usage: select strategy at runtime
const shipping = strategies[order.shippingMethod];
const cost = shipping.calculate(order.totalWeight);
2. Factory - Notification creation:
class NotificationFactory {
create(type: string): Notification {
switch (type) {
case "email": return new EmailNotification();
case "sms": return new SMSNotification();
case "push": return new PushNotification();
default: throw new Error("Unsupported type");
}
}
}
3. Observer - Order events:
class OrderEventBus {
private handlers: Map<string, Function[]> = new Map();
on(event: string, handler: Function) {
if (!this.handlers.has(event)) this.handlers.set(event, []);
this.handlers.get(event).push(handler);
}
emit(event: string, data: any) {
this.handlers.get(event)?.forEach(h => h(data));
}
}
// Subscribers: inventory, email, analytics
bus.on("order.created", updateInventory);
bus.on("order.created", sendConfirmation);
bus.on("order.created", trackAnalytics);
4. Decorator - Logging and cache:
function withLogging(fn: Function) {
return async (...args: any[]) => {
console.log("Calling:", fn.name, args);
const result = await fn(...args);
console.log("Result:", result);
return result;
};
}
function withCache(fn: Function, ttl: number) {
const cache = new Map();
return async (...args: any[]) => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = await fn(...args);
cache.set(key, result);
setTimeout(() => cache.delete(key), ttl);
return result;
};
}
// Composition: logging + cache
const cachedLoggedFetch = withCache(withLogging(fetchProduct), 60000);
5. Command - Order operations:
interface Command { execute(): Promise<void>; }
class CancelOrderCommand implements Command {
constructor(private order: Order, private inventory: Inventory) {}
async execute() {
await this.inventory.release(this.order.items);
await this.order.update({ status: "cancelled" });
}
}
// Enables undo, queue, and operation logging
Anti-patterns to avoid:
- Singleton for everything (global coupling)
- Factory when a constructor suffices
- Observer without unsubscribe (memory leaks)
- Excessive decorator stacking (> 3 layers)
- Command without undo (loses half the value)
Lessons:
- Apply patterns when the problem requires it, not before
- Modern languages absorb patterns into their stdlib
- Composition > inheritance in most cases
- Each pattern adds complexity: weigh against value
- Refactor towards patterns, do not design them from the start
How do patterns relate to SOLID?
Strategy implements Open/Closed (new strategies without changing existing code). Factory implements Single Responsibility (creation separated from usage). Observer implements Dependency Inversion (depends on abstraction, not concretion). Decorator implements Open/Closed (extends without modifying). Command implements Single Responsibility (each command one responsibility).
Related Resources
Singleton Pattern
Ensure a class has only one instance and provide global access to it. A creational design pattern for controlled object creation.
PatternObserver Pattern
Define a subscription mechanism to notify multiple objects about events. A behavioral design pattern for event-driven communication.
PatternStrategy Pattern
Define a family of algorithms, encapsulate each one, and make them interchangeable. A behavioral design pattern for flexible behavior selection.
PatternRepository Pattern
Abstract data access logic behind a clean interface. An architectural design pattern for testable, maintainable data layers.
GuideDomain-Driven Design (DDD) — A Practical Guide
Learn DDD fundamentals: bounded contexts, entities, value objects, aggregates, and how to model complex business domains in code.
GuideWhat Works in Code Review — For Authors and Reviewers
A practical guide to useful code reviews: how to write reviewable code, give constructive feedback, and keep reviews fast and focused.
GuideSOLID Principles Explained with Examples
Learn the five SOLID principles with practical code examples: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.