Implementing Design Patterns in Python: A Guide to Singleton, Factory, and Observer Patterns

Implementing Design Patterns in Python: A Guide to Singleton, Factory, and Observer Patterns

October 25, 202510 min read76 viewsImplementing 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
We'll break down each pattern, show idiomatic Python implementations, and highlight practical considerations like thread-safety, memory management, debugging techniques (from print statements to IDE tools), and f-strings for clear, efficient string formatting. We'll also reinforce object-oriented programming (OOP) best practices.

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?
Patterns provide tested structures to avoid these pitfalls. They don't solve all problems, but they give robust starting points.

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
Related topics you'll see in examples:
  • 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 Logger class with name and an internal _logs list.
  • log() appends messages.
  • get_logs() returns a copy to protect internal state.
  • At module bottom, instantiate logger once — importing modules reuse the same module object, so logger acts as a singleton.
Inputs/outputs/edge cases:
  • 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 _logs may 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:

  • SingletonMeta inherits from type and overrides __call__ to control instance creation.
  • _instances stores instances per class; _lock ensures thread-safety.
  • Double-checked locking reduces lock contention.
  • Configuration uses the metaclass; calls to Configuration() always return the same instance.
Edge cases:
  • Subclassing: subclasses get their own singletons by class key.
  • Serialization/pickling: you may need to customize __getstate__ / __setstate__.
  • Testing: singletons persist across tests—reset _instances between 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:

  • Notifier is 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-strings for clear, efficient formatting in the message returns (see f-string best practices below).
Inputs/outputs:
  • 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 Abstract Factory pattern.

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:

  • Event holds weak references so subscribers can be garbage-collected.
  • subscribe() accepts functions and bound methods. Bound methods need WeakMethod.
  • 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.
Inputs/outputs:
  • Input: event.subscribe(handler); event.emit(data=1)
  • Output: handler runs with provided args
  • Edge cases: subscriber raising exceptions—handled per observer and logged via print (or better, Python logging).
Advanced: For production systems, replace 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 logging module 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.
Example f-string usage in our factory:
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:
- Use locks (threading.Lock) where shared mutable state is modified by multiple threads. - Consider 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 asyncio for non-blocking observers:
Diagram (textual):
  • 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:

  • Config uses SingletonMeta to ensure one configuration instance.
  • on_alert is 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.
Edge cases:
  • If alert_handler raises, other subscribers still run. Improve by changing print to logging.

Error handling and testing strategies

  • Use unit tests for factories and observers:
- Mock concrete implementations. - For observers, test subscribe/unsubscribe and that dead objects are removed.
  • Use pytest fixtures to reset state (e.g., clear metaclass _instances).
  • For production, replace prints with logging and add structured logs with context.

Further reading and references

  • Official Python docs:
- threading: https://docs.python.org/3/library/threading.html - weakref: https://docs.python.org/3/library/weakref.html - abc (Abstract Base Classes): https://docs.python.org/3/library/abc.html - logging: https://docs.python.org/3/library/logging.html
  • 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!

Stay Updated with Python Tips

Get weekly Python tutorials and best practices delivered to your inbox

We respect your privacy. Unsubscribe at any time.

Related Posts

Exploring Data Classes in Python: Simplifying Your Code and Enhancing Readability

Discover how Python's dataclasses can make your code cleaner, safer, and easier to maintain. This post walks intermediate Python developers through core concepts, practical examples, integrations (functools, Flask, Singleton), best practices, and common pitfalls with hands-on code and explanations.

Deploying Python Applications with Docker: A Step-by-Step Guide for Efficient and Scalable Deployments

Dive into the world of containerization and learn how to deploy your Python applications seamlessly using Docker. This comprehensive guide walks you through every step, from setting up your environment to advanced techniques, ensuring your apps run consistently across different systems. Whether you're building web scrapers with async/await or enhancing code with functional programming tools, you'll gain the skills to containerize and deploy like a pro—perfect for intermediate Python developers looking to level up their deployment game.

Implementing Python's Built-in Unit Testing Framework: Best Practices for Writing Effective Tests

Discover how to write reliable, maintainable unit tests using Python's built-in unittest framework. This guide walks through core concepts, practical examples (including dataclasses and multiprocessing), Docker-based test runs, and actionable best practices to improve test quality and developer productivity.