DEV Community

Cover image for 6 Essential Python Design Patterns: Practical Solutions for Better Code
Aarav Joshi
Aarav Joshi

Posted on

6 Essential Python Design Patterns: Practical Solutions for Better Code

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Python design patterns represent practical solutions to recurring software design problems. I've written and used several of these patterns throughout my career, finding them tremendously valuable for creating well-structured code. Let me share what I've learned about six essential patterns that can transform how you approach Python development.

The Singleton Pattern

The Singleton pattern ensures a class has only one instance while providing global access to it. This pattern is particularly useful for resources that should be shared across your application.

I often use Singletons for database connections, configuration managers, and logging systems. The pattern prevents unnecessary resource duplication and ensures consistent access throughout the application.

class DatabaseConnection:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.connect()
        return cls._instance

    def connect(self):
        self.connection = "Connected to database"
        print("New database connection established")

    def query(self, sql):
        return f"Executing '{sql}' on {self.connection}"

# Usage example
conn1 = DatabaseConnection()
conn2 = DatabaseConnection()  # No new connection created

print(conn1 is conn2)  # True
print(conn1.query("SELECT * FROM users"))
Enter fullscreen mode Exit fullscreen mode

A more modern approach uses a decorator to create Singletons:

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 ConfigManager:
    def __init__(self):
        self.settings = {}
        self.load_config()

    def load_config(self):
        # Simulate loading from file
        self.settings = {
            'debug': True,
            'api_key': 'secret123',
            'max_connections': 100
        }

    def get(self, key, default=None):
        return self.settings.get(key, default)
Enter fullscreen mode Exit fullscreen mode

The Factory Method Pattern

I've found the Factory Method pattern incredibly useful when working with complex object creation logic. This pattern defines an interface for creating objects but lets subclasses decide which classes to instantiate.

The Factory Method helps maintain the Single Responsibility Principle by separating object creation from business logic.

from abc import ABC, abstractmethod

# Product interface
class Document(ABC):
    @abstractmethod
    def create(self):
        pass

# Concrete products
class PDFDocument(Document):
    def create(self):
        return "PDF document created"

class WordDocument(Document):
    def create(self):
        return "Word document created"

class SpreadsheetDocument(Document):
    def create(self):
        return "Spreadsheet document created"

# Creator interface
class DocumentFactory(ABC):
    @abstractmethod
    def create_document(self):
        pass

    def open_document(self):
        # Call factory method to create a Document object
        document = self.create_document()
        result = document.create()
        return f"Opening: {result}"

# Concrete creators
class PDFFactory(DocumentFactory):
    def create_document(self):
        return PDFDocument()

class WordFactory(DocumentFactory):
    def create_document(self):
        return WordDocument()

class SpreadsheetFactory(DocumentFactory):
    def create_document(self):
        return SpreadsheetDocument()

# Client code
def client_code(factory):
    print(factory.open_document())

# Usage
client_code(PDFFactory())
client_code(WordFactory())
client_code(SpreadsheetFactory())
Enter fullscreen mode Exit fullscreen mode

The Observer Pattern

The Observer pattern has saved me countless hours when building event-driven systems. This pattern establishes a one-to-many relationship between objects, where multiple observers are notified when the subject changes state.

I've used this pattern extensively in user interfaces, real-time data processing, and notification systems.

class Subject:
    def __init__(self):
        self._observers = []
        self._state = None

    def attach(self, observer):
        if observer not in self._observers:
            self._observers.append(observer)

    def detach(self, observer):
        try:
            self._observers.remove(observer)
        except ValueError:
            pass

    def notify(self):
        for observer in self._observers:
            observer.update(self)

    @property
    def state(self):
        return self._state

    @state.setter
    def state(self, value):
        self._state = value
        self.notify()

class Observer:
    def update(self, subject):
        pass

# Concrete implementations
class StockMarket(Subject):
    def __init__(self):
        super().__init__()
        self._price = 0

    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        self._price = value
        self.state = f"Stock price changed to {value}"

class Investor(Observer):
    def __init__(self, name):
        self.name = name

    def update(self, subject):
        print(f"{self.name} received alert: {subject.state}")

# Usage
market = StockMarket()
investor1 = Investor("John")
investor2 = Investor("Jane")

market.attach(investor1)
market.attach(investor2)

market.price = 101.25  # Triggers notifications
market.detach(investor1)
market.price = 102.75  # Only notifies investor2
Enter fullscreen mode Exit fullscreen mode

The Strategy Pattern

