Skip to main content
โšก Calmops

Design Patterns in Python: Singleton, Factory, and Observer

Design patterns are reusable solutions to common problems in software design. They represent best practices and can accelerate development by providing tested, proven approaches to solving recurring design problems. Three of the most fundamental and widely-used patterns are Singleton, Factory, and Observer.

This guide explores these patterns in depth, showing you not just how to implement them, but when and why to use them in your Python projects.

Understanding Design Patterns

Before diving into specific patterns, let’s establish why design patterns matter:

  • Reusability: Proven solutions you can apply to similar problems
  • Communication: Common vocabulary for discussing architecture
  • Maintainability: Patterns make code easier to understand and modify
  • Scalability: Patterns help structure code for growth
  • Best Practices: Encapsulate years of collective experience

Design patterns are typically categorized into three types:

  • Creational Patterns: Deal with object creation mechanisms
  • Structural Patterns: Deal with object composition and relationships
  • Behavioral Patterns: Deal with object collaboration and responsibility distribution

The three patterns we’ll explore include two creational patterns (Singleton and Factory) and one behavioral pattern (Observer).

Part 1: The Singleton Pattern

What is the Singleton Pattern?

The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. It’s one of the simplest yet most misunderstood patterns.

Problem It Solves

Imagine you’re building a logging system. You want all parts of your application to write to the same log file. Without a Singleton, you might create multiple logger instances, each with its own file handle, leading to:

  • Multiple file handles to the same file
  • Inconsistent logging state
  • Resource waste
  • Potential data corruption

The Singleton pattern solves this by ensuring only one instance exists.

Basic Implementation

class Logger:
    """Basic Singleton implementation using a class variable"""
    _instance = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.logs = []
        return cls._instance
    
    def log(self, message):
        """Add a message to the log"""
        self.logs.append(message)
    
    def get_logs(self):
        """Retrieve all logged messages"""
        return self.logs

# Usage
logger1 = Logger()
logger2 = Logger()

logger1.log("First message")
logger2.log("Second message")

print(logger1 is logger2)  # True - same instance
print(logger1.get_logs())  # ['First message', 'Second message']

Singleton Using a Decorator

A more Pythonic approach uses a decorator:

def singleton(cls):
    """Decorator to make a class a Singleton"""
    instances = {}
    
    def get_instance(*args, **kwargs):
        if cls not in instances:
            instances[cls] = cls(*args, **kwargs)
        return instances[cls]
    
    return get_instance

@singleton
class Database:
    def __init__(self):
        self.connection = None
    
    def connect(self, url):
        """Establish database connection"""
        self.connection = f"Connected to {url}"
        print(self.connection)
    
    def query(self, sql):
        """Execute a query"""
        return f"Executing: {sql}"

# Usage
db1 = Database()
db2 = Database()

print(db1 is db2)  # True
db1.connect("postgresql://localhost/mydb")
print(db2.query("SELECT * FROM users"))

Singleton Using a Metaclass

For more control, use a metaclass:

class SingletonMeta(type):
    """Metaclass for Singleton pattern"""
    _instances = {}
    
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class ConfigManager(metaclass=SingletonMeta):
    def __init__(self):
        self.config = {}
    
    def set(self, key, value):
        """Set a configuration value"""
        self.config[key] = value
    
    def get(self, key, default=None):
        """Get a configuration value"""
        return self.config.get(key, default)

# Usage
config1 = ConfigManager()
config2 = ConfigManager()

config1.set("debug", True)
print(config2.get("debug"))  # True
print(config1 is config2)    # True

Real-World Use Cases

1. Logging System

class Logger(metaclass=SingletonMeta):
    def __init__(self):
        self.logs = []
    
    def log(self, level, message):
        """Log a message with a level"""
        log_entry = f"[{level}] {message}"
        self.logs.append(log_entry)
        print(log_entry)

# Throughout your application
logger = Logger()
logger.log("INFO", "Application started")
logger.log("ERROR", "An error occurred")

2. Database Connection Pool

class DatabasePool(metaclass=SingletonMeta):
    def __init__(self):
        self.connections = []
        self.max_connections = 10
    
    def get_connection(self):
        """Get a connection from the pool"""
        if self.connections:
            return self.connections.pop()
        elif len(self.connections) < self.max_connections:
            return self._create_connection()
        else:
            raise Exception("No connections available")
    
    def _create_connection(self):
        """Create a new database connection"""
        return f"Connection-{len(self.connections)}"
    
    def return_connection(self, conn):
        """Return a connection to the pool"""
        self.connections.append(conn)

