class PlainTextFormatter:
def format(self, title: str, value: float) -> str:
"""Format a summary as plain text"""
return f"{title}: {value}"
class MarkdownFormatter:
def format(self, title: str, value: float) -> str:
"""Format a summary as markdown"""
return f"**{title}**: {value}"
class HTMLFormatter:
def format(self, title: str, value: float) -> str:
"""Format a summary as html"""
return f"<p><strong>{title}</strong>: {value}</p>"Introduction to Design Patterns
In this session we will look at design patterns. Design patterns are named, reusable solutions to problems that come up again and again when structuring code. We’ll look at what problem each pattern solves, what the code looks like without it, and how to implement a simple version of the pattern in Python.
- Why Design Patterns Matter
- Creating Objects
- The Factory Pattern
- The Singleton Pattern
- Coordinating Behaviour
- The Strategy Pattern
- The Observer Pattern
This session will only present an overview of four common patterns, but the idea is to show you what is possible, so that you recognise these shapes when you encounter them in code (or encounter problems that require them).
Why Design Patterns Matter
As code grows, certain problems come up repeatedly. Some common problems include:
- Creating the right kind of object based on some input,
- Swapping between different ways of doing the same calculation,
- Making sure only one instance of something exists,
- Making several things happen automatically when one value changes.
Design patterns are just the solutions for these recurrent problems. They’re not Python-specific; the same patterns turn up in other languages too. Knowing the names is useful mainly because it gives you a shared vocabulary and makes it easier to identify these patterns in the future.
A pattern is a starting point, not a requirement. If a simpler approach (a plain function, a dictionary, an if statement) solves the problem clearly, use that instead. Patterns are necessary only once the simple approach starts causing repeated problems.
Creating Objects
The Factory Pattern
Imagine you need to produce a summary in several output formats (plain text, markdown, html). Each format needs slightly different logic. Without a factory, that “which format do I need” decision tends to get repeated everywhere the summary is built.
Here is the decision about which formatter to use, handled inline wherever it’s needed:
def build_summary(title, value, output_type):
"""Build a summary, deciding on the formatter inline"""
if output_type == "text":
formatter = PlainTextFormatter()
elif output_type == "markdown":
formatter = MarkdownFormatter()
elif output_type == "html":
formatter = HTMLFormatter()
else:
raise ValueError(f"Unsupported output type: {output_type}")
return formatter.format(title, value)
print(build_summary("Revenue", 125000, "markdown"))**Revenue**: 125000
This works, but that same if/elif chain gets copied into every function that needs a formatter. A factory function centralises the decision in one place:
def create_formatter(output_type: str):
"""Factory function - Return the correct formatter for a given output type"""
formatters = {
"text": PlainTextFormatter,
"markdown": MarkdownFormatter,
"html": HTMLFormatter,
}
if output_type not in formatters:
raise ValueError(f"Unsupported output type: {output_type}")
return formatters[output_type]()
formatter = create_formatter("html")
print(formatter.format("Revenue", 125000))<p><strong>Revenue</strong>: 125000</p>
- The factory is the only place that knows about
PlainTextFormatter,MarkdownFormatter,HTMLFormatter. - Calling code only ever deals with a string like
"html", not the class names. - Adding a new output type means adding one line to the
formattersdictionary, not hunting through the codebase for everyif/elif.
The Singleton Pattern
Some things should only exist once, like application configuration, a database connection, or a logger. Without enforcing that, it’s easy to accidentally create several separate copies, each with its own state.
class AppConfig:
def __init__(self):
print("Loading configuration...") # simulate an expensive load
self.settings = {"currency": "GBP", "region": "UK"}
config_a = AppConfig()
config_b = AppConfig()
print(config_a is config_b) # False - two separate objects, loaded twiceLoading configuration...
Loading configuration...
False
The configuration was loaded twice, and config_a and config_b are different objects. A singleton guarantees there is only ever one instance:
class AppConfig:
_instance = None # class-level attribute, shared by every instance
def __new__(cls):
"""Control object creation - Reuse the existing instance if there is one"""
if cls._instance is None:
print("Loading configuration...") # only ever happens once
cls._instance = super().__new__(cls)
cls._instance.settings = {"currency": "GBP", "region": "UK"}
return cls._instance
config_a = AppConfig()
config_b = AppConfig()
print(config_a is config_b) # True - same object, loaded onceLoading configuration...
True
__new__runs before__init__and decides what object gets created (or reused)._instancelives on the class itself, so it persists across every call toAppConfig().- Every part of the program is guaranteed to be working with the same object and the same state.
In Python, a module is only ever imported once and then cached, so it already behaves like a singleton. Often the simplest way to get “one shared instance” is to just define settings as variables in a config.py file and import them, rather than writing a singleton class.
Coordinating Behaviour
The Strategy Pattern
Suppose you want to flag outliers in some data, and you want to support more than one method for doing so (e.g. z-score vs interquartile range). One option is to put both methods inside a single function, branching on a string:
def flag_outliers_hardcoded(values, method):
"""Flag outliers, with both methods hardcoded inside one function"""
if method == "zscore":
mean = sum(values) / len(values)
std = (sum((v - mean) ** 2 for v in values) / len(values)) ** 0.5
return [abs((v - mean) / std) > 2 for v in values]
elif method == "iqr":
sorted_v = sorted(values)
q1 = sorted_v[len(sorted_v) // 4]
q3 = sorted_v[3 * len(sorted_v) // 4]
iqr = q3 - q1
return [v < q1 - 1.5 * iqr or v > q3 + 1.5 * iqr for v in values]
else:
raise ValueError(f"Unknown method: {method}")Every time you want a new method, you have to edit this function and risk breaking the methods already in it. The strategy pattern instead writes each method as its own function, and passes the chosen one in as an argument:
def zscore_outliers(values: list[float]) -> list[bool]:
"""Flag values more than 2 standard deviations from the mean"""
mean = sum(values) / len(values)
std = (sum((v - mean) ** 2 for v in values) / len(values)) ** 0.5
return [abs((v - mean) / std) > 2 for v in values]
def iqr_outliers(values: list[float]) -> list[bool]:
"""Flag values outside 1.5 times the interquartile range"""
sorted_v = sorted(values)
q1 = sorted_v[len(sorted_v) // 4]
q3 = sorted_v[3 * len(sorted_v) // 4]
iqr = q3 - q1
return [v < q1 - 1.5 * iqr or v > q3 + 1.5 * iqr for v in values]
def flag_outliers(values: list[float], strategy) -> list[bool]:
"""Flag outliers using whichever strategy function is supplied"""
return strategy(values)
readings = [12, 15, 14, 13, 300, 11, 14, 16, 12, -50]
print(flag_outliers(readings, strategy=zscore_outliers))
print(flag_outliers(readings, strategy=iqr_outliers))[False, False, False, False, True, False, False, False, False, False]
[False, False, False, False, True, False, False, False, False, True]
- The strategy is just a function (or any callable) passed in as a parameter, rather than a branch inside one large function.
- Swapping the method means passing a different strategy;
flag_outliersitself never changes. - Adding a new method means writing a new function, without touching the existing ones.
- This is the same idea we’ve already used when passing functions to
map()andfilter().
The Observer Pattern
Say a price changes, and several things need to happen as a result: logging the change, checking whether it crosses an alert threshold, refreshing a dashboard. Handling all of this directly inside one function means editing that function every time a new requirement appears:
def update_price(new_price):
"""Update the price and directly handle every side effect inline"""
print(f"Price updated to {new_price}")
print(f"Logging - Price changed to {new_price}") # every new requirement
if new_price > 100: # means another line
print(f"Alert - Price {new_price} exceeds threshold") # added here
update_price(120)Price updated to 120
Logging - Price changed to 120
Alert - Price 120 exceeds threshold
The observer pattern separates “something changed” from “what happens as a result”. A subject keeps a list of observers and notifies all of them, without needing to know what any of them actually do:
class PriceTracker:
def __init__(self):
self._observers = [] # functions to call whenever the price changes
def subscribe(self, observer):
"""Register a function to be called whenever the price changes"""
self._observers.append(observer)
def update_price(self, new_price):
"""Update the price and notify every subscribed observer"""
for observer in self._observers:
observer(new_price)
def log_change(new_price):
"""Observer - Record the change"""
print(f"Logging - Price changed to {new_price}")
def alert_if_high(new_price):
"""Observer - Raise an alert if the price is too high"""
if new_price > 100:
print(f"Alert - Price {new_price} exceeds threshold")
tracker = PriceTracker()
tracker.subscribe(log_change)
tracker.subscribe(alert_if_high)
tracker.update_price(120)Logging - Price changed to 120
Alert - Price 120 exceeds threshold
PriceTrackerdoesn’t know or care whatlog_changeandalert_if_highdo, only that they’re callable.- Adding a new reaction (e.g. sending an email) means writing a new function and subscribing it, not editing
PriceTracker. - This is the same underlying idea behind event handling in dashboard libraries and pub/sub messaging systems.
Anywhere one change needs to trigger several independent reactions: a new row of data arriving and needing to be logged, validated, and plotted, for instance. The observer pattern keeps those reactions as separate, addable/removable pieces rather than one growing function.