Gang of Four Patterns for Modern Developers

TL;DR / Key Takeaways

  • Use patterns to create clean seams where variability exists (factories/strategies) and to shield boundaries (adapters/facades/proxies).
  • Don’t add ceremony when language or frameworks already solve it; avoid premature abstractions (YAGNI).
  • Choose with a quick heuristic: variability, boundary isolation, workflow complexity, testability, performance, and team familiarity.
  • Prefer modern alternatives when appropriate: DI containers, client middleware, higher‑order functions, gateways/meshes.
  • Examples include Factory, Builder, Adapter, Facade, Proxy, Strategy, Observer, Command, and Mediator with cloud‑friendly code.

Introduction

Design patterns are like seasoned colleagues: helpful when invited to the right meetings, distracting when they show up everywhere. The Gang of Four (GoF) patterns still add real value—especially in cloud‑native systems—when you pick them intentionally. This guide translates core GoF patterns into modern use, shows when not to reach for them, and offers pragmatic alternatives when language features or frameworks already solve the problem.

Background: Why GoF Still Matters (and Where It Doesn’t)

The original GoF catalog focused on object‑oriented systems. Today, we build with containers, serverless, message brokers, and polyglot stacks. Many problems remain the same—adapting third‑party APIs, encapsulating variability, simplifying complex subsystems—but modern languages (generics, first‑class functions) and frameworks (DI containers, HTTP clients) can make “by‑the‑book” implementations unnecessary. The goal: use patterns to reduce complexity, not add ceremony.

Deep Dive: Patterns in Modern Context

Creational Patterns in Cloud Environments

Use creational patterns to hide provider specifics, control lifecycle, and centralize configuration.

1) Factory / Abstract Factory — selecting providers at runtime

classDiagram
  class BlobStore {
    +put(key: str, data: bytes) str
  }
  class S3Store {
    +put(key: str, data: bytes) str
  }
  class GcsStore {
    +put(key: str, data: bytes) str
  }
  class SimpleFactory {
    +make(provider: str, env) BlobStore
  }
  BlobStore <|.. S3Store
  BlobStore <|.. GcsStore
  SimpleFactory ..> BlobStore
# storage_factory.py — pick S3 or GCS via config
from abc import ABC, abstractmethod

class BlobStore(ABC):
    @abstractmethod
    def put(self, key: str, data: bytes) -> str: ...

class S3Store(BlobStore):
    def __init__(self, s3_client, bucket: str):
        self.s3 = s3_client
        self.bucket = bucket
    def put(self, key: str, data: bytes) -> str:
        # self.s3.put_object(Bucket=self.bucket, Key=key, Body=data)
        return f"s3://{self.bucket}/{key}"

class GcsStore(BlobStore):
    def __init__(self, gcs_client, bucket: str):
        self.gcs = gcs_client
        self.bucket = bucket
    def put(self, key: str, data: bytes) -> str:
        # self.gcs.bucket(self.bucket).blob(key).upload_from_string(data)
        return f"gs://{self.bucket}/{key}"

def make_blob_store(env: dict, s3_client=None, gcs_client=None) -> BlobStore:
    provider = env.get("STORE_PROVIDER", "s3").lower()
    if provider == "gcs":
        return GcsStore(gcs_client, env["GCS_BUCKET"])
    return S3Store(s3_client, env["S3_BUCKET"])

When to use: multiple implementations behind a stable interface; tests mock the interface.

When not to: if there’s only one concrete implementation and no foreseeable second one (YAGNI).

2) Builder — taming complex object construction (e.g., HTTP requests, infra configs)

classDiagram
  class Request {
    +method: str
    +url: str
    +headers: Dict~str,str~
    +body: bytes
  }
  class RequestBuilder {
    +method(m: str) RequestBuilder
    +url(u: str) RequestBuilder
    +header(k: str, v: str) RequestBuilder
    +body(data: bytes) RequestBuilder
    +build() Request
  }
  RequestBuilder ..> Request
