Design Patterns I

Lessons from the Gang of Four

Author

Karsten Naert

Published

November 15, 2025

Introduction: What Are Design Patterns?

In the 1990s, four authors published a book that would fundamentally shape how programmers think about software design. They became known as the “Gang of Four”—not to be confused with the Maoist faction of the same name.

The iconic Design Patterns book from 1994

Design patterns are reusable solutions to common problems in software design. They’re like recipes: not the final dish, but a proven template for creating something that works.

The Gang of Four (GoF) cataloged 23 patterns, organizing them into three categories:

  • Creational: How objects are created
  • Structural: How objects are composed
  • Behavioral: How objects interact and communicate

These patterns emerged from Smalltalk and C++ development, languages quite different from Python. Which brings us to an important point…

The Python Reality

Not all GoF patterns are useful in Python! Some patterns solve problems that don’t exist in Python, while others are already built into the language itself.

Blindly copying Java or C++ patterns to Python often creates unnecessarily complex code.

The Python Perspective

Python developer Brandon Rhodes gave an excellent talk categorizing GoF patterns by their usefulness in Python:

6 patterns rendered useless because Python has first-class functions: - Factory Method, Abstract Factory, Prototype, Singleton, Template Method, Strategy

2 patterns built into the language: - Iterator (generators and the iterator protocol) - Visitor (AST node visitors, but rarely needed)

3 patterns to avoid (hide data, scatter logic): - Proxy, Memento, Observer

4 “behaviors” (reasonable but rarely used): - Bridge, Decorator, Mediator, State

3 “keepers” (small but useful): - Builder (e.g., matplotlib’s fluent interface) - Adapter (e.g., socket.makefile()) - Flyweight (e.g., small integer caching)

4 “big ones” (important patterns): - Composite, Chain of Responsibility, Command, Interpreter

1 “odd one”: - Facade

This lecture focuses on a few key patterns: showing why some are obsolete in Python and diving deep into the ones that matter.

Pattern #1: The Factory Method Pattern (Mostly Obsolete)

Let’s start with a pattern you don’t need in modern Python.

The Problem

You’re building a database viewer that needs to work with different database backends:

class DataViewer:
    def __init__(self, filename: str):
        self.connection = ?  # How to create the right connection?
    
    def print_tables(self):
        tables = self.connection.list_tables()
        print(tables)

How do you handle multiple database types (SQLite, DuckDB, PostgreSQL) without hardcoding the choice?

The GoF Solution: Factory Method

The traditional approach creates a factory method that subclasses override:

class Connection:
    def list_tables(self) -> list[str]:
        return []

class DataViewer:
    def __init__(self, filename: str):
        self.connection = self.make_connection(filename)
    
    def make_connection(self, filename: str) -> Connection:
        # Subclasses override this "factory method"
        return Connection(filename)
    
    def print_tables(self):
        self.connection.list_tables()

class SqliteDataViewer(DataViewer):
    def make_connection(self, filename: str):
        return SqliteConnection(filename)

class DuckDBDataViewer(DataViewer):
    def make_connection(self, filename: str):
        return DuckDBConnection(filename)

This works, but requires creating a new DataViewer subclass for every connection type. Tedious!

The Python Way: Pass the Class

Python has first-class functions and classes. Just pass the class directly:

class Connection:
    def __init__(self, filename: str):
        pass
    
    def list_tables(self) -> list[str]:
        return []

class DataViewer:
    def __init__(self, filename: str, connection_type: type[Connection]):
        self.connection = connection_type(filename)
    
    def print_tables(self):
        self.connection.list_tables()

# Usage - just pass the class itself!
viewer = DataViewer("mydb.db", SqliteConnection)

No factory method, no subclasses, no boilerplate.

Alternative: Pass a Factory Function

You can also pass any callable that produces a connection:

from typing import Callable

class DataViewer:
    def __init__(self, filename: str, factory: Callable[[str], Connection]):
        self.connection = factory(filename)

# Pass the class (it's callable)
viewer1 = DataViewer("mydb.db", SqliteConnection)

# Or pass a custom function
def make_sqlite_connection(filename: str) -> Connection:
    filename = filename.strip()
    return SqliteConnection(filename)

viewer2 = DataViewer("mydb.db", make_sqlite_connection)
When to Use Factory Patterns

In Python, you rarely need the traditional Factory Method pattern. Instead:

  • Simple case: Pass the class or constructor function directly
  • Complex case: Use a configuration dictionary or registry
  • Very complex case: Consider the Builder pattern (see later section)

