Unlocking Python's Design Patterns: Exploring Powerful Solutions with Examples
Glenn Viroux
Posted on July 14, 2023
Design Patterns are invaluable solutions for common problems in software design. While they may not provide ready-made solutions that can be directly copied and pasted into your code, they serve as powerful blueprints that can be customized and tailored to address specific design challenges.
One of the significant advantages of using design patterns is the clarity they bring to your codebase. By leveraging well-established patterns, you effectively communicate to your fellow developers the problem you're addressing and the approach you're taking. It's often said that code is written once but read many times, highlighting the importance of code readability. In the software design process, placing greater emphasis on code readability over ease of writing is essential. Incorporating battle-tested design patterns into your codebase is a highly effective means of enhancing code readability.
In this article, we'll delve into three distinct design patterns and provide detailed examples of how they can be applied in Python. By understanding and harnessing these patterns, you'll be equipped with powerful tools to enhance the structure, maintainability, and clarity of your code.
Decorator
The decorator design pattern enables you to wrap existing objects in special wrapper objects, adding new behavior without modifying the original object.
To illustrate this pattern, imagine yourself on a cold winter day, walking outside in a t-shirt. As you start feeling the chill, you decide to put on a sweater, acting as your first decorator. However, if you're still cold, you might also choose to wrap yourself in a jacket, serving as another decorator. These additional layers "decorate" your basic behavior of wearing just a t-shirt. Importantly, you have the flexibility to remove any garment whenever you no longer need it, without altering your fundamental state.
By employing the decorator pattern, you can dynamically enhance objects with new functionality while keeping the core object unchanged. It offers a flexible and modular approach to extending behavior, much like adding or removing layers of clothing based on your comfort level.
Different Coffee Products, the Wrong Way
Imagine you're tasked with developing a coffee ordering system for a local coffee shop. The system should support various types of coffee with different flavors and toppings. Let's consider the following coffee variations:
- Simple coffee
- Coffee with milk
- Coffee with vanilla topping
- Coffee with milk and vanilla topping
Without knowing about the decorator design pattern, you might just implement all different types of coffee with their toppings as a separate variable.
In this approach, we start by defining an interface for all types of coffee. This interface specifies common properties such as the coffee's name, price, list of ingredients, and whether it's vegan-friendly. We also include an order
method to allow customers to request a specific coffee and specify the quantity.
from dataclasses import dataclass
@dataclass
class Coffee:
name: str
price: float
ingredients: list[str]
is_vegan: bool
def order(self, amount: int) -> str:
if amount == 1:
return f"Ordered 1 '{self.name}' for the price of {self.price}€"
return f"Ordered {amount} times a '{self.name}' for the total price of {self.price * amount:.2f}€"SIMPLE_COFFEE = Coffee(name="Simple Coffee", price=1.0, ingredients=["Coffee"], is_vegan=True)
Next, let’s define our four different types of coffee:
SIMPLE_COFFEE = Coffee(name="Simple Coffee", price=1.0, ingredients=["Coffee"], is_vegan=True)
SIMPLE_COFFEE_WITH_MILK = Coffee(name="Simple Coffee + Milk", price=1.2, ingredients=["Coffee", "Milk"], is_vegan=False)
SIMPLE_COFFEE_WITH_VANILLA = Coffee(name="Simple Coffee + Vanilla", price=1.3, ingredients=["Coffee", "Vanilla"], is_vegan=True)
SIMPLE_COFFEE_WITH_MILK_AND_VANILLA = Coffee(name="Simple Coffee + Milk + Vanilla", price=1.5, ingredients=["Coffee", "Milk", "Vanilla"], is_vegan=False)
Now, a client can place an order as follows:
orders = [
SIMPLE_COFFEE.order(amount=1),
SIMPLE_COFFEE_WITH_MILK.order(amount=3),
SIMPLE_COFFEE_WITH_MILK_AND_VANILLA.order(amount=2)
]
for order in orders:
print(order)
# Output:
# Ordered 1 'Simple Coffee' for the price of 1.0€
# Ordered 3 times a 'Simple Coffee + Milk' for the total price of 3.60€
# Ordered 2 times a 'Simple Coffee + Milk + Vanilla topping' for the total price of 3.00€
You’ll quickly see how this code will grow exponentially when new types of coffee and more combinations with different types of toppings and flavors. Instead of following this dark spiral of infinite code, let’s explore how decorators can help us out here.
Different Coffee Products, the Right Way
As we discussed earlier, the decorator pattern allows us to wrap a simple base coffee object with additional functionality to create the desired final coffee product. In this improved approach, we'll leverage the power of decorators to add various toppings and flavors to our base coffee.
We'll begin by maintaining our Coffee
interface class and our SIMPLE_COFFEE
variable as the foundation for all coffee variations. However, instead of defining separate classes for each type of coffee, we'll implement decorators that wrap around our base coffee object.
To get started, let's define a basic interface for our coffee decorators. This interface will ensure consistency among all decorator classes and provide a common set of methods to work with.
from abc import ABC
from dataclasses import dataclass
@dataclass
class BaseCoffeeDecorator(ABC):
coffee: Coffee
@property
@abstractmethod
def extra_cost(self) -> float:
raise NotImplementedError
@property
@abstractmethod
def extra_name(self) -> str:
raise NotImplementedError
@property
@abstractmethod
def extra_ingredients(self) -> list[str]:
raise NotImplementedError
def __call__(self) -> Coffee:
name = f"{self.coffee.name} + {self.extra_name}"
price = self.coffee.price + self.extra_cost
ingredients = self.coffee.ingredients + self.extra_ingredients
is_vegan = self.coffee.is_vegan and not any(
ingredient in NON_VEGAN_INGREDIENTS for ingredient in self.extra_ingredients
)
return replace(self.coffee, name=name, price=price, ingredients=ingredients, is_vegan=is_vegan)
Here, we say how each coffee decorator should define an extra cost, an extra name and possible extra ingredients that will be added to the coffee.
By implementing specific decorator classes, such as MilkDecorator
or VanillaDecorator
, we can easily add desired toppings or flavors to our coffee. Each decorator class will encapsulate the base coffee object and modify its behavior by adding the desired functionality.
class MilkDecorator(BaseCoffeeDecorator):
extra_name = "Milk"
extra_cost = 0.2
extra_ingredients = ["Milk"]
class VanillaDecorator(BaseCoffeeDecorator):
extra_name = "Vanilla"
extra_cost = 0.3
extra_ingredients = ["Vanilla"]
And our client can place the order like follows:
coffee_with_milk = MilkDecorator(SIMPLE_COFFEE)()
coffee_with_milk_and_vanilla = VanillaDecorator(MilkDecorator(SIMPLE_COFFEE)())()
orders = [
SIMPLE_COFFEE.order(amount=1),
coffee_with_milk.order(amount=3),
coffee_with_milk_and_vanilla.order(amount=2),
]
for order in orders:
print(order)
# Output:
# Ordered 1 'Simple Coffee' for the price of 1.0€
# Ordered 3 times a 'Simple Coffee + Milk' for the total price of 3.60€
# Ordered 2 times a 'Simple Coffee + Milk + Vanilla' for the total price of 3.00€
The significant advantage of this approach is that you only need to define one decorator class per type of topping or flavor, rather than creating subclasses for every possible combination. Consider the scenario where customers can combine any topping or flavor with any other topping or flavor. In this case, with just 10 different types of toppings, there would be a whopping 1023 possible combinations. It would be impractical and cumbersome to create 1023 variables for each combination.
By adopting this approach, we achieve a more flexible and modular design. New toppings or flavors can be added by creating additional decorator classes, without the need to modify the existing coffee classes. This allows for easy customization and expansion of our coffee offerings.
In summary, by utilizing the decorator pattern, we create a cohesive and extensible coffee ordering system where various combinations of toppings and flavors can be applied to our base coffee, resulting in a delightful and customizable coffee experience for our customers.
Different Coffee Products, Done Right with Python Decorators
Taking our coffee ordering system to the next level, we can leverage the powerful built-in decorator functionality offered by Python itself. With this approach, we can achieve the same functionality as before while embracing the elegance and simplicity of Python decorators.
Instead of creating separate decorator classes like MilkDecorator
and VanillaDecorator
, we can utilize the @
symbol and apply decorators directly to our coffee functions. This not only streamlines the code but also enhances its readability and maintainability.
Let's dive into an example of how we can achieve the same results using built-in Python decorators:
from typing import Callable
from functools import wraps
def milk_decorator(func: Callable[[], Coffee]) -> Callable[[], Coffee]:
@wraps(func)
def wrapper() -> Coffee:
coffee = func()
return replace(coffee, name=f"{coffee.name} + Milk", price=coffee.price + 0.2)
return wrapper
def vanilla_decorator(func: Callable[[], Coffee]) -> Callable[[], Coffee]:
@wraps(func)
def wrapper() -> Coffee:
coffee = func()
return replace(coffee, name=f"{coffee.name} + Vanilla", price=coffee.price + 0.3)
return wrapper
@milk_decorator
def make_coffee_with_milk():
return SIMPLE_COFFEE
@vanilla_decorator
@milk_decorator
def make_coffee_with_milk_and_vanilla():
return SIMPLE_COFFEE
# Output:
# Ordered 1 'Simple Coffee' for the price of 1.0€
# Ordered 3 times a 'Simple Coffee + Milk' for the total price of 3.60€
# Ordered 2 times a 'Simple Coffee + Milk + Vanilla' for the total price of 3.00€
Notice how here, we achieved the exact same result as before, but instead of using our BaseCoffeeDecorator
class, we used python’s built-in decorator functionality by using the wraps
function from the functools
package (https://docs.python.org/3/library/functools.html#functools.wraps).
Chain of Responsability
The Chain of Responsibility (CoR) design pattern provides a flexible and organized approach to handle sequential operations or requests on an object by passing it through a series of handlers. Each handler in the chain has the ability to perform specific actions or checks on the object and make a decision to either process the request or delegate it to the next handler in line.
Imagine you, as an employee, want to have a second monitor for your work setup. To make this happen, you need to go through a chain of approval. First, you submit a request to your immediate team lead, who evaluates whether it aligns with the department's policies and budget. If your team lead approves, they pass the request to the finance department, which verifies the availability of funds. The finance department might then consult with other relevant departments, such as procurement or IT infrastructure, to ensure the request can be fulfilled. Eventually, the request reaches the final decision-maker, such as the department head or the finance director, who makes the ultimate decision.
In this scenario, each level of approval corresponds to a link in the chain of responsibility. Each person in the chain has a specific responsibility and authority to handle their part of the request. The chain allows for a structured and sequential flow of information and decision-making, ensuring that each level of the organization's hierarchy is involved and has the opportunity to contribute to the final decision.
Performing Checks on Coffee Orders
Let's delve further into our coffee ordering system example and explore additional checks that can be applied. In a real-life scenario, the system could become more intricate than our current implementation. Imagine the coffee place owner specifying that no single coffee should cost more than 10€ to maintain an affordable brand reputation. Additionally, there should be a mechanism in place to double-check the vegan status of a coffee based on its ingredients.
For this section, we’ll define three different types of coffee: a simple coffee, a cappuccino and an expensive cappuccino:
SIMPLE_COFFEE = Coffee(name="Simple Coffee", price=1.0, ingredients=["Coffee"], is_vegan=True)
CAPPUCCINO = Coffee(name="Cappuccino", price=2.0, ingredients=["Coffee", "Milk"], is_vegan=True)
EXPENSIVE_CAPPUCCINO = Coffee(name="Cappuccino", price=12.0, ingredients=["Coffee", "Milk"], is_vegan=False)
Notice how we marked the cappuccino as vegan, while milk is included in its ingredients. Also, the price of the expensive cappuccino is higher than 10€.
Next, we establish a unified interface for all the handlers in our Chain of Responsibility (CoR):
@dataclass
class BaseHandler(ABC):
next_handler: BaseHandler | None = None
@abstractmethod
def __call__(self, coffee: Coffee) -> Coffee:
raise NotImplementedError
In this code snippet, we define the BaseHandler
class as an abstract base class (ABC) using the @dataclass
decorator. The class includes a next_handler
attribute that represents the next handler in the chain. Each handler in the CoR implements this interface and overrides the __call__
method to define its specific action or check on the coffee object.
If a next_handler
is provided when creating a handler instance, it signifies that there is another handler to process the coffee object after the current handler completes its operation. Conversely, if no next handler is provided, the current handler serves as the endpoint of the chain.
This common interface ensures that all handlers adhere to a consistent structure, allowing for seamless chaining and flexibility in adding, removing, or rearranging the handlers as needed.
Now we can go on to define our two handlers that will check for the maximum price of €10, and will verify whether no coffee is marked as vegan incorrectly:
NON_VEGAN_INGREDIENTS = ["Milk"]
@dataclass
class MaximumPriceHandler(BaseHandler):
def __call__(self, coffee: Coffee) -> Coffee:
if coffee.price > 10.0:
raise RuntimeError(f"{coffee.name} costs more than €10?!")
return coffee if self.next_handler is None else self.next_handler(coffee)
@dataclass
class VeganHandler(BaseHandler):
def __call__(self, coffee: Coffee) -> Coffee:
if coffee.is_vegan and any(ingredient in NON_VEGAN_INGREDIENTS for ingredient in coffee.ingredients):
raise RuntimeError(f"Coffee {coffee.name} is said to be vegan but contains non-vegan ingredients")
if not coffee.is_vegan and all(ingredient not in NON_VEGAN_INGREDIENTS for ingredient in coffee.ingredients):
raise RuntimeError(f"Coffee {coffee.name} is not not labelled as vegan when it should be")
return coffee if self.next_handler is None else self.next_handler(coffee)
Let’s test our handlers with the following code:
handlers = MaximumPriceHandler(VeganHandler())
try:
cappuccino = handlers(CAPPUCCINO)
except RuntimeError as err:
print(str(err))
try:
cappuccino = handlers(EXPENSIVE_CAPPUCCINO)
except RuntimeError as err:
print(str(err))
# Output:
# Coffee Cappuccino is said to be vegan but contains non-vegan ingredients
# Expensive Cappuccino costs more than €10?!
We observed how the ordering system correctly handles the addition of toppings to a simple coffee order. However, attempting to create a Cappuccino
or an ExpensiveCappuccino
order results in an exception being raised. This behavior highlights the strict processing logic enforced by the chain of responsibility.
What's noteworthy is how easily we can extend this code by defining additional handlers to perform other specific operations on coffee orders. For instance, let's say you want to offer a 10% discount on takeaway orders. You can effortlessly create a new handler and add it to the chain. This handler would reduce the price of the order by 10% if it's flagged as a takeaway order.
One of the key advantages of the Chain of Responsibility design pattern is its adherence to the Open-Closed principle in software development. The existing handlers remain closed for modification, promoting code stability and reusability. However, the design pattern allows for easy expansion by introducing new handlers to the chain when necessary. This flexibility empowers developers to accommodate changing requirements without disrupting the existing codebase.
Composite / Object Tree
The Composite design pattern comes into play when you're faced with a scenario involving a mixture of simple end objects (leaves) and more complex containers that can hold other containers (branches) or end objects (leaves).
A real-world analogy of the Composite pattern can be found in a file system. Consider a directory structure where folders (branches) can contain other folders or files (leaves), while individual files (leaves) exist independently. This hierarchical arrangement allows you to treat the entire structure as a unified object, regardless of whether you're working with a single file or a complex directory.
Composite Coffee Orders
In our coffee ordering system, we can apply the Composite design pattern to handle both simple individual coffee orders (leaves) and more complex coffee order structures (branches).
Consider a scenario where a single coffee order represents an individual line on the bill, such as "Two simple coffees for a total price of €2." However, we also need to accommodate complex coffee orders that consist of multiple individual orders or even other complete coffee orders. For instance, customers ordering from the terrace might decide to add additional items to their existing processed order.
To manage this complexity, we can utilize the Composite design pattern, which provides a unified interface for both individual coffee orders and composite order structures. This common interface defines essential methods, like calculating the total price of the final order, regardless of whether it's a single coffee or a complex combination.
By employing the Composite pattern, we can streamline the handling of coffee orders, ensure consistent operations across different order types, and enable seamless integration of new functionalities into the coffee ordering system.
from dataclasses import dataclass
@dataclass
class Coffee:
name: str
price: float
ingredients: list[str]
is_vegan: bool
SIMPLE_COFFEE = Coffee(name="Simple Coffee", price=1.0, ingredients=["Coffee"], is_vegan=True)
CAPPUCCINO = Coffee(name="Cappuccino", price=2.0, ingredients=["Coffee", "Milk"], is_vegan=False)
class CoffeeOrderComponentBase(ABC):
@property
@abstractmethod
def total_price(self) -> float:
raise NotImplementedError
@property
@abstractmethod
def all_ingredients(self) -> list[str]:
raise NotImplementedError
@property
@abstractmethod
def is_vegan(self) -> bool:
raise NotImplementedError
@property
@abstractmethod
def order_lines(self) -> list[str]:
raise NotImplementedError
Here, we have defined our Coffee
data class, which specifies the properties required for each type of base coffee. We have created two instances of base coffees, namely SIMPLE_COFFEE
and CAPPUCCINO
.
Next, we introduce the CoffeeOrderComponentBase
, which serves as the common interface for both leaves (single coffee orders) and complex containers (composite coffee orders). This interface defines the following methods that should be implemented by both types:
-
total_price
: Calculates the total price of the order. -
all_ingredients
: Retrieves all the ingredients included in the order. -
is_vegan
: Indicates whether the complete order is vegan or not. -
order_lines
: Generates a summary of the order as text lines.
Now, let's focus on the implementation of the leaf component, which represents a single coffee order:
@dataclass
class CoffeeOrder(CoffeeOrderComponentBase):
base_coffee: Coffee
amount: int
@property
def total_price(self) -> float:
return self.amount * self.base_coffee.price
@property
def all_ingredients(self) -> list[str]:
return self.base_coffee.ingredients
@property
def is_vegan(self) -> bool:
return self.base_coffee.is_vegan
@property
def order_lines(self) -> list[str]:
if self.amount == 1:
return [f"Ordered 1 '{self.base_coffee.name}' for the price of {self.total_price}€"]
return [
f"Ordered {self.amount} times a '{self.base_coffee.name}' for the total price of {self.total_price:.2f}€"
]
Now, let's move on to the implementation of the more complex coffee order, which is capable of holding a list of children. This allows for the nesting of both leaves (single coffee orders) and other complex containers.
from dataclasses import field
from more_itertools import flatten
@dataclass
class CompositeCoffeeOrder(CoffeeOrderComponentBase):
children: list[CoffeeOrderComponentBase] = field(default_factory=list)
@property
def total_price(self) -> float:
return sum(child.total_price for child in self.children)
@property
def all_ingredients(self) -> list[str]:
return list(set(flatten([child.all_ingredients for child in self.children])))
@property
def is_vegan(self) -> bool:
return all(child.is_vegan for child in self.children) or not len(self.children)
@property
def order_lines(self) -> list[str]:
return list(flatten([child.order_lines for child in self.children]))
Now we can represent a complex, composite order like this:
order = CompositeCoffeeOrder(
children=[
CoffeeOrder(amount=2, base_coffee=CAPPUCCINO),
CoffeeOrder(amount=1, base_coffee=SIMPLE_COFFEE),
CompositeCoffeeOrder(
children=[CoffeeOrder(amount=3, base_coffee=SIMPLE_COFFEE)]
),
]
)
for order_line in order.order_lines:
print(order_line)
print("-" * 40)
print(f"The total price of the order is {order.total_price:.2f}€")
print(f"These are all the ingredients included in this order: {', '.join(order.all_ingredients)}")
# Output:
# Ordered 2 times a 'Cappuccino' for the total price of 4.00€
# Ordered 1 'Simple Coffee' for the price of 1.0€
# Ordered 3 times a 'Simple Coffee' for the total price of 3.00€
# ----------------------------------------
# The total price of the order is 8.00€
# These are all the ingredients included in this order: Milk, Coffee
Combining Different Design Patterns
Now that we have explored the Decorator, Chain of Responsibility, and Composite design patterns individually, let's see how we can bring them all together to build a complex coffee order with different toppings and flavors. This example will demonstrate how we can apply the Chain of Responsibility pattern to perform validation steps on our coffee order while utilizing the Composite pattern to create a structured order hierarchy.
By combining these patterns, we can create a powerful and flexible coffee ordering system that allows customers to customize their orders with various toppings and flavors while ensuring that all validation checks are performed seamlessly.
handlers = MaximumPriceHandler(VeganHandler())
coffee_with_milk_and_vanilla = VanillaDecorator(MilkDecorator(SIMPLE_COFFEE)())()
order = CompositeCoffeeOrder(
children=[
CoffeeOrder(amount=2, base_coffee=handlers(CAPPUCCINO)),
CoffeeOrder(amount=1, base_coffee=handlers(coffee_with_milk_and_vanilla)),
CompositeCoffeeOrder(
children=[CoffeeOrder(amount=3, base_coffee=handlers(VanillaDecorator(CAPPUCCINO)()))]
),
]
)
for order_line in order.order_lines:
print(order_line)
print("-" * 40)
print(f"The total price of the order is {order.total_price:.2f}€")
print(f"These are all the ingredients included in this order: {', '.join(order.all_ingredients)}")
print(f"This order is {'' if order.is_vegan else 'not'} vegan")
# Output:
# Ordered 2 times a 'Cappuccino' for the total price of 4.00€
# Ordered 1 'Simple Coffee + Milk + Vanilla' for the price of 1.5€
# Ordered 3 times a 'Cappuccino + Vanilla' for the total price of 6.90€
# ----------------------------------------
# The total price of the order is 12.40€
# These are all the ingredients included in this order: Coffee, Vanilla, Milk
# This order is not vegan
First, we define our Chain of Responsibility, which consists of the VeganHandler
is responsible for checking if any product is incorrectly labeled as vegan, and the MaximumPriceHandler
responsible for verifying that no single coffee exceeds the price of 10€.
Next, we utilize the VanillaDecorator
and MilkDecorator
to transform a simple coffee into a coffee with milk and vanilla, respectively.
Finally, we employ the CompositeCoffeeOrder
to create an order that includes two single coffee orders and another complex order.
When we run the script, we can observe the decorators modifying the names and prices of the different orders. The CompositeCoffeeOrder
correctly calculates the total price of the final order. Additionally, we can view the complete list of ingredients and determine whether the entire order is vegan or not.
Conclusion
In conclusion, design patterns play a crucial role in software development, offering solutions to common problems and promoting code reusability, flexibility, and maintainability. In this blog post, we explored three powerful design patterns in Python: Decorator, Chain of Responsibility, and Composite.
The Decorator pattern allowed us to dynamically add new behavior to existing objects, demonstrating its usefulness in extending the functionality of coffee orders with various toppings and flavors. We learned how decorators can be implemented both with custom wrapper classes and Python's built-in decorator functionality, providing flexible options for code organization.
The Chain of Responsibility pattern proved valuable in performing sequential operations on coffee orders, emulating a real-world scenario of approval processes in an organization. By creating a chain of handlers, each responsible for a specific task, we achieved modularity and extensibility while ensuring that requests pass through the chain correctly.
The Composite pattern enabled us to create structured hierarchies of coffee orders, incorporating both simple orders and more complex compositions. By defining a common interface, we achieved consistency in accessing and manipulating orders, regardless of their complexity.
Throughout our examples, we witnessed the power of design patterns in enhancing code readability, maintainability, and extensibility. By adopting these proven solutions, we can improve collaboration among developers and build software systems that are robust, flexible, and adaptable to changing requirements.
You can find all the source code provided in this blog post here: https://github.com/GlennViroux/design-patterns-blog
Feel free to contact me with any questions or comments!
References
https://refactoring.guru/design-patterns/composite
https://refactoring.guru/design-patterns/chain-of-responsibility
Posted on July 14, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.