How to use Design Patterns in Python
In this article, we will deep dive into the most common design patterns used in software development with appropriate code skeletons using Python.
Introduction
Design patterns are reusable solutions to common challenges in software design. They serve as generic templates that can be applied across various use cases to develop flexible, scalable, and maintainable codebases.
A common confusion among engineers lies between algorithms and design patterns. It is important to distinguish design patterns from algorithms. While algorithms define a specific sequence of steps to solve a problem, design patterns provide guidelines or best practices for structuring and organizing code to address recurring software design challenges. Although the principles behind design patterns are reusable, their implementation is tailored to the specific problem being solved.
Design Patterns
Design Patterns can be broken down into 3 categories. Each of the common design pattern that we would discuss below can be classified into one of the categories depending upon the purpose (or intention) of the design pattern.
Creational Design Patterns
These type of design patterns provide guidelines for defining structures that improve reusability and flexibility of object creation
Singleton
The Singleton pattern guarantees the creation of only a single object instance of a class and its availability across software. This is achieved by restricting access to the constructor of the class and defining a single method that would be responsible for providing instance of the class.
class Example:
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self, value):
if not hasattr(self, 'value'): # Ensure value is set only once
self.value = value
As seen in the above code reference, when the constructor of `Example` class is invoked, it checks if there is already an instance of the class. If so, it returns the same instance without creating a new instance object.
A common real-world use case for singleton is Database Connection Manager which ensures only a single connection to database is created and shared across applications, thereby avoiding creation and maintenance of unnecessary multiple connections as well as prevent data inconsistency across the different connections.
Factory Method
The Factory Method pattern provides an interface for creating objects but allows the subclasses to dictate the type of object being instantiated. Factor Method design pattern is usually used when object creation is logic and a tight coupling between client and object creation logic needs to be avoided.
from abc import ABC, abstractmethod
class Delivery(ABC):
@abstractmethod
def deliver(self, message):
pass
class AirDelivery(Delivery):
def deliver(self, tracking_id):
return f"Delivering parcel by air: {tracking_id}"
class LandDelivery(Delivery):
def deliver(self, tracking_id):
return f"Delivering parcel by land: {tracking_id}"
class DeliveryFactory:
@staticmethod
def initiate_delivery(method):
if method == "air":
return AirDelivery()
elif method == "land":
return LandDelivery()
else:
raise ValueError("Unknown delivery option")
package = DeliveryFactory.initiate_delivery("air")
print(package.deliver(1234565))
package = DeliveryFactory.initiate_delivery("land")
print(package.deliver(6574534))
The main advantages of this design pattern are that it promotes loose coupling between objects wherein the clients do not need to know the type of objects being created.
Builder
The Builder pattern allows creation of complex objects in step-wise manner rather than defining a complex constructor
class Coffee:
def __init__(self, size, milk="", extra_coffee_shot=False, sugar=False):
self.size = size
self.milk = milk
self.extra_coffee_shot = extra_coffee_shot
self.sugar = sugar
def __str__(self):
return f"Coffee(size={self.size}, milk={self.milk}, extra_coffee_shot={self.extra_coffee_shot}, sugar={self.sugar})"
class CoffeeBuilder:
def __init__(self, size):
self._coffee = Coffee(size)
def add_milk(self, type_of_milk="Whole Milk"):
self._coffee.milk = type_of_milk
return self
def add_extra_coffee_shot (self):
self._extra_coffee_shot = True
return self
def add_sugar(self):
self._coffee.sugar = True
return self
def build(self):
return self._coffee
coffee = CoffeeBuilder("12oz").add_milk("Oat Milk").add_sugar().build()
print(coffee)
The main advantages of builder design pattern are improved readability and scalability as all attributes are not required when initializing using constructor.
Structural Design Patterns
These type of design patterns provide guidelines on association among objects and class composition.
Facade
The Facade design pattern provides an interface to library or complex set of classes. The interface provides a single entry point to the library or classes and therefore does not require all classes to be exposed to the client.
class Aircondition: def on(self): print("Air condition is ON") def set_temperature(self, temperature): print(f"Setting air conditioner temperature to '{temperature}'") def off(self): print("Air condition is OFF") class Lights: def on(self): print("Lights are ON") def off(self): print("Lights are OFF") class HomeAutomationFacade: def __init__(self, air_condition, lights): self.air_condition= air_condition self.lights = lights def enter_home(self): print("\nStarting Home Automation Systems") self.air_condition.on() self.air_condition.set_temperature(40) self.lights.on() def exit_home(self): print("\nShutting down Home Automation Systems") self.air_condition.off() self.lights.off() air_condition = Aircondition() lights = Lights() home_automation_facade = HomeAutomationFacade(air_condition, lights) home_automation_facade.enter_home() home_automation_facade.exit_home()
A common real world example is Banking API where clients use the provided interface to interact with the Banking systems without any knowledge of the internal classes. This pattern promotes loose coupling with the clients, improves readability and maintainability as well as simplifies the entire system by providing limited interface to complicated systems
Proxy
The Proxy structural design pattern acts as substitute for another object by providing controlled access to the original object.
class Database:
def __init__(self, database):
self.database = database
def read(self, table_name):
query = f"Select * from {table_name}"
print(query)
def write(self, table_name, name, age):
query = f"Insert into {table_name} (Name, Age) VALUES ('{name}', {age})"
print(query)
class DatabaseProxy:
def __init__(self, db_name, user_role):
self.db = Database(db_name)
self.user_role = user_role # e.g., "admin" or ""
def read(self):
self.db.read("audit")
def write(self, table, name, age):
if self.user_role == "admin":
self.db.write(table, name, age)
else:
print("Access Denied: You don't have permission to write to this database.")
admin_file = DatabaseProxy("audit", "admin")
admin_file.read()
admin_file.write("audit", "Tim", 21)
The proxy can be used to introduce additional functionality (e.g. permission checks) on top of core functionality that need not be attached to the core object. A common example is API Gateway Proxy.
Adapter
The Adapter Pattern structural design pattern allows objects with incompatible interfaces to collaborate by converting one interface into another.
class LegacyClass:
def doSomething(self, variable):
return f"Doing something with {variable}"
class NewClass:
def doSomethingV2(self, variable):
return f"Doing something using next version with {variable}"
class Adapter:
def __init__(self):
self.new_class = NewClass()
def play(self, variable):
return self.new_class.doSomethingV2(variable)
class Client:
"""Client that uses the adapter to support multiple formats"""
def play(self, class_type, variable):
if class_type == "legacy":
obj = LegacyClass()
return obj.doSomething(variable)
else:
adapter = Adapter()
return adapter.play(variable)
test_client = Client()
print(test_client.play("legacy", "Hello")) # Output: Playing MP3 file: song.mp3
print(test_client.play("new", "Ola!!!")) # Output: Playing MP4 file: video.mp4
A very common usecase for employing this design pattern is when integrating new code with legacy frameworks and libraries. Opting for such approach keeps classes separate and adheres to Single Responsibility Principle**.**
Behavioral Design Patterns
These type of design patterns provide guidelines that focus on inter object communication and object responsibilities.
Iterator
As the name suggests, Iterator is a behavioral design pattern that allows traversal of elements in a collection(list, stack, tree, etc.) without exposing its underlying representation
class Song:
def __init__(self, title, singer):
self.title = title
self.singer = singer
def __str__(self):
return f"{self.title} by {self.singer}"
class SongIterator:
def __init__(self, songs):
self._songs = songs
self._index = 0
def __iter__(self):
return self
def __next__(self):
if self._index < len(self._songs):
song = self._songs[self._index]
self._index += 1
return song
else:
raise StopIteration
class SongCollection:
def __init__(self):
self._songs = []
def add_song(self, song):
self._songs.append(song)
def __iter__(self):
return SongIterator(self._songs)
catalogue = SongCollection()
catalogue.add_song(Song("Song_A", "Artist_A"))
catalogue.add_song(Song("Song_B", "Artist_B"))
catalogue.add_song(Song("Song_C", "Artist_C"))
for song in catalogue:
print(song)
This design pattern allows user to define variety of iterations (e.g. forward, backward, filtered) whilst abstracting out the implementation from clients.
Conclusion
Design patterns are essential in software engineering as they provide well-established solutions to common design challenges. By learning and implementing these patterns effectively, developers can write code that is more organized, maintainable, and adaptable for future scaling and enhancements.