# request_builder.py — readable optional params
from dataclasses import dataclass, field
from typing import Dict, Optional

@dataclass
class Request:
    method: str
    url: str
    headers: Dict[str, str] = field(default_factory=dict)
    body: Optional[bytes] = None

class RequestBuilder:
    def __init__(self):
        self._method = "GET"
        self._url = ""
        self._headers: Dict[str, str] = {}
        self._body: Optional[bytes] = None
    def method(self, m: str): self._method = m; return self
    def url(self, u: str): self._url = u; return self
    def header(self, k: str, v: str): self._headers[k] = v; return self
    def body(self, data: bytes): self._body = data; return self
    def build(self) -> Request:
        return Request(self._method, self._url, self._headers, self._body)

3) Prototype — cloning preconfigured templates (e.g., Kubernetes pod specs, default policies)

Tip: In many languages, copy/clone or struct literals already give you this—don’t over‑abstract.

classDiagram
  class Prototype {
    +clone() Prototype
  }
  class ConcretePrototype {
    +clone() Prototype
  }
  class Client
  Prototype <|.. ConcretePrototype
  Client ..> Prototype
# prototype.py — clone a preconfigured template
import copy
default_policy = {
    "retries": 3,
    "timeout": 2.0,
    "headers": {"X-App": "svc"},
}
job_policy = copy.deepcopy(default_policy)
job_policy["timeout"] = 5.0

Structural Patterns for API Design

Use structural patterns to compose or shield complexity at service edges.

1) Adapter — normalize third‑party APIs

classDiagram
  class PaymentGateway {
    +charge(amount_cents: int, token: str) str
  }
  class StripeAdapter {
    +charge(amount_cents: int, token: str) str
  }
  class AdyenAdapter {
    +charge(amount_cents: int, token: str) str
  }
  class StripeClient
  class AdyenClient
  PaymentGateway <|.. StripeAdapter
  PaymentGateway <|.. AdyenAdapter
  StripeAdapter ..> StripeClient
  AdyenAdapter ..> AdyenClient

Adapter class diagram

# payment_adapter.py — map provider-specific payloads to our domain
class PaymentGateway:
    def charge(self, amount_cents: int, token: str) -> str: ...

class StripeAdapter(PaymentGateway):
    def __init__(self, stripe_client): self.client = stripe_client
    def charge(self, amount_cents, token):
        res = self.client.PaymentIntent.create(amount=amount_cents, currency='usd', payment_method=token, confirm=True)
        return res.id

class AdyenAdapter(PaymentGateway):
    def __init__(self, adyen_client): self.client = adyen_client
    def charge(self, amount_cents, token):
        res = self.client.payments.create({'amount': {'value': amount_cents, 'currency': 'USD'}, 'paymentMethod': {'storedPaymentMethodId': token}})
        return res['pspReference']

2) Facade — simplify subsystems for controllers and CLIs

classDiagram
  class UserFacade {
    +register(input: dict) User
  }
  class UserRepo {
    +create(input: dict) User
  }
  class Mailer {
    +send_welcome(email: str)
  }
  class Analytics {
    +track(event: str, data: dict)
  }
  UserFacade --> UserRepo
  UserFacade --> Mailer
  UserFacade --> Analytics
# user_facade.py — controllers call one method; facade orchestrates services
class UserFacade:
    def __init__(self, repo, mailer, analytics):
        self.repo = repo
        self.mailer = mailer
        self.analytics = analytics

    def register(self, input: dict):
        user = self.repo.create(input)
        self.mailer.send_welcome(user.email)
        self.analytics.track("user_signup", {"id": user.id})
        return user

3) Proxy — add cross‑cutting controls (caching, rate limits) without changing clients

classDiagram
  class Repo {
    +by_id(id: str) Any
  }
  class CachedRepo {
    +by_id(id: str) Any
  }
  Repo <|.. CachedRepo
  CachedRepo ..> Repo
# cache_proxy.py — memoize read calls transparently
import time

