> "Design patterns are not about specific implementations. They are about capturing design ideas in a form that people can effectively use." — Erich Gamma
In This Chapter
- Learning Objectives
- 25.1 Design Patterns in the AI Era
- 25.2 Creational Patterns: Factory, Builder, Singleton
- 25.3 Structural Patterns: Adapter, Decorator, Facade
- 25.4 Behavioral Patterns: Observer, Strategy, Command
- 25.5 Pythonic Pattern Implementations
- 25.6 Clean Code Principles
- 25.7 Refactoring Toward Patterns
- 25.8 When Patterns Help vs. Over-Engineer
- 25.9 AI-Specific Patterns and Idioms
- 25.10 Code Smells and Their Remedies
- Chapter Summary
- Key Terms
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:
- Analyze how classic Gang of Four (GoF) design patterns translate into idiomatic Python, distinguishing between Java-centric implementations and Pythonic alternatives (Bloom's: Analyze)
- Apply creational, structural, and behavioral patterns to solve recurring software design problems using Python protocols, dataclasses, and first-class functions (Bloom's: Apply)
- Evaluate when design patterns improve a codebase versus when they introduce unnecessary complexity (Bloom's: Evaluate)
- Create clean, maintainable code by applying naming conventions, function decomposition, DRY, and KISS principles (Bloom's: Create)
- Synthesize AI-specific patterns—prompt templates, conversation managers, and output parsers—into production-quality systems (Bloom's: Synthesize)
- 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
FactoryClasswith 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-elifchains 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
Strategybase class or interface at all. ACallabletype 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
ProtocoloverABCwhen you want to define an interface that third-party code can satisfy without modification. UseABCwhen 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
- 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.
- The pattern adds more code than it saves. A Strategy pattern for two strategies that never change is overhead.
- You are building for imaginary future requirements. YAGNI (You Aren't Gonna Need It) should temper pattern enthusiasm.
- 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
- You are modifying the same function or class for every new feature. The Open/Closed Principle is being violated.
- You have duplicated logic across multiple places. A pattern can centralize and standardize the approach.
- Testing requires mocking complex dependencies. Patterns like Dependency Injection and Strategy make code testable.
- 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:
- Does the current code have a concrete problem? (Not a hypothetical future problem.)
- Will the pattern make the code easier to read? (Not just more "correct" in theory.)
- Will the pattern reduce the effort of the next likely change? (Based on actual project direction, not speculation.)
- 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:
- Read the code without running it. Can you understand the intent?
- Check names. Are they descriptive and consistent?
- Check function sizes. Is each function focused on one task?
- Check for duplication. Is any logic repeated?
- Check error handling. Is it consistent and appropriate?
- Check type hints. Are they present and accurate?
- Check imports. Are all imports used? Are standard library solutions overlooked?
- Check comments. Do they add value or just restate code?
- Run a linter. Tools like
ruff,flake8, orpylintcatch many smells automatically. - Run type checking.
mypyorpyrightverifies 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
Related Reading
Explore this topic in other books
Vibe Coding Software Architecture Learning COBOL Coding Standards