The factory pattern shines in statically-typed languages where you can’t easily pass classes around. Python doesn’t have that limitation.

Pattern #2: The Iterator Pattern (Built-In)

The Iterator pattern is another example of GoF thinking that Python has internalized.

The Problem: Complex Iteration Logic

Imagine you have nested data about passengers:

passengers = {
    "People": [
        {"name": "Frank", "age": 10, "weight": 20},
        {"name": "Pete", "age": 5, "weight": 15}
    ],
    "Animals": {
        "Dogs": [{"name": "Blackie", "age": 2, "weight": 15}],
        "Cats": [{"name": "Miauw", "age": 7, "weight": 3}]
    }
}

You want to calculate total weight. The naive approach mixes iteration logic with business logic:

total_weight = 0

for person in passengers["People"]:
    total_weight += person["weight"]

for animal_type in passengers["Animals"].values():
    for animal in animal_type:
        total_weight += animal["weight"]

print(f"Total weight: {total_weight}")
Total weight: 53

Can we separate the “how to iterate” from the “what to do with each item”?

The GoF Solution: Iterator Classes

Traditionally, you’d create iterator classes with __iter__ and __next__ methods. We covered this in the Iterators, Generators, and Decorators lecture.

The Python Way: Generators

Just use a generator function:

def all_passengers(passengers):
    """Iterate over all passengers regardless of type"""
    for person in passengers["People"]:
        yield person
    
    for animal_type in passengers["Animals"].values():
        for animal in animal_type:
            yield animal

# Now the iteration logic is separate
total_weight = sum(p["weight"] for p in all_passengers(passengers))
print(f"Total weight: {total_weight}")
Total weight: 53

You can create different iterators for different needs:

def dogs_only(passengers):
    """Iterate over dogs only"""
    for dog in passengers["Animals"]["Dogs"]:
        yield dog

for dog in dogs_only(passengers):
    print(f"{dog['name']} is ready!")
Blackie is ready!
Already Learned

If this feels familiar, it’s because you already studied the Iterator pattern—you just called it “generators”! Python has made the pattern so natural that you use it without thinking about the formal pattern name.

The GoF Iterator pattern requires multiple classes and boilerplate. Python’s generators give you the same power with simple functions and yield.

Pattern #3: The Composite Pattern ⭐

Finally, a pattern that’s genuinely useful in Python! The Composite pattern is one of the “big four” structural patterns.

The Core Idea

The Composite pattern lets you build tree structures where:

  • Individual objects (leaves) and
  • Containers of objects (branches)

…share the same interface.

Real-world examples:

  • File systems: Folders contain folders contain files
  • GUI hierarchies: Windows contain panels contain buttons
  • Organization charts: Departments contain teams contain employees
  • Document structures: Sections contain paragraphs contain text

The key insight: If leaves and branches implement the same methods, you can treat them uniformly. You don’t need to check “is this a container or a leaf?”

Example 1: Generic Tree Structure

Let’s build a tree that can hold numeric values:

class TreeNode:
    """A leaf node - holds a single value"""
    def __init__(self, value):
        self.value = value
    
    def __repr__(self):
        return f'TreeNode({self.value})'
    
    def get_value(self):
        return self.value
    
    def set_value(self, value):
        self.value = value
    
    def total(self):
        return self.value

class TreeValue:
    """A branch node - holds multiple children"""
    def __init__(self, **kwargs):
        self.children = kwargs
    
    def __repr__(self):
        items = ', '.join(f'{key}={child!r}' for key, child in self.children.items())
        return f'TreeValue({items})'
    
    def get_value(self):
        return {
            key: child.get_value()
            for key, child in self.children.items()
        }
    
    def set_value(self, value_dict):
        for key, value in value_dict.items():
            self.children[key].set_value(value)
    
    def total(self):
        return sum(child.total() for child in self.children.values())

Now we can build nested structures:

mydata = TreeValue(
    first_tab=TreeValue(
        width=TreeNode(20),
        length=TreeNode(10),
    ),
    second_tab=TreeValue(
        upper=TreeValue(
            width=TreeNode(20),
            length=TreeNode(5)
        ),
        lower=TreeValue(
            x=TreeNode(4),
            y=TreeNode(3)
        )
    )
)

print("Structure:", mydata)
print("\nValues:", mydata.get_value())
print("\nTotal:", mydata.total())
Structure: TreeValue(first_tab=TreeValue(width=TreeNode(20), length=TreeNode(10)), second_tab=TreeValue(upper=TreeValue(width=TreeNode(20), length=TreeNode(5)), lower=TreeValue(x=TreeNode(4), y=TreeNode(3))))

