20 min read

> "Design patterns are not about specific implementations. They are about capturing design ideas in a form that people can effectively use." — Erich Gamma

Chapter 25: Design Patterns and Clean Code

"Design patterns are not about specific implementations. They are about capturing design ideas in a form that people can effectively use." — Erich Gamma

Learning Objectives

By the end of this chapter, you will be able to:

  1. Analyze how classic Gang of Four (GoF) design patterns translate into idiomatic Python, distinguishing between Java-centric implementations and Pythonic alternatives (Bloom's: Analyze)
  2. Apply creational, structural, and behavioral patterns to solve recurring software design problems using Python protocols, dataclasses, and first-class functions (Bloom's: Apply)
  3. Evaluate when design patterns improve a codebase versus when they introduce unnecessary complexity (Bloom's: Evaluate)
  4. Create clean, maintainable code by applying naming conventions, function decomposition, DRY, and KISS principles (Bloom's: Create)
  5. Synthesize AI-specific patterns—prompt templates, conversation managers, and output parsers—into production-quality systems (Bloom's: Synthesize)
  6. Identify code smells in AI-generated output and apply systematic remedies through refactoring toward appropriate patterns (Bloom's: Analyze)

25.1 Design Patterns in the AI Era

Design patterns have been a cornerstone of software engineering since the Gang of Four—Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides—published Design Patterns: Elements of Reusable Object-Oriented Software in 1994. Their catalog of 23 patterns, drawn from the architecture writings of Christopher Alexander, gave developers a shared vocabulary for solving recurring design problems.

Three decades later, we find ourselves in a world where AI coding assistants generate code at extraordinary speed. This raises a provocative question: do design patterns still matter when an AI can write hundreds of lines of code in seconds?

The answer is an emphatic yes—but the relationship between developers and patterns has fundamentally changed.

Why Patterns Matter More, Not Less

When you ask an AI assistant to build a notification system, it might generate perfectly functional code that handles email, SMS, and push notifications in a single monolithic function. The code works. The tests pass. But three months later, when you need to add Slack notifications and webhook callbacks, you discover that the code resists change. It was built to solve today's problem, not tomorrow's.

Design patterns provide the structural vocabulary to guide AI-generated code toward extensibility. When you prompt an AI with "implement this using the Observer pattern," you are encoding decades of collective design wisdom into a single architectural directive. The AI produces code that is not just functional but structurally sound.

Key Insight: In vibe coding, design patterns serve as a communication protocol between you and your AI assistant. They compress complex architectural requirements into shared terminology that both humans and AI models understand.

Patterns as Prompting Tools

Consider the difference between these two prompts:

Vague prompt:

Build a system where different parts of the application can react when data changes.

Pattern-informed prompt:

Implement an Observer pattern where a DataStore subject maintains a list of
observers. When data changes, notify all registered observers. Use Python's
Protocol class for the observer interface. Include type hints and dataclasses.

The second prompt leverages pattern knowledge to produce code that is immediately recognizable to any developer familiar with the pattern. It provides the AI with structural constraints that lead to better architecture.

How This Chapter Is Organized

We will cover the most practically useful patterns from the GoF catalog, implemented in idiomatic Python rather than translated literally from Java. Python's dynamic nature, first-class functions, and powerful standard library mean that many patterns look dramatically different—and some become almost trivial.

Throughout this chapter, we build on the architectural foundations from Chapter 24 (Software Architecture). Where Chapter 24 focused on high-level system organization—layered architectures, microservices, and domain-driven design—this chapter zooms in on the class-level and function-level patterns that make individual components clean and maintainable.

Cross-Reference: If you have not yet read Chapter 24, review Sections 24.1–24.3 on architectural styles and principles. The patterns in this chapter implement the SOLID principles discussed there.


25.2 Creational Patterns: Factory, Builder, Singleton

Creational patterns abstract the instantiation process, making systems independent of how their objects are created, composed, and represented. In Python, these patterns often look quite different from their Java counterparts because Python provides powerful built-in mechanisms like __init__ customization, class methods, and module-level singletons.

The Factory Pattern

The Factory pattern delegates object creation to a separate function or method, decoupling the client code from the concrete classes it instantiates. Python offers several idiomatic approaches.

Simple Factory

The simplest form uses a function that returns instances based on a parameter:

from dataclasses import dataclass
from typing import Protocol

class Serializer(Protocol):
    def serialize(self, data: dict) -> str: ...

@dataclass
class JSONSerializer:
    indent: int = 2

    def serialize(self, data: dict) -> str:
        import json
        return json.dumps(data, indent=self.indent)

@dataclass
class XMLSerializer:
    root_tag: str = "data"

    def serialize(self, data: dict) -> str:
        elements = [f"  <{k}>{v}</{k}>" for k, v in data.items()]
        body = "\n".join(elements)
        return f"<{self.root_tag}>\n{body}\n</{self.root_tag}>"

def create_serializer(format: str, **kwargs) -> Serializer:
    """Factory function that creates the appropriate serializer."""
    serializers = {
        "json": JSONSerializer,
        "xml": XMLSerializer,
    }
    if format not in serializers:
        raise ValueError(f"Unknown format: {format}. Choose from {list(serializers)}")
    return serializers[format](**kwargs)

Notice how Python's dictionary dispatch replaces the switch or if-elif chain common in other languages. The dictionary maps format strings directly to classes, and **kwargs passes through any configuration parameters.

Pythonic Note: In Python, a simple factory function is often preferred over a FactoryClass with a single method. Functions are first-class objects—there is no need to wrap them in a class just for the sake of being "object-oriented."

Abstract Factory

The Abstract Factory provides an interface for creating families of related objects. In Python, we use Protocol (from typing) instead of abstract base classes when we want structural subtyping:

from typing import Protocol

class Button(Protocol):
    def render(self) -> str: ...
    def on_click(self, handler: callable) -> None: ...

class TextField(Protocol):
    def render(self) -> str: ...
    def get_value(self) -> str: ...

class UIFactory(Protocol):
    def create_button(self, label: str) -> Button: ...
    def create_text_field(self, placeholder: str) -> TextField: ...

Concrete factories implement this protocol for different UI frameworks (web, desktop, mobile) without any explicit inheritance. Any class with the right method signatures satisfies the protocol—this is Python's structural typing at work.

When AI Generates Factory Code

AI assistants readily produce factory patterns when prompted. The key is to specify the interface you want the factory to produce and the discrimination criteria for choosing implementations:

Prompt: "Create a factory for database connections that supports PostgreSQL,
SQLite, and MySQL. Use a Protocol for the connection interface. Each connection
type should support connect(), execute(), and close() methods."

Callout — Common AI Mistake: AI assistants sometimes generate factories with hardcoded if-elif chains instead of dictionary dispatch. When reviewing AI-generated factory code, look for this pattern and refactor to a registry dictionary. It is more extensible—new types can be added without modifying the factory function.

The Builder Pattern

The Builder pattern separates the construction of a complex object from its representation. In Java, Builders are verbose affairs with chained method calls. In Python, we have more elegant options.

Dataclass-Based Builder

Python's dataclasses module provides much of what the Builder pattern was designed to solve—constructing objects with many optional parameters:

from dataclasses import dataclass, field

@dataclass
class DatabaseConfig:
    host: str = "localhost"
    port: int = 5432
    database: str = "app"
    username: str = "admin"
    password: str = ""
    pool_size: int = 5
    timeout: float = 30.0
    ssl_enabled: bool = False
    options: dict = field(default_factory=dict)

For many cases, this is all you need. But when construction involves validation or multi-step assembly, a true Builder adds value:

class QueryBuilder:
    """Builds SQL queries step by step with validation."""

    def __init__(self):
        self._table: str = ""
        self._columns: list[str] = []
        self._conditions: list[str] = []
        self._order_by: list[str] = []
        self._limit: int | None = None

    def from_table(self, table: str) -> "QueryBuilder":
        self._table = table
        return self

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

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

    def order_by(self, column: str, descending: bool = False) -> "QueryBuilder":
        direction = "DESC" if descending else "ASC"
        self._order_by.append(f"{column} {direction}")
        return self

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

    def build(self) -> str:
        if not self._table:
            raise ValueError("Table name is required")

        cols = ", ".join(self._columns) if self._columns else "*"
        query = f"SELECT {cols} FROM {self._table}"

        if self._conditions:
            query += " WHERE " + " AND ".join(self._conditions)
        if self._order_by:
            query += " ORDER BY " + ", ".join(self._order_by)
        if self._limit is not None:
            query += f" LIMIT {self._limit}"

        return query

Usage is fluent and readable:

query = (
    QueryBuilder()
    .from_table("users")
    .select("name", "email")
    .where("active = true")
    .where("role = 'admin'")
    .order_by("name")
    .limit(10)
    .build()
)

The Singleton Pattern (And Why You Probably Don't Need It)

The Singleton pattern ensures a class has only one instance. In Java, implementing Singleton correctly requires careful handling of thread safety and lazy initialization. In Python, the story is quite different.

Module-Level Singletons

Python modules are themselves singletons. When you import a module, Python caches it in sys.modules. A module-level instance is a singleton:

# config.py
class _AppConfig:
    def __init__(self):
        self.debug = False
        self.log_level = "INFO"
        self.database_url = ""

# Module-level singleton instance
config = _AppConfig()

Any module that does from config import config gets the same object. No metaclass magic. No __new__ override. No thread-locking gymnastics.

When You Actually Need Singleton

True Singleton (enforced at the class level) is rarely necessary in Python. The few legitimate cases include:

  • Hardware resource managers (a single connection to a specific serial port)
  • Global registries where accidental duplication would cause bugs
  • Thread pool managers in specific concurrency scenarios

For these cases, a __new__-based approach works cleanly:

class ConnectionPool:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self, max_connections: int = 10):
        if not hasattr(self, '_initialized'):
            self._max_connections = max_connections
            self._connections: list = []
            self._initialized = True

Warning — Anti-Pattern Alert: Singletons make testing difficult because they carry state across tests. If you find yourself reaching for a Singleton, first consider dependency injection (passing the shared object as a parameter). It achieves the same sharing while remaining testable. AI assistants frequently suggest Singletons when dependency injection would be more appropriate—push back on this.


25.3 Structural Patterns: Adapter, Decorator, Facade

Structural patterns deal with how classes and objects are composed to form larger structures. Python's dynamic typing and duck typing make many structural patterns lighter than in statically typed languages.

The Adapter Pattern

The Adapter pattern converts the interface of a class into another interface that clients expect. It lets classes work together that could not otherwise because of incompatible interfaces.

from typing import Protocol
from dataclasses import dataclass

class ModernPaymentGateway(Protocol):
    def charge(self, amount_cents: int, currency: str, token: str) -> dict: ...

@dataclass
class LegacyPaymentProcessor:
    """An old payment processor with a different interface."""
    api_key: str

    def process_payment(self, dollars: float, card_number: str) -> bool:
        print(f"Processing ${dollars:.2f} on card {card_number[-4:]}")
        return True

class LegacyPaymentAdapter:
    """Adapts LegacyPaymentProcessor to the ModernPaymentGateway protocol."""

    def __init__(self, legacy_processor: LegacyPaymentProcessor, card_number: str):
        self._processor = legacy_processor
        self._card_number = card_number

    def charge(self, amount_cents: int, currency: str, token: str) -> dict:
        dollars = amount_cents / 100.0
        success = self._processor.process_payment(dollars, self._card_number)
        return {
            "success": success,
            "amount_cents": amount_cents,
            "currency": currency,
        }

The adapter wraps the legacy class and translates between the old interface (process_payment with dollars) and the new interface (charge with cents). Client code works exclusively with the ModernPaymentGateway protocol and never knows whether it is talking to a modern gateway or an adapted legacy system.

Real-World Adapter Use Case

Adapters shine when integrating third-party libraries or APIs. When you switch from one email provider to another, an adapter lets you maintain your internal email interface while changing the underlying implementation:

class EmailService(Protocol):
    def send(self, to: str, subject: str, body: str) -> bool: ...

class SendGridAdapter:
    def __init__(self, client):
        self._client = client

    def send(self, to: str, subject: str, body: str) -> bool:
        # Translate to SendGrid's specific API
        message = self._client.create_message(
            recipient=to, title=subject, content=body, content_type="text/plain"
        )
        response = self._client.deliver(message)
        return response.status_code == 202

The Decorator Pattern

Here we must make an important distinction: the Decorator design pattern and Python decorators are related but not identical.

Python Decorators (Language Feature)

Python decorators are syntactic sugar for wrapping functions or classes. They are a natural fit for cross-cutting concerns like logging, caching, and access control:

import functools
import time
from typing import Callable, TypeVar, ParamSpec

P = ParamSpec("P")
T = TypeVar("T")

def retry(max_attempts: int = 3, delay: float = 1.0):
    """Decorator that retries a function on failure."""
    def decorator(func: Callable[P, T]) -> Callable[P, T]:
        @functools.wraps(func)
        def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
            last_exception = None
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_exception = e
                    if attempt < max_attempts:
                        time.sleep(delay)
            raise last_exception
        return wrapper
    return decorator

@retry(max_attempts=3, delay=0.5)
def fetch_data(url: str) -> dict:
    """Fetch data from an API endpoint."""
    import urllib.request
    import json
    with urllib.request.urlopen(url) as response:
        return json.loads(response.read())

The Decorator Design Pattern (GoF)

The GoF Decorator pattern dynamically adds responsibilities to objects. In Python, this often uses composition:

from typing import Protocol

class TextProcessor(Protocol):
    def process(self, text: str) -> str: ...

class PlainTextProcessor:
    def process(self, text: str) -> str:
        return text

class UpperCaseDecorator:
    def __init__(self, wrapped: TextProcessor):
        self._wrapped = wrapped

    def process(self, text: str) -> str:
        return self._wrapped.process(text).upper()

class TrimDecorator:
    def __init__(self, wrapped: TextProcessor):
        self._wrapped = wrapped

    def process(self, text: str) -> str:
        return self._wrapped.process(text).strip()

# Stack decorators
processor = UpperCaseDecorator(TrimDecorator(PlainTextProcessor()))
result = processor.process("  hello world  ")  # "HELLO WORLD"

Pythonic Note: For simple transformations, a list of functions is often more Pythonic than a chain of decorator objects: python transforms = [str.strip, str.upper] text = " hello world " for transform in transforms: text = transform(text) Use the full Decorator pattern when decorators need to carry state or when the object interface is richer than a single method.

The Facade Pattern

The Facade pattern provides a simplified interface to a complex subsystem. It does not add new functionality—it merely provides a convenient entry point.

class VideoConverter:
    """Facade for a complex video conversion subsystem."""

    def __init__(self):
        self._file_reader = VideoFileReader()
        self._codec_factory = CodecFactory()
        self._audio_mixer = AudioMixer()
        self._encoder = BitrateEncoder()

    def convert(self, filename: str, target_format: str) -> str:
        """Convert a video file to the target format.

        This single method orchestrates the entire conversion pipeline,
        hiding the complexity of codecs, audio mixing, and encoding.
        """
        video_data = self._file_reader.read(filename)
        codec = self._codec_factory.get_codec(target_format)
        audio = self._audio_mixer.extract_and_normalize(video_data)
        encoded = self._encoder.encode(video_data, codec, audio)
        output_file = filename.rsplit(".", 1)[0] + f".{target_format}"
        self._file_reader.write(output_file, encoded)
        return output_file

Facades are particularly valuable in vibe coding when an AI generates a complex subsystem. You can wrap the generated complexity behind a clean facade that your application code interacts with. If the AI-generated internals need to be replaced later, only the facade internals change.

Callout — Facade + AI Generation: A powerful workflow: (1) Define the facade interface yourself with clear method signatures. (2) Ask the AI to implement the complex internals. (3) Wire the internals through the facade. This gives you a clean, stable API regardless of the quality of the generated implementation.


25.4 Behavioral Patterns: Observer, Strategy, Command

Behavioral patterns focus on communication between objects, defining how they interact and distribute responsibility.

The Observer Pattern

The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified. Python's first-class functions make this pattern elegant.

from typing import Callable, Any
from dataclasses import dataclass, field

class EventEmitter:
    """A simple, Pythonic observer implementation using callbacks."""

    def __init__(self):
        self._listeners: dict[str, list[Callable]] = {}

    def on(self, event: str, callback: Callable) -> None:
        """Register a listener for an event."""
        self._listeners.setdefault(event, []).append(callback)

    def off(self, event: str, callback: Callable) -> None:
        """Remove a listener for an event."""
        if event in self._listeners:
            self._listeners[event] = [
                cb for cb in self._listeners[event] if cb is not callback
            ]

    def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
        """Notify all listeners of an event."""
        for callback in self._listeners.get(event, []):
            callback(*args, **kwargs)

# Usage
store = EventEmitter()

def log_change(key, value):
    print(f"[LOG] {key} changed to {value}")

def validate_change(key, value):
    if key == "email" and "@" not in str(value):
        raise ValueError(f"Invalid email: {value}")

store.on("data_changed", log_change)
store.on("data_changed", validate_change)

store.emit("data_changed", "email", "user@example.com")

This callback-based approach is far more Pythonic than the interface-heavy Subject/Observer hierarchy found in Java implementations. Python's first-class functions eliminate the need for dedicated observer classes in most cases.

Typed Events with Dataclasses

For larger systems, typed event objects provide better documentation and IDE support:

from dataclasses import dataclass
from datetime import datetime

@dataclass(frozen=True)
class UserCreatedEvent:
    user_id: str
    email: str
    timestamp: datetime = field(default_factory=datetime.now)

@dataclass(frozen=True)
class OrderPlacedEvent:
    order_id: str
    user_id: str
    total_cents: int
    timestamp: datetime = field(default_factory=datetime.now)

Using frozen=True makes events immutable—once created, they cannot be modified, which prevents subtle bugs where handlers inadvertently alter events as they propagate.

The Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. In Python, strategies are often just functions:

from typing import Callable

# Strategies as functions
def price_by_weight(weight_kg: float, rate: float = 5.0) -> float:
    return weight_kg * rate

def price_by_distance(distance_km: float, rate: float = 0.5) -> float:
    return distance_km * rate

def price_flat_rate(rate: float = 15.0, **kwargs) -> float:
    return rate

# Context that uses a strategy
PricingStrategy = Callable[..., float]

class ShippingCalculator:
    def __init__(self, strategy: PricingStrategy):
        self._strategy = strategy

    def calculate(self, **kwargs) -> float:
        return self._strategy(**kwargs)

    def set_strategy(self, strategy: PricingStrategy) -> None:
        self._strategy = strategy

# Usage
calculator = ShippingCalculator(price_by_weight)
cost = calculator.calculate(weight_kg=2.5)  # 12.5

calculator.set_strategy(price_flat_rate)
cost = calculator.calculate()  # 15.0

Pythonic Note: When your strategies are simple functions, you do not need a Strategy base class or interface at all. A Callable type hint communicates the contract. For more complex strategies that carry configuration state, use classes with a __call__ method—they remain callable but can hold internal state.

Strategy with Callable Classes

@dataclass
class TieredPricing:
    """A stateful strategy using tiered pricing."""
    tiers: list[tuple[float, float]]  # (threshold_kg, rate_per_kg)

    def __call__(self, weight_kg: float, **kwargs) -> float:
        total = 0.0
        remaining = weight_kg
        for threshold, rate in sorted(self.tiers):
            tier_weight = min(remaining, threshold)
            total += tier_weight * rate
            remaining -= tier_weight
            if remaining <= 0:
                break
        if remaining > 0:
            total += remaining * self.tiers[-1][1]
        return total

tiered = TieredPricing(tiers=[(5.0, 3.0), (10.0, 2.5), (float("inf"), 2.0)])
calculator = ShippingCalculator(tiered)

The Command Pattern

The Command pattern encapsulates a request as an object, allowing you to parameterize clients with different requests, queue requests, and support undo operations.

from dataclasses import dataclass, field
from typing import Protocol

class Command(Protocol):
    def execute(self) -> None: ...
    def undo(self) -> None: ...

@dataclass
class TextDocument:
    content: str = ""

    def insert(self, position: int, text: str) -> None:
        self.content = self.content[:position] + text + self.content[position:]

    def delete(self, position: int, length: int) -> str:
        deleted = self.content[position:position + length]
        self.content = self.content[:position] + self.content[position + length:]
        return deleted

@dataclass
class InsertCommand:
    document: TextDocument
    position: int
    text: str

    def execute(self) -> None:
        self.document.insert(self.position, self.text)

    def undo(self) -> None:
        self.document.delete(self.position, len(self.text))

@dataclass
class DeleteCommand:
    document: TextDocument
    position: int
    length: int
    _deleted_text: str = field(default="", init=False)

    def execute(self) -> None:
        self._deleted_text = self.document.delete(self.position, self.length)

    def undo(self) -> None:
        self.document.insert(self.position, self._deleted_text)

class CommandHistory:
    """Manages command execution and undo/redo."""

    def __init__(self):
        self._history: list[Command] = []
        self._redo_stack: list[Command] = []

    def execute(self, command: Command) -> None:
        command.execute()
        self._history.append(command)
        self._redo_stack.clear()

    def undo(self) -> None:
        if self._history:
            command = self._history.pop()
            command.undo()
            self._redo_stack.append(command)

    def redo(self) -> None:
        if self._redo_stack:
            command = self._redo_stack.pop()
            command.execute()
            self._history.append(command)

The Command pattern is particularly useful in applications that need undo/redo functionality, macro recording, or operation queuing. In AI-assisted applications, it provides a clean way to track and reverse AI-generated changes.


25.5 Pythonic Pattern Implementations

Python's language features make many design patterns either simpler or entirely unnecessary. This section covers the most important Pythonic adaptations.

Protocols Over Abstract Base Classes

Python 3.8 introduced typing.Protocol for structural subtyping. Unlike abstract base classes (ABCs), protocols do not require explicit inheritance:

from typing import Protocol, runtime_checkable

@runtime_checkable
class Renderable(Protocol):
    def render(self) -> str: ...

class HTMLWidget:
    """No inheritance needed—just implement the method."""
    def render(self) -> str:
        return "<div>widget</div>"

class JSONResponse:
    def render(self) -> str:
        return '{"status": "ok"}'

def display(item: Renderable) -> None:
    print(item.render())

# Both work—structural typing checks method signatures, not inheritance
display(HTMLWidget())
display(JSONResponse())

# Runtime checking (when @runtime_checkable is used)
assert isinstance(HTMLWidget(), Renderable)

Best Practice: Prefer Protocol over ABC when you want to define an interface that third-party code can satisfy without modification. Use ABC when you want to enforce that subclasses explicitly opt in to your hierarchy.

Dataclasses as Value Objects

The dataclass module provides automatic __init__, __repr__, __eq__, and optional __hash__ for free. Combined with frozen=True, dataclasses make excellent value objects:

from dataclasses import dataclass

@dataclass(frozen=True)
class Money:
    amount: int  # Store in cents to avoid float issues
    currency: str = "USD"

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError(f"Cannot add {self.currency} and {other.currency}")
        return Money(self.amount + other.amount, self.currency)

    def __str__(self) -> str:
        dollars = self.amount / 100
        return f"${dollars:,.2f} {self.currency}"

First-Class Functions Replace Strategy Classes

As shown in Section 25.4, many behavioral patterns collapse when functions are first-class objects:

# Instead of creating a SortStrategy class hierarchy:
from typing import Callable

SortKey = Callable[[dict], Any]

def sort_by_name(item: dict) -> str:
    return item.get("name", "").lower()

def sort_by_price(item: dict) -> float:
    return item.get("price", 0.0)

def sort_by_rating(item: dict) -> float:
    return -item.get("rating", 0.0)  # Negative for descending

def display_catalog(items: list[dict], sort_key: SortKey = sort_by_name) -> None:
    for item in sorted(items, key=sort_key):
        print(f"{item['name']}: ${item['price']:.2f} ({item['rating']}*)")

Context Managers as Resource Management Pattern

Python's with statement and context managers implement the resource acquisition is initialization (RAII) pattern more cleanly than explicit try/finally blocks:

from contextlib import contextmanager
from typing import Generator

@contextmanager
def database_transaction(connection) -> Generator:
    """Context manager that handles transaction commit/rollback."""
    cursor = connection.cursor()
    try:
        yield cursor
        connection.commit()
    except Exception:
        connection.rollback()
        raise
    finally:
        cursor.close()

Descriptors Replace Property Patterns

Python descriptors provide a powerful mechanism for managed attributes that replaces many property-based patterns:

class Validated:
    """A descriptor that validates values on assignment."""

    def __init__(self, validator, error_message: str = "Invalid value"):
        self.validator = validator
        self.error_message = error_message
        self.attr_name = ""

    def __set_name__(self, owner, name):
        self.attr_name = f"_{name}"

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self.attr_name, None)

    def __set__(self, obj, value):
        if not self.validator(value):
            raise ValueError(f"{self.attr_name[1:]}: {self.error_message}")
        setattr(obj, self.attr_name, value)

class User:
    name = Validated(lambda x: isinstance(x, str) and len(x) > 0, "Name cannot be empty")
    age = Validated(lambda x: isinstance(x, int) and 0 < x < 150, "Age must be between 1 and 149")
    email = Validated(lambda x: isinstance(x, str) and "@" in x, "Invalid email format")

    def __init__(self, name: str, age: int, email: str):
        self.name = name
        self.age = age
        self.email = email

25.6 Clean Code Principles

Clean code is code that is easy to read, understand, and modify. While AI assistants can generate functional code rapidly, the code they produce is not always clean. Applying clean code principles to AI-generated code is one of the most valuable skills a vibe coder can develop.

Meaningful Names

Names should reveal intent. A variable named d tells you nothing; elapsed_days tells you everything.

Poor names (common in AI-generated code):

def proc(d, t):
    r = []
    for i in d:
        if i["s"] == t:
            r.append(i)
    return r

Clean names:

def filter_orders_by_status(orders: list[dict], target_status: str) -> list[dict]:
    return [order for order in orders if order["status"] == target_status]

Rules for naming: - Variables: Noun phrases that describe the value (active_users, total_price, error_message) - Functions: Verb phrases that describe the action (calculate_tax, send_notification, validate_input) - Classes: Noun phrases that describe the entity (UserRepository, PaymentProcessor, EventListener) - Booleans: Phrases that read as true/false questions (is_active, has_permission, should_retry) - Constants: UPPER_SNAKE_CASE that describes the value (MAX_RETRY_COUNT, DEFAULT_TIMEOUT)

Callout — AI Naming Quality: AI assistants usually generate reasonable names but sometimes fall into patterns like data, result, temp, or single-letter variables. Always review names in AI-generated code and rename anything that does not clearly convey its purpose.

Small Functions

Functions should do one thing, do it well, and do it only. A useful heuristic: if you cannot describe what a function does without using the word "and," it does too much.

Too large:

def process_order(order):
    # Validate
    if not order.get("items"):
        raise ValueError("Order has no items")
    if not order.get("customer_id"):
        raise ValueError("No customer ID")

    # Calculate totals
    subtotal = sum(item["price"] * item["quantity"] for item in order["items"])
    tax = subtotal * 0.08
    total = subtotal + tax

    # Apply discounts
    if order.get("coupon"):
        discount = lookup_coupon(order["coupon"])
        total -= discount

    # Process payment
    charge_customer(order["customer_id"], total)

    # Send confirmation
    send_email(order["customer_id"], f"Order confirmed: ${total:.2f}")

    return {"total": total, "status": "confirmed"}

Decomposed into focused functions:

def process_order(order: dict) -> dict:
    validate_order(order)
    total = calculate_order_total(order)
    charge_customer(order["customer_id"], total)
    send_order_confirmation(order["customer_id"], total)
    return {"total": total, "status": "confirmed"}

def validate_order(order: dict) -> None:
    if not order.get("items"):
        raise ValueError("Order has no items")
    if not order.get("customer_id"):
        raise ValueError("No customer ID")

def calculate_order_total(order: dict) -> float:
    subtotal = sum(item["price"] * item["quantity"] for item in order["items"])
    tax = subtotal * 0.08
    total = subtotal + tax
    return apply_coupon_discount(total, order.get("coupon"))

def apply_coupon_discount(total: float, coupon: str | None) -> float:
    if coupon:
        return total - lookup_coupon(coupon)
    return total

DRY (Don't Repeat Yourself)

Duplication is the root of many maintenance problems. When AI generates similar code for multiple endpoints or handlers, extract the common logic:

# DRY violation — same validation logic duplicated
def create_user(data):
    if not data.get("email") or "@" not in data["email"]:
        raise ValueError("Invalid email")
    if not data.get("name") or len(data["name"]) < 2:
        raise ValueError("Invalid name")
    # ... create user

def update_user(user_id, data):
    if not data.get("email") or "@" not in data["email"]:
        raise ValueError("Invalid email")
    if not data.get("name") or len(data["name"]) < 2:
        raise ValueError("Invalid name")
    # ... update user

# DRY — extract shared validation
def validate_user_data(data: dict) -> None:
    if not data.get("email") or "@" not in data["email"]:
        raise ValueError("Invalid email")
    if not data.get("name") or len(data["name"]) < 2:
        raise ValueError("Invalid name")

def create_user(data: dict) -> User:
    validate_user_data(data)
    # ... create user

def update_user(user_id: str, data: dict) -> User:
    validate_user_data(data)
    # ... update user

KISS (Keep It Simple, Stupid)

Simplicity is the ultimate sophistication. Do not introduce a pattern or abstraction until you have a clear reason for it. Three concrete examples are better than one premature abstraction.

The Rule of Three: Wait until you have three instances of duplicated code before extracting an abstraction. Two might be coincidence; three is a pattern.

The Boy Scout Rule

"Leave the code cleaner than you found it." When you modify a file—whether the code was written by a human, an AI, or both—improve at least one thing: rename an unclear variable, extract a helper function, add a missing type hint. Over time, the codebase improves continuously.


25.7 Refactoring Toward Patterns

Refactoring toward patterns is the process of gradually restructuring existing code to incorporate a design pattern. This is usually safer and more practical than trying to design the perfect pattern upfront.

Recognizing the Need

Signs that code would benefit from a pattern:

Symptom Potential Pattern
Large if-elif-else chains selecting behavior Strategy
Duplicated code across similar classes Template Method
Need to notify multiple components of changes Observer
Complex object construction with many parameters Builder
Multiple classes with incompatible interfaces Adapter
Complex subsystem used through many scattered calls Facade
Need to support undo/redo operations Command

Step-by-Step Refactoring Example

Consider AI-generated code with a large conditional block:

def process_payment(method: str, amount: float, details: dict) -> bool:
    if method == "credit_card":
        card = details["card_number"]
        expiry = details["expiry"]
        cvv = details["cvv"]
        # 15 lines of credit card processing...
        return True
    elif method == "paypal":
        email = details["paypal_email"]
        # 12 lines of PayPal processing...
        return True
    elif method == "bank_transfer":
        account = details["account_number"]
        routing = details["routing_number"]
        # 10 lines of bank transfer processing...
        return True
    elif method == "crypto":
        wallet = details["wallet_address"]
        # 8 lines of crypto processing...
        return True
    else:
        raise ValueError(f"Unknown payment method: {method}")

Step 1: Extract each branch into a function.

def process_credit_card(amount: float, details: dict) -> bool: ...
def process_paypal(amount: float, details: dict) -> bool: ...
def process_bank_transfer(amount: float, details: dict) -> bool: ...
def process_crypto(amount: float, details: dict) -> bool: ...

Step 2: Create a strategy registry.

PaymentHandler = Callable[[float, dict], bool]

PAYMENT_HANDLERS: dict[str, PaymentHandler] = {
    "credit_card": process_credit_card,
    "paypal": process_paypal,
    "bank_transfer": process_bank_transfer,
    "crypto": process_crypto,
}

Step 3: Replace the conditional with dispatch.

def process_payment(method: str, amount: float, details: dict) -> bool:
    handler = PAYMENT_HANDLERS.get(method)
    if handler is None:
        raise ValueError(f"Unknown payment method: {method}")
    return handler(amount, details)

Step 4: Make it extensible. Add a registration function so new payment methods can be added without modifying the core code:

def register_payment_handler(method: str, handler: PaymentHandler) -> None:
    PAYMENT_HANDLERS[method] = handler

This four-step refactoring transforms rigid, monolithic code into a clean, extensible design. Each step is small and testable. At no point is the system in a broken state.

Vibe Coding Tip: You can ask AI assistants to perform these refactoring steps. A prompt like "Refactor this function to use the Strategy pattern with a dictionary registry" gives the AI precise structural guidance. See Case Study 01 for a detailed walkthrough.


25.8 When Patterns Help vs. Over-Engineer

Design patterns are tools, not goals. Applying a pattern where it is not needed creates unnecessary complexity—a phenomenon sometimes called patternitis or architecture astronautics.

Signs You Are Over-Engineering

  1. You have an interface with only one implementation. If there is only one concrete class and no foreseeable need for another, the interface adds indirection without benefit.
  2. The pattern adds more code than it saves. A Strategy pattern for two strategies that never change is overhead.
  3. You are building for imaginary future requirements. YAGNI (You Aren't Gonna Need It) should temper pattern enthusiasm.
  4. The code is harder to follow with the pattern than without. If a junior developer cannot understand the code flow, the pattern may not be the right choice.

Signs a Pattern Would Help

  1. You are modifying the same function or class for every new feature. The Open/Closed Principle is being violated.
  2. You have duplicated logic across multiple places. A pattern can centralize and standardize the approach.
  3. Testing requires mocking complex dependencies. Patterns like Dependency Injection and Strategy make code testable.
  4. You are frequently getting merge conflicts in the same file. The code may have too many responsibilities.

The YAGNI Principle

YAGNI does not mean "never use patterns." It means "do not add patterns until you have evidence they are needed." The evidence might be:

  • A second (or third) concrete implementation is needed
  • The code needs to be tested in isolation
  • A new requirement cannot be added without modifying existing code
  • Performance profiling reveals a need for interchangeable algorithms

Callout — AI and Over-Engineering: AI assistants sometimes over-apply patterns because their training data includes many pattern-heavy codebases and textbook examples. If an AI generates a full Abstract Factory hierarchy for two simple classes, do not hesitate to simplify. The simplest solution that works correctly and is reasonably maintainable is the best solution.

A Decision Framework

Ask yourself these questions before applying a pattern:

  1. Does the current code have a concrete problem? (Not a hypothetical future problem.)
  2. Will the pattern make the code easier to read? (Not just more "correct" in theory.)
  3. Will the pattern reduce the effort of the next likely change? (Based on actual project direction, not speculation.)
  4. Can I explain why this pattern is needed in one sentence? If not, reconsider.

25.9 AI-Specific Patterns and Idioms

As AI-powered applications mature, recurring patterns emerge for managing AI interactions. These are not GoF patterns—they are domain-specific patterns born from practical experience building applications that integrate large language models.

The Prompt Template Pattern

Prompt templates separate the structure of a prompt from its variable content, similar to how web templates separate HTML structure from data:

from dataclasses import dataclass
from string import Template

@dataclass
class PromptTemplate:
    """A reusable template for constructing AI prompts."""
    template: str
    required_variables: set[str]

    def render(self, **variables) -> str:
        missing = self.required_variables - set(variables.keys())
        if missing:
            raise ValueError(f"Missing required variables: {missing}")
        return Template(self.template).safe_substitute(variables)

SUMMARIZE_TEMPLATE = PromptTemplate(
    template=(
        "Summarize the following $content_type in $word_count words or fewer.\n"
        "Focus on: $focus_areas\n\n"
        "Content:\n$content"
    ),
    required_variables={"content_type", "word_count", "focus_areas", "content"},
)

prompt = SUMMARIZE_TEMPLATE.render(
    content_type="research paper",
    word_count="200",
    focus_areas="methodology and key findings",
    content="The study investigated the effect of..."
)

The Conversation Manager Pattern

The Conversation Manager maintains context across multiple interactions with an AI model:

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class Message:
    role: str  # "system", "user", "assistant"
    content: str
    timestamp: datetime = field(default_factory=datetime.now)

class ConversationManager:
    """Manages multi-turn conversations with context windowing."""

    def __init__(self, system_prompt: str, max_history: int = 20):
        self._system_prompt = system_prompt
        self._max_history = max_history
        self._messages: list[Message] = []

    def add_user_message(self, content: str) -> None:
        self._messages.append(Message(role="user", content=content))
        self._trim_history()

    def add_assistant_message(self, content: str) -> None:
        self._messages.append(Message(role="assistant", content=content))

    def get_context(self) -> list[dict[str, str]]:
        """Return the conversation context for the AI model."""
        context = [{"role": "system", "content": self._system_prompt}]
        context.extend(
            {"role": msg.role, "content": msg.content}
            for msg in self._messages
        )
        return context

    def _trim_history(self) -> None:
        """Keep only the most recent messages to manage context window."""
        if len(self._messages) > self._max_history:
            self._messages = self._messages[-self._max_history:]

    def clear(self) -> None:
        self._messages.clear()

The Output Parser Pattern

AI models return unstructured text. The Output Parser pattern extracts structured data from that text:

import json
import re
from typing import TypeVar, Type
from dataclasses import dataclass

T = TypeVar("T")

class OutputParser:
    """Parses structured data from AI-generated text."""

    @staticmethod
    def parse_json(text: str) -> dict:
        """Extract and parse JSON from AI output.

        Handles common issues like markdown code fences and trailing commas.
        """
        # Remove markdown code fences if present
        json_match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text)
        if json_match:
            text = json_match.group(1)

        # Remove trailing commas (common AI mistake)
        text = re.sub(r",\s*([}\]])", r"\1", text.strip())

        return json.loads(text)

    @staticmethod
    def parse_list(text: str) -> list[str]:
        """Extract a list of items from AI output."""
        lines = text.strip().split("\n")
        items = []
        for line in lines:
            cleaned = re.sub(r"^[\s\-\*\d.]+", "", line).strip()
            if cleaned:
                items.append(cleaned)
        return items

    @staticmethod
    def parse_code(text: str, language: str = "python") -> str:
        """Extract code from AI output, stripping markdown fences."""
        pattern = rf"```{language}\s*([\s\S]*?)```"
        match = re.search(pattern, text)
        if match:
            return match.group(1).strip()
        return text.strip()

The Retry with Fallback Pattern

AI API calls can fail or return poor results. This pattern combines retry logic with model fallback:

from dataclasses import dataclass, field
import time

@dataclass
class ModelConfig:
    name: str
    max_tokens: int
    temperature: float = 0.7

@dataclass
class RetryConfig:
    max_retries: int = 3
    backoff_factor: float = 2.0
    initial_delay: float = 1.0
    fallback_models: list[ModelConfig] = field(default_factory=list)

def call_with_retry(
    prompt: str,
    primary_model: ModelConfig,
    retry_config: RetryConfig,
    call_fn: callable,
) -> str:
    """Call an AI model with retry logic and model fallback."""
    models = [primary_model] + retry_config.fallback_models

    for model in models:
        delay = retry_config.initial_delay
        for attempt in range(retry_config.max_retries):
            try:
                return call_fn(prompt, model)
            except Exception as e:
                if attempt < retry_config.max_retries - 1:
                    time.sleep(delay)
                    delay *= retry_config.backoff_factor
                else:
                    print(f"Model {model.name} failed after {retry_config.max_retries} attempts: {e}")

    raise RuntimeError("All models and retries exhausted")

Callout — Production AI Systems: In production, the Retry with Fallback pattern is essential. AI APIs have rate limits, outages, and occasional quality degradation. Building resilience at the pattern level means your application gracefully handles these realities.


25.10 Code Smells and Their Remedies

Code smells are surface indicators of deeper problems in the code. They are not bugs—the code works—but they signal that the code could be improved. AI-generated code has its own characteristic smells.

Classic Code Smells

Smell Description Remedy
Long Method Function exceeds 20-30 lines Extract Method
Large Class Class has too many responsibilities Extract Class, Single Responsibility
Long Parameter List Function takes more than 3-4 parameters Introduce Parameter Object (dataclass)
Duplicated Code Same or similar code in multiple places Extract Method, Template Method
Feature Envy Method uses another class's data more than its own Move Method
Data Clumps Groups of variables that always appear together Extract into a dataclass
Primitive Obsession Using primitives instead of small objects Introduce Value Object
Switch Statements Long if-elif chains for type-based dispatch Strategy, Polymorphism
Speculative Generality Abstractions for hypothetical future use Remove unused abstractions (YAGNI)
Dead Code Code that is never executed Delete it

AI-Specific Code Smells

AI-generated code has additional smells that experienced vibe coders learn to recognize:

1. Over-commented obvious code:

# Bad — AI over-explains simple operations
# Initialize an empty list to store the results
results = []
# Loop through each item in the items list
for item in items:
    # Check if the item's status is active
    if item.status == "active":
        # Append the item to the results list
        results.append(item)

Remedy: Remove comments that merely restate the code. Only comment on why, not what.

2. Unnecessary class wrapping:

# Bad — AI wraps a simple function in a class
class StringHelper:
    @staticmethod
    def reverse_string(s: str) -> str:
        return s[::-1]

# Good — just use a function
def reverse_string(s: str) -> str:
    return s[::-1]

3. Java-in-Python syndrome:

# Bad — Java patterns in Python
class UserService:
    def __init__(self):
        self._user_repository = UserRepository()

    def get_user_by_id(self, user_id: int) -> User:
        return self._user_repository.find_by_id(user_id)

    def get_all_users(self) -> list[User]:
        return self._user_repository.find_all()

# Better — Pythonic approach (for simple cases)
def get_user(user_id: int, repository) -> User:
    return repository.find_by_id(user_id)

4. Reinventing standard library features:

# Bad — AI rewrites what the standard library provides
def flatten_list(nested: list) -> list:
    result = []
    for item in nested:
        if isinstance(item, list):
            result.extend(flatten_list(item))
        else:
            result.append(item)
    return result

# Good — use itertools (for single-level flattening)
from itertools import chain
flat = list(chain.from_iterable(nested_list))

5. Inconsistent error handling:

# Bad — AI mixes error handling styles
def process(data):
    try:
        result = step_one(data)
    except Exception:
        return None  # Silently swallows errors

    if result is None:
        raise ValueError("Step one failed")  # Raises exceptions

    step_two(result)  # No error handling at all

Remedy: Choose a consistent error handling strategy (exceptions, Result types, or sentinel values) and apply it uniformly.

A Systematic Approach to Cleaning AI Code

When reviewing AI-generated code, apply this checklist:

  1. Read the code without running it. Can you understand the intent?
  2. Check names. Are they descriptive and consistent?
  3. Check function sizes. Is each function focused on one task?
  4. Check for duplication. Is any logic repeated?
  5. Check error handling. Is it consistent and appropriate?
  6. Check type hints. Are they present and accurate?
  7. Check imports. Are all imports used? Are standard library solutions overlooked?
  8. Check comments. Do they add value or just restate code?
  9. Run a linter. Tools like ruff, flake8, or pylint catch many smells automatically.
  10. Run type checking. mypy or pyright verifies type correctness.

Callout — The 80/20 Rule of AI Code Review: About 80% of AI-generated code is correct and well-structured. The remaining 20% contains subtle issues—wrong patterns, missed edge cases, unnecessary complexity. Your job as a vibe coder is to efficiently identify and fix that 20%. The patterns and principles in this chapter give you the vocabulary and techniques to do so.


Chapter Summary

Design patterns and clean code principles form the bridge between working software and maintainable software. In the AI era, they serve a dual purpose: they guide AI assistants toward better code generation, and they provide the criteria by which we evaluate and improve generated code.

The key insight of this chapter is that Python's language features—first-class functions, protocols, dataclasses, context managers, and decorators—naturally implement many patterns that are verbose in other languages. A Python developer who understands these features often applies patterns without even naming them.

At the same time, the disciplined vocabulary of patterns remains invaluable for communication—both between developers and between developers and AI assistants. "Use the Strategy pattern" is a far more precise instruction than "make this more flexible."

As you continue through Part IV, carry these principles forward. In Chapter 26, we will apply them to the specific challenge of refactoring legacy code, where pattern recognition and systematic transformation are essential skills.


Key Terms

  • Design Pattern: A reusable solution to a commonly occurring problem in software design
  • Creational Pattern: A pattern that deals with object creation mechanisms
  • Structural Pattern: A pattern that deals with object composition
  • Behavioral Pattern: A pattern that deals with communication between objects
  • Protocol: Python's structural subtyping mechanism for defining interfaces
  • Code Smell: A surface indication that usually corresponds to a deeper problem in the system
  • Refactoring: Improving the internal structure of code without changing its external behavior
  • YAGNI: "You Aren't Gonna Need It"—a principle advising against premature abstraction
  • DRY: "Don't Repeat Yourself"—a principle against code duplication
  • KISS: "Keep It Simple, Stupid"—a principle favoring simplicity over complexity
  • Duck Typing: A typing philosophy where an object's suitability is determined by the presence of methods, not by type hierarchy
  • Prompt Template: An AI-specific pattern that separates prompt structure from variable content
  • Output Parser: An AI-specific pattern that extracts structured data from unstructured AI output