def with_cache(repo, ttl_seconds: int = 60):
    cache = {}  # key -> (value, expires_at)
    def by_id(id_: str):
        key = f"user:{id_}"
        hit = cache.get(key)
        now = time.time()
        if hit and hit[1] > now:
            return hit[0]
        val = repo.by_id(id_)
        cache[key] = (val, now + ttl_seconds)
        return val
    return type("CachedRepo", (), {"by_id": staticmethod(by_id)})

4) Rate‑Limiting Proxy — token bucket

classDiagram
  class Repo {
    +by_id(id: str) Any
  }
  class RateLimitedRepo {
    +by_id(id: str, caller: str) Any
  }
  Repo <|.. RateLimitedRepo
  RateLimitedRepo ..> Repo
# rate_limit_proxy.py — simple token bucket per caller
import time

def with_rate_limit(repo, limit_per_minute: int = 60):
    buckets = {}  # caller -> {tokens, last_refill}
    def bucket_for(caller: str):
        now = time.time()
        b = buckets.get(caller, {"tokens": limit_per_minute, "last_refill": now})
        elapsed = now - b["last_refill"]
        refill = int(elapsed // 60) * limit_per_minute
        if refill > 0:
            b["tokens"] = min(limit_per_minute, b["tokens"] + refill)
            b["last_refill"] = now
        buckets[caller] = b
        return b
    def by_id(id_: str, caller: str = "default"):
        b = bucket_for(caller)
        if b["tokens"] <= 0:
            raise RuntimeError("rate_limited")
        b["tokens"] -= 1
        return repo.by_id(id_)
    return type("RateLimitedRepo", (), {"by_id": staticmethod(by_id)})
# Note: For production, prefer gateway/service mesh policies for rate limiting.

Behavioral Patterns in Event‑Driven Systems

Behavioral patterns shine where workflows, policies, and reactions to events vary.

1) Strategy — pluggable policies (retry, pricing, feature rollout)

classDiagram
  class RetryStrategy {
    +next_delay(attempt: int) float
  }
  class ExponentialBackoff {
    +next_delay(attempt: int) float
  }
  class JitterBackoff {
    +next_delay(attempt: int) float
  }
  RetryStrategy <|.. ExponentialBackoff
  RetryStrategy <|.. JitterBackoff
# strategy_retry.py — pluggable retry backoff policies
import random, time
from abc import ABC, abstractmethod

class RetryStrategy(ABC):
    @abstractmethod
    def next_delay(self, attempt: int) -> float: ...  # seconds

class ExponentialBackoff(RetryStrategy):
    def next_delay(self, attempt: int) -> float:
        return min((2 ** attempt) * 0.1, 5.0)

class JitterBackoff(RetryStrategy):
    def next_delay(self, attempt: int) -> float:
        base = min((2 ** attempt) * 0.1, 5.0)
        return random.random() * base

def with_retry(fn, strat: RetryStrategy, max_attempts: int = 5):
    for a in range(max_attempts):
        try:
            return fn()
        except Exception:
            time.sleep(strat.next_delay(a))
    raise RuntimeError("exhausted")

2) Observer — react to domain events without tight coupling

classDiagram
  class Subject {
    +attach(obs: Observer)
    +detach(obs: Observer)
    +notify(event: dict)
  }
  class Observer {
    +update(event: dict)
  }
  class EventBus
  class BillingObserver
  class EmailObserver
  Subject <|.. EventBus
  Observer <|.. BillingObserver
  Observer <|.. EmailObserver
  EventBus ..> Observer
flowchart LR
  Producer -- emits --> EventBus
  EventBus -- notifies --> Billing
  EventBus -- notifies --> Email
  EventBus -- notifies --> Analytics
# observer.py — simple in-process event subscribers (swap with real broker later)
subscribers = { 'order_paid': [] }
def on(event, handler): subscribers.setdefault(event, []).append(handler)
def emit(event, payload): [h(payload) for h in subscribers.get(event, [])]

