Behavioral Patterns: Streamlining Complex Interactions

herchila

Hernán Chilabert

Posted on December 1, 2023

Behavioral Patterns: Streamlining Complex Interactions

[UPDATED 12/7/2023]
✨ GitHub repo with real-world use cases: https://github.com/herchila/design-patterns/tree/main/behavioral_patterns

Chain of Responsibility Pattern

Purpose: To pass a request along a chain of handlers. Upon receiving a request, each handler decides either to process it or to pass it to the next handler in the chain.

Example: A logging system that sends messages to different handlers (console, file, email) based on the message's severity.

class Handler:
    def __init__(self, successor=None):
        self._successor = successor

    def handle(self, request):
        res = self.check_request(request)
        if not res and self._successor:
            self._successor.handle(request)

    def check_request(self, request): pass

class ConsoleHandler(Handler):
    def check_request(self, request):
        if request < 3:
            print(f"Console: {request}")
            return True

class FileHandler(Handler):
    def check_request(self, request):
        if 3 <= request < 5:
            print(f"File: {request}")
            return True

class EmailHandler(Handler):
    def check_request(self, request):
        if request >= 5:
            print(f"Email: {request}")
            return True

# Usage
chain = ConsoleHandler(FileHandler(EmailHandler()))
chain.handle(2)  # Console: 2
chain.handle(4)  # File: 4
chain.handle(5)  # Email: 5
Enter fullscreen mode Exit fullscreen mode

Real-world use case

** Scenario**:
Imagine a Customer Support System for a technology company that provides various services such as hardware repair, software troubleshooting, and account management. Customers can reach out with a wide range of issues, from simple password resets to complex hardware malfunctions. The system needs to handle these queries efficiently, ensuring that each issue is addressed by the most appropriate department. Without proper handling, queries could be misdirected, leading to delays and customer dissatisfaction.

Problem:
The challenge lies in efficiently categorizing and routing these customer queries to the right department. Handling this manually can be error-prone and inefficient. Moreover, as the company grows and the range of services expands, the system must be adaptable to handle new types of queries without major overhauls.

Solution:
The Chain of Responsibility pattern is ideal for this situation. It allows us to pass requests along a chain of handlers. Each handler decides either to process the request or to pass it to the next handler in the chain.

class Handler:
    """Abstract Handler: inherited by all concrete handlers."""
    def __init__(self, successor=None):
        self._successor = successor

    def handle_request(self, request):
        if not self.can_handle(request):
            if self._successor is not None:
                self._successor.handle_request(request)

    def can_handle(self, request):
        raise NotImplementedError('Must provide implementation in subclass!')


class HardwareSupportHandler(Handler):
    """Concrete handler: handles hardware support requests."""
    def can_handle(self, request):
        return request == "Hardware Support"


class SoftwareSupportHandler(Handler):
    """Concrete handler: handles software support requests."""
    def can_handle(self, request):
        return request == "Software Support"


class AccountSupportHandler(Handler):
    """Concrete handler: handles account support requests."""
    def can_handle(self, request):
        return request == "Account Support"


# Create handlers
hardware_handler = HardwareSupportHandler()
software_handler = SoftwareSupportHandler(hardware_handler)
account_handler = AccountSupportHandler(software_handler)

# Client code
def client_code(handler, request):
    print(f"Processing request for {request}")
    handler.handle_request(request)

# Test our chain
client_code(account_handler, "Hardware Support")
client_code(account_handler, "Software Support")
client_code(account_handler, "Account Support")
Enter fullscreen mode Exit fullscreen mode

In this code, we have a base Handler class that defines the interface for handling requests. The HardwareSupportHandler, SoftwareSupportHandler, and AccountSupportHandler are concrete handlers that process specific types of requests. We link these handlers in a chain (account -> software -> hardware). When a request comes in, it starts at the beginning of the chain (account support) and moves down the chain until it finds a handler that can process it.

Observer Pattern