Values: {'first_tab': {'width': 20, 'length': 10}, 'second_tab': {'upper': {'width': 20, 'length': 5}, 'lower': {'x': 4, 'y': 3}}}

Total: 62

Notice how TreeValue and TreeNode both implement:

  • get_value() - returns data
  • set_value() - updates data
  • total() - computes sum

We can update nested values:

data = {
    'first_tab': {'width': 25, 'length': 15},
    'second_tab': {
        'upper': {'width': 30, 'length': 8},
        'lower': {'x': 6, 'y': 12}
    }
}

mydata.set_value(data)
print("New total:", mydata.total())
New total: 96

Example 2: TKinter Application State

Here’s a practical application: managing form state in a GUI. We’ll evolve the solution through three stages.

Stage 1: Hardcoded Variables

First attempt—hardcode each variable:

import tkinter as tk
import tkinter.ttk as ttk

class AppState:
    def __init__(self, val1, val2):
        self.evar1 = tk.StringVar(value=val1)
        self.evar2 = tk.StringVar(value=val2)
    
    def get(self):
        return {
            'var1': self.evar1.get(),
            'var2': self.evar2.get()
        }
    
    def set(self, value):
        self.evar1.set(value['var1'])
        self.evar2.set(value['var2'])

app_state = AppState("abc", "xyz")

# Create GUI elements
master = tk.Tk()
e1 = ttk.Entry(master, textvariable=app_state.evar1)
e2 = ttk.Entry(master, textvariable=app_state.evar2)
# ... etc

This works but doesn’t scale. Every new field requires code changes.

Stage 2: Dictionary-Based

Use a dictionary to make it flexible:

class AppState:
    def __init__(self, string_vars: dict[str, tk.StringVar]):
        self.string_vars = string_vars
    
    def get(self):
        return {
            key: value.get() 
            for key, value in self.string_vars.items()
        }
    
    def set(self, value_dict: dict[str, str]):
        for key, value in value_dict.items():
            self.string_vars[key].set(value)

app_state = AppState({
    'var1': tk.StringVar(value="abc"),
    'var2': tk.StringVar(value="xyz"),
    'var3': tk.StringVar(value="123"),
    'var4': tk.StringVar(value="789"),
})

# Access by key
e1 = ttk.Entry(master, textvariable=app_state.string_vars['var1'])

Better! But what if we have multiple tabs or sections?

Stage 3: Composite Pattern (The Solution)

Add __getitem__ to allow nesting:

class AppState:
    def __init__(self, string_vars: dict[str, tk.StringVar]):
        self.string_vars = string_vars
    
    def get(self):
        return {
            key: value.get() 
            for key, value in self.string_vars.items()
        }
    
    def set(self, value_dict: dict[str, str]):
        for key, value in value_dict.items():
            self.string_vars[key].set(value)
    
    def __getitem__(self, key):
        return self.string_vars[key]

# Now we can nest AppState inside AppState!
app_state = AppState({
    'tab1': AppState({
        'var1': tk.StringVar(value="abc"),
        'var2': tk.StringVar(value="xyz"),
    }),
    'tab2': AppState({
        'var3': tk.StringVar(value="123"),
        'var4': tk.StringVar(value="789"),
    }),
    'var5': tk.IntVar(value=3),
})

# Hierarchical access!
e1 = ttk.Entry(master, textvariable=app_state['tab1']['var1'])
e2 = ttk.Entry(master, textvariable=app_state['tab2']['var3'])

Now we can organize state hierarchically: tabs contain sections contain fields. The same AppState class works at every level.

def swap_tab1():
    state = app_state.get()
    tab1 = state['tab1']
    tab1['var1'], tab1['var2'] = tab1['var2'], tab1['var1']
    app_state.set(state)
The Composite Pattern’s Power

The composite pattern shines when you have:

  1. Hierarchical data (trees, nested structures)
  2. Uniform interface (leaves and branches share methods)
  3. Recursive operations (traverse/process the whole tree)

Without the pattern, you’d need type checking everywhere:

if isinstance(item, Container):
    for child in item.children:
        process(child)
elif isinstance(item, Leaf):
    process_leaf(item)

With the pattern, just:

item.process()  # Works for both!

Pattern #4: The Builder Pattern

