Design Patterns That Actually Matter: Strategy, Observer, and Builder in Modern Code

Most Design Patterns Are Solutions to Problems You Don't Have

The Gang of Four book describes 23 patterns. In 15 years of writing production code, I regularly use maybe 6 of them. The rest solve problems specific to C++ circa 1994 — rigid class hierarchies, no first-class functions, no generics. Modern languages have absorbed many of these patterns into their standard features.

Iterator? It's a for loop. Observer? It's an event emitter. Command? It's a function. Template Method? It's a function that takes a callback.

But a handful of patterns remain genuinely useful because they address structural problems that haven't gone away. Here are the ones that show up in real codebases.

Strategy: Swappable Behavior Without Conditionals

The problem: you need different algorithms or behaviors based on context, and you're tired of giant if/else chains.

# Python: functions are already strategies
from typing import Callable

PricingStrategy = Callable[[float, int], float]

def standard_pricing(base_price: float, quantity: int) -> float:
    return base_price * quantity

def bulk_pricing(base_price: float, quantity: int) -> float:
    if quantity >= 100:
        return base_price * quantity * 0.8
    return base_price * quantity * 0.9

def seasonal_pricing(base_price: float, quantity: int) -> float:
    return base_price * quantity * 0.7

def calculate_order(
    items: list[tuple[float, int]],
    pricing: PricingStrategy = standard_pricing,
) -> float:
    return sum(pricing(price, qty) for price, qty in items)

# Usage
total = calculate_order(items, bulk_pricing)

In languages with first-class functions, Strategy is just "pass a function." No interfaces, no abstract base classes, no PricingStrategyFactory. The pattern is valuable — the traditional OOP ceremony around it isn't.

TypeScript version:

type PricingStrategy = (basePrice: number, quantity: number) => number;

const bulkPricing: PricingStrategy = (price, qty) =>
  qty >= 100 ? price * qty * 0.8 : price * qty * 0.9;

function calculateOrder(
  items: [number, number][],
  pricing: PricingStrategy = (p, q) => p * q,
): number {
  return items.reduce((sum, [price, qty]) => sum + pricing(price, qty), 0);
}

Observer: Decoupling Event Producers From Consumers

The problem: when something happens in module A, modules B, C, and D need to know about it, but A shouldn't depend on B, C, or D.

In Node.js, EventEmitter is the Observer pattern built in:

const EventEmitter = require('events');

class OrderService extends EventEmitter {
  async createOrder(data) {
    const order = await this.db.insert(data);

    // OrderService doesn't know who's listening
    this.emit('order:created', order);
    return order;
  }
}

// Separate modules subscribe independently
orderService.on('order:created', (order) => emailService.sendConfirmation(order));
orderService.on('order:created', (order) => inventoryService.reserve(order));
orderService.on('order:created', (order) => analyticsService.track('purchase', order));

The value here is real: OrderService can be tested without mocking the email service, inventory service, or analytics. New consumers can be added without modifying OrderService.

The trap: overusing events makes control flow invisible. When a bug occurs during order creation, you have to trace through event handlers to find it. I'd argue observer is best for side effects (notifications, logging, analytics) — not for core business logic where explicit function calls are clearer.

Builder: Constructing Complex Objects Step by Step

The problem: creating an object that has many optional parameters. Constructors with 15 arguments are unreadable. Named parameters help (Python, Kotlin) but don't enforce valid combinations.

class QueryBuilder:
    def __init__(self, table: str):
        self._table = table
        self._conditions: list[str] = []
        self._params: list = []
        self._order: str | None = None
        self._limit: int | None = None
        self._offset: int | None = None
        self._columns: list[str] = ["*"]

    def select(self, *columns: str) -> "QueryBuilder":
        self._columns = list(columns)
        return self

    def where(self, condition: str, *params) -> "QueryBuilder":
        self._conditions.append(condition)
        self._params.extend(params)
        return self

    def order_by(self, column: str, direction: str = "ASC") -> "QueryBuilder":
        self._order = f"{column} {direction}"
        return self

    def limit(self, n: int) -> "QueryBuilder":
        self._limit = n
        return self

    def offset(self, n: int) -> "QueryBuilder":
        self._offset = n
        return self

    def build(self) -> tuple[str, list]:
        sql = f"SELECT {', '.join(self._columns)} FROM {self._table}"
        if self._conditions:
            sql += " WHERE " + " AND ".join(self._conditions)
        if self._order:
            sql += f" ORDER BY {self._order}"
        if self._limit is not None:
            sql += f" LIMIT {self._limit}"
        if self._offset is not None:
            sql += f" OFFSET {self._offset}"
        return sql, self._params

# Usage
query, params = (
    QueryBuilder("users")
    .select("id", "name", "email")
    .where("status = %s", "active")
    .where("created_at > %s", "2026-01-01")
    .order_by("created_at", "DESC")
    .limit(20)
    .build()
)

Builder shines when the construction steps matter — when the object is immutable after creation, when certain combinations of parameters are invalid, or when you want IDE autocomplete to guide the user through the configuration.

Decorator: Adding Behavior Without Modifying Code

Python has decorators baked into the syntax. TypeScript has them too (stage 3 proposal, widely used with experimental flag). The pattern is universally useful for cross-cutting concerns:

import functools
import time
import logging

logger = logging.getLogger(__name__)

def retry(max_attempts: int = 3, delay: float = 1.0):
    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
                    logger.warning(f"{func.__name__} failed (attempt {attempt}): {e}")
                    time.sleep(delay * attempt)
        return wrapper
    return decorator

def timed(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        logger.info(f"{func.__name__} took {elapsed:.3f}s")
        return result
    return wrapper

@retry(max_attempts=3)
@timed
def fetch_external_data(url: str) -> dict:
    response = requests.get(url, timeout=5)
    response.raise_for_status()
    return response.json()

Decorator composes cleanly: @retry @timed applies both behaviors without either function knowing about the other. This is the open/closed principle in practice — extending behavior without modifying existing code.

When to Reach for a Pattern

Don't start with a pattern. Start with the simplest code that solves the problem. When you find yourself writing the third if strategy == "bulk" branch, that's when Strategy earns its place. When the constructor has 12 optional parameters, that's when Builder makes sense.

Patterns are refactoring targets, not starting points. The code tells you when it needs more structure — listen to it rather than imposing structure preemptively.