Python Design Patterns: Strategy, Factory, and Observer
Learn how to implement the Strategy, Factory, and Observer design patterns in Python using idiomatic constructs like first-class functions and protocols.
What you'll learn
- ✓Why design patterns look different in Python than in Java or C++
- ✓Implementing the Strategy pattern with first-class functions
- ✓Building flexible Factory patterns with registry dictionaries
- ✓Creating the Observer pattern using weakrefs and callbacks
Prerequisites
None — this post is self-contained.
Design patterns solve recurring problems in software design. In statically typed languages like Java, patterns often require elaborate class hierarchies. Python’s dynamic nature and first-class functions let you express the same ideas with far less boilerplate. This article walks through three of the most useful patterns and shows how to write them idiomatically in Python.
Strategy Pattern
The Strategy pattern lets you swap an algorithm at runtime without changing the code that uses it. In Java, you define an interface and create a class for each strategy. In Python, a plain function is often enough.
The Classic Approach
from abc import ABC, abstractmethod
class SortStrategy(ABC):
@abstractmethod
def sort(self, data: list) -> list:
...
class BubbleSort(SortStrategy):
def sort(self, data: list) -> list:
arr = data[:]
for i in range(len(arr)):
for j in range(len(arr) - 1 - i):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
class QuickSort(SortStrategy):
def sort(self, data: list) -> list:
if len(data) <= 1:
return data
pivot = data[len(data) // 2]
left = [x for x in data if x < pivot]
middle = [x for x in data if x == pivot]
right = [x for x in data if x > pivot]
return self.sort(left) + middle + self.sort(right)
class Sorter:
def __init__(self, strategy: SortStrategy):
self._strategy = strategy
def sort(self, data: list) -> list:
return self._strategy.sort(data)
The Pythonic Approach
Since strategies are single-method objects, you can replace the entire hierarchy with a callable.
from typing import Callable
SortFunc = Callable[[list], list]
def bubble_sort(data: list) -> list:
arr = data[:]
for i in range(len(arr)):
for j in range(len(arr) - 1 - i):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
return arr
class Sorter:
def __init__(self, strategy: SortFunc):
self._strategy = strategy
def sort(self, data: list) -> list:
return self._strategy(data)
# Usage
sorter = Sorter(bubble_sort)
sorter.sort([3, 1, 2]) # [1, 2, 3]
# Swap strategy at runtime
sorter = Sorter(sorted) # Built-in works too
The key insight is that any callable with the right signature is a valid strategy. You do not need an interface or base class.
Factory Pattern
The Factory pattern centralizes object creation so that callers do not need to know concrete class names. Python dictionaries make this clean and extensible.
Registry-Based Factory
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 = "root"
def serialize(self, data: dict) -> str:
elements = "".join(
f" <{k}>{v}</{k}>\n" for k, v in data.items()
)
return f"<{self.root_tag}>\n{elements}</{self.root_tag}>"
class CsvSerializer:
def serialize(self, data: dict) -> str:
header = ",".join(data.keys())
values = ",".join(str(v) for v in data.values())
return f"{header}\n{values}"
# The registry
_SERIALIZERS: dict[str, type[Serializer]] = {
"json": JsonSerializer,
"xml": XmlSerializer,
"csv": CsvSerializer,
}
def create_serializer(format: str, **kwargs) -> Serializer:
cls = _SERIALIZERS.get(format)
if cls is None:
raise ValueError(
f"Unknown format '{format}'. "
f"Available: {list(_SERIALIZERS.keys())}"
)
return cls(**kwargs)
# Usage
s = create_serializer("json", indent=4)
print(s.serialize({"name": "Ada", "age": 36}))
Auto-Registration with Decorators
You can make registration automatic so new serializers just need a decorator.
def register_serializer(name: str):
def decorator(cls):
_SERIALIZERS[name] = cls
return cls
return decorator
@register_serializer("yaml")
class YamlSerializer:
def serialize(self, data: dict) -> str:
return "\n".join(f"{k}: {v}" for k, v in data.items())
Observer Pattern
The Observer pattern lets objects subscribe to events and get notified when something changes. Python’s flexibility with callbacks and weak references makes this straightforward.
import weakref
from collections import defaultdict
from typing import Callable, Any
class EventEmitter:
def __init__(self):
self._listeners: dict[str, list[Callable]] = defaultdict(list)
def on(self, event: str, callback: Callable) -> None:
self._listeners[event].append(callback)
def off(self, event: str, callback: Callable) -> None:
self._listeners[event] = [
cb for cb in self._listeners[event] if cb != callback
]
def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
for callback in self._listeners[event]:
callback(*args, **kwargs)
Using the EventEmitter
class OrderService:
def __init__(self, events: EventEmitter):
self._events = events
def place_order(self, order_id: str, total: float) -> None:
print(f"Order {order_id} placed for ${total:.2f}")
self._events.emit("order_placed", order_id, total)
# Subscribers
def send_confirmation(order_id: str, total: float) -> None:
print(f"Email sent for order {order_id}")
def update_inventory(order_id: str, total: float) -> None:
print(f"Inventory updated for order {order_id}")
events = EventEmitter()
events.on("order_placed", send_confirmation)
events.on("order_placed", update_inventory)
service = OrderService(events)
service.place_order("ORD-001", 99.99)
Typed Events with dataclasses
For larger systems, you can use dataclasses as event payloads to get type safety.
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderPlaced:
order_id: str
total: float
customer_email: str
class TypedEventEmitter:
def __init__(self):
self._listeners: dict[type, list[Callable]] = defaultdict(list)
def on(self, event_type: type, callback: Callable) -> None:
self._listeners[event_type].append(callback)
def emit(self, event: object) -> None:
for callback in self._listeners[type(event)]:
callback(event)
emitter = TypedEventEmitter()
emitter.on(OrderPlaced, lambda e: print(f"Got order {e.order_id}"))
emitter.emit(OrderPlaced("ORD-002", 49.99, "user@example.com"))
When to Use Which Pattern
| Pattern | Use When |
|---|---|
| Strategy | You need to swap algorithms or behaviors at runtime |
| Factory | Object creation logic is complex or needs to be decoupled from usage |
| Observer | Multiple components need to react to events without tight coupling |
Key Takeaways
Python’s first-class functions, duck typing, and protocols mean you rarely need the heavyweight class hierarchies that patterns require in other languages. A Strategy can be a plain function. A Factory can be a dictionary lookup. An Observer can be a list of callables. Start with the simplest approach, and reach for classes only when you need state or multiple methods on the strategy object.
Related articles
- Python Python Descriptors: The Protocol Behind Properties
Learn how Python descriptors work under the hood, powering properties, class methods, and custom attribute access with __get__, __set__, and __delete__.
- Python Python ABCs vs Protocols: Choosing the Right Abstraction
Understand the difference between Abstract Base Classes and Protocols in Python. Learn when nominal typing beats structural typing and vice versa.
- Python Dependency Injection in Python Without a Framework
Learn how to apply dependency injection in Python using constructor injection, factory functions, and simple containers -- no framework required.
- Python Advanced Python Dataclasses: Beyond the Basics
Go beyond basic dataclass usage with post-init processing, field factories, inheritance, frozen instances, and custom serialization.