StackPractices
intermediate By Mathias Paulenko

Practical Design Patterns Guide

Pick the right design pattern for each problem: Singleton, Factory, Observer, Strategy and more, with runnable code and honest trade-offs.

Introduction

Your codebase is six months old and the OrderService class just passed 900 lines. Every new feature means touching code that already worked, and the last three bugs came from changes that leaked sideways into unrelated logic. That’s the moment design patterns were made for: they’re named, tested answers to design problems that show up in almost every codebase.

The term comes from Design Patterns: Elements of Reusable Object-Oriented Software (Gamma, Helm, Johnson and Vlissides, 1994), the “Gang of Four” book that cataloged 23 patterns in three families: creational (how objects get built), structural (how objects fit together) and behavioral (how objects talk to each other). The catalog is 30 years old and still accurate, but the value was never the pattern list itself. It’s the shared vocabulary. “Wrap it in an adapter” ends an argument that would otherwise take a whiteboard session.

This guide is for developers who already write classes or modules and keep asking whether there is a cleaner way to do something. It covers the 12 patterns you will reach for most, each with runnable code, the situation that calls for it, and the case where it makes things worse. For depth on a single pattern, each section links to its dedicated recipe or pattern page, and Refactoring.Guru keeps a good interactive catalog of all 23.

How to Choose a Pattern

The reliable way to pick a pattern is to name the pain before naming the solution. “I need patterns” produces nothing; “the report generator keeps breaking when I touch the exporter” points at a real answer. Most pattern decisions reduce to one of three questions: how objects get created, how pieces connect, or how behavior flows between them.

flowchart diagram: What hurts?

A useful second filter: prefer the pattern that removes the most coupling per line added. A Factory that hides a three-line switch costs more than it saves; a Decorator that replaces five boolean flags pays for itself immediately.

One distinction worth keeping straight: a pattern is not a principle and it’s not an idiom. SOLID tells you what good design looks like; idioms are language-specific tricks (Python’s with, JavaScript’s destructuring); patterns are the reusable structures in between. You apply principles to write the code, idioms to write it well, and patterns to organize the pieces.

Creational Patterns

Creational patterns control how objects come into existence, mostly so the calling code stops caring about concrete classes. The three here cover the cases that account for almost every creation problem in practice: sharing one instance, choosing a class at runtime, and assembling an object piece by piece.

Singleton

Use it when the whole process should share exactly one object, like a connection pool, a config registry or a metrics collector.

class DatabasePool:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.connections = []
        return cls._instance

# Every call returns the same object
pool_a = DatabasePool()
pool_b = DatabasePool()
assert pool_a is pool_b

Python offers a calmer alternative: put the instance in a module and import it. Modules are already singletons, so pool = DatabasePool() at module scope gives you the same guarantee without the __new__ trick. When not to use it: when the “single” object holds mutable state that tests or tenants need to isolate. A global pool you can’t reset becomes the reason your test suite passes alone and fails in CI. For the variants (lazy, thread-safe, metaclass), see the Singleton pattern.

Factory Method

Reach for a factory when the caller knows what it wants (“a notifier”) but not which class should provide it, and the choice happens at runtime.

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(kind: str) -> Notification:
        if kind == "email": return EmailNotification()
        if kind == "sms": return SMSNotification()
        raise ValueError(f"Unknown notification: {kind}")

notifier = NotificationFactory.create("email")
notifier.send("Your order shipped")

When not to use it: when there’s exactly one implementation and no second one on the horizon. A factory with one product is an if that learned object-oriented vocabulary. It earns its keep once the set of variants is open: new payment providers, new export formats, new notification channels.

Builder

Builder shines when the constructor signature is the problem: ten parameters, eight of them optional, and every call site a guessing game about argument order.

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(' ') + ';';
  }
}

const query = new QueryBuilder()
  .select(['id', 'name', 'email'])
  .from('users')
  .where('active = true')
  .build();

When not to use it: for objects with two or three required fields. A builder around new User(name, email) is ceremony, and the fluent API hides nothing that named parameters don’t already solve in Python or TypeScript.