The Strategy pattern has transformed how I approach algorithm design. This pattern defines a family of interchangeable algorithms, allowing clients to select an algorithm at runtime.

I use this pattern whenever I need multiple ways to accomplish a task, such as different sorting methods, payment processors, or authentication strategies.

from abc import ABC, abstractmethod

# Strategy interface
class SortStrategy(ABC):
    @abstractmethod
    def sort(self, data):
        pass

# Concrete strategies
class QuickSort(SortStrategy):
    def sort(self, data):
        # Implementation would go here
        return f"Quick sorting: {data}"

class MergeSort(SortStrategy):
    def sort(self, data):
        # Implementation would go here
        return f"Merge sorting: {data}"

class BubbleSort(SortStrategy):
    def sort(self, data):
        # Implementation would go here
        return f"Bubble sorting: {data}"

# Context
class Sorter:
    def __init__(self, strategy=None):
        self._strategy = strategy

    @property
    def strategy(self):
        return self._strategy

    @strategy.setter
    def strategy(self, strategy):
        self._strategy = strategy

    def sort(self, data):
        if not self._strategy:
            raise ValueError("Sorting strategy not set")
        return self._strategy.sort(data)

# Usage
data = [1, 5, 3, 9, 2]
sorter = Sorter()

# Choose strategy based on data size
if len(data) > 1000:
    sorter.strategy = QuickSort()
elif len(data) > 100:
    sorter.strategy = MergeSort()
else:
    sorter.strategy = BubbleSort()

result = sorter.sort(data)
print(result)
Enter fullscreen mode Exit fullscreen mode

The Decorator Pattern

Python's built-in support for decorators makes this pattern particularly elegant. The Decorator pattern lets you add behavior to objects without affecting other objects of the same class.

I regularly use decorators for authentication, logging, caching, and input validation.

import time
import functools

# Function decorators
def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        print(f"{func.__name__} executed in {end_time - start_time:.4f} seconds")
        return result
    return wrapper