Purpose: To define a one-to-many dependency between objects, where a change in one object results in automatic notifications and updates to its dependents.

Example: A weather station broadcasting temperature updates to multiple display devices.

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

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

    def attach(self, observer):
        self._observers.append(observer)

    def detach(self, observer):
        self._observers.remove(observer)

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

class WeatherStation(Subject):
    _temperature = None

    @property
    def temperature(self):
        return self._temperature

    @temperature.setter
    def temperature(self, temp):
        self._temperature = temp
        self.notify()

class DisplayDevice(Observer):
    def update(self, subject):
        print(f"Temperature Update: {subject.temperature}°C")

# Usage
weather_station = WeatherStation()
display = DisplayDevice()
weather_station.attach(display)

weather_station.temperature = 26  # Temperature Update: 26°C
Enter fullscreen mode Exit fullscreen mode

Real-world use case

Scenario:
Imagine an Online Auction System where users can bid on various items. In this system, bidders need to be constantly updated about the current highest bid so they can decide whether to place a higher bid. Each item has multiple bidders watching it, and they all need to receive updates whenever the bid changes. Managing these updates efficiently is crucial to ensure a smooth and responsive auction experience.

Problem:
The challenge is in notifying all interested bidders of the current highest bid without requiring them to constantly check the item manually. Manually updating each bidder would be inefficient and impractical, especially as the number of bidders and items increases. This could lead to a poor user experience, as bidders might miss out on placing higher bids due to delayed updates.

Solution:
The Observer Pattern provides an elegant solution to this problem. It allows multiple objects (observers) to watch another object (subject) and be notified automatically whenever there is a change in the subject. In the context of our online auction system, each item can be a subject, and the bidders can be observers.

class AuctionItem:
    """Subject: Auction Item being bid on."""

    def __init__(self, name):
        self._name = name
        self._bidders = []
        self._highest_bid = 0

    def attach(self, bidder):
        self._bidders.append(bidder)

    def detach(self, bidder):
        self._bidders.remove(bidder)

    def notify(self):
        for bidder in self._bidders:
            bidder.update(self)

    def receive_bid(self, bid_amount):
        if bid_amount > self._highest_bid:
            self._highest_bid = bid_amount
            self.notify()

    def get_highest_bid(self):
        return self._highest_bid


class Bidder:
    """Observer: Bidders watching the auction item."""

    def __init__(self, name):
        self._name = name

    def update(self, auction_item):
        print(f"{self._name} has been notified. New highest bid: {auction_item.get_highest_bid()} on item {auction_item._name}")


# Client code
item = AuctionItem("Vintage Vase")

bidder1 = Bidder("Alice")
bidder2 = Bidder("Bob")
bidder3 = Bidder("Charlie")

item.attach(bidder1)
item.attach(bidder2)
item.attach(bidder3)

# Bids are received
item.receive_bid(100)  # Updates all bidders
item.receive_bid(150)  # Updates all bidders
Enter fullscreen mode Exit fullscreen mode

In this implementation, AuctionItem is the subject, and Bidder is the observer. Each Bidder instance is attached to an AuctionItem. When a new bid is placed on the item (receive_bid method), the item updates its highest bid and notifies all attached bidders by calling their update method.

Strategy Pattern

Purpose: To define a family of algorithms, encapsulate each one, and make them interchangeable. The strategy pattern lets the algorithm vary independently from the clients that use it.

Example: Different sorting algorithms used in a context based on the size or complexity of the data set.

from typing import List

class SortingStrategy:
    def sort(self, dataset: List[int]) -> List[int]: pass

class QuickSort(SortingStrategy):
    def sort(self, dataset: List[int]) -> List[int]:
        print("Sorting using quick sort")
        return sorted(dataset)  # Simplification for demonstration

class MergeSort(SortingStrategy):
    def sort(self, dataset: List[int]) -> List[int]:
        print("Sorting using merge sort")
        return sorted(dataset)  # Simplification for demonstration