Structural Patterns

Structural patterns decide how classes and objects compose, usually so two pieces can cooperate without knowing each other’s details. Four of them solve the daily cases: translating interfaces, adding behavior on the fly, hiding complexity, and intercepting access.

Adapter

Adapter is the diplomatic pattern: an existing class does the job, but its interface doesn’t match what your code expects, and you can’t or shouldn’t change it.

class LegacyPrinter:
    def old_print(self, text):
        print(f"OLD: {text}")

class PrinterAdapter:
    def __init__(self, printer: LegacyPrinter):
        self._printer = printer

    def print(self, text):          # the interface your code expects
        self._printer.old_print(text)

adapter = PrinterAdapter(LegacyPrinter())
adapter.print("Hello")  # works through the new interface

You will meet this constantly when wrapping third-party SDKs or migrating an API version: the adapter isolates the vendor’s shape so a future migration touches one file instead of forty. When not to use it: when you control both interfaces. If both sides are yours, fix one of them rather than adding a translation layer nobody asked for.

Decorator

Decorator adds behavior around an object or function without subclassing it. Python made it syntax: the @ keyword is the pattern built into the language.

import time
from functools import wraps

def timing(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        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():
    time.sleep(0.1)
    return {"rows": 42}

data = fetch_data()

The functools.wraps call matters: without it the decorated function loses its name and docstring, which breaks introspection and some test tooling. When not to use it: when the added behavior is core to the object’s job. Decorators are for orthogonal concerns (logging, caching, retries, timing), not for finishing an incomplete class. And keep the stack shallow: more than two or three layers makes call order hard to reason about.

Facade

A facade gives a complicated subsystem one boring entry point. The subsystem stays flexible; your callers stop needing a degree in it.

class VideoConverter:
    def convert(self, source: str, target: str) -> bytes:
        raw = self._read(source)
        frames = self._decode(raw)
        compressed = self._encode(frames, codec="h264")
        return compressed

    def _read(self, source): ...
    def _decode(self, raw): ...
    def _encode(self, frames, codec): ...

# Callers see one method, not the codec pipeline
converter = VideoConverter()
mp4 = converter.convert("input.mov", "output.mp4")

Frameworks do this constantly: fetch() is a facade over DNS, TLS, sockets and streams. When not to use it: when the subsystem is already simple. A facade over two methods is a function with a class costume.

Proxy

A proxy stands in front of an object and controls access to it: lazy loading, caching, permission checks, or hiding that the real object lives on another machine.

class ExpensiveReport:
    def generate(self) -> str:
        return "report computed in 40 seconds"

class CachedReportProxy:
    def __init__(self):
        self._real = ExpensiveReport()
        self._cache = None

    def generate(self) -> str:
        if self._cache is None:
            self._cache = self._real.generate()
        return self._cache

To the caller, the proxy is indistinguishable from the real object, which is the point: you add the cross-cutting behavior without touching either side. When not to use it: when interception adds a failure mode you don’t need. A caching proxy in front of data that changes every second serves stale results with confidence.

Behavioral Patterns

Behavioral patterns are about communication: who calls whom, who reacts to what, and how algorithms get swapped without rewriting the caller. These six show up in event systems, plugin architectures, workflow engines and anything that needs undo.

Observer

Observer is publish-subscribe at object scale: one subject emits events, any number of listeners react, and neither side knows the other exists.

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);
  }

  off(event: string, callback: Function): void {
    const cbs = this.listeners.get(event) ?? [];
    this.listeners.set(event, cbs.filter(cb => cb !== callback));
  }

  emit(event: string, data: unknown): void {
    this.listeners.get(event)?.forEach(cb => cb(data));
  }
}

const emitter = new EventEmitter();
const logLogin = (user: any) => console.log(`${user.name} logged in`);
emitter.on('user:login', logLogin);
emitter.emit('user:login', { name: 'Alice' });

