Lesson 31 β’ Advanced
Design Patterns in Python (Singleton, Factory, Strategy)
Master the essential design patterns used in professional Python development. Learn Singleton, Factory, and Strategy patterns with real-world examples and best practices.
1. What Are Design Patterns (Really)?
Design patterns are reusable solutions to common design problems in software.
They're not code you copy 1:1. They're mental templates you adapt:
| Problem You Have | Pattern to Use | Real Example |
|---|---|---|
| "I need only one of this thing globally" | Singleton | Database connection, Logger |
| "I need a flexible way to create objects" | Factory | Payment processors, Notification senders |
| "I need to swap behaviour/algorithm easily" | Strategy | Discount calculators, Sorting algorithms |
In Python, we also care about:
- Not over-engineering (patterns are tools, not religion)
- Using Python features (modules, first-class functions, dataclasses, typing)
- Keeping code readable and testable
In this lesson we'll cover:
- Singleton β one global instance
- Factory β flexible object creation
- Strategy β swappable algorithms/behaviours
Part 1: Singleton Pattern
2. Singleton Pattern β Intent & When to Use
Intent: Ensure only one instance of a class exists, and provide a global access point to it.
Typical use cases:
- Configuration manager
- Database connection pool
- Logging manager
- Global cache
sys.modules). So we don't always need a "classic" OO Singleton β a simple module often works!3. The Simplest Python Singleton: Just Use a Module
Instead of creating a complex class-based singleton, you can just create a module:
Module as Singleton
The simplest Pythonic singleton pattern
# config.py
SETTINGS = {
"debug": True,
"db_url": "postgres://localhost",
}
def is_debug():
return SETTINGS["debug"]
# Then everywhere:
# anywhere.py
# import config
# print(config.SETTINGS["db_url"])
print(SETTINGS)
print(is_debug())configis imported once- Python caches modules in
sys.modules - Every
import configgets the same object
π For many apps, this is the most Pythonic "singleton".
Only use heavy OO-singleton patterns if you really need class-based behaviour.
4. Classic OO Singleton With __new__
Let's look at the "canonical" pattern:
Classic Singleton with __new__
The canonical OO singleton pattern
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
# create the one and only instance
cls._instance = super().__new__(cls)
return cls._instance
class AppConfig(Singleton):
def __init__(self, debug=False):
# __init__ may be called multiple times, be careful
if not hasattr(self, "_initialised"):
self.debug = debug
self._initialised = True
config1 = AppConfig(de
...Key points:
- We override
__new__(object creation) not__init__(initialisation). - We store the one instance in
cls._instance. - We guard
__init__with a flag (_initialised) to avoid re-running logic.
Pros:
- Looks like a normal class.
- Supports inheritance.
Cons:
- Can be overkill for many cases.
- Harder to test (global state).
5. Singleton via Decorator (Reusable & Clean)
We can build a @singleton decorator that turns any class into a singleton:
Singleton Decorator
A reusable decorator to make any class a singleton
def singleton(cls):
instances = {}
def get_instance(*args, **kwargs):
if cls not in instances:
instances[cls] = cls(*args, **kwargs)
return instances[cls]
return get_instance
@singleton
class Logger:
def __init__(self):
self.logs = []
def log(self, msg):
self.logs.append(msg)
logger1 = Logger()
logger2 = Logger()
logger1.log("Hello")
logger2.log("World")
print(logger1 is logger2) # True
print(logger1.logs) # ['Hello',
...What happened?
@singletonreplaces the class with a function (get_instance)- Calling
Logger()actually callsget_instance() - The real instance is cached in
instances[cls]
Pros:
- Super simple to apply.
- Easy to reuse on different classes.
Cons:
- You lose the "type" a bit β
Loggeris now actually a function returning a Logger instance (can confuse IDEs / type checkers). - Some tools (like MyPy) might need extra hints.
6. The Borg Pattern (Shared State Singleton)
Alternative: many instances, shared state.
Borg Pattern
Shared state across multiple instances
class Borg:
_shared_state = {}
def __init__(self):
self.__dict__ = self._shared_state
class Settings(Borg):
def __init__(self):
super().__init__()
if not hasattr(self, "initialised"):
self.debug = True
self.initialised = True
s1 = Settings()
s2 = Settings()
s1.debug = False
print(s2.debug) # False
print(s1 is s2) # False (different instances)All instances share the same __dict__.
Changing an attribute in one changes it for all.
When might this be useful?
- When you want instances that act independent for type/identity, but share config or state.
- Some advanced patterns where identity and shared state are separate concerns.
7. Singleton in Real Projects β When & When NOT to Use
β Good scenarios:
- Application-wide config (with care)
- Database connection pool / engine (like SQLAlchemy's engine)
- Global metrics collector
- Logging facility
β Bad scenarios:
- To "hack around" passing objects properly
- To avoid dependency injection
- To hide poor architecture or circular dependencies
- For everything β overuse = messy, untestable code
Example of something better than a singleton: dependency injection:
class Service:
def __init__(self, config):
self.config = config
service = Service(config=AppConfig(debug=True))
# Easier to test:
test_service = Service(config=FakeConfig())Part 2: Factory Patterns
Factory patterns control how objects are created:
- You want to be able to easily swap implementations
- You don't want
if type == "x"everywhere in the code - You want code that is open for extension, closed for modification
We'll cover:
- Simple Factory (function-based)
- Factory Method (OO inheritance-based)
- Abstract Factory (families of objects)
8. Simple Factory (Function-Based)
This is the most Pythonic and often best starting point.
Example: Notification Sender Factory
We want to create different notification senders: email, SMS, push.
Simple Factory
Function-based factory for creating notification senders
class EmailSender:
def send(self, to, message):
print(f"[EMAIL to {to}] {message}")
class SMSSender:
def send(self, to, message):
print(f"[SMS to {to}] {message}")
class PushSender:
def send(self, to, message):
print(f"[PUSH to {to}] {message}")
def create_notifier(channel: str):
channel = channel.lower()
if channel == "email":
return EmailSender()
elif channel == "sms":
return SMSSender()
elif channel == "push":
retu
...What changed?
- Your app code only knows about
create_notifier("email") - All the "which class?" logic is inside the factory function
If you add WhatsApp later, only one place changes.
9. Improving the Simple Factory with a Registry
Instead of if/elif, we can use a mapping (much cleaner for many types).
Factory with Registry
Use a dictionary registry for cleaner factory pattern
class EmailSender:
def send(self, to, message):
print(f"[EMAIL to {to}] {message}")
class SMSSender:
def send(self, to, message):
print(f"[SMS to {to}] {message}")
class PushSender:
def send(self, to, message):
print(f"[PUSH to {to}] {message}")
NOTIFIER_REGISTRY = {
"email": EmailSender,
"sms": SMSSender,
"push": PushSender,
}
def create_notifier(channel: str):
try:
cls = NOTIFIER_REGISTRY[channel.lower()]
except KeyError:
...Benefits:
- Easy to extend: just add to
NOTIFIER_REGISTRY - Great for config-driven systems: channel can come from JSON/env
This "simple factory + registry" pattern is used everywhere in real-world Python code (ML frameworks, plugin systems, etc.).
10. Factory Method Pattern (OO Style)
Sometimes you don't want a separate function, you want subclasses to decide what they create.
Definition:
Factory Method = a method in a parent class that is overridden in subclasses to decide what object to create.
Factory Method Pattern
Subclasses decide which objects to create
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def create_client(self):
"""Factory method: subclasses return appropriate client."""
pass
def process_payment(self, amount: float):
client = self.create_client()
client.charge(amount)
class StripeClient:
def charge(self, amount):
print(f"[Stripe] Charging Β£{amount:.2f}")
class PayPalClient:
def charge(self, amount):
print(f"[PayPal] Charging Β£{a
...Here:
process_payment()is defined once in the base classcreate_client()is the factory method- Each subclass defines what concrete client it wants
This pattern is great when:
- You have a common workflow (template), but some steps vary
- You want subclasses to control those steps via factory methods
11. Abstract Factory β Creating "Families" of Related Objects
Problem:
You're building a cross-platform GUI toolkit:
- On Windows: WindowsButton, WindowsCheckbox
- On macOS: MacButton, MacCheckbox
You want to:
- Ensure you never accidentally mix WindowsButton with MacCheckbox
- Make it easy to "switch theme" or "switch platform" by changing one factory
Abstract Factory returns a family of related objects.
Abstract Factory
Create families of related objects
from abc import ABC, abstractmethod
# Product interfaces
class Button(ABC):
@abstractmethod
def render(self): ...
class Checkbox(ABC):
@abstractmethod
def render(self): ...
# Concrete products
class LightButton(Button):
def render(self):
print("[Light Button]")
class DarkButton(Button):
def render(self):
print("[Dark Button]")
class LightCheckbox(Checkbox):
def render(self):
print("[Light Checkbox]")
class DarkCheckbox(Checkbox):
def
...Key benefits:
- You switch entire families by swapping the factory.
- You never accidentally pair incompatible components.
This is similar to:
- Theme systems
- Cross-platform widgets
- Database driver families (e.g. Postgres/MySQL adapters)
Part 3: Strategy Pattern
The Strategy pattern allows you to change how something works without changing the code that uses it.
In simple terms:
Strategy = a set of interchangeable algorithms you can swap at runtime.
Python makes this pattern extremely powerful because functions are first-class objects.
12. Why Strategy Pattern Exists
Without Strategy, developers often write:
if method == "percentage":
total = price * 0.9
elif method == "fixed":
total = price - 5
elif method == "bogo":
total = handle_bogo(price)Problems:
- Hard to add new rules (must edit this function)
- Conditionals spread everywhere
- Testing each rule becomes harder
- Breaking OpenβClosed Principle (OCP)
Strategy solves all this.
13. Strategy Pattern β Functional (Most Pythonic)
The simplest and BEST way in Python is using functions as strategies.
Functional Strategy Pattern
The most Pythonic way to implement strategies
# Step 1: Define behaviors
def discount_percentage(price):
return price * 0.9
def discount_fixed(price):
return price - 5
def discount_bogo(price):
return price / 2
# Step 2: Strategy dictionary
DISCOUNT_STRATEGIES = {
"percentage": discount_percentage,
"fixed": discount_fixed,
"bogo": discount_bogo
}
# Step 3: Apply strategy
def apply_discount(price, strategy_name):
strategy = DISCOUNT_STRATEGIES[strategy_name]
return strategy(price)
print(apply_discount(100
...Benefits:
- β easiest
- β most Pythonic
- β extremely fast
- β ideal for data pipelines, ML preprocessing, game logic, pricing engines
14. Strategy Using Classes (Classic OOP Approach)
Sometimes you need stateful strategies or polymorphism.
Class-Based Strategy
OOP approach for stateful strategies
from abc import ABC, abstractmethod
# Step 1: Strategy interface
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, price):
pass
# Step 2: Implement concrete strategies
class PercentageDiscount(DiscountStrategy):
def apply(self, price):
return price * 0.9
class FixedDiscount(DiscountStrategy):
def apply(self, price):
return price - 5
# Step 3: Use strategy
def calculate_total(price, strategy: DiscountStrategy):
return strategy.apply(price
...Why use this?
- When strategies need state
- When strategies become complex
- When building enterprise-level frameworks
15. Strategy in Real Systems
βΆ 1. Machine Learning Preprocessing Pipelines
strategies = {
"normalize": lambda x: (x - x.mean()) / x.std(),
"standardize": lambda x: x / 255,
"none": lambda x: x,
}βΆ 2. Payment Processing Logic
Choose pricing or fraud-checking algorithms at runtime.
βΆ 3. Game Development
Enemy movement strategies:
- aggressive
- defensive
- random
- follow-player
βΆ 4. AI Agents
- Reward calculation strategies
- Exploration strategies (epsilon-greedy, Boltzmann, UCB)
βΆ 5. Web Framework Routing
Different caching strategies:
- LRU
- LFU
- write-through
- write-back
βΆ 6. E-commerce Pricing
- Black Friday discount mode
- Bulk discounts
- VIP customer strategies
π Conclusion
You now fully understand all 3 major design patterns:
β Singleton
Control instance creation, global state, config, caching.
β Factory (Simple, Method, Abstract)
- Control which objects are created
- Promote extensibility
- Reduce conditionals
- Enable plugin architectures
β Strategy
- Control how objects behave
- Swap algorithms at runtime
- Enable configurability and clean architecture
These patterns together give you:
- π₯ scalable systems
- π₯ cleaner abstraction
- π₯ professional architecture
- π₯ maintainable large codebases
- π₯ flexible logic for real projects
π Quick Reference β Design Patterns
| Pattern | Use when |
|---|---|
| Singleton | Only one instance needed (config, DB pool) |
| Factory | Create objects without exposing class details |
| Observer | Event-driven: notify multiple subscribers |
| Strategy | Swap algorithms at runtime |
| Decorator | Add behaviour without changing the class |
π Great work! You've completed this lesson.
You can now recognise and apply the most important design patterns β the vocabulary every senior engineer uses when discussing architecture.
Up next: Module Architecture β structure large Python codebases with clear boundaries and imports.
Sign up for free to track which lessons you've completed and get learning reminders.