def debug(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        print(f"Calling {func.__name__}({signature})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result!r}")
        return result
    return wrapper

# Class-based decorators
class ClassDecorator:
    def __init__(self, func):
        self.func = func
        functools.update_wrapper(self, func)

    def __call__(self, *args, **kwargs):
        print(f"Before {self.func.__name__}")
        result = self.func(*args, **kwargs)
        print(f"After {self.func.__name__}")
        return result

# Usage
@timer
@debug
def factorial(n):
    """Calculate factorial"""
    if n <= 1:
        return 1
    return n * factorial(n-1)

@ClassDecorator
def greet(name):
    return f"Hello, {name}!"

# Test the functions
factorial(5)
greet("World")
Enter fullscreen mode Exit fullscreen mode

The Command Pattern

The Command pattern has helped me create flexible systems that support operations like undo/redo and request queuing. This pattern encapsulates a request as an object, separating the sender from the receiver.

I've implemented this pattern for document editing, transaction processing, and multi-step workflows.

from abc import ABC, abstractmethod
from typing import List

# Command interface
class Command(ABC):
    @abstractmethod
    def execute(self):
        pass

    @abstractmethod
    def undo(self):
        pass

# Receiver
class Document:
    def __init__(self):
        self.content = ""
        self.filename = ""

    def write(self, text):
        self.content += text
        return f"Added: '{text}'"

    def erase(self, length):
        if length <= len(self.content):
            erased = self.content[-length:]
            self.content = self.content[:-length]
            return f"Erased: '{erased}'"
        return "Nothing to erase"

    def save(self, filename):
        self.filename = filename
        return f"Document saved as '{filename}'"

    def __str__(self):
        return f"Document('{self.content}', saved as '{self.filename}')"

# Concrete commands
class WriteCommand(Command):
    def __init__(self, document, text):
        self.document = document
        self.text = text

    def execute(self):
        return self.document.write(self.text)

    def undo(self):
        return self.document.erase(len(self.text))

class EraseCommand(Command):
    def __init__(self, document, length):
        self.document = document
        self.length = length
        self.erased_text = ""

    def execute(self):
        if self.length <= len(self.document.content):
            self.erased_text = self.document.content[-self.length:]
        return self.document.erase(self.length)

    def undo(self):
        return self.document.write(self.erased_text)

class SaveCommand(Command):
    def __init__(self, document, filename):
        self.document = document
        self.filename = filename
        self.old_filename = document.filename

    def execute(self):
        return self.document.save(self.filename)

    def undo(self):
        return self.document.save(self.old_filename)

# Invoker
class Editor:
    def __init__(self):
        self.document = Document()
        self.history: List[Command] = []
        self.redo_stack: List[Command] = []

    def execute_command(self, command):
        result = command.execute()
        self.history.append(command)
        self.redo_stack.clear()  # Clear redo stack when new command is executed
        return result

    def undo(self):
        if not self.history:
            return "Nothing to undo"

        command = self.history.pop()
        self.redo_stack.append(command)
        return command.undo()

    def redo(self):
        if not self.redo_stack:
            return "Nothing to redo"

        command = self.redo_stack.pop()
        self.history.append(command)
        return command.execute()

# Client code
editor = Editor()
print(editor.execute_command(WriteCommand(editor.document, "Hello, ")))
print(editor.execute_command(WriteCommand(editor.document, "world!")))
print(editor.execute_command(SaveCommand(editor.document, "greeting.txt")))
print(f"Current state: {editor.document}")

print(editor.undo())  # Undo save
print(editor.undo())  # Undo "world!"
print(f"After undo operations: {editor.document}")

print(editor.redo())  # Redo "world!"
print(f"After redo: {editor.document}")
Enter fullscreen mode Exit fullscreen mode

Practical Considerations for Using Design Patterns

When implementing these patterns, I've discovered some important considerations:

Don't force patterns where they don't fit. Using the wrong pattern can make your code more complex rather than simpler. Start with the simplest solution and refactor to patterns when complexity demands it.

Python's dynamic nature sometimes allows for simpler implementations than in static languages. For example, Python's first-class functions make the Strategy pattern more concise.

Combining patterns can create powerful solutions. I often use the Factory Method to create the right Strategy, or Decorators to enhance Command objects.

Document your pattern usage. Many developers recognize these patterns, but explicit documentation helps everyone understand your design choices.

Test thoroughly. Design patterns may introduce multiple layers of abstraction, making bugs harder to spot. Comprehensive testing is essential.

Real-World Applications

In my projects, I've applied these patterns to solve various problems:

For a data processing pipeline, I used the Strategy pattern to implement different analysis algorithms that could be swapped based on data characteristics.

In a web application, I implemented the Observer pattern to update various UI components when the underlying data model changed.

For a content management system, the Command pattern allowed for complex content operations with undo/redo capability.

A configuration system used the Singleton pattern to ensure consistent settings across the application.

For API integrations, I employed the Factory Method to create appropriate clients for different service providers.

Authentication middleware used the Decorator pattern to protect routes based on user permissions.

Performance Considerations

While design patterns improve code organization, they can impact performance. I've learned to be mindful of several factors:

The Singleton pattern can create testing difficulties and hidden dependencies. Consider alternatives like dependency injection when appropriate.

Excessive use of the Observer pattern can lead to performance issues in notification-heavy systems. Use throttling or batching when necessary.

Deep chains of Decorators can make debugging challenging. Keep chains reasonably short and use meaningful names.

The Command pattern may increase memory usage when storing command history. Consider implementing size limits for undo/redo stacks.

Conclusion

Design patterns have been invaluable tools in my Python development toolkit. They provide tested solutions to common problems and a shared vocabulary among developers. When used judiciously, these six patterns can significantly improve code quality, maintainability, and extensibility.

Remember that patterns are guidelines, not rigid rules. Feel free to adapt them to your specific needs and Python's idioms. The goal is always to create code that solves problems effectively while remaining clear and maintainable.

By mastering these patterns, you'll develop a deeper understanding of software design principles that transcend specific languages or frameworks. This knowledge will serve you throughout your programming career, helping you create elegant solutions to complex problems.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

DevCycle image

OpenFeature Multi-Provider: Enabling New Feature Flagging Use-Cases

DevCycle is the first feature management platform with OpenFeature built in. We pair the reliability, scalability, and security of a managed service with freedom from vendor lock-in, helping developers ship faster with true OpenFeature-native feature flagging.

Watch Full Video 🎥

Top comments (0)

Feature flag article image

Create a feature flag in your IDE in 5 minutes with LaunchDarkly’s MCP server 🏁

How to create, evaluate, and modify flags from within your IDE or AI client using natural language with LaunchDarkly's new MCP server. Follow along with this tutorial for step by step instructions.

Read full post

👋 Kindness is contagious

Discover fresh viewpoints in this insightful post, supported by our vibrant DEV Community. Every developer’s experience matters—add your thoughts and help us grow together.

A simple “thank you” can uplift the author and spark new discussions—leave yours below!

On DEV, knowledge-sharing connects us and drives innovation. Found this useful? A quick note of appreciation makes a real impact.

Okay