Note the off method: the original GoF version and most textbook examples skip unsubscription, and that omission is a classic source of memory leaks in long-lived apps. When not to use it: for one-to-one calls where the caller needs the result back. Events tell you something happened; they’re a terrible way to ask for a value. See the full Observer recipe for a production-shaped version.

Strategy

Strategy packages interchangeable algorithms behind one interface so the caller picks one at runtime instead of hardcoding a branch.

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)

cart = ShoppingCart(PayPalPayment())
cart.checkout(99.99)

In JavaScript or Python you often don’t need the class at all: a dict of functions { "card": pay_card, "paypal": pay_paypal } is the same pattern wearing less ceremony. When not to use it: when the “algorithm” never changes. Strategy over a fixed computation is indirection without payoff. The Strategy recipe shows a fuller implementation.

Command

Command turns a method call into an object, which sounds abstract until you want to queue it, log it, retry it or undo it. Those are all the same pattern.

interface Command { execute(): Promise<void>; undo(): 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" });
  }

  async undo() {
    await this.inventory.reserve(this.order.items);
    await this.order.update({ status: "active" });
  }
}

// Commands are data: store them, queue them, replay them
const history: Command[] = [];
const cmd = new CancelOrderCommand(order, inventory);
await cmd.execute();
history.push(cmd);

When not to use it: for fire-and-forget calls that never need undo, queuing or auditing. And if you do implement Command, implement undo honestly or not at all: a undo() that throws is worse than no undo, because callers build workflows on the promise.

Template Method

Template Method fixes the skeleton of an algorithm in a base class and lets subclasses override the steps that vary. It’s the pattern behind “hooks” in most frameworks.

from abc import ABC, abstractmethod

class DataImporter(ABC):
    def import_file(self, path: str):          # the template
        raw = self.read(path)
        rows = self.parse(raw)
        self.validate(rows)
        self.save(rows)

    def read(self, path: str):
        with open(path) as f:
            return f.read()

    def validate(self, rows):
        if not rows:
            raise ValueError("empty file")

    @abstractmethod
    def parse(self, raw): ...                   # the variable step

    @abstractmethod
    def save(self, rows): ...

class CSVImporter(DataImporter):
    def parse(self, raw):
        return [line.split(",") for line in raw.strip().split("\n")]

    def save(self, rows):
        print(f"saved {len(rows)} rows")

Subclasses fill in parse and save; the ordering (read, parse, validate, save) stays in one place. When not to use it: when steps change order or are optional. Template Method is rigid by design; if the flow itself varies, you want composition (Strategy or a pipeline) instead of inheritance.

State

State is for objects whose behavior changes with their internal status: an order that’s pending, paid, shipped or cancelled, each with its own rules about what can happen next.

class Order:
    def __init__(self):
        self._state = PendingState()

    def cancel(self):
        self._state = self._state.cancel(self)

class PendingState:
    def cancel(self, order):
        return CancelledState()

class ShippedState:
    def cancel(self, order):
        raise ValueError("can't cancel a shipped order")

Without the pattern this becomes a switch on self.status in every method, and each new state means auditing all of them. When not to use it: for two states and one transition. A boolean flag beats a class hierarchy until the transition rules start multiplying — that’s usually around three states with constraints between them.

Where You Already Use Patterns

Half the value of learning patterns is recognizing that your tools already run on them:

  • Express and Django middleware are Chain of Responsibility: each handler processes the request or passes it along.
  • React’s re-render model is Observer: components subscribe to state, and a state change notifies every subscriber.
  • ORMs are Repository plus Unit of Work: you ask for objects, the ORM translates to SQL and batches the writes.
  • Dependency injection frameworks (NestJS, Spring, FastAPI’s Depends) are Factory plus Singleton managed for you, which is why handwritten getInstance() calls have mostly disappeared from modern code.
  • Callbacks and props in JavaScript are Strategy without the interface ceremony: passing onSort to a table component is selecting an algorithm at runtime.
  • Python decorators, TypeScript decorators, middleware wrappers are all the Decorator pattern with different syntax.