# Usage
pool = DatabasePool()
conn = pool.get_connection()
pool.return_connection(conn)

3. Application Settings

class Settings(metaclass=SingletonMeta):
    def __init__(self):
        self.settings = {
            'debug': False,
            'database_url': 'postgresql://localhost/db',
            'secret_key': 'your-secret-key'
        }
    
    def get(self, key):
        return self.settings.get(key)
    
    def set(self, key, value):
        self.settings[key] = value

# Usage
settings = Settings()
print(settings.get('debug'))

Advantages and Disadvantages

Advantages:

  • Ensures single instance across application
  • Lazy initialization possible
  • Global access point
  • Thread-safe (with proper implementation)

Disadvantages:

  • Can hide dependencies
  • Makes testing harder (global state)
  • Can be overused
  • May indicate design issues

Best Practices

  1. Use sparingly: Singletons can hide dependencies and make code harder to test
  2. Consider dependency injection: Often a better alternative
  3. Make thread-safe: Use metaclass or decorator approach
  4. Document clearly: Make it obvious why a Singleton is needed
  5. Provide a way to reset: Useful for testing

Part 2: The Factory Pattern

What is the Factory Pattern?

The Factory pattern provides an interface for creating objects without specifying their exact classes. Instead of directly instantiating classes, you use a factory method that returns the appropriate object.

Problem It Solves

Imagine you’re building a payment processing system. You need to support multiple payment methods: credit card, PayPal, cryptocurrency. Without a factory, your code might look like:

# โŒ Without Factory Pattern
if payment_method == 'credit_card':
    processor = CreditCardProcessor()
elif payment_method == 'paypal':
    processor = PayPalProcessor()
elif payment_method == 'crypto':
    processor = CryptoProcessor()
else:
    raise ValueError("Unknown payment method")

This approach has problems:

  • Tight coupling to concrete classes
  • Hard to add new payment methods
  • Logic scattered throughout the code
  • Violates the Open/Closed Principle

The Factory pattern centralizes object creation.

Simple Factory Implementation

class PaymentProcessor:
    """Base class for payment processors"""
    def process(self, amount):
        raise NotImplementedError

class CreditCardProcessor(PaymentProcessor):
    def process(self, amount):
        return f"Processing ${amount} via Credit Card"

class PayPalProcessor(PaymentProcessor):
    def process(self, amount):
        return f"Processing ${amount} via PayPal"

class CryptoProcessor(PaymentProcessor):
    def process(self, amount):
        return f"Processing ${amount} via Cryptocurrency"

class PaymentFactory:
    """Factory for creating payment processors"""
    _processors = {
        'credit_card': CreditCardProcessor,
        'paypal': PayPalProcessor,
        'crypto': CryptoProcessor
    }
    
    @classmethod
    def create(cls, payment_method):
        """Create a payment processor"""
        processor_class = cls._processors.get(payment_method)
        if processor_class is None:
            raise ValueError(f"Unknown payment method: {payment_method}")
        return processor_class()

# Usage
factory = PaymentFactory()
processor = factory.create('credit_card')
print(processor.process(100))  # Processing $100 via Credit Card

Factory Method Pattern

The Factory Method pattern defines an interface for creating objects, but lets subclasses decide which class to instantiate:

from abc import ABC, abstractmethod

class Document(ABC):
    """Abstract base class for documents"""
    @abstractmethod
    def open(self):
        pass
    
    @abstractmethod
    def save(self):
        pass

class PDFDocument(Document):
    def open(self):
        return "Opening PDF document"
    
    def save(self):
        return "Saving PDF document"

class WordDocument(Document):
    def open(self):
        return "Opening Word document"
    
    def save(self):
        return "Saving Word document"

class DocumentCreator(ABC):
    """Abstract factory for creating documents"""
    @abstractmethod
    def create_document(self):
        pass
    
    def new_document(self):
        """Template method"""
        doc = self.create_document()
        print(doc.open())
        return doc

class PDFCreator(DocumentCreator):
    def create_document(self):
        return PDFDocument()

class WordCreator(DocumentCreator):
    def create_document(self):
        return WordDocument()

# Usage
pdf_creator = PDFCreator()
pdf_doc = pdf_creator.new_document()  # Opening PDF document
print(pdf_doc.save())  # Saving PDF document

Abstract Factory Pattern

The Abstract Factory pattern provides an interface for creating families of related objects:

from abc import ABC, abstractmethod

class Button(ABC):
    @abstractmethod
    def render(self):
        pass

class Input(ABC):
    @abstractmethod
    def render(self):
        pass

# Windows UI Components
class WindowsButton(Button):
    def render(self):
        return "Rendering Windows Button"