class Sorter:
    def __init__(self, strategy: SortingStrategy):
        self._strategy = strategy

    def sort(self, dataset: List[int]) -> List[int]:
        return self._strategy.sort(dataset)

# Usage
dataset = [1, 5, 3, 4, 2]

sorter = Sorter(QuickSort())
sorter.sort(dataset)  # Sorting using quick sort

sorter = Sorter(MergeSort())
sorter.sort(dataset)  # Sorting using merge sort
Enter fullscreen mode Exit fullscreen mode

Real-world use case

Scenario:
Imagine an E-commerce platform where different products are shipped to various locations worldwide. Shipping costs vary based on several factors such as destination, weight, and shipping speed. The platform needs a flexible way to calculate shipping costs that can adapt to various shipping strategies, such as standard, expedited, and international shipping. Each strategy involves different cost calculations.

Problem:
The main challenge is implementing a shipping cost calculation system that is adaptable and maintainable. Hard-coding each shipping method's cost calculation into the main application can lead to a bloated codebase, making it difficult to add or modify shipping methods in the future. As the business grows and shipping requirements evolve, this inflexibility can become a significant hindrance.

Solution:
The Strategy Pattern offers a solution by defining a family of algorithms, encapsulating each one, and making them interchangeable. The strategy pattern lets the algorithm vary independently from clients that use it. In the context of our E-commerce platform, we can define different shipping strategies as separate algorithms and switch between them as needed.

from abc import ABC, abstractmethod


class ShippingStrategy(ABC):
    """Abstract base class for shipping strategies."""

    @abstractmethod
    def calculate(self, order):
        pass


class StandardShipping(ShippingStrategy):
    """Concrete strategy for standard shipping."""

    def calculate(self, order):
        return 5.00  # Flat rate


class ExpeditedShipping(ShippingStrategy):
    """Concrete strategy for expedited shipping."""

    def calculate(self, order):
        return 10.00  # Flat rate

class InternationalShipping(ShippingStrategy):
    """Concrete strategy for international shipping."""

    def calculate(self, order):
        return 20.00  # Flat rate


class Order:
    """Client class that uses a shipping strategy."""

    def __init__(self, strategy: ShippingStrategy):
        self.strategy = strategy

    def calculate_shipping_cost(self):
        return self.strategy.calculate(self)


# Usage
# Client code
standard_order = Order(StandardShipping())
print(f"Standard Shipping Cost: {standard_order.calculate_shipping_cost()}")

expedited_order = Order(ExpeditedShipping())
print(f"Expedited Shipping Cost: {expedited_order.calculate_shipping_cost()}")

international_order = Order(InternationalShipping())
print(f"International Shipping Cost: {international_order.calculate_shipping_cost()}")
Enter fullscreen mode Exit fullscreen mode

In this implementation, ShippingStrategy is an abstract base class that defines a method calculate. Classes StandardShipping, ExpeditedShipping, and InternationalShipping implement this method to calculate shipping costs according to their respective strategies. The Order class, which represents a customer order, uses a ShippingStrategy to calculate its shipping cost.

Visitor Pattern

Purpose: To separate an algorithm from the object structure on which it operates. It allows adding new operations to existing object structures without modifying them.

Example: Performing different operations on a set of objects representing different elements of an HTML document.

class HtmlElement:
    def accept(self, visitor): pass


class HtmlParagraph(HtmlElement):
    def accept(self, visitor):
        visitor.visit_paragraph(self)


class HtmlAnchor(HtmlElement):
    def accept(self, visitor):
        visitor.visit_anchor(self)


class Visitor:
    def visit_paragraph(self, paragraph): pass
    def visit_anchor(self, anchor): pass


class RenderVisitor(Visitor):
    def visit_paragraph(self, paragraph):
        print("<p>Paragraph content</p>")

    def visit_anchor(self, anchor):
        print("<a href='url'>Anchor text</a>")