Seeing the pattern inside the framework matters for design decisions: once you recognize your middleware stack as Chain of Responsibility, questions like “should this handler run before auth?” answer themselves.

Choosing Between Look-Alike Patterns

Most selection mistakes happen between patterns that look similar from a distance:

  • Adapter vs Facade vs Proxy. All three wrap something. Adapter translates an interface so two incompatible pieces can talk. Facade simplifies an interface so callers need less knowledge. Proxy preserves the interface and inserts control (cache, auth, laziness) between caller and object. The tell: if the method signatures change, it’s an adapter or facade; if they stay identical, it’s a proxy.
  • Strategy vs State. Both swap behavior through a common interface. Strategy is chosen by the caller and usually doesn’t know the others exist; State is driven by the object’s own lifecycle and each state knows which state comes next. Payment methods are Strategy; order status is State.
  • Factory Method vs Abstract Factory. Factory Method creates one product chosen at runtime. Abstract Factory creates a family of related products that must stay consistent — a light theme’s button, dialog and input that can’t be mixed with the dark theme’s. You need the abstract version only when the “don’t mix these” constraint is real.
  • Decorator vs Proxy. Both wrap an object with the same interface. Decorator adds responsibilities (logging, caching as a feature); Proxy controls access (lazy loading, permissions). A caching wrapper is technically both, so name it by intent: added behavior is Decorator, gatekeeping is Proxy.

Patterns and Testability

Pattern choices decide how easy your code is to test. Anything injected through a constructor (Strategy, Command, Adapter, Facade dependencies) can be replaced with a stub in one line. Anything reached through a global (Singleton, static factories) has to be monkey-patched or reset between tests, which is where fragile suites come from. If a class can’t be unit-tested without touching the network or the filesystem, the fix is usually not a better test — it’s injecting the dependency the class was reaching for.

Pattern Selection Cheat Sheet

ProblemPattern
I need exactly one instanceSingleton
I create objects based on a string or typeFactory Method
This object has ten optional parametersBuilder
A family of related objects must stay consistentAbstract Factory
Legacy code doesn’t match my interfaceAdapter
I need to add logging to everythingDecorator
A subsystem is too complicated to call directlyFacade
I need lazy loading or access checksProxy
Components need to react to eventsObserver
I want to swap algorithms at runtimeStrategy
I need to track, queue or undo operationsCommand
The algorithm is fixed but some steps varyTemplate Method
Behavior depends on lifecycle statusState
I need to abstract database accessRepository
I need to traverse without exposing internalsIterator

What Works

  • Don’t force patterns. Not every problem needs one, and a pattern applied to a trivial problem is how codebases earn their “over-engineered” reputation. Refactor into a pattern when duplication or coupling actually appears.
  • Start simple, refactor toward the pattern. Write the if first. When the third variant arrives, the pattern to extract usually names itself. See clean code for the refactor discipline.
  • Let names carry the pattern. UserRepository, EmailStrategy, PricingFactory tell the next reader which rules apply before they open the file.
  • Document the why, not the what. “We use Strategy here” is noise; “we use Strategy here so billing can add payment providers without touching checkout” is a decision record.
  • Respect the language. Half the GoF catalog exists because C++ lacked closures, modules and decorators. In Python or JavaScript, several patterns collapse into language features, and using the language feature is the correct move.
  • Delete patterns that stop paying rent. A Factory whose second product never shipped, a Strategy with one algorithm, a Facade over two functions — when the constraint that justified the pattern disappears, the right move is to inline it, not to preserve the architecture it was part of.

Common Mistakes

  • Singleton for everything. Global instances couple every module that touches them and make tests order-dependent. Most singletons should be constructor-injected dependencies.
  • Pattern explosion. A module using four or five patterns is usually doing several jobs; the fix is splitting the module, not adding another pattern.
  • Ignoring language idioms. Writing a Java-style getInstance() singleton in Python fights the module system; writing a class-based Strategy where a function suffices adds weight without benefit.
  • Observer without unsubscribe. Listeners that never detach keep subjects and their dependencies alive forever. Always provide off/unsubscribe and use it in cleanup paths.
  • Adapter where a fix belongs. If you control both interfaces, an adapter freezes the mismatch in place instead of removing it.