class WindowsInput(Input):
    def render(self):
        return "Rendering Windows Input"

# Mac UI Components
class MacButton(Button):
    def render(self):
        return "Rendering Mac Button"

class MacInput(Input):
    def render(self):
        return "Rendering Mac Input"

class UIFactory(ABC):
    """Abstract factory for UI components"""
    @abstractmethod
    def create_button(self):
        pass
    
    @abstractmethod
    def create_input(self):
        pass

class WindowsUIFactory(UIFactory):
    def create_button(self):
        return WindowsButton()
    
    def create_input(self):
        return WindowsInput()

class MacUIFactory(UIFactory):
    def create_button(self):
        return MacButton()
    
    def create_input(self):
        return MacInput()

def create_ui(os_type):
    """Factory function to create appropriate UI factory"""
    if os_type == 'windows':
        return WindowsUIFactory()
    elif os_type == 'mac':
        return MacUIFactory()
    else:
        raise ValueError(f"Unknown OS: {os_type}")

# Usage
factory = create_ui('windows')
button = factory.create_button()
input_field = factory.create_input()

print(button.render())      # Rendering Windows Button
print(input_field.render()) # Rendering Windows Input

Real-World Use Cases

1. Database Connection Factory

class DatabaseConnection(ABC):
    @abstractmethod
    def connect(self):
        pass

class PostgreSQLConnection(DatabaseConnection):
    def connect(self):
        return "Connected to PostgreSQL"

class MySQLConnection(DatabaseConnection):
    def connect(self):
        return "Connected to MySQL"

class DatabaseFactory:
    @staticmethod
    def create_connection(db_type):
        if db_type == 'postgresql':
            return PostgreSQLConnection()
        elif db_type == 'mysql':
            return MySQLConnection()
        else:
            raise ValueError(f"Unknown database type: {db_type}")

# Usage
db = DatabaseFactory.create_connection('postgresql')
print(db.connect())

2. Logger Factory

class Logger(ABC):
    @abstractmethod
    def log(self, message):
        pass

class FileLogger(Logger):
    def log(self, message):
        return f"Logging to file: {message}"

class ConsoleLogger(Logger):
    def log(self, message):
        return f"Logging to console: {message}"

class LoggerFactory:
    @staticmethod
    def create_logger(logger_type):
        if logger_type == 'file':
            return FileLogger()
        elif logger_type == 'console':
            return ConsoleLogger()
        else:
            raise ValueError(f"Unknown logger type: {logger_type}")

# Usage
logger = LoggerFactory.create_logger('console')
print(logger.log("Application started"))

3. Shape Factory

class Shape(ABC):
    @abstractmethod
    def draw(self):
        pass

class Circle(Shape):
    def draw(self):
        return "Drawing Circle"

class Rectangle(Shape):
    def draw(self):
        return "Drawing Rectangle"

class Triangle(Shape):
    def draw(self):
        return "Drawing Triangle"

class ShapeFactory:
    @staticmethod
    def create_shape(shape_type):
        shapes = {
            'circle': Circle,
            'rectangle': Rectangle,
            'triangle': Triangle
        }
        shape_class = shapes.get(shape_type)
        if shape_class is None:
            raise ValueError(f"Unknown shape: {shape_type}")
        return shape_class()

# Usage
for shape_type in ['circle', 'rectangle', 'triangle']:
    shape = ShapeFactory.create_shape(shape_type)
    print(shape.draw())

Advantages and Disadvantages

Advantages:

  • Decouples object creation from usage
  • Easy to add new types
  • Centralizes creation logic
  • Follows Open/Closed Principle
  • Makes code more maintainable

Disadvantages:

  • Can add unnecessary complexity for simple cases
  • May create more classes than needed
  • Requires careful design

Best Practices

  1. Use when you have multiple related types: Don’t use for single types
  2. Keep factories simple: Don’t add unnecessary logic
  3. Use type hints: Make it clear what types are returned
  4. Consider using a registry: For dynamic type registration
  5. Document supported types: Make it clear what can be created

Part 3: The Observer Pattern

What is the Observer Pattern?

The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified automatically. It’s also known as the Publish-Subscribe pattern.

Problem It Solves

Imagine you’re building a stock trading application. When a stock price changes, multiple components need to be notified:

  • A price display needs to update
  • A trading algorithm needs to check conditions
  • A notification system needs to alert users
  • A logging system needs to record the change

Without the Observer pattern, the stock object would need to know about all these components and call them directly, creating tight coupling.

The Observer pattern decouples the stock from its observers.

Basic Implementation

from abc import ABC, abstractmethod