on('order_paid', lambda e: send_receipt(e['user_email']))
on('order_paid', lambda e: track('order_paid', e))

3) Command — encapsulate actions; pairs well with Outbox and retries

classDiagram
  class Command {
    +execute()
  }
  class EmailCommand
  class Invoker {
    +enqueue(cmd)
  }
  class QueueBus
  class Receiver {
    +send(email: str, template: str)
  }
  class EmailWorker
  class Client
  Command <|.. EmailCommand
  Invoker <|.. QueueBus
  Receiver <|.. EmailWorker
  Invoker ..> Command
  Command ..> Receiver
  Client ..> Invoker
# command.py — serializable command for background workers
from dataclasses import dataclass
from queue import Queue
from abc import ABC, abstractmethod

@dataclass
class EmailCommand:
    to: str
    template: str
    data: dict

class CommandBus(ABC):
    @abstractmethod
    def enqueue(self, cmd) -> None: ...

class QueueBus(CommandBus):
    def __init__(self, q: Queue):
        self.q = q
    def enqueue(self, cmd) -> None:
        self.q.put(cmd)

class EmailService:
    def __init__(self, bus: CommandBus):
        self.bus = bus
    def send_welcome(self, email: str) -> None:
        self.bus.enqueue(EmailCommand(email, "welcome", {}))

4) Mediator — coordinate complex interactions in one place (orchestrators vs. choreography)

Use for single‑service complexity; prefer events for cross‑service flows.

classDiagram
  class Mediator {
    +place_order(order)
  }
  class Inventory {
    +reserve(items)
    +release(items)
  }
  class Payments {
    +charge(user_id, amount)
  }
  class Shipping {
    +create_shipment(order_id, address)
  }
  Mediator --> Inventory
  Mediator --> Payments
  Mediator --> Shipping
# mediator.py — coordinate complex interactions
class Mediator:
    def __init__(self, inventory, payments, shipping):
        self.inventory = inventory
        self.payments = payments
        self.shipping = shipping

    def place_order(self, order):
        # Reserve items
        if not self.inventory.reserve(order.items):
            return {"status": "rejected", "reason": "out_of_stock"}
        try:
            # Charge payment
            txn_id = self.payments.charge(order.user_id, order.total())
            # Arrange shipping
            tracking = self.shipping.create_shipment(order.id, order.address)
            return {"status": "confirmed", "txn_id": txn_id, "tracking": tracking}
        except Exception as e:
            # Compensate by releasing inventory on failure
            self.inventory.release(order.items)
            return {"status": "rejected", "reason": str(e)}

# Minimal stubs for illustration
class _Inv: 
    def reserve(self, items): return True
    def release(self, items): pass
class _Pay: 
    def charge(self, user, amt): return "txn_123"
class _Ship: 
    def create_shipment(self, oid, addr): return "trk_123"

# Example usage
mediator = Mediator(_Inv(), _Pay(), _Ship())
class _Order: 
    id="o1"; user_id="u1"; address="addr"; items=[1,2]
    def total(self): return 5000
result = mediator.place_order(_Order())

When NOT to Use a Pattern

  • If the language already provides it cleanly (e.g., higher‑order functions instead of Strategy)
  • If there’s only one implementation and no proven need for variation (avoid premature abstractions)
  • If a framework offers a standard solution (e.g., a DI container over home‑grown factories)
  • If adding the pattern increases indirection without improving clarity or testability

Red flags: exploding number of interfaces, “utils” that do everything, abstraction parameters like mode='special'.

Pattern Selection Criteria (Quick Heuristic)

  • Variability axis: Will implementations differ over time? (Strategy/Factory)
  • Boundary isolation: Do we shield the domain from I/O? (Adapter/Facade/Proxy)
  • Workflow complexity: Do steps depend on outcomes? (Command/Mediator/State)
  • Testability: Does the pattern make seams easier to mock?
  • Performance/overhead: Any hot path impact? Measure first.
  • Team familiarity: Choose patterns your team can maintain.