# Usage
# Client code
paragraph = HtmlParagraph()
anchor = HtmlAnchor()

render_visitor = RenderVisitor()

paragraph.accept(render_visitor)
anchor.accept(render_visitor)
Enter fullscreen mode Exit fullscreen mode

In this implementation, the visit_paragraph method in RenderVisitor prints a simple HTML paragraph element, and visit_anchor prints an anchor (link) element. This is just a basic representation, and you can modify the content and attributes of the HTML elements as needed for your application.

The client code at the end creates instances of HtmlParagraph and HtmlAnchor, and then it uses the RenderVisitor to "visit" these elements, which results in printing their HTML representations.

Real-world use case

Scenario:
Consider a scenario in a Customer Support System where different types of customer queries, like Technical Support, Billing, and General Inquiries, are handled. The system needs to generate various reports based on these queries, such as performance analysis, query type breakdown, and customer feedback. Given the diverse nature of the data and the different types of reports required, the system needs a flexible way to handle these reporting tasks without altering the existing query classes whenever a new report is needed.

Problem:
The challenge is to create a reporting system that can process a variety of customer query types and generate different reports without constantly modifying the query classes. Adding reporting functionalities directly to the query classes would violate the Single Responsibility Principle and lead to a rigid design that's hard to maintain and update, especially when new types of reports are required.

Solution:
The Visitor Pattern provides an elegant solution to this problem. It allows adding new operations to existing object structures without modifying them. The pattern involves a visitor class that performs operations on elements of an object structure. This way, you can add new reporting capabilities by simply adding new visitor classes.

from abc import ABC, abstractmethod


# Element Interface
class CustomerQuery(ABC):
    @abstractmethod
    def accept(self, visitor):
        pass


# Concrete Elements
class TechnicalSupportQuery(CustomerQuery):
    def accept(self, visitor):
        visitor.visit_technical_support(self)


class BillingQuery(CustomerQuery):
    def accept(self, visitor):
        visitor.visit_billing(self)


class GeneralInquiry(CustomerQuery):
    def accept(self, visitor):
        visitor.visit_general_inquiry(self)


# Visitor Interface
class ReportVisitor(ABC):
    @abstractmethod
    def visit_technical_support(self, query):
        pass

    @abstractmethod
    def visit_billing(self, query):
        pass

    @abstractmethod
    def visit_general_inquiry(self, query):
        pass


# Concrete Visitors
class PerformanceReportVisitor(ReportVisitor):
    def visit_technical_support(self, query):
        # Perform operations specific to performance report for technical support
        pass

    def visit_billing(self, query):
        # Perform operations specific to performance report for billing
        pass

    def visit_general_inquiry(self, query):
        # Perform operations specific to performance report for general inquiries
        pass


class FeedbackReportVisitor(ReportVisitor):
    def visit_technical_support(self, query):
        # Perform operations specific to feedback report for technical support
        pass

    def visit_billing(self, query):
        # Perform operations specific to feedback report for billing
        pass

    def visit_general_inquiry(self, query):
        # Perform operations specific to feedback report for general inquiries
        pass


#Usage
# Client code
technical_query = TechnicalSupportQuery()
billing_query = BillingQuery()
general_query = GeneralInquiry()

performance_report_visitor = PerformanceReportVisitor()
feedback_report_visitor = FeedbackReportVisitor()

technical_query.accept(performance_report_visitor)
billing_query.accept(feedback_report_visitor)
Enter fullscreen mode Exit fullscreen mode

In this implementation, CustomerQuery is an interface for different types of customer queries, and ReportVisitor is an interface for different types of report visitors. Concrete query classes like TechnicalSupportQuery, BillingQuery, and GeneralInquiry implement the CustomerQuery interface. Concrete visitor classes like PerformanceReportVisitor and FeedbackReportVisitor implement the ReportVisitor interface to handle specific reporting tasks.

💖 💪 🙅 🚩
herchila
Hernán Chilabert

Posted on December 1, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related