Design patterns have a reputation problem. The Gang of Four book describes 23 patterns, most developers memorize a few for interviews, and then never consciously use them. But the truth is you use design patterns every day — you just do not call them by name.
This guide covers the 7 patterns that genuinely appear in production code, with practical examples in Python and TypeScript.
1. Strategy Pattern: Swappable Algorithms
Replace conditional logic with interchangeable objects that encapsulate different behaviors. You have already used this if you have ever passed a function as an argument.
# Without Strategy: growing if/else chain
def calculate_shipping(order, method):
if method == "standard":
return order.weight * 0.5
elif method == "express":
return order.weight * 1.5 + 10
elif method == "overnight":
return order.weight * 3.0 + 25
elif method == "drone": # New method = modify this function
return 50.0
# With Strategy: each algorithm is a separate callable
from typing import Protocol
class ShippingStrategy(Protocol):
def calculate(self, order) -> float: ...
class StandardShipping:
def calculate(self, order) -> float:
return order.weight * 0.5
class ExpressShipping:
def calculate(self, order) -> float:
return order.weight * 1.5 + 10
class OvernightShipping:
def calculate(self, order) -> float:
return order.weight * 3.0 + 25
# Usage: swap strategy without changing caller
def checkout(order, shipping: ShippingStrategy):
cost = shipping.calculate(order)
return cost
checkout(order, ExpressShipping()) # Easy to add new strategies
Where you see it: Sorting algorithms (key functions), authentication strategies (Passport.js), payment processors, serialization formats.
2. Observer Pattern: Event-Driven Communication
When one object changes, notify all interested objects automatically. This is the foundation of event-driven programming.
# Python implementation
class EventEmitter:
def __init__(self):
self._listeners: dict[str, list] = {}
def on(self, event: str, callback):
self._listeners.setdefault(event, []).append(callback)
def emit(self, event: str, data=None):
for callback in self._listeners.get(event, []):
callback(data)
# Usage
events = EventEmitter()
# Register observers
events.on("order:created", lambda order: send_email(order))
events.on("order:created", lambda order: update_inventory(order))
events.on("order:created", lambda order: notify_warehouse(order))
# Trigger event - all observers run
events.emit("order:created", new_order)
// TypeScript: Angular uses this everywhere (RxJS Subjects)
import { Subject } from 'rxjs';
class OrderService {
private orderCreated = new Subject<Order>();
orderCreated$ = this.orderCreated.asObservable();
createOrder(data: OrderData): Order {
const order = this.save(data);
this.orderCreated.next(order); // Notify all subscribers
return order;
}
}
// Subscribers
orderService.orderCreated$.subscribe(order => sendEmail(order));
orderService.orderCreated$.subscribe(order => updateAnalytics(order));
Where you see it: DOM events (addEventListener), RxJS, Node.js EventEmitter, React state management, message queues.
3. Factory Pattern: Object Creation Logic
Encapsulate object creation when the exact type depends on runtime conditions. Instead of the caller knowing every possible class, the factory decides.
# Without Factory: caller must know every type
def process_payment(method, amount):
if method == "credit_card":
processor = CreditCardProcessor()
elif method == "paypal":
processor = PayPalProcessor()
elif method == "crypto":
processor = CryptoProcessor()
processor.charge(amount)
# With Factory: creation logic in one place
class PaymentFactory:
_processors = {
"credit_card": CreditCardProcessor,
"paypal": PayPalProcessor,
"crypto": CryptoProcessor,
}
@classmethod
def create(cls, method: str) -> PaymentProcessor:
processor_cls = cls._processors.get(method)
if not processor_cls:
raise ValueError(f"Unknown payment method: {method}")
return processor_cls()
# Usage: clean and extensible
processor = PaymentFactory.create("paypal")
processor.charge(99.99)
# Adding a new method: just register it
PaymentFactory._processors["apple_pay"] = ApplePayProcessor
Where you see it: Django’s ORM (model instances from query results), React.createElement, database connection factories, logger factories.
4. Decorator Pattern: Adding Behavior Without Modification
Wrap an object to add functionality without changing its interface. Python decorators are the most famous implementation, but the pattern is broader than the syntax.
# Python decorators ARE the decorator pattern
import functools
import time
import logging
def retry(max_attempts=3, delay=1):
"""Decorator that retries a function on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
time.sleep(delay * (2 ** attempt))
return None
return wrapper
return decorator
def log_calls(func):
"""Decorator that logs function calls."""
@functools.wraps(func)
def wrapper(*args, **kwargs):
logging.info(f"Calling {func.__name__}")
result = func(*args, **kwargs)
logging.info(f"{func.__name__} returned {result}")
return result
return wrapper
# Stack decorators: each wraps the previous
@retry(max_attempts=3)
@log_calls
def fetch_user_data(user_id):
return api.get(f"/users/{user_id}")
// TypeScript: class-based decorator pattern
interface DataSource {
read(): string[];
}
class FileDataSource implements DataSource {
read(): string[] { return readFile('data.csv'); }
}
// Decorator: adds caching without modifying FileDataSource
class CachedDataSource implements DataSource {
private cache: string[] | null = null;
constructor(private wrapped: DataSource) {}
read(): string[] {
if (!this.cache) {
this.cache = this.wrapped.read();
}
return this.cache;
}
}
// Decorator: adds logging
class LoggedDataSource implements DataSource {
constructor(private wrapped: DataSource) {}
read(): string[] {
console.log('Reading data...');
const result = this.wrapped.read();
console.log(`Read ${result.length} records`);
return result;
}
}
// Compose: logged + cached + file
const source = new LoggedDataSource(
new CachedDataSource(
new FileDataSource()
)
);
Where you see it: Python decorators (@app.route, @login_required), Express middleware, Java annotations, TypeScript decorators.
5. Singleton Pattern: One Instance, Global Access
Ensure a class has exactly one instance. Controversial but practical for shared resources like database connections, loggers, and configuration.
# Python: module-level is already a singleton
# config.py
class Config:
def __init__(self):
self.db_url = os.environ["DATABASE_URL"]
self.redis_url = os.environ["REDIS_URL"]
self.debug = os.environ.get("DEBUG", "false") == "true"
config = Config() # Created once when module is imported
# All other files:
from config import config # Same instance everywhere
// TypeScript: Angular services are singletons by default
@Injectable({ providedIn: 'root' }) // One instance for the entire app
export class AuthService {
private currentUser = signal<User | null>(null);
// Every component that injects AuthService gets the SAME instance
login(credentials: Credentials) { ... }
logout() { ... }
}
Where you see it: Angular services (providedIn: root), Python modules, database connection pools, logging.getLogger().
6. Adapter Pattern: Making Incompatible Interfaces Work Together
Wrap an existing class so it matches the interface your code expects. Essential when integrating third-party libraries or legacy systems.
# Your code expects this interface:
class PaymentGateway(Protocol):
def charge(self, amount: float, currency: str) -> dict: ...
# But the Stripe SDK has a different interface:
# stripe.PaymentIntent.create(amount=1000, currency="usd") # amount in cents!
# Adapter: wraps Stripe to match your interface
class StripeAdapter:
def charge(self, amount: float, currency: str) -> dict:
# Convert dollars to cents (Stripe's format)
intent = stripe.PaymentIntent.create(
amount=int(amount * 100),
currency=currency.lower(),
)
return {
"id": intent.id,
"status": "success" if intent.status == "succeeded" else "pending",
"amount": amount,
}
# Another adapter for PayPal (different API entirely)
class PayPalAdapter:
def charge(self, amount: float, currency: str) -> dict:
order = paypal.Order.create(
purchase_units=[{"amount": {"value": str(amount), "currency_code": currency}}]
)
return {
"id": order.id,
"status": "success" if order.status == "COMPLETED" else "pending",
"amount": amount,
}
# Your code works with any adapter:
def process_payment(gateway: PaymentGateway, amount: float):
result = gateway.charge(amount, "USD")
return result
Where you see it: ORM adapters (SQLAlchemy supports PostgreSQL, MySQL, SQLite through adapters), logging handlers, API client wrappers.
7. Builder Pattern: Complex Object Construction
Construct complex objects step by step instead of through a constructor with 15 parameters.
# Without Builder: constructor with too many parameters
query = SQLQuery(
table="users",
columns=["name", "email"],
where="age > 18",
order_by="name",
order_dir="ASC",
limit=100,
offset=0,
join_table="orders",
join_condition="users.id = orders.user_id",
group_by="name",
having="COUNT(*) > 5",
)
# With Builder: readable, chainable construction
class QueryBuilder:
def __init__(self, table: str):
self._table = table
self._columns = ["*"]
self._conditions = []
self._order = None
self._limit = None
def select(self, *columns):
self._columns = list(columns)
return self # Return self for chaining
def where(self, condition: str):
self._conditions.append(condition)
return self
def order_by(self, column: str, direction: str = "ASC"):
self._order = f"{column} {direction}"
return self
def limit(self, n: int):
self._limit = n
return self
def build(self) -> str:
query = f"SELECT {', '.join(self._columns)} FROM {self._table}"
if self._conditions:
query += " WHERE " + " AND ".join(self._conditions)
if self._order:
query += f" ORDER BY {self._order}"
if self._limit:
query += f" LIMIT {self._limit}"
return query
# Usage: reads like English
query = (QueryBuilder("users")
.select("name", "email")
.where("age > 18")
.where("status = 'active'")
.order_by("name")
.limit(100)
.build()
)
Where you see it: ORM query builders (Django QuerySet, SQLAlchemy), HTTP request builders, test data builders, UI component builders.
Pattern Selection Guide
| Problem | Pattern |
|---|---|
| Multiple algorithms for the same task | Strategy |
| Notify multiple objects of state changes | Observer |
| Object creation depends on runtime data | Factory |
| Add behavior without modifying existing code | Decorator |
| Exactly one shared instance needed | Singleton |
| Integrate incompatible third-party interfaces | Adapter |
| Complex object with many configuration options | Builder |
Key Takeaways
- Patterns are tools, not goals — do not force a pattern where a simple function would do
- Strategy eliminates if/else chains — use it when you have multiple algorithms for the same task
- Observer decouples producers from consumers — the foundation of event-driven architecture
- Factory centralizes creation logic — add new types without modifying calling code
- Decorator adds behavior without inheritance — compose small, focused wrappers
- Singleton is just a module-level instance in Python and providedIn: root in Angular
- Adapter wraps third-party code to match your interfaces — essential for swappable integrations
- Builder replaces constructors with 10+ parameters — readable, chainable, self-documenting
The best code uses patterns without naming them. If you write a function that takes a callback, you are using Strategy. If you emit events, you are using Observer. If you wrap a class to add logging, you are using Decorator. The patterns are already in your code — knowing their names helps you communicate about them and apply them intentionally.