Modern Alternatives to Classical Patterns

  • Language features: generics, enums/ADTs, pattern matching, decorators, higher‑order functions
  • Framework features: DI containers, HTTP clients, ORMs (Repository/Unit of Work), policy libraries
  • Cloud primitives: managed retries/timeouts/circuit breakers (service mesh, SDK middleware)

Examples:

  • Strategy → function parameters or dependency injection
  • Factory → DI container registrations, configuration binding
  • Observer → message brokers (Kafka/SNS/SQS) or an internal event bus
  • Proxy → reverse proxy/API gateway, or client middleware (axios interceptors, gRPC interceptors)

Best Practices

  • Start simple; introduce patterns when a concrete pain emerges
  • Keep names honest and intent‑revealing; avoid patterns for their own sake
  • Co‑locate pattern code with the domain it serves (not a giant patterns/ folder)
  • Add tests at the seams (adapters, strategies, facades)
  • Document rationale with a short ADR when introducing non‑obvious patterns

Anti‑pattern spotlight: Over‑engineered factories

# ❌ Too much ceremony for one implementation
class CsvExporter:
    def __init__(self, cfg): pass
    def export(self, data): pass

class ExporterFactory:
    @staticmethod
    def create(kind: str, cfg):
        if kind == "csv":
            return CsvExporter(cfg)
        raise ValueError("unsupported")

cfg = {}
exp = ExporterFactory.create("csv", cfg)  # unnecessary indirection

# ✅ YAGNI: construct directly; add a factory when a second variant appears
exp2 = CsvExporter(cfg)

Real‑World Scenarios (Before → After)

1) Third‑party API churn → Adapter

  • Before: controller depends directly on provider SDK; breaking changes ripple through
  • After: the controller uses PaymentGateway interface; swappable adapters contain churn

2) Flaky external calls → Strategy + Retry wrapper

  • Before: duplicated retry loops across services
  • After: withRetry(fn, strategy) consolidates policy, tuned by environment

3) Controller doing too much → Facade + Command

  • Before: the controller orchestrates user creation, email, and analytics
  • After: UserFacade.register() manages orchestration; emails sent via command bus

Measuring Design Quality (Signals, Not Scores)

  • Change surface: provider swap touches adapters, not controllers
  • Indirection: patterns reduce, not increase, the number of mental hops
  • Test shape: strategy/adapters mocked in unit tests; E2E stays thin
  • Performance: proxy/cache layers measured with clear hit ratios and latency budgets

Implementation Checklist

  • Define the variability you’re isolating (what may change?)
  • Choose the smallest pattern that creates a clear seam
  • Keep constructors simple; prefer DI for wiring
  • Add contract tests at edges (adapters/facades)
  • Document intent in a short ADR (why this pattern, why now)

Contract testing tip: prefer additive API changes; version schemas, deprecate with clear timelines, and automate consumer/provider checks.

ADR Snippet Template

ADR-N: Adopt <Pattern Name> for <Module/Context>
Date: YYYY-MM-DD
Status: Proposed | Accepted | Superseded

Context:
- What variability or complexity are we isolating?
- Alternatives considered (incl. doing nothing)

Decision:
- We will use <Pattern> to create a seam between <A> and <B>.
- Scope of use (where it applies, where it does not)

Consequences:
- Positive: testability, isolation, clarity
- Negative: indirection, maintenance cost
- Measures of success: <metrics/signals>

Review:
- Revisit by <date/event> to confirm the pattern still pays for itself

Conclusion

GoF patterns remain useful—not as rituals, but as tools. In modern systems, the best pattern is often the one that makes the change you need today safe and boring. Reach for factories and strategies when variability arrives, adapters and facades at boundaries, and commands for durable workflows. Skip the ceremony when language features or frameworks already give you a clean, testable path.

This article is part of the Software Architecture Mastery Series. Next: “Beyond GoF: Modern Software Patterns”

Leave a Comment

Your email address will not be published. Required fields are marked *