Implementing Design Patterns in Python: A Guide to Singleton, Factory, and Observer Patterns
Discover how to implement three essential design patterns in Python—**Singleton**, **Factory**, and **Observer**—with clear, practical examples and best practices. This guide walks intermediate Python developers through idiomatic implementations, threading and memory considerations, debugging tips, and how to leverage f-strings and OOP principles for clean, testable code.
Introduction
Design patterns are repeatable solutions to common software design problems. They give structure to your codebase, improve maintainability, and help teams share a common vocabulary. In this post we'll focus on three widely used patterns:
- Singleton — ensure one instance of a class
- Factory — create objects without exposing concrete classes
- Observer — notify multiple dependents about state changes
Prerequisites: intermediate Python familiarity, basic OOP, and comfort running Python 3.x code.
Why patterns matter — a quick, practical view
Imagine you are building a logging system, a widget factory, and an event bus for a microservice. Would you:
- Hardcode constructors everywhere?
- Allow multiple logger instances to confuse logs?
- Let observers keep references that prevent garbage collection?
Prerequisites and core concepts
Before implementing:
- Python 3.x installed (3.7+ recommended)
- Understanding of classes, modules, decorators, and exceptions
- Familiarity with threading and basic concurrent issues
- Familiarity with debugging tools: print(), logging, pdb, and IDE debuggers
- Debugging Techniques in Python: using print, logging, pdb, and IDE breakpoints
- Leveraging Python's f-strings: f'{var=}' and formatting
- Implementing OOP in Python: composition, encapsulation, and SOLID principles
Design Pattern 1 — Singleton
Concept
A Singleton guarantees a class has only one instance and provides a global access point.Why use with care? Singletons introduce global state, which can complicate testing and increase coupling.
Idiomatic Python implementations
1) Module-level singleton (most Pythonic) 2) Class decorator 3) Metaclass (powerful, but more complex) 4) Thread-safe variants
We'll show a safe metaclass version plus the simple module approach.
Example A — Module-level singleton (recommended)
Create a module logger_singleton.py:
# logger_singleton.py
class Logger:
def __init__(self, name="app"):
self.name = name
self._logs = []
def log(self, message):
self._logs.append(message)
def get_logs(self):
return list(self._logs)
logger = Logger() # single instance exposed from the module
Explanation line-by-line:
- Define
Loggerclass with name and an internal_logslist. log()appends messages.get_logs()returns a copy to protect internal state.- At module bottom, instantiate
loggeronce — importing modules reuse the same module object, sologgeracts as a singleton.
- Input: calls to
logger.log("...") - Output:
logger.get_logs()returns stored messages - Edge cases: multiple imports still refer to same module-level instance. No thread-safety — if multiple threads write concurrently, data races on
_logsmay occur. Use threading locks if needed.
Example B — Metaclass-based Singleton (thread-safe)
# singleton_meta.py
import threading
class SingletonMeta(type):
_instances = {}
_lock = threading.Lock() # protects _instances
def __call__(cls, args, kwargs):
# Double-checked locking pattern
if cls not in cls._instances:
with cls._lock:
if cls not in cls._instances:
instance = super().__call__(args, kwargs)
cls._instances[cls] = instance
return cls._instances[cls]
Usage
class Configuration(metaclass=SingletonMeta):
def __init__(self, setting=None):
self.setting = setting
Explanation:
SingletonMetainherits fromtypeand overrides__call__to control instance creation._instancesstores instances per class;_lockensures thread-safety.- Double-checked locking reduces lock contention.
Configurationuses the metaclass; calls toConfiguration()always return the same instance.
- Subclassing: subclasses get their own singletons by class key.
- Serialization/pickling: you may need to customize
__getstate__/__setstate__. - Testing: singletons persist across tests—reset
_instancesbetween tests or avoid singletons in tests.
Design Pattern 2 — Factory
Concept
The Factory pattern centralizes object creation and lets you return different classes through a common interface. This decouples client code from concrete implementations.When to use:
- When creation logic varies
- When you want to hide class names or plug in implementations easily
Example — Simple Factory for notifications
We will implement a NotificationFactory to create different notifiers: Email, SMS, Push.
# notifications.py
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, recipient, message):
pass
class EmailNotifier(Notifier):
def send(self, recipient, message):
return f"Email sent to {recipient}: {message}"
class SMSNotifier(Notifier):
def send(self, recipient, message):
return f"SMS sent to {recipient}: {message}"
class PushNotifier(Notifier):
def send(self, recipient, message):
return f"Push notification to {recipient}: {message}"
class NotificationFactory:
_creators = {
"email": EmailNotifier,
"sms": SMSNotifier,
"push": PushNotifier,
}
@classmethod
def create(cls, method: str) -> Notifier:
creator = cls._creators.get(method)
if not creator:
raise ValueError(f"Unknown notification method: {method}")
return creator()
Explanation:
Notifieris an abstract base class defining the interface.- Concrete notifiers implement
send. NotificationFactory.create()returns an instance based on a string key, raising an error for unknown methods.- Use
f-stringsfor clear, efficient formatting in the message returns (see f-string best practices below).
- Input:
NotificationFactory.create("email").send("me@example.com", "Hello") - Output: formatted string indicating the send action
- Edge cases: misspelled method raises
ValueError
Factory method vs abstract factory
- The example uses a simple factory. For families of related objects (e.g., UI themes producing buttons and windows), consider the
Using factories with OOP best practices
- Favor composition: Keep creation responsibilities in factories so classes have single responsibilities (SRP).
- Dependencies can be injected as constructor parameters for testability.
Design Pattern 3 — Observer
Concept
The Observer* pattern allows objects to subscribe to events and be notified when changes occur. Common in GUIs, event buses, or reactive systems.Example — Lightweight event bus with weak references
To avoid preventing observers' garbage collection, use weakref.WeakSet or weakref.ref.
# event_bus.py
import weakref
from typing import Callable, Any
class Event:
def __init__(self):
# store weakrefs to callables or objects
self._subscribers = []
def subscribe(self, callback: Callable):
# store weakref; handle functions and bound methods
try:
# bound method
ref = weakref.WeakMethod(callback)
except TypeError:
# function
ref = weakref.ref(callback)
self._subscribers.append(ref)
def unsubscribe(self, callback: Callable):
to_remove = []
for ref in self._subscribers:
obj = ref()
if obj is None or obj == callback:
to_remove.append(ref)
for ref in to_remove:
self._subscribers.remove(ref)
def emit(self,
args, *kwargs):
dead = []
for ref in list(self._subscribers):
callback = ref()
if callback is None:
dead.append(ref)
continue
try:
callback(args, kwargs)
except Exception as exc:
# basic error handling: log and continue
print(f"Observer error: {exc}")
for ref in dead:
self._subscribers.remove(ref)
Explanation:
Eventholds weak references so subscribers can be garbage-collected.subscribe()accepts functions and bound methods. Bound methods needWeakMethod.unsubscribe()removes matching references and cleans up dead refs.emit()calls each callback and catches exceptions to prevent a single failing observer from breaking others. It also removes dead refs.
- Input:
event.subscribe(handler); event.emit(data=1) - Output:
handlerruns with provided args - Edge cases: subscriber raising exceptions—handled per observer and logged via print (or better, Python logging).
print with logging and consider dispatching asynchronously (e.g., via concurrent.futures.ThreadPoolExecutor or asyncio) to avoid blocking.
Debugging, logging, and f-strings — practical tips
- Start with simple prints for quick checks: print(f"variable={variable}") — f-strings allow
f'{variable=}'in Python 3.8+ which prints "variable=...". - Use the
loggingmodule for production-grade observability. Example:
import logging
logging.basicConfig(level=logging.INFO)
logging.info("Created notifier %s", notifier_name)
- For deeper inspection, use
pdb(import pdb; pdb.set_trace()) or IDE breakpoints and step-through execution. - Use f-strings in logs carefully: logging's lazy formatting (logging.info("x=%s", x)) avoids message construction unless needed, which can be more efficient for expensive reprs. However, f-strings are excellent for quick debugging and readability; combine both approaches pragmatically.
name = "email"
notifier = NotificationFactory.create(name)
message = f"Using notifier={name!r} to send to {recipient}"
Best practices:
- Use
f'{var=}'during debugging to reduce copy-paste mistakes. - For user-facing messages or logs, prefer structured logging when possible; avoid building huge strings repeatedly.
Best practices and performance considerations
- Prefer module-level singletons for simple global resources.
- Use metaclasses only when necessary—metaclasses increase complexity.
- Use weak references in observer lists to avoid memory leaks.
- Handle exceptions in observer notifications to prevent a faulty observer from affecting others.
- For heavy workloads, consider asynchronous notifications to improve responsiveness.
- For thread-safety:
concurrent.futures or asyncio for concurrent designs.
Performance note: f-strings are generally fast and preferred for readability. For logging inside performance-critical loops, use logging's parameterized messages to avoid constructing unnecessary strings.
Common pitfalls and how to avoid them
- Global state abuse: Singletons act like globals—limit their scope and prefer dependency injection for testability.
- Hidden dependencies: Factories that reach into global state reduce transparency—keep factories pure when possible.
- Memory leaks in Observer: Strong references prevent GC—use weakrefs or explicit unsubscribe patterns.
- Thread-safety: Omitting locks on shared mutable singletons leads to subtle bugs.
- Testing: Singletons persist across tests—reset singletons or design tests to avoid shared state.
Advanced tips
- For testing singletons, expose a
_clear_instances()on metaclasses only in test builds, or inject dependencies rather than relying on singletons. - Combine patterns: A Factory can return Singleton instances when appropriate (e.g., a centralized service manager).
- Use
asynciofor non-blocking observers:
- A central Event bus (box) with arrows pointing to Subscriber A, B, C (circles).
- Each arrow labeled "emit()" and "callback".
- Implement an async Event.emit that awaits each subscriber coroutine and gathers results concurrently.
Example: Putting it together — a small app
We'll build a tiny system: a config singleton, a factory to create handlers, and an event notifying handlers.
# app.py
from singleton_meta import SingletonMeta
from notifications import NotificationFactory
from event_bus import Event
class Config(metaclass=SingletonMeta):
def __init__(self, env="dev"):
self.env = env
Create global event
on_alert = Event()
def alert_handler(recipient, message):
notifier = NotificationFactory.create("email")
result = notifier.send(recipient, message)
print(result)
on_alert.subscribe(alert_handler)
Emit an alert
on_alert.emit("ops@example.com", "High memory usage")
Explanation:
ConfigusesSingletonMetato ensure one configuration instance.on_alertis an Event instance.alert_handler()uses factory to create an EmailNotifier and sends a message.on_alert.subscribe(...)registers the handler.on_alert.emit(...)triggers notifications.
- If
alert_handlerraises, other subscribers still run. Improve by changing print to logging.
Error handling and testing strategies
- Use unit tests for factories and observers:
- Use
pytestfixtures to reset state (e.g., clear metaclass_instances). - For production, replace prints with
loggingand add structured logs with context.
Further reading and references
- Official Python docs:
- Design patterns in Python (book and patterns catalogs)
- Debugging resources: pdb docs and IDE tutorials
Conclusion
Design patterns like
Singleton, Factory, and Observer are powerful tools when applied thoughtfully. Python offers idiomatic ways to implement them—module-level singletons, factories using classes or mappings, and observer systems using weakrefs. Combine these patterns with solid OOP practices, robust debugging techniques, and efficient f-strings** for readable, maintainable code.Try the examples: copy the snippets, run them, and experiment with edge cases like multithreading and failing observers. If you liked this guide, consider exploring Abstract Factory patterns and async observer implementations next.
Call to action: Implement one of these patterns in a small project this week — and add tests and logging. Share your code or questions below!
Was this article helpful?
Your feedback helps us improve our content. Thank you!