Design Patterns & Software Architecture Principles
Senior software architect designing maintainable, extensible systems using proven design patterns and principles. Applies GoF patterns, SOLID principles, and DRY/YAGNI guidelines to produce code that is easy to understand, test, and evolve. Evaluates architectural tradeoffs, prevents over-engineering, and selects the simplest pattern that solves the identified problem.
TL;DR Checklist
- Identify the core problem and change point before reaching for a pattern
- Prefer composition over inheritance (SOLID - Open/Closed Principle)
- Apply Single Responsibility: each class has one reason to change
- Verify Dependence Inversion: high-level modules depend on abstractions, not concretions
- Avoid over-engineering — YAGNI means don't add abstractions prematurely
- Eliminate duplication: DRY means abstract shared behavior, not repeat it
- Choose the simplest pattern that solves the problem
- Ensure every pattern has a concrete problem it addresses — no pattern for pattern's sake
When to Use
Use this skill when:
- Designing new modules or services from scratch and needing structural guidance
- Refactoring legacy code that is hard to test or extend
- Resolving tight coupling between components that prevents independent changes
- Deciding between inheritance and composition for a new abstraction
- Introducing testability into code that lacks dependency injection
- Evaluating whether a proposed abstraction is warranted or premature (YAGNI check)
- Resolving a conditional explosion of if/elif/else branches that encode algorithm selection
- Managing cross-cutting concerns that would otherwise scatter code across many classes
When NOT to Use
Avoid applying design patterns when:
- Building a simple script or one-off tool where no extension is expected (YAGNI applies)
- A standard library function or language feature already solves the problem cleanly
- For data structures that don't require behavioral flexibility (don't over-engineer)
- The team lacks the experience to maintain the chosen pattern correctly
- The added complexity of a pattern outweighs the benefit of the change it enables
- You are introducing an abstraction without a concrete second use case
Core Workflow
Analyze Requirements — Identify what changes, what stays stable, and what abstractions are genuinely needed vs. speculative. Map the change points in the system. Checkpoint: Can the problem be solved without any abstraction? If yes, YAGNI applies — skip the pattern.
Apply SOLID Principles — Evaluate the design against each principle:
- SRP: Does each class have a single responsibility?
- OCP: Can new behavior be added without modifying existing code?
- LSP: Will subclasses be substitutable for base classes?
- ISP: Are interfaces slim and client-specific?
- DIP: Do high-level modules depend on abstractions, not concretions? Checkpoint: If more than one principle is violated, the design needs restructuring before selecting a pattern.
Select a Pattern — Match the problem to the appropriate GoF category:
- Creational (object creation): Factory Method, Builder, Singleton, Abstract Factory, Prototype
- Structural (composition): Adapter, Decorator, Facade, Proxy, Composite, Bridge, Flyweight
- Behavioral (communication): Strategy, Observer, Command, Iterator, State, Template Method, Mediator, Memento, Visitor Checkpoint: The pattern must address the actual change point identified in step 1.
Implement with DRY — Extract shared behavior. Avoid duplication by factoring out common logic into well-named, single-responsibility components. Checkpoint: Run the duplication test — if the same logic appears in two places with only minor differences, it should be abstracted.
Review & Validate — Check against all constraints below. Ensure the design can be unit-tested in isolation. Verify no hierarchy exceeds 3 levels. Checkpoint: Can each concrete class be tested independently? If not, DIP has not been applied correctly.
Implementation Patterns — GoF Creational
Factory Method
The Factory Method defines an interface for creating objects, but lets subclasses or a factory class decide which concrete class to instantiate. This decouples the client from concrete implementations.
# ❌ BAD — direct instantiation couples client to concrete classes
class OrderProcessor:
def process(self, payment_type: str) -> None:
if payment_type == "credit_card":
gateway = CreditCardGateway()
elif payment_type == "paypal":
gateway = PayPalGateway()
elif payment_type == "crypto":
gateway = CryptoGateway()
gateway.charge(100.0)
# ✅ GOOD — Factory Method enables adding new payment types without modification
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
"""Abstract product — all payment gateways share this interface."""
@abstractmethod
def charge(self, amount: float) -> bool:
"""Process a payment and return success status."""
...
class CreditCardGateway(PaymentGateway):
def charge(self, amount: float) -> bool:
# Process credit card payment
return True
class PayPalGateway(PaymentGateway):
def charge(self, amount: float) -> bool:
# Process PayPal payment
return True
class CryptoGateway(PaymentGateway):
def charge(self, amount: float) -> bool:
# Process cryptocurrency payment
return True
class PaymentFactory:
@staticmethod
def create_gateway(payment_type: str) -> PaymentGateway:
factories: dict[str, type[PaymentGateway]] = {
"credit_card": CreditCardGateway,
"paypal": PayPalGateway,
"crypto": CryptoGateway,
}
factory_cls = factories.get(payment_type)
if factory_cls is None:
raise ValueError(f"Unknown payment type: {payment_type}")
return factory_cls()
class OrderProcessor:
def __init__(self, factory: PaymentFactory | None = None):
self._factory = factory or PaymentFactory()
def process(self, payment_type: str, amount: float) -> None:
gateway = self._factory.create_gateway(payment_type)
gateway.charge(amount)
Why this works: Adding a new payment type requires only adding a new class implementing PaymentGateway and registering it in the factory dictionary. OrderProcessor never changes — satisfying the Open/Closed Principle.
Builder
The Builder pattern separates complex object construction from its representation. Useful when an object requires many configuration parameters, some optional, and the construction process has multiple steps.
# ❌ BAD — telescoping constructor makes objects hard to create correctly
class HttpRequest:
def __init__(
self, method: str, url: str, headers: dict | None = None,
body: str | None = None, timeout: int = 30,
retry_count: int = 0, verify_ssl: bool = True,
proxy: str | None = None, auth: tuple | None = None,
):
# With 10 parameters, remembering which is which is error-prone
self.method = method
self.url = url
self.headers = headers or {}
self.body = body
self.timeout = timeout
self.retry_count = retry_count
self.verify_ssl = verify_ssl
self.proxy = proxy
self.auth = auth
# ✅ GOOD — Builder provides fluent, readable construction
class HttpRequest:
def __init__(
self, method: str, url: str,
headers: dict | None = None, body: str | None = None,
timeout: int = 30, retry_count: int = 0,
verify_ssl: bool = True, proxy: str | None = None,
auth: tuple | None = None,
):
self.method = method
self.url = url
self.headers = headers or {}
self.body = body
self.timeout = timeout
self.retry_count = retry_count
self.verify_ssl = verify_ssl
self.proxy = proxy
self.auth = auth
class HttpRequestBuilder:
def __init__(self, method: str, url: str) -> None:
self._method = method
self._url = url
self._headers: dict = {}
self._body: str | None = None
self._timeout: int = 30
self._retry_count: int = 0
self._verify_ssl: bool = True
self._proxy: str | None = None
self._auth: tuple | None = None
def with_header(self, key: str, value: str) -> "HttpRequestBuilder":
self._headers[key] = value
return self
def with_body(self, body: str) -> "HttpRequestBuilder":
self._body = body
return self
def with_timeout(self, seconds: int) -> "HttpRequestBuilder":
self._timeout = seconds
return self
def with_retry(self, count: int) -> "HttpRequestBuilder":
self._retry_count = count
return self
def with_ssl_verification(self, verify: bool) -> "HttpRequestBuilder":
self._verify_ssl = verify
return self
def with_proxy(self, proxy_url: str) -> "HttpRequestBuilder":
self._proxy = proxy_url
return self
def with_auth(self, username: str, password: str) -> "HttpRequestBuilder":
self._auth = (username, password)
return self
def build(self) -> HttpRequest:
return HttpRequest(
method=self._method, url=self._url,
headers=self._headers, body=self._body,
timeout=self._timeout, retry_count=self._retry_count,
verify_ssl=self._verify_ssl, proxy=self._proxy,
auth=self._auth,
)
# Usage — clear and readable:
# request = (HttpRequestBuilder("POST", "https://api.example.com/data")
# .with_header("Content-Type", "application/json")
# .with_body('{"key": "value"}')
# .with_timeout(60)
# .with_retry(3)
# .build())
Singleton (Anti-Pattern Warning)
Singleton ensures a class has only one instance and provides global access. This skill covers Singleton for reference, but strongly discourages its use — it hides dependencies, prevents testing, and creates implicit coupling. Use dependency injection instead.
# ❌ BAD — Singleton hides dependencies and makes testing impossible
class DatabaseConnection:
_instance: "DatabaseConnection | None" = None
def __new__(cls) -> "DatabaseConnection":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._connect()
return cls._instance
def _connect(self) -> None:
self._connection = create_connection("postgres://localhost/mydb")
def query(self, sql: str) -> list[dict]:
return self._connection.execute(sql)
# Cannot inject a mock — all code shares the same real database connection
db = DatabaseConnection() # Always the same instance
# ✅ GOOD — Dependency Injection replaces Singleton
# The calling code manages the single instance and injects it.
class DatabaseConnection:
def __init__(self, connection_string: str):
self._connection = create_connection(connection_string)
def query(self, sql: str) -> list[dict]:
return self._connection.execute(sql)
# App startup — single instance created once, injected everywhere:
# db = DatabaseConnection("postgres://localhost/mydb")
# router = create_router(database=db) # Injected, testable
Rule of thumb: If you reach for Singleton, ask: "Can I manage this as a singleton at the composition root instead?" The answer is almost always yes.
Abstract Factory
Abstract Factory provides an interface for creating families of related objects without specifying their concrete classes. Useful when your system must work with multiple product families (e.g., UI themes, database backends).
# ❌ BAD — Concrete factory methods scattered across the codebase
def create_button(theme: str) -> Button:
if theme == "dark":
return DarkButton()
elif theme == "light":
return LightButton()
raise ValueError(f"Unknown theme: {theme}")
# ✅ GOOD — Abstract Factory creates cohesive product families
from abc import ABC, abstractmethod
class Button(ABC):
@abstractmethod
def render(self) -> str: ...
@abstractmethod
def click(self) -> str: ...
class Checkbox(ABC):
@abstractmethod
def render(self) -> str: ...
@abstractmethod
def toggle(self) -> str: ...
class DarkButton(Button):
def render(self) -> str: return "<button class='dark'>"
def click(self) -> str: return "dark button clicked"
class LightButton(Button):
def render(self) -> str: return "<button class='light'>"
def click(self) -> str: return "light button clicked"
class DarkCheckbox(Checkbox):
def render(self) -> str: return "<checkbox class='dark'>"
def toggle(self) -> str: return "dark checkbox toggled"
class LightCheckbox(Checkbox):
def render(self) -> str: return "<checkbox class='light'>"
def toggle(self) -> str: return "light checkbox toggled"
class UIFactory(ABC):
"""Abstract Factory — creates a family of UI components."""
@abstractmethod
def create_button(self) -> Button: ...
@abstractmethod
def create_checkbox(self) -> Checkbox: ...
class DarkUIFactory(UIFactory):
def create_button(self) -> Button: return DarkButton()
def create_checkbox(self) -> Checkbox: return DarkCheckbox()
class LightUIFactory(UIFactory):
def create_button(self) -> Button: return LightButton()
def create_checkbox(self) -> Checkbox: return LightCheckbox()
# Client code works with abstract factories only
class SettingsPanel:
def __init__(self, factory: UIFactory):
self._factory = factory
def render(self) -> str:
button = self._factory.create_button()
checkbox = self._factory.create_checkbox()
return f"{button.render()} {checkbox.render()}"
# Usage — swap entire theme by changing the factory:
# panel = SettingsPanel(DarkUIFactory())
Prototype
The Prototype pattern creates new objects by copying an existing instance (the prototype). Useful when object creation is expensive or when you need to avoid subclassing to create instances of varying types.
# ✅ GOOD — Prototype pattern for expensive object creation
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any
@dataclass
class ConfigTemplate:
"""A prototype configuration that can be cloned and customized."""
host: str = "localhost"
port: int = 8080
debug: bool = False
features: list[str] = field(default_factory=list)
overrides: dict[str, Any] = field(default_factory=dict)
def clone(self, **overrides: Any) -> "ConfigTemplate":
"""Create a deep copy with selective overrides applied."""
cloned = deepcopy(self)
cloned.overrides.update(overrides)
return cloned
# Usage — create many variations from a single template:
# production_config = ConfigTemplate(host="prod.example.com", debug=False)
# staging_config = production_config.clone(host="staging.example.com", debug=True)
# dev_config = production_config.clone(host="localhost", debug=True,
# features=["hot_reload", "profiler"])
Implementation Patterns — GoF Structural
Decorator
The Decorator pattern attaches additional responsibilities to objects dynamically. It provides a flexible alternative to subclassing for adding behavior. Ideal for cross-cutting concerns like logging, caching, or authentication.
# ❌ BAD — inheritance for cross-cutting concerns creates a combinatorial explosion
class LoggedDatabaseConnection:
def __init__(self, host: str):
self._connection = connect(host)
def query(self, sql: str) -> list[dict]:
log(f"Executing: {sql}")
return self._connection.query(sql)
class CachedLoggedDatabaseConnection(LoggedDatabaseConnection):
def __init__(self, host: str):
super().__init__(host)
self._cache: dict[str, list[dict]] = {}
def query(self, sql: str) -> list[dict]:
if sql in self._cache:
return self._cache[sql]
result = super().query(sql)
self._cache[sql] = result
return result
# Adding AuthenticationDecorator on top requires a new class for every combination.
# With N concerns and M levels of nesting, you get N! combinations.
# ✅ GOOD — Decorator composes cross-cutting concerns cleanly
from abc import ABC, abstractmethod
class DatabaseConnection(ABC):
@abstractmethod
def query(self, sql: str) -> list[dict]: ...
class RealDatabaseConnection(DatabaseConnection):
def __init__(self, host: str) -> None:
self._connection = connect(host)
def query(self, sql: str) -> list[dict]:
return self._connection.query(sql)
class LoggingDecorator(DatabaseConnection):
def __init__(self, wrapped: DatabaseConnection) -> None:
self._wrapped = wrapped
def query(self, sql: str) -> list[dict]:
log(f"Executing: {sql}")
return self._wrapped.query(sql)
class CacheDecorator(DatabaseConnection):
def __init__(self, wrapped: DatabaseConnection) -> None:
self._wrapped = wrapped
self._cache: dict[str, list[dict]] = {}
def query(self, sql: str) -> list[dict]:
if sql in self._cache:
return self._cache[sql]
result = self._wrapped.query(sql)
self._cache[sql] = result
return result
class TimingDecorator(DatabaseConnection):
def __init__(self, wrapped: DatabaseConnection) -> None:
self._wrapped = wrapped
def query(self, sql: str) -> list[dict]:
import time
start = time.monotonic()
result = self._wrapped.query(sql)
log(f"Query took {time.monotonic() - start:.4f}s")
return result
# Usage — compose at runtime in any order:
# conn = TimingDecorator(CacheDecorator(LoggingDecorator(RealDatabaseConnection("localhost"))))
# results = conn.query("SELECT * FROM users")
Adapter
The Adapter pattern converts the interface of a class into another interface clients expect. It allows incompatible interfaces to work together, wrapping legacy or third-party code.
# ❌ BAD — modifying existing code to fit a new interface
class LegacyPaymentAPI:
def process_transaction(self, credit_card_number: str, amount: float, currency: str) -> bool:
"""Legacy API with an awkward interface."""
# ... complex legacy logic ...
return True
# After adding a new requirement, we're forced to modify or duplicate the wrapper:
class PaymentService:
def __init__(self):
self._api = LegacyPaymentAPI()
def process_payment(self, card: str, amount: float, currency: str) -> bool:
# We can use the legacy API, but the interface mismatch is ugly
return self._api.process_transaction(card, amount, currency)
# ✅ GOOD — Adapter cleanly separates the interface mismatch
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
"""New, clean interface."""
@abstractmethod
def process(self, card_number: str, amount: float, currency: str) -> bool: ...
class LegacyPaymentAdapter(PaymentProcessor):
"""Adapts the legacy API to the new interface."""
def __init__(self) -> None:
self._legacy = LegacyPaymentAPI()
def process(self, card_number: str, amount: float, currency: str) -> bool:
# Adapt the call to the legacy API's signature
return self._legacy.process_transaction(card_number, amount, currency)
class StripePaymentProcessor(PaymentProcessor):
"""New, native implementation."""
def process(self, card_number: str, amount: float, currency: str) -> bool:
# Stripe-specific implementation
return stripe.charge(card_number, amount, currency)
class PaymentService:
def __init__(self, processor: PaymentProcessor):
self._processor = processor
def process_payment(self, card: str, amount: float, currency: str) -> bool:
return self._processor.process(card, amount, currency)
Facade
The Facade pattern provides a simplified interface to a complex subsystem. It hides complexity and decouples clients from the subsystem's classes.
# ❌ BAD — client must understand and coordinate many subsystem classes
class ImageProcessingClient:
def process_and_upload(self, image_path: str) -> str:
# Client must know about every subsystem:
raw = ImageLoader(image_path).load()
resized = ImageResizer(raw).resize(800, 600)
compressed = ImageCompressor(resized).compress(0.8)
watermark = Watermarker(compressed).apply("logo.png")
encoded = ImageEncoder(watermark, "webp").encode()
url = StorageUploader(encoded).upload()
return url
# ✅ GOOD — Facade provides a single, simple entry point
from abc import ABC, abstractmethod
class ImageFacade:
"""Simplified interface for the entire image processing pipeline."""
def process_and_upload(self, image_path: str, max_width: int = 800) -> str:
raw = ImageLoader(image_path).load()
resized = ImageResizer(raw).resize(max_width, int(max_width * 0.75))
compressed = ImageCompressor(resized).compress(0.8)
watermark = Watermarker(compressed).apply("logo.png")
encoded = ImageEncoder(watermark, "webp").encode()
return StorageUploader(encoded).upload()
# Client code is now one line:
# url = ImageFacade().process_and_upload("photo.jpg")
Proxy
The Proxy pattern provides a placeholder or surrogate for another object to control access to it. Useful for lazy initialization, access control, caching, and logging.
# ✅ GOOD — Lazy initialization proxy for expensive object creation
from abc import ABC, abstractmethod
class Document(ABC):
@abstractmethod
def render(self) -> str: ...
class ExpensiveDocument(Document):
"""Expensive to create — reads from disk, parses content."""
def __init__(self, filepath: str) -> None:
self._filepath = filepath
# Simulate expensive initialization
import time; time.sleep(0.1)
self._content = self._load_content(filepath)
def _load_content(self, filepath: str) -> str:
with open(filepath, "r") as f:
return f.read()
def render(self) -> str:
return f"[Document: {self._filepath}]\n{self._content}"
class DocumentProxy(Document):
"""Proxy that defers document loading until first access."""
def __init__(self, filepath: str) -> None:
self._filepath = filepath
self._document: ExpensiveDocument | None = None
def render(self) -> str:
if self._document is None:
self._document = ExpensiveDocument(self._filepath)
return self._document.render()
# The ExpensiveDocument is only created when render() is first called:
# doc = DocumentProxy("large_file.txt") # No expense yet
# print(doc.render()) # Expensive load happens here
# print(doc.render()) # Reuses the already-loaded document
Composite
The Composite pattern lets you compose objects into tree structures and treat individual objects and compositions uniformly. Ideal for UI component trees, filesystems, and organizational hierarchies.
# ✅ GOOD — Treat leaves and composites uniformly
from abc import ABC, abstractmethod
class Component(ABC):
@abstractmethod
def render(self, indent: int = 0) -> str: ...
@abstractmethod
def get_cost(self) -> float: ...
class Leaf(Component):
"""An indivisible component."""
def __init__(self, name: str, cost: float) -> None:
self._name = name
self._cost = cost
def render(self, indent: int = 0) -> str:
prefix = " " * indent
return f"{prefix}- {self._name} (${self._cost:.2f})"
def get_cost(self) -> float:
return self._cost
class Composite(Component):
"""A container of components."""
def __init__(self, name: str) -> None:
self._name = name
self._children: list[Component] = []
def add(self, component: Component) -> None:
self._children.append(component)
def remove(self, component: Component) -> None:
self._children.remove(component)
def render(self, indent: int = 0) -> str:
lines = [f"{' ' * indent}+ {self._name}"]
for child in self._children:
lines.append(child.render(indent + 1))
return "\n".join(lines)
def get_cost(self) -> float:
return sum(child.get_cost() for child in self._children)
# Build a project structure:
# project = Composite("E-Commerce Platform")
# project.add(Leaf("Backend API", 5000))
# project.add(Leaf("Frontend", 4000))
#
# frontend = Composite("Frontend")
# frontend.add(Leaf("React Components", 2500))
# frontend.add(Leaf("Styling", 1500))
# project.add(frontend)
#
# print(project.render())
# print(f"Total cost: ${project.get_cost():.2f}") # $11,000.00
Bridge
The Bridge pattern decouples an abstraction from its implementation so that both can vary independently. Unlike composition (where the implementation is chosen at construction), Bridge allows runtime swapping.
# ✅ GOOD — Drawing abstraction independent of rendering implementation
from abc import ABC, abstractmethod
class Renderer(ABC):
"""Implementation hierarchy."""
@abstractmethod
def render_circle(self, x: float, y: float, radius: float) -> str: ...
@abstractmethod
def render_rectangle(self, x: float, y: float, w: float, h: float) -> str: ...
class VectorRenderer(Renderer):
def render_circle(self, x: float, y: float, radius: float) -> str:
return f"<circle cx='{x}' cy='{y}' r='{radius}' />"
def render_rectangle(self, x: float, y: float, w: float, h: float) -> str:
return f"<rect x='{x}' y='{y}' width='{w}' height='{h}' />"
class RasterRenderer(Renderer):
def render_circle(self, x: float, y: float, radius: float) -> str:
return f"bitmap circle at ({x},{y}) radius {radius}"
def render_rectangle(self, x: float, y: float, w: float, h: float) -> str:
return f"bitmap rect at ({x},{y}) size {w}x{h}"
class Shape(ABC):
"""Abstraction — depends on Renderer, not on specific implementations."""
def __init__(self, renderer: Renderer) -> None:
self._renderer = renderer
@abstractmethod
def draw(self) -> str: ...
@abstractmethod
def resize(self, factor: float) -> None: ...
class Circle(Shape):
def __init__(self, x: float, y: float, radius: float, renderer: Renderer) -> None:
super().__init__(renderer)
self._x = x
self._y = y
self._radius = radius
def draw(self) -> str:
return self._renderer.render_circle(self._x, self._y, self._radius)
def resize(self, factor: float) -> None:
self._radius *= factor
class Rectangle(Shape):
def __init__(self, x: float, y: float, w: float, h: float, renderer: Renderer) -> None:
super().__init__(renderer)
self._x = x
self._y = y
self._w = w
self._h = h
def draw(self) -> str:
return self._renderer.render_rectangle(self._x, self._y, self._w, self._h)
def resize(self, factor: float) -> None:
self._w *= factor
self._h *= factor
# Swap implementations at runtime:
# vector_circle = Circle(10, 20, 5, VectorRenderer())
# raster_circle = Circle(10, 20, 5, RasterRenderer())
Flyweight
The Flyweight pattern minimizes memory usage by sharing common state between objects. Useful when you need to create large numbers of fine-grained objects that share data (e.g., characters in a text editor, game entities with shared visual assets).
# ✅ GOOD — Share intrinsic state to reduce memory footprint
from dataclasses import dataclass
@dataclass(frozen=True)
class ChessPieceType:
"""Intrinsic state — shared across all pieces of a given type."""
name: str
symbol: str
value: int
class ChessPiece:
"""Extrinsic state (position) is separate from intrinsic state (type)."""
# Pool of shared flyweight objects
_cache: dict[str, ChessPieceType] = {}
@classmethod
def get_type(cls, name: str) -> ChessPieceType:
if name not in cls._cache:
symbols = {"pawn": "♙", "rook": "♖", "knight": "♘", "bishop": "♗", "queen": "♕", "king": "♔"}
values = {"pawn": 1, "rook": 5, "knight": 3, "bishop": 3, "queen": 9, "king": 0}
cls._cache[name] = ChessPieceType(name, symbols[name], values[name])
return cls._cache[name]
def __init__(self, piece_type: ChessPieceType, row: int, col: int) -> None:
self._type = piece_type # Shared (intrinsic)
self._row = row # Unique (extrinsic)
self._col = col # Unique (extrinsic)
def display(self) -> str:
return f"{self._type.symbol}({self._row},{self._col})"
# Creating 64 pieces on a board — only 6 ChessPieceType objects are created:
# board: list[ChessPiece] = []
# for col in range(8):
# board.append(ChessPiece(ChessPieceType.get_type("pawn"), 1, col))
# board.append(ChessPiece(ChessPieceType.get_type("rook"), 0, 0))
# ...
Implementation Patterns — GoF Behavioral
Strategy
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The client selects the strategy at runtime. Replaces conditional logic with polymorphism.
# ❌ BAD — conditional logic for algorithm selection is hard to test and extend
class DiscountService:
def calculate_discount(self, customer: dict) -> float:
if customer["type"] == "premium":
return customer["subtotal"] * 0.15
elif customer["type"] == "standard":
return customer["subtotal"] * 0.05
elif customer["type"] == "vip":
return customer["subtotal"] * 0.25
return 0.0
# ✅ GOOD — Strategy pattern makes discount rules open/closed and individually testable
from abc import ABC, abstractmethod
from typing import Protocol
class DiscountStrategy(Protocol):
def calculate(self, subtotal: float) -> float: ...
class PremiumDiscount:
def calculate(self, subtotal: float) -> float:
return subtotal * 0.15
class StandardDiscount:
def calculate(self, subtotal: float) -> float:
return subtotal * 0.05
class VIPDiscount:
def calculate(self, subtotal: float) -> float:
return subtotal * 0.25
class DiscountService:
def __init__(self, strategy: DiscountStrategy) -> None:
self._strategy = strategy
def apply_discount(self, subtotal: float) -> float:
return self._strategy.calculate(subtotal)
# Usage — swap strategies at runtime:
# service = DiscountService(PremiumDiscount())
# discount = service.apply_discount(200.0) # returns 30.0
# service = DiscountService(VIPDiscount()) # swap strategy
# discount = service.apply_discount(200.0) # returns 50.0
Observer
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified. Replaces manual notification chains with a publish-subscribe mechanism.
# ❌ BAD — manual notification is error-prone and easily forgotten
class OrderManager:
def __init__(self) -> None:
self._email_sender = EmailSender()
self._inventory_service = InventoryService()
def create_order(self, order: Order) -> None:
self._save_order(order)
self._email_sender.send(order.customer_email, "Order confirmed") # easily missed
self._inventory_service.reserve(order.items) # easily missed
# ✅ GOOD — Observer ensures all subscribers are notified automatically
from abc import ABC, abstractmethod
from typing import List
class OrderObserver(ABC):
@abstractmethod
def on_order_created(self, order: Order) -> None: ...
class OrderManager:
def __init__(self) -> None:
self._observers: List[OrderObserver] = []
self._orders: List[Order] = []
def subscribe(self, observer: OrderObserver) -> None:
self._observers.append(observer)
def create_order(self, order: Order) -> None:
self._orders.append(order)
for observer in self._observers:
observer.on_order_created(order)
class EmailNotifier(OrderObserver):
def __init__(self, sender: EmailSender) -> None:
self._sender = sender
def on_order_created(self, order: Order) -> None:
self._sender.send(order.customer_email, "Order confirmed")
class InventoryReserver(OrderObserver):
def __init__(self, inventory: InventoryService) -> None:
self._inventory = inventory
def on_order_created(self, order: Order) -> None:
self._inventory.reserve(order.items)
Command
The Command pattern encapsulates a request as an object, allowing parameterization, queuing, logging, and undo operations. Decouples the invoker from the receiver.
# ✅ GOOD — Commands are first-class objects that can be queued, logged, and undone
from abc import ABC, abstractmethod
from typing import List
class Command(ABC):
@abstractmethod
def execute(self) -> None: ...
@abstractmethod
def undo(self) -> None: ...
class Light:
def turn_on(self) -> None:
self._state = True
def turn_off(self) -> None:
self._state = False
class LightOnCommand(Command):
def __init__(self, light: Light) -> None:
self._light = light
def execute(self) -> None:
self._light.turn_on()
def undo(self) -> None:
self._light.turn_off()
class LightOffCommand(Command):
def __init__(self, light: Light) -> None:
self._light = light
def execute(self) -> None:
self._light.turn_off()
def undo(self) -> None:
self._light.turn_on()
class RemoteControl:
def __init__(self) -> None:
self._commands: List[Command] = []
self._history: List[Command] = []
def press_button(self, command: Command) -> None:
command.execute()
self._history.append(command)
def undo_last(self) -> None:
if self._history:
self._history.pop().undo()
# Commands can be queued, stored, and replayed:
# light = Light()
# remote = RemoteControl()
# remote.press_button(LightOnCommand(light))
# remote.undo_last() # Turns off the light
Iterator
The Iterator pattern provides a way to access elements of a collection sequentially without exposing its underlying representation.
# ✅ GOOD — Custom iterator for a collection with complex traversal logic
from abc import ABC, abstractmethod
from typing import Any, Iterator
class Container(ABC):
@abstractmethod
def create_iterator(self) -> "Iterator": ...
class Iterator(ABC):
@abstractmethod
def first(self) -> Any: ...
@abstractmethod
def next(self) -> Any: ...
@abstractmethod
def is_done(self) -> bool: ...
@abstractmethod
def current(self) -> Any: ...
class BinaryIterator(Iterator):
"""Iterator that visits nodes in binary tree order."""
def __init__(self, tree: list[dict]) -> None:
self._tree = tree
self._index = 0
def first(self) -> Any:
self._index = 0
return self.current()
def next(self) -> Any:
self._index += 1
return self.current()
def is_done(self) -> bool:
return self._index >= len(self._tree)
def current(self) -> Any:
if self.is_done():
raise StopIteration
return self._tree[self._index]
class BinarySearchTree(Container):
def __init__(self) -> None:
self._nodes: list[dict] = []
def add(self, value: int) -> None:
self._nodes.append({"value": value})
def create_iterator(self) -> Iterator:
return BinaryIterator(self._nodes)
# Usage:
# tree = BinarySearchTree()
# tree.add(5)
# tree.add(3)
# tree.add(7)
# it = tree.create_iterator()
# while not it.is_done():
# print(it.next())
State
The State pattern allows an object to alter its behavior when its internal state changes. The object appears to change its class. Replaces large conditional blocks that switch behavior based on state.
# ✅ GOOD — Each state is a separate class with explicit transitions
from abc import ABC, abstractmethod
class OrderState(ABC):
@abstractmethod
def process_payment(self, amount: float) -> None: ...
@abstractmethod
def ship(self) -> None: ...
@abstractmethod
def cancel(self) -> None: ...
@abstractmethod
def return_order(self) -> None: ...
class PendingOrderState(OrderState):
def __init__(self, context: "Order") -> None:
self._context = context
def process_payment(self, amount: float) -> None:
if amount > 0:
print("Payment processed")
self._context._state = self._context._paid_state
def ship(self) -> None:
print("Cannot ship: order is pending payment")
def cancel(self) -> None:
print("Order cancelled")
self._context._state = self._context._cancelled_state
def return_order(self) -> None:
print("Cannot return: order has not shipped")
class PaidOrderState(OrderState):
def __init__(self, context: "Order") -> None:
self._context = context
def process_payment(self, amount: float) -> None:
print("Payment already processed")
def ship(self) -> None:
print("Order shipped")
self._context._state = self._context._shipped_state
def cancel(self) -> None:
print("Cannot cancel: order has been paid. Initiate return.")
def return_order(self) -> None:
print("Return initiated")
self._context._state = self._context._returned_state
class ShippedOrderState(OrderState):
def __init__(self, context: "Order") -> None:
self._context = context
def process_payment(self, amount: float) -> None:
print("Payment already processed")
def ship(self) -> None:
print("Order already shipped")
def cancel(self) -> None:
print("Cannot cancel: order has shipped. Initiate return.")
def return_order(self) -> None:
print("Return accepted — refund processing")
self._context._state = self._context._returned_state
class CancelledOrderState(OrderState):
def __init__(self, context: "Order") -> None:
self._context = context
def process_payment(self, amount: float) -> None:
print("Cannot process payment: order is cancelled")
def ship(self) -> None:
print("Cannot ship: order is cancelled")
def cancel(self) -> None:
print("Order already cancelled")
def return_order(self) -> None:
print("Cannot return: order is cancelled")
class Order:
def __init__(self) -> None:
self._pending_state = PendingOrderState(self)
self._paid_state = PaidOrderState(self)
self._shipped_state = ShippedOrderState(self)
self._cancelled_state = CancelledOrderState(self)
self._returned_state = CancelledOrderState(self) # simplified
self._state: OrderState = self._pending_state
def process_payment(self, amount: float) -> None:
self._state.process_payment(amount)
def ship(self) -> None:
self._state.ship()
def cancel(self) -> None:
self._state.cancel()
def return_order(self) -> None:
self._state.return_order()
# State transitions are explicit and testable:
# order = Order()
# order.process_payment(99.99) # → PaidOrderS
…(truncated)