class Observer(ABC):
    """Abstract observer class"""
    @abstractmethod
    def update(self, subject):
        pass

class Subject:
    """Subject that notifies observers"""
    def __init__(self):
        self._observers = []
    
    def attach(self, observer):
        """Attach an observer"""
        if observer not in self._observers:
            self._observers.append(observer)
    
    def detach(self, observer):
        """Detach an observer"""
        if observer in self._observers:
            self._observers.remove(observer)
    
    def notify(self):
        """Notify all observers"""
        for observer in self._observers:
            observer.update(self)

class Stock(Subject):
    """Concrete subject"""
    def __init__(self, symbol, price):
        super().__init__()
        self._symbol = symbol
        self._price = price
    
    @property
    def price(self):
        return self._price
    
    @price.setter
    def price(self, value):
        self._price = value
        self.notify()  # Notify observers when price changes
    
    @property
    def symbol(self):
        return self._symbol

class PriceDisplay(Observer):
    """Concrete observer - displays price"""
    def update(self, subject):
        print(f"Price Display: {subject.symbol} is now ${subject.price}")

class TradingAlgorithm(Observer):
    """Concrete observer - executes trades"""
    def update(self, subject):
        if subject.price < 100:
            print(f"Trading Algorithm: BUY {subject.symbol} at ${subject.price}")
        elif subject.price > 200:
            print(f"Trading Algorithm: SELL {subject.symbol} at ${subject.price}")

class NotificationService(Observer):
    """Concrete observer - sends notifications"""
    def update(self, subject):
        print(f"Notification: Alert! {subject.symbol} price changed to ${subject.price}")

# Usage
stock = Stock("AAPL", 150)

display = PriceDisplay()
algorithm = TradingAlgorithm()
notification = NotificationService()

stock.attach(display)
stock.attach(algorithm)
stock.attach(notification)

stock.price = 95  # Triggers all observers
# Output:
# Price Display: AAPL is now $95
# Trading Algorithm: BUY AAPL at $95
# Notification: Alert! AAPL price changed to $95

stock.price = 210  # Triggers all observers again
# Output:
# Price Display: AAPL is now $210
# Trading Algorithm: SELL AAPL at $210
# Notification: Alert! AAPL price changed to $210

Observer Using Callbacks

A simpler approach using callbacks:

class EventEmitter:
    """Simple event emitter using callbacks"""
    def __init__(self):
        self._callbacks = {}
    
    def on(self, event, callback):
        """Register a callback for an event"""
        if event not in self._callbacks:
            self._callbacks[event] = []
        self._callbacks[event].append(callback)
    
    def off(self, event, callback):
        """Unregister a callback"""
        if event in self._callbacks:
            self._callbacks[event].remove(callback)
    
    def emit(self, event, *args, **kwargs):
        """Emit an event"""
        if event in self._callbacks:
            for callback in self._callbacks[event]:
                callback(*args, **kwargs)

class Button:
    def __init__(self):
        self.emitter = EventEmitter()
    
    def click(self):
        """Simulate button click"""
        self.emitter.emit('click')
    
    def on_click(self, callback):
        """Register click handler"""
        self.emitter.on('click', callback)

# Usage
button = Button()

def on_button_click():
    print("Button was clicked!")

def log_click():
    print("Logging click event...")

button.on_click(on_button_click)
button.on_click(log_click)

button.click()
# Output:
# Button was clicked!
# Logging click event...

Observer Using Properties and Decorators

class Observable:
    """Base class for observable objects"""
    def __init__(self):
        self._observers = {}
    
    def observe(self, property_name, callback):
        """Observe changes to a property"""
        if property_name not in self._observers:
            self._observers[property_name] = []
        self._observers[property_name].append(callback)
    
    def _notify_observers(self, property_name, value):
        """Notify observers of property change"""
        if property_name in self._observers:
            for callback in self._observers[property_name]:
                callback(value)

class User(Observable):
    def __init__(self, name):
        super().__init__()
        self._name = name
        self._email = None
    
    @property
    def name(self):
        return self._name
    
    @property
    def email(self):
        return self._email
    
    @email.setter
    def email(self, value):
        self._email = value
        self._notify_observers('email', value)

# Usage
user = User("Alice")

def on_email_changed(new_email):
    print(f"Email changed to: {new_email}")

def log_email_change(new_email):
    print(f"Logging: Email updated to {new_email}")

user.observe('email', on_email_changed)
user.observe('email', log_email_change)

user.email = "[email protected]"
# Output:
# Email changed to: [email protected]
# Logging: Email updated to [email protected]

Real-World Use Cases