The Builder pattern separates object construction from representation. It’s useful when:

  • Objects have many optional parameters
  • Construction is complex or has multiple steps
  • You want a fluent interface (method chaining)

The Problem: Complex Configuration

Imagine creating a plot with matplotlib. Without a builder, it’s messy:

import matplotlib.pyplot as plt

# Direct approach - lots of individual calls
fig = plt.figure(figsize=(10, 6))
ax = fig.add_subplot(111)
ax.plot([1, 2, 3], [1, 4, 9])
ax.set_xlabel('x values')
ax.set_ylabel('y values')
ax.set_title('My Plot')
ax.grid(True)
ax.legend(['Quadratic'])
plt.show()

Or with nested dictionaries:

config = {
    'figsize': (10, 6),
    'xlabel': 'x values',
    'ylabel': 'y values',
    'title': 'My Plot',
    'grid': True,
    'legend': ['Quadratic']
}
# Still need to apply these somehow...

The Builder Solution: Fluent Interface

Matplotlib actually uses a builder-like pattern:

import matplotlib.pyplot as plt

# Fluent interface - chain methods
(plt.figure(figsize=(10, 6))
    .add_subplot(111)
    .plot([1, 2, 3], [1, 4, 9])
    .set(xlabel='x values', 
         ylabel='y values',
         title='My Plot'))
plt.grid(True)
plt.legend(['Quadratic'])
plt.show()

Let’s build our own simple builder:

class PlotBuilder:
    """A fluent interface for creating plots"""
    
    def __init__(self):
        self._data = []
        self._labels = []
        self._title = None
        self._xlabel = None
        self._ylabel = None
        self._figsize = (8, 6)
    
    def add_line(self, x, y, label=None):
        """Add a line to the plot"""
        self._data.append((x, y))
        self._labels.append(label)
        return self  # Return self for chaining!
    
    def title(self, title):
        """Set plot title"""
        self._title = title
        return self
    
    def xlabel(self, label):
        """Set x-axis label"""
        self._xlabel = label
        return self
    
    def ylabel(self, label):
        """Set y-axis label"""
        self._ylabel = label
        return self
    
    def figsize(self, width, height):
        """Set figure size"""
        self._figsize = (width, height)
        return self
    
    def build(self):
        """Actually create the plot"""
        import matplotlib.pyplot as plt
        
        fig, ax = plt.subplots(figsize=self._figsize)
        
        for (x, y), label in zip(self._data, self._labels):
            ax.plot(x, y, label=label)
        
        if self._title:
            ax.set_title(self._title)
        if self._xlabel:
            ax.set_xlabel(self._xlabel)
        if self._ylabel:
            ax.set_ylabel(self._ylabel)
        if any(self._labels):
            ax.legend()
        
        return fig, ax
    
    def show(self):
        """Build and display the plot"""
        self.build()
        import matplotlib.pyplot as plt
        plt.show()

# Usage - clean and readable
plot = (
  PlotBuilder()
    .title("Quadratic and Cubic Functions")
    .xlabel("x")
    .ylabel("f(x)")
    .figsize(10, 6)
    .add_line([1, 2, 3, 4], [1, 4, 9, 16], label="x²")
    .add_line([1, 2, 3, 4], [1, 8, 27, 64], label="x³")
)

print("Plot configured successfully!")
# plot.show()  # Uncomment to display
Plot configured successfully!

The key features:

  1. Fluent interface: Each method returns self, allowing chaining
  2. Flexible order: Call methods in any order
  3. Optional parameters: Only set what you need
  4. Clear intent: Code reads like configuration
  5. Separation: Building (configuration) is separate from creation
Builder vs Constructor

Compare the builder:

plot = (
  PlotBuilder()
    .title("My Plot")
    .xlabel("x")
    .add_line(x, y)
)

To a mega-constructor:

plot = Plot(
    title="My Plot",
    xlabel="x", 
    ylabel=None,
    lines=[(x, y)],
    figsize=(8, 6),
    grid=False,
    # ... 20 more parameters
)

The builder is more readable and flexible.

Real-World Builder Examples

Python libraries that use builder patterns:

SQLAlchemy (query building):

query = (select(User)
    .where(User.age > 18)
    .order_by(User.name)
    .limit(10))

Pandas (method chaining):

df = (pd.read_csv('data.csv')
    .dropna()
    .query('age > 18')
    .groupby('city')
    .mean())

Pathlib (path construction):

path = Path.home() / 'documents' / 'project' / 'file.txt'

The builder pattern is alive and well in Python—it’s just more implicit than in languages like Java or C#.

