Design Patterns That Actually Show Up in Real Codebases
A practical guide to the design patterns you'll actually encounter and use. Covers the patterns that matter in modern web development, with real code examples, and skips the ones that only exist in textbooks.
The Gang of Four book lists 23 design patterns. Most design pattern tutorials dutifully walk through all of them, including patterns you'll never use in your career (looking at you, Flyweight). The result is that developers either memorize patterns they never apply, or get so overwhelmed they don't learn any.
Here's the truth: about 6-8 patterns show up constantly in real code. You're probably already using some of them without knowing the name. Let's focus on the ones that actually matter, with examples from real-world code rather than abstract shapes and animals.
Why Patterns Exist
A design pattern is a reusable solution to a recurring problem. That's it. Not a framework, not a library, not a rule you must follow. It's more like a vocabulary -- when someone says "that's an observer pattern," everyone on the team immediately understands the architecture without reading the code.
The danger is treating patterns as goals. You don't look at a problem and think "where can I use the Strategy pattern?" You look at a problem, solve it, and sometimes realize "oh, this solution has a name." Patterns describe good solutions; they don't prescribe them.
1. Observer (Event Emitter)
The problem: one part of your system needs to notify other parts when something happens, without being tightly coupled to them. Where you've seen it: DOM event listeners, Node.js EventEmitter, React state management, pub/sub systems, webhooks.// You use this pattern every day
button.addEventListener("click", handleClick);
window.addEventListener("resize", handleResize);
// Node.js EventEmitter
import { EventEmitter } from "events";
class OrderService extends EventEmitter {
placeOrder(order) {
// Process the order
const savedOrder = this.saveToDatabase(order);
// Notify interested parties
this.emit("orderPlaced", savedOrder);
}
}
const orders = new OrderService();
// Different parts of the system react independently
orders.on("orderPlaced", (order) => sendConfirmationEmail(order));
orders.on("orderPlaced", (order) => updateInventory(order));
orders.on("orderPlaced", (order) => notifyWarehouse(order));
The beauty: OrderService doesn't know or care about emails, inventory, or warehouses. It just announces what happened. Listeners subscribe to what they care about. You can add new listeners without modifying the order service.
from typing import Callable
class EventEmitter:
def __init__(self):
self._listeners: dict[str, list[Callable]] = {}
def on(self, event: str, callback: Callable):
self._listeners.setdefault(event, []).append(callback)
def emit(self, event: str, args, *kwargs):
for callback in self._listeners.get(event, []):
callback(args, *kwargs)
# Usage
class UserService:
def __init__(self):
self.events = EventEmitter()
def register(self, email, password):
user = create_user(email, password)
self.events.emit("user_registered", user)
return user
user_service = UserService()
user_service.events.on("user_registered", send_welcome_email)
user_service.events.on("user_registered", create_default_workspace)
2. Strategy
The problem: you need to swap out an algorithm or behavior at runtime without changing the code that uses it. Where you've seen it: sorting comparators, payment processors, authentication strategies (Passport.js), validation rules, pricing calculations.// Sorting with different strategies -- you already use this
const users = [
{ name: "Alice", age: 30 },
{ name: "Bob", age: 25 },
{ name: "Charlie", age: 35 },
];
users.sort((a, b) => a.name.localeCompare(b.name)); // Strategy: by name
users.sort((a, b) => a.age - b.age); // Strategy: by age
A more structured example -- payment processing:
const paymentStrategies = {
creditCard: async (amount, details) => {
const result = await stripe.charges.create({
amount: amount * 100,
currency: "usd",
source: details.token,
});
return { success: true, transactionId: result.id };
},
paypal: async (amount, details) => {
const result = await paypal.payment.create({
amount,
returnUrl: details.returnUrl,
});
return { success: true, redirectUrl: result.approvalUrl };
},
bankTransfer: async (amount, details) => {
const ref = generateReference();
await recordPendingTransfer(ref, amount, details.accountNumber);
return { success: true, reference: ref, status: "pending" };
},
};
async function processPayment(method, amount, details) {
const strategy = paymentStrategies[method];
if (!strategy) {
throw new Error(Unsupported payment method: ${method});
}
return strategy(amount, details);
}
// Usage
await processPayment("creditCard", 49.99, { token: "tok_..." });
await processPayment("paypal", 49.99, { returnUrl: "..." });
The processPayment function doesn't know how each payment method works. It delegates to the appropriate strategy. Adding a new payment method means adding a new entry to the object -- no modification to existing code.
from typing import Callable
# Strategies as functions
def compress_gzip(data: bytes) -> bytes:
import gzip
return gzip.compress(data)
def compress_zlib(data: bytes) -> bytes:
import zlib
return zlib.compress(data)
def compress_none(data: bytes) -> bytes:
return data
class FileExporter:
def __init__(self, compression: Callable[[bytes], bytes] = compress_none):
self.compress = compression
def export(self, data: str, filename: str):
raw = data.encode("utf-8")
compressed = self.compress(raw)
with open(filename, "wb") as f:
f.write(compressed)
# Swap strategies at runtime
exporter = FileExporter(compression=compress_gzip)
exporter.export("lots of data...", "output.gz")
3. Factory
The problem: you need to create objects, but the creation logic is complex or varies based on input. Where you've seen it:document.createElement(), React's createElement(), database connection pools, API client libraries.
// Simple factory
function createNotification(type, message) {
const base = {
id: crypto.randomUUID(),
message,
createdAt: new Date(),
read: false,
};
switch (type) {
case "email":
return {
...base,
type: "email",
subject: Notification: ${message.slice(0, 50)},
send: () => sendEmail(base),
};
case "sms":
return {
...base,
type: "sms",
truncatedMessage: message.slice(0, 160),
send: () => sendSMS(base),
};
case "push":
return {
...base,
type: "push",
title: "New Notification",
send: () => sendPushNotification(base),
};
default:
throw new Error(Unknown notification type: ${type});
}
}
// Usage -- caller doesn't worry about construction details
const notification = createNotification("email", "Your order shipped!");
notification.send();
The caller doesn't need to know the differences between email, SMS, and push notifications. The factory handles the construction details.
In Python:class DatabaseConnection:
"""Factory that creates the right database connection based on URL."""
@staticmethod
def create(url: str):
if url.startswith("postgres://"):
return PostgresConnection(url)
elif url.startswith("mysql://"):
return MySQLConnection(url)
elif url.startswith("sqlite://"):
return SQLiteConnection(url)
else:
raise ValueError(f"Unsupported database: {url}")
# Usage
db = DatabaseConnection.create("postgres://localhost/mydb")
db = DatabaseConnection.create("sqlite:///local.db")
4. Middleware (Chain of Responsibility)
The problem: a request needs to pass through multiple processing steps, each of which might modify it, short-circuit it, or pass it along. Where you've seen it: Express.js middleware, Django middleware, Redux middleware, HTTP interceptors, logging pipelines.// Express middleware -- you use this constantly
app.use(cors());
app.use(express.json());
app.use(authenticate);
app.use(rateLimit);
app.use(logRequest);
// Each middleware is a link in the chain
function authenticate(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: "Unauthorized" });
// Chain stops here -- next() is never called
}
req.user = verifyToken(token);
next(); // Pass to next middleware
}
function rateLimit(req, res, next) {
const requests = getRequestCount(req.ip);
if (requests > 100) {
return res.status(429).json({ error: "Too many requests" });
}
next();
}
Building your own middleware pipeline:
class Pipeline {
constructor() {
this.middlewares = [];
}
use(fn) {
this.middlewares.push(fn);
return this; // Enable chaining
}
async execute(context) {
let index = 0;
const next = async () => {
if (index < this.middlewares.length) {
const middleware = this.middlewares[index++];
await middleware(context, next);
}
};
await next();
return context;
}
}
// Usage
const pipeline = new Pipeline()
.use(async (ctx, next) => {
ctx.startTime = Date.now();
await next();
ctx.duration = Date.now() - ctx.startTime;
})
.use(async (ctx, next) => {
console.log(Processing: ${ctx.url});
await next();
})
.use(async (ctx, next) => {
ctx.result = await fetchData(ctx.url);
await next();
});
const result = await pipeline.execute({ url: "/api/users" });
5. Singleton (and Why to Be Careful)
The problem: you need exactly one instance of something -- a database connection pool, a logger, a configuration object. Where you've seen it: database connections, loggers, caches, configuration managers.// In JavaScript modules, this is often just... a module
// database.js
import { Pool } from "pg";
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export default pool;
// Any file that imports this gets the same pool instance
// fileA.js
import db from "./database.js"; // Same instance
// fileB.js
import db from "./database.js"; // Same instance
JavaScript modules are singletons by default -- the module code runs once, and every import gets the same exported values. You rarely need an explicit Singleton class.
In Python:# config.py -- module-level singleton
import json
class Config:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._load()
return cls._instance
def _load(self):
with open("config.json") as f:
self._data = json.load(f)
def get(self, key, default=None):
return self._data.get(key, default)
# Every call returns the same instance
config = Config()
config.get("database_url")
The warning: Singletons introduce global state, which makes code harder to test and reason about. Use them for genuinely global resources (database connections, loggers) and avoid them for business logic. If you're making something a singleton because "I only need one," ask whether dependency injection would be cleaner.
6. Adapter (Wrapper)
The problem: you have code that expects one interface, but the thing you're connecting to has a different interface. Where you've seen it: ORM libraries (adapting SQL to objects), API wrappers, polyfills, data format converters.// Your app expects this interface for storage
// { get(key), set(key, value), delete(key) }
// But you might use different backends:
class RedisStorageAdapter {
constructor(redisClient) {
this.client = redisClient;
}
async get(key) {
const value = await this.client.get(key);
return value ? JSON.parse(value) : null;
}
async set(key, value) {
await this.client.set(key, JSON.stringify(value));
}
async delete(key) {
await this.client.del(key);
}
}
class FileStorageAdapter {
constructor(directory) {
this.dir = directory;
}
async get(key) {
try {
const data = await fs.readFile(${this.dir}/${key}.json, "utf-8");
return JSON.parse(data);
} catch {
return null;
}
}
async set(key, value) {
await fs.writeFile(
${this.dir}/${key}.json,
JSON.stringify(value)
);
}
async delete(key) {
await fs.unlink(${this.dir}/${key}.json).catch(() => {});
}
}
// Your app code works with either one
class UserCache {
constructor(storage) {
this.storage = storage; // RedisStorageAdapter or FileStorageAdapter
}
async getCachedUser(id) {
return this.storage.get(user:${id});
}
}
The adapter makes different backends look identical to the rest of your code. Swapping Redis for file storage (or an in-memory map for testing) requires changing one line.
7. Decorator (Wrapper Functions)
The problem: you want to add behavior to a function or object without modifying it. Where you've seen it: Python decorators, higher-order components in React, Express middleware, logging wrappers, caching wrappers, retry logic.import functools
import time
def retry(max_attempts=3, delay=1):
"""Retry a function on failure."""
def decorator(func):
@functools.wraps(func)
def wrapper(args, *kwargs):
for attempt in range(1, max_attempts + 1):
try:
return func(args, *kwargs)
except Exception as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying...")
time.sleep(delay * attempt)
return wrapper
return decorator
def cache(ttl_seconds=300):
"""Cache function results."""
def decorator(func):
_cache = {}
@functools.wraps(func)
def wrapper(*args):
now = time.time()
if args in _cache:
result, timestamp = _cache[args]
if now - timestamp < ttl_seconds:
return result
result = func(*args)
_cache[args] = (result, now)
return result
return wrapper
return decorator
def log_calls(func):
"""Log every call with arguments and return value."""
@functools.wraps(func)
def wrapper(args, *kwargs):
print(f"Calling {func.__name__}({args}, {kwargs})")
result = func(args, *kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
# Stack decorators -- each wraps the next
@retry(max_attempts=3)
@cache(ttl_seconds=60)
@log_calls
def fetch_user(user_id):
return api.get(f"/users/{user_id}")
In JavaScript:
function withRetry(fn, maxAttempts = 3) {
return async function (...args) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn(...args);
} catch (error) {
if (attempt === maxAttempts) throw error;
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
};
}
function withLogging(fn) {
return async function (...args) {
console.log(Calling ${fn.name} with, args);
const result = await fn(...args);
console.log(${fn.name} returned, result);
return result;
};
}
// Compose decorators
const fetchUser = withRetry(withLogging(async (id) => {
const res = await fetch(/api/users/${id});
return res.json();
}));
Patterns to Know About (But Rarely Build From Scratch)
Repository -- abstracts data access behind an interface. Your code callsuserRepo.findById(id) instead of writing SQL directly. Most ORMs implement this for you.
Builder -- constructs complex objects step by step. Query builders (knex, SQLAlchemy) are the most common example: db.select("*").from("users").where("age", ">", 21).
Iterator -- provides a way to access elements sequentially. Built into every modern language (for...of in JS, for...in in Python). You'll rarely implement one from scratch.
Proxy -- wraps an object to control access. JavaScript's Proxy object, Vue.js reactivity, lazy loading. The language or framework usually handles this.
The Anti-Pattern: Pattern Obsession
The biggest mistake with design patterns is forcing them where they don't belong. If your code is simple and readable, it doesn't need a pattern. Adding a Strategy pattern to handle two cases that will never grow is over-engineering. Adding an Observer pattern for two components that directly communicate is unnecessary indirection.
Use patterns when they solve a real problem: reducing coupling, managing complexity, or enabling extension. Not because the code "should" use a pattern.
The best code is often the simplest code that works. Patterns are tools for managing complexity, and if the complexity isn't there, neither should the pattern.
If you're building your programming skills and want hands-on practice with writing clean, well-structured code, CodeUp gives you the kind of project experience where these patterns start making intuitive sense.