1. Model-View Pattern

class Model(Subject):
    """Data model that notifies views of changes"""
    def __init__(self):
        super().__init__()
        self._data = {}
    
    def set_data(self, key, value):
        self._data[key] = value
        self.notify()
    
    def get_data(self, key):
        return self._data.get(key)

class View(Observer):
    """View that updates when model changes"""
    def __init__(self, name):
        self.name = name
    
    def update(self, subject):
        print(f"{self.name} View: Model updated - {subject._data}")

# Usage
model = Model()
view1 = View("View1")
view2 = View("View2")

model.attach(view1)
model.attach(view2)

model.set_data("user", "Alice")
# Output:
# View1 View: Model updated - {'user': 'Alice'}
# View2 View: Model updated - {'user': 'Alice'}

2. Event System

class EventSystem:
    """Global event system"""
    _instance = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._listeners = {}
        return cls._instance
    
    def subscribe(self, event_type, listener):
        """Subscribe to an event"""
        if event_type not in self._listeners:
            self._listeners[event_type] = []
        self._listeners[event_type].append(listener)
    
    def unsubscribe(self, event_type, listener):
        """Unsubscribe from an event"""
        if event_type in self._listeners:
            self._listeners[event_type].remove(listener)
    
    def publish(self, event_type, data):
        """Publish an event"""
        if event_type in self._listeners:
            for listener in self._listeners[event_type]:
                listener(data)

# Usage
events = EventSystem()

def on_user_login(data):
    print(f"User logged in: {data['username']}")

def log_login(data):
    print(f"Logging login: {data['username']}")

events.subscribe('user_login', on_user_login)
events.subscribe('user_login', log_login)

events.publish('user_login', {'username': 'alice'})
# Output:
# User logged in: alice
# Logging login: alice

3. File System Watcher

class FileSystemWatcher(Subject):
    """Watches for file system changes"""
    def __init__(self, path):
        super().__init__()
        self.path = path
    
    def file_created(self, filename):
        print(f"File created: {filename}")
        self.notify()
    
    def file_deleted(self, filename):
        print(f"File deleted: {filename}")
        self.notify()

class BackupService(Observer):
    """Backs up files when they change"""
    def update(self, subject):
        print(f"BackupService: Backing up {subject.path}")

class IndexService(Observer):
    """Indexes files when they change"""
    def update(self, subject):
        print(f"IndexService: Indexing {subject.path}")

# Usage
watcher = FileSystemWatcher("/home/user/documents")
backup = BackupService()
index = IndexService()

watcher.attach(backup)
watcher.attach(index)

watcher.file_created("document.txt")
# Output:
# File created: document.txt
# BackupService: Backing up /home/user/documents
# IndexService: Indexing /home/user/documents

Advantages and Disadvantages

Advantages:

  • Loose coupling between subject and observers
  • Dynamic relationships at runtime
  • Supports broadcast communication
  • Follows Open/Closed Principle
  • Easy to add new observers

Disadvantages:

  • Observers are notified in random order
  • Memory leaks if observers aren’t unsubscribed
  • Can be hard to debug
  • Performance impact with many observers

Best Practices

  1. Always unsubscribe: Prevent memory leaks
  2. Use weak references: For automatic cleanup
  3. Keep observers simple: Don’t do heavy work in observers
  4. Document events: Make it clear what events are available
  5. Consider async notifications: For long-running operations
  6. Use type hints: Make observer signatures clear

Comparing the Three Patterns

Pattern Purpose When to Use Complexity
Singleton Ensure single instance Global state, resource pools Low
Factory Create objects Multiple related types Medium
Observer Notify multiple objects Event handling, MVC Medium

Conclusion

These three design patterns solve fundamental problems in software design:

Singleton ensures you have exactly one instance of a class. Use it for shared resources like loggers, configuration managers, and connection pools. However, use it sparinglyโ€”it can hide dependencies and make testing harder.

Factory centralizes object creation and decouples creation from usage. Use it when you have multiple related types or when creation logic is complex. It makes adding new types easier and follows the Open/Closed Principle.

Observer decouples objects that need to communicate. Use it for event handling, MVC architectures, and any situation where multiple objects need to react to state changes. It enables loose coupling and dynamic relationships.

The key to using design patterns effectively is recognizing when a problem matches a pattern’s solution. Don’t force patterns where they’re not neededโ€”simplicity is often better than clever architecture. Start with the simplest solution, and introduce patterns when you encounter the problems they solve.

Remember: design patterns are tools, not rules. Use them to make your code clearer, more maintainable, and more flexible. When used appropriately, they’ll significantly improve your software design.

Comments