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”