When to Use (and Not Use) Design Patterns

Design patterns are tools, not goals.

Use patterns when:

Multiple developers need a shared vocabulary
The pattern genuinely simplifies your code
You’re working with a framework that expects certain patterns
The problem is a natural fit (e.g., tree structures → Composite)

Don’t use patterns when:

A simpler Python idiom exists (e.g., factories → pass the class)
You’re pattern-matching just to pattern-match (“we must use a pattern!”)
It makes code harder to read (over-engineering)
The pattern solves a problem you don’t have

The Pattern Trap

Patterns are like spices in cooking: a little enhances flavor, too much ruins the meal.

Bad pattern usage signals: - Your code has more abstraction layers than logic - Colleagues say “this is clever” (not “this is clear”) - You need a diagram to explain your simple task - You’re using patterns because they’re in your design patterns book

Good pattern usage signals: - The code is easier to understand than before - Adding new features requires minimal changes - The structure matches the problem domain naturally - Your colleagues say “oh, that makes sense!”

Key Takeaways

  1. Many GoF patterns are obsolete in Python because Python has first-class functions, dynamic typing, and built-in features like generators.

  2. The Composite pattern is genuinely useful when dealing with tree structures where leaves and branches should be treated uniformly.

  3. The Builder pattern provides clean APIs for complex object construction, especially with fluent interfaces.

  4. Patterns are means, not ends. Use them when they genuinely improve your code, not because they exist.

  5. Think Pythonically first. Before reaching for a design pattern, ask: “Is there a simpler Python way?”

Exercises

Exercise 1: Recognize Obsolete Patterns

For each code snippet, identify if it’s using an obsolete pattern that could be simplified:

# A
class ShapeFactory:
    def create_shape(self, type):
        if type == "circle":
            return Circle()
        elif type == "square":
            return Square()

# B
def create_shape(type):
    shapes = {"circle": Circle, "square": Square}
    return shapes[type]()

# C  
class Iterator:
    def __init__(self, data):
        self.data = data
        self.index = 0
    
    def __next__(self):
        if self.index >= len(self.data):
            raise StopIteration
        result = self.data[self.index]
        self.index += 1
        return result

# D
def iterate(data):
    for item in data:
        yield item

Which approaches (A, B, C, or D) are more Pythonic?

Exercise 2: Build a Menu Composite

Create a composite pattern for a restaurant menu system:

  • MenuItem (leaf): individual dish with name, price
  • MenuSection (composite): can contain MenuItems or other MenuSections
  • Both should implement: get_price(), describe()

Example structure:

Restaurant Menu
├── Appetizers ($25 total)
│   ├── Soup ($8)
│   └── Salad ($12)
└── Mains ($45 total)
    ├── Pasta ($18)
    └── Steak ($27)

Test your implementation:

menu = MenuSection("Restaurant Menu")
menu.add(MenuSection("Appetizers")
    .add(MenuItem("Soup", 8))
    .add(MenuItem("Salad", 12)))
menu.add(MenuSection("Mains")
    .add(MenuItem("Pasta", 18))
    .add(MenuItem("Steak", 27)))

print(menu.describe())
print(f"Total: ${menu.get_price()}")
Exercise 3: Extend the PlotBuilder

Add these features to the PlotBuilder class:

  1. grid(True/False) - enable/disable grid
  2. style(style_name) - set matplotlib style (‘ggplot’, ‘seaborn’, etc.)
  3. save(filename) - save instead of showing
  4. Support for scatter plots: .add_scatter(x, y, label)

Test with:

(PlotBuilder()
    .title("Data Visualization")
    .grid(True)
    .style('ggplot')
    .add_scatter([1, 2, 3], [2, 4, 6], label="Data A")
    .add_line([1, 2, 3], [3, 3, 3], label="Average")
    .save("plot.png"))
Exercise 4: File System Composite

Build a simple file system using the Composite pattern:

  • File (leaf): has name and size
  • Directory (composite): can contain Files or Directories
  • Both implement: get_size(), list_contents(indent=0)

Example:

root = Directory("root")
root.add(File("readme.txt", 100))

docs = Directory("documents")
docs.add(File("report.pdf", 5000))
docs.add(File("notes.txt", 200))
root.add(docs)

print(root.list_contents())
print(f"Total size: {root.get_size()} bytes")

Expected output:

root/
  readme.txt (100 bytes)
  documents/
    report.pdf (5000 bytes)
    notes.txt (200 bytes)
Total size: 5300 bytes

Additional Resources