Putting It Together: Patterns in an Order System

The order pipeline below shows how the patterns stack in a real-ish system. Each one enters because a specific pain demanded it, which is the only reason a pattern should ever appear.

// Strategy: shipping cost varies by method, picked at runtime
interface ShippingStrategy {
  calculate(weightKg: number): number;
}
const strategies: Record<string, ShippingStrategy> = {
  standard: { calculate: w => w * 0.5 },
  express:  { calculate: w => w * 1.5 },
  sameDay:  { calculate: w => w * 3.0 },
};
const shippingCost = strategies[order.shippingMethod].calculate(order.weightKg);

// Factory: the order needs a notifier, type chosen per channel
class NotificationFactory {
  static create(kind: string): Notifier {
    switch (kind) {
      case "email": return new EmailNotifier();
      case "sms":   return new SMSNotifier();
      case "push":  return new PushNotifier();
      default: throw new Error(`Unsupported channel: ${kind}`);
    }
  }
}

// Observer: one event, three independent reactions
bus.on("order.created", updateInventory);
bus.on("order.created", sendConfirmation);
bus.on("order.created", trackAnalytics);

// Decorator: logging and caching wrapped around the product fetch
const fetchProductFast = withCache(withLogging(fetchProduct), 60_000);

// Command: cancellations as objects so they can be undone and audited
await new CancelOrderCommand(order, inventory).execute();

Notice what is absent: no Abstract Factory, no Visitor, no four-layer inheritance. Five patterns, five pains. That ratio is roughly what production systems look like: a handful of patterns carrying the design, chosen because a real constraint demanded each one. The teams that get this wrong do it in the other direction — they start from a diagram full of patterns and then go hunting for problems to justify them. The lesson of the GoF catalog isn’t the 23 solutions; it’s that experienced developers keep hitting the same handful of problems, and naming the problem is most of the work. The full pipeline is runnable in the companion repository — six files, five patterns, one working order system.

Further Reading

  • Design Patterns: Elements of Reusable Object-Oriented Software — Gamma, Helm, Johnson, Vlissides (1994). The original catalog; still the reference for intent and trade-offs.
  • Refactoring.Guru — Design Patterns — interactive catalog with UML diagrams and multi-language examples.

Frequently Asked Questions

When should I use a design pattern?

When you hit the problem it solves, not before. A pattern is a response to concrete duplication, coupling or complexity you can point to in the code. If you can't name the pain, the pattern is premature, and premature patterns are just indirection with a famous name. Combine this judgment with the SOLID principles, which describe the underlying goals (small classes, local changes, replaceable dependencies) that patterns serve.

Are design patterns still relevant in modern languages?

Yes, but several of them moved into the language. JavaScript's Promise and event emitters are Observer machinery, Python's @decorator syntax is the Decorator pattern, and iterators are built into for loops everywhere. The surviving value is the vocabulary and the trade-off analysis, not the boilerplate, so learn the intent even when the language writes the code for you.

What's the difference between Factory Method and Builder?

Factory answers "which class do I instantiate?" and Builder answers "how do I assemble a complicated object?" A factory chooses between interchangeable products (email vs SMS notifier); a builder walks you through constructing one complex product step by step (a query, a request, a config object). If your problem is a long parameter list, you want Builder; if it's runtime selection between variants, you want Factory.

How do design patterns relate to SOLID principles?

SOLID states the goals; patterns are recurring tactics that reach them. Strategy gives you Open/Closed because new strategies extend the system without changing existing code. Factory keeps construction in one place, which is Single Responsibility. Observer depends on an abstraction (the event interface) rather than a concrete subscriber, which is Dependency Inversion. Learning SOLID first makes pattern selection feel less like memorization.

How many patterns should I use in one module?

As many as the problems justify and no more. Each pattern adds indirection a reader must decode, so past two or three in a single module, ask whether the module is doing too much rather than whether it needs another pattern.