py4u guide

Structural vs. Behavioral: Python Design Patterns Compared

Design patterns are proven solutions to common software design problems. They provide a shared vocabulary for developers, enabling more efficient communication and robust, maintainable code. While there are many types of design patterns, two broad categories stand out for their focus on distinct aspects of object-oriented design: **Structural** and **Behavioral** patterns. Structural patterns deal with *object composition*—how classes and objects are combined to form larger, more flexible structures. They help solve problems related to "how to build" systems by defining relationships between entities. Behavioral patterns, by contrast, focus on *interaction* between objects—how they communicate, delegate responsibilities, and coordinate behavior. They address "how to behave" by defining algorithms, workflows, and state management. In this blog, we’ll dive deep into Structural and Behavioral patterns, explore key examples with Python code, and compare their use cases to help you choose the right pattern for your project.

Table of Contents

  1. What Are Design Patterns?
  2. Structural Design Patterns
  3. Behavioral Design Patterns
  4. Structural vs. Behavioral: Key Differences
  5. When to Use Which?
  6. Conclusion
  7. References

What Are Design Patterns?

Design patterns are reusable solutions to recurring problems in software design. They are not code snippets but templates that guide how to structure classes and objects to solve specific challenges. The “Gang of Four” (GoF) formalized 23 core patterns in their 1994 book Design Patterns: Elements of Reusable Object-Oriented Software, categorizing them into three groups:

  • Creational: Focus on object instantiation (e.g., Singleton, Factory).
  • Structural: Focus on object composition (e.g., Adapter, Decorator).
  • Behavioral: Focus on object interaction (e.g., Observer, Strategy).

This blog focuses on Structural and Behavioral patterns, exploring their use cases, implementations, and differences.

Structural Design Patterns

Structural patterns focus on how objects and classes are composed to form larger structures. They simplify relationships between entities, enabling flexible and efficient system architectures. Common goals include adapting interfaces, combining objects, or hiding complexity.

Adapter Pattern

Purpose: Convert the interface of a class into another interface that clients expect. Useful for integrating legacy code or third-party libraries with incompatible interfaces.

Use Case: Suppose you have a legacy payment processor with a method make_payment(amount), but your new e-commerce system expects a process_payment(amount) interface.

Python Example:

# Legacy payment processor (incompatible interface)
class LegacyPaymentProcessor:
    def make_payment(self, amount: float) -> None:
        print(f"Legacy: Processing payment of ${amount}")

# Target interface expected by the new system
class PaymentProcessor:
    def process_payment(self, amount: float) -> None:
        raise NotImplementedError

# Adapter: Wraps LegacyPaymentProcessor to match PaymentProcessor
class PaymentAdapter(PaymentProcessor):
    def __init__(self, legacy_processor: LegacyPaymentProcessor):
        self.legacy_processor = legacy_processor

    def process_payment(self, amount: float) -> None:
        # Adapt the new interface to the legacy method
        self.legacy_processor.make_payment(amount)

# Client code (new system)
def main():
    legacy_processor = LegacyPaymentProcessor()
    adapter = PaymentAdapter(legacy_processor)
    adapter.process_payment(100.0)  # Works with the new interface!

if __name__ == "__main__":
    main()

Output:

Legacy: Processing payment of $100.0

Decorator Pattern

Purpose: Dynamically add responsibilities to an object without altering its structure. Ideal for adding features (e.g., logging, caching) to objects at runtime.

Use Case: A coffee shop where customers can add toppings (milk, sugar, foam) to their coffee, with each topping increasing the cost.

Python Example:

# Base component: Coffee
class Coffee:
    def cost(self) -> float:
        return 5.0  # Base coffee cost

    def description(self) -> str:
        return "Basic Coffee"

# Decorator base class (follows the Coffee interface)
class CoffeeDecorator(Coffee):
    def __init__(self, coffee: Coffee):
        self._coffee = coffee

    def cost(self) -> float:
        return self._coffee.cost()

    def description(self) -> str:
        return self._coffee.description()

# Concrete decorators
class MilkDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return super().cost() + 1.5  # Add milk cost

    def description(self) -> str:
        return super().description() + ", Milk"

class SugarDecorator(CoffeeDecorator):
    def cost(self) -> float:
        return super().cost() + 0.5  # Add sugar cost

    def description(self) -> str:
        return super().description() + ", Sugar"

# Client code
def main():
    coffee = Coffee()
    coffee_with_milk = MilkDecorator(coffee)
    coffee_with_milk_sugar = SugarDecorator(coffee_with_milk)

    print(f"Order: {coffee_with_milk_sugar.description()}")
    print(f"Total Cost: ${coffee_with_milk_sugar.cost()}")

if __name__ == "__main__":
    main()

Output:

Order: Basic Coffee, Milk, Sugar
Total Cost: $7.0

Composite Pattern

Purpose: Treat individual objects and compositions of objects uniformly. Useful for tree-like structures (e.g., file systems, organization hierarchies).

Use Case: A file system where files and directories are treated as “components”—both have a get_size() method, but directories aggregate sizes of their children.

Python Example:

from abc import ABC, abstractmethod

# Component interface
class FileSystemComponent(ABC):
    @abstractmethod
    def get_size(self) -> int:
        pass

# Leaf: Individual file
class File(FileSystemComponent):
    def __init__(self, name: str, size: int):
        self.name = name
        self.size = size

    def get_size(self) -> int:
        return self.size

# Composite: Directory (contains files/directories)
class Directory(FileSystemComponent):
    def __init__(self, name: str):
        self.name = name
        self.children: list[FileSystemComponent] = []

    def add_child(self, component: FileSystemComponent) -> None:
        self.children.append(component)

    def remove_child(self, component: FileSystemComponent) -> None:
        self.children.remove(component)

    def get_size(self) -> int:
        # Sum sizes of all children
        return sum(child.get_size() for child in self.children)

# Client code
def main():
    # Create files
    file1 = File("document.txt", 1024)
    file2 = File("image.jpg", 2048)

    # Create directories
    docs_dir = Directory("Documents")
    docs_dir.add_child(file1)

    root_dir = Directory("Root")
    root_dir.add_child(docs_dir)
    root_dir.add_child(file2)

    print(f"Total size of Root directory: {root_dir.get_size()} bytes")

if __name__ == "__main__":
    main()

Output:

Total size of Root directory: 3072 bytes

Proxy Pattern

Purpose: Provide a placeholder for another object to control access to it (e.g., lazy initialization, access control, or logging).

Use Case: A database connection that is expensive to create. Use a proxy to delay initialization until the connection is first needed.

Python Example:

class RealDatabase:
    def __init__(self):
        # Simulate expensive connection setup
        print("Connecting to database... (takes 2s)")
        import time
        time.sleep(2)
        print("Connected!")

    def query(self, sql: str) -> None:
        print(f"Executing query: {sql}")

class DatabaseProxy:
    def __init__(self):
        self._real_db = None  # Lazy initialization

    def query(self, sql: str) -> None:
        if not self._real_db:
            self._real_db = RealDatabase()  # Initialize only when needed
        self._real_db.query(sql)

# Client code
def main():
    db = DatabaseProxy()
    print("Proxy created (no connection yet)")
    db.query("SELECT * FROM users")  # Triggers connection
    db.query("INSERT INTO logs ...")  # Uses existing connection

if __name__ == "__main__":
    main()

Output:

Proxy created (no connection yet)
Connecting to database... (takes 2s)
Connected!
Executing query: SELECT * FROM users
Executing query: INSERT INTO logs ...

Facade Pattern

Purpose: Provide a simplified interface to a complex subsystem. Reduces dependencies between clients and subsystem components.

Use Case: A home theater system with multiple components (DVD player, projector, speakers). Instead of controlling each component individually, use a facade to coordinate them.

Python Example:

# Complex subsystem components
class DVDPlayer:
    def play(self, movie: str) -> None:
        print(f"DVD: Playing '{movie}'")

class Projector:
    def turn_on(self) -> None:
        print("Projector: On")
    def set_input(self, source: str) -> None:
        print(f"Projector: Input set to {source}")

class Speakers:
    def turn_on(self) -> None:
        print("Speakers: On")
    def set_volume(self, level: int) -> None:
        print(f"Speakers: Volume {level}")

# Facade
class HomeTheaterFacade:
    def __init__(self):
        self.dvd = DVDPlayer()
        self.projector = Projector()
        self.speakers = Speakers()

    def watch_movie(self, movie: str) -> None:
        print("\n=== Preparing to watch movie ===")
        self.projector.turn_on()
        self.projector.set_input("DVD")
        self.speakers.turn_on()
        self.speakers.set_volume(15)
        self.dvd.play(movie)

# Client code
def main():
    theater = HomeTheaterFacade()
    theater.watch_movie("Inception")

if __name__ == "__main__":
    main()

Output:

=== Preparing to watch movie ===
Projector: On
Projector: Input set to DVD
Speakers: On
Speakers: Volume 15
DVD: Playing 'Inception'

Behavioral Design Patterns

Behavioral patterns focus on how objects interact and distribute responsibility. They define algorithms, workflows, and communication protocols to ensure flexible and maintainable interaction between components.

Observer Pattern

Purpose: Define a one-to-many dependency between objects. When one object (subject) changes state, all its dependents (observers) are notified and updated automatically.

Use Case: A weather station where multiple displays (e.g., temperature, humidity) update whenever new weather data is available.

Python Example:

from abc import ABC, abstractmethod

# Subject interface
class Subject(ABC):
    @abstractmethod
    def attach(self, observer: "Observer") -> None:
        pass
    @abstractmethod
    def detach(self, observer: "Observer") -> None:
        pass
    @abstractmethod
    def notify(self) -> None:
        pass

# Observer interface
class Observer(ABC):
    @abstractmethod
    def update(self, temperature: float, humidity: float) -> None:
        pass

# Concrete Subject: Weather Station
class WeatherStation(Subject):
    def __init__(self):
        self._observers: list[Observer] = []
        self._temperature: float = 0.0
        self._humidity: float = 0.0

    def attach(self, observer: Observer) -> None:
        self._observers.append(observer)

    def detach(self, observer: Observer) -> None:
        self._observers.remove(observer)

    def notify(self) -> None:
        for observer in self._observers:
            observer.update(self._temperature, self._humidity)

    def set_measurements(self, temperature: float, humidity: float) -> None:
        self._temperature = temperature
        self._humidity = humidity
        self.notify()  # Notify observers of new data

# Concrete Observers: Displays
class TemperatureDisplay(Observer):
    def update(self, temperature: float, humidity: float) -> None:
        print(f"Temperature Display: {temperature}°C")

class HumidityDisplay(Observer):
    def update(self, temperature: float, humidity: float) -> None:
        print(f"Humidity Display: {humidity}%")

# Client code
def main():
    station = WeatherStation()
    temp_display = TemperatureDisplay()
    humidity_display = HumidityDisplay()

    station.attach(temp_display)
    station.attach(humidity_display)

    station.set_measurements(22.5, 65.0)  # Triggers updates
    station.set_measurements(24.0, 60.0)

if __name__ == "__main__":
    main()

Output:

Temperature Display: 22.5°C
Humidity Display: 65.0%
Temperature Display: 24.0°C
Humidity Display: 60.0%

Strategy Pattern

Purpose: Encapsulate a family of algorithms and make them interchangeable. Allows selecting an algorithm at runtime without changing client code.

Use Case: A payment system supporting multiple payment methods (credit card, PayPal, Bitcoin). Each method is a “strategy” that can be swapped dynamically.

Python Example:

from abc import ABC, abstractmethod

# Strategy interface
class PaymentStrategy(ABC):
    @abstractmethod
    def pay(self, amount: float) -> None:
        pass

# Concrete strategies
class CreditCardStrategy(PaymentStrategy):
    def __init__(self, card_number: str):
        self.card_number = card_number

    def pay(self, amount: float) -> None:
        print(f"Paid ${amount} via Credit Card (****-****-****-{self.card_number[-4:]})")

class PayPalStrategy(PaymentStrategy):
    def __init__(self, email: str):
        self.email = email

    def pay(self, amount: float) -> None:
        print(f"Paid ${amount} via PayPal ({self.email})")

# Context: Uses a strategy
class ShoppingCart:
    def __init__(self, payment_strategy: PaymentStrategy):
        self._payment_strategy = payment_strategy

    def checkout(self, amount: float) -> None:
        self._payment_strategy.pay(amount)

# Client code
def main():
    # Pay with credit card
    cart = ShoppingCart(CreditCardStrategy("4111-1111-1111-1234"))
    cart.checkout(99.99)

    # Switch to PayPal
    cart = ShoppingCart(PayPalStrategy("[email protected]"))
    cart.checkout(49.99)

if __name__ == "__main__":
    main()

Output:

Paid $99.99 via Credit Card (****-****-****-1234)
Paid $49.99 via PayPal ([email protected])

Command Pattern

Purpose: Encapsulate a request as an object, allowing parameterization of clients with different requests, queuing of requests, or support for undo/redo.

Use Case: A remote control with buttons that trigger actions (e.g., turn on TV, adjust volume). Each button maps to a “command” object.

Python Example:

from abc import ABC, abstractmethod

# Command interface
class Command(ABC):
    @abstractmethod
    def execute(self) -> None:
        pass
    @abstractmethod
    def undo(self) -> None:
        pass

# Receiver: Object that performs the action
class TV:
    def turn_on(self) -> None:
        print("TV: On")
    def turn_off(self) -> None:
        print("TV: Off")
    def volume_up(self) -> None:
        print("TV: Volume +")
    def volume_down(self) -> None:
        print("TV: Volume -")

# Concrete commands
class TurnOnTVCommand(Command):
    def __init__(self, tv: TV):
        self.tv = tv

    def execute(self) -> None:
        self.tv.turn_on()
    def undo(self) -> None:
        self.tv.turn_off()

class VolumeUpCommand(Command):
    def __init__(self, tv: TV):
        self.tv = tv

    def execute(self) -> None:
        self.tv.volume_up()
    def undo(self) -> None:
        self.tv.volume_down()

# Invoker: Sends commands
class RemoteControl:
    def __init__(self):
        self._command: Command | None = None

    def set_command(self, command: Command) -> None:
        self._command = command

    def press_button(self) -> None:
        if self._command:
            self._command.execute()

    def press_undo(self) -> None:
        if self._command:
            self._command.undo()

# Client code
def main():
    tv = TV()
    remote = RemoteControl()

    # Turn on TV
    remote.set_command(TurnOnTVCommand(tv))
    remote.press_button()  # TV: On
    remote.press_undo()    # TV: Off

    # Adjust volume
    remote.set_command(VolumeUpCommand(tv))
    remote.press_button()  # TV: Volume +
    remote.press_button()  # TV: Volume +
    remote.press_undo()    # TV: Volume -

if __name__ == "__main__":
    main()

Output:

TV: On
TV: Off
TV: Volume +
TV: Volume +
TV: Volume -

Iterator Pattern

Purpose: Provide a way to access elements of a collection sequentially without exposing its underlying structure (e.g., list, tree).

Use Case: A custom Playlist collection that allows iterating over songs without exposing its internal storage (e.g., a dictionary).

Python Example:

from collections.abc import Iterable, Iterator

# Custom collection
class Playlist(Iterable[str]):
    def __init__(self):
        self._songs: dict[int, str] = {}  # Internal storage (hidden)
        self._next_id = 1

    def add_song(self, song: str) -> None:
        self._songs[self._next_id] = song
        self._next_id += 1

    # Implement Iterable: return an iterator
    def __iter__(self) -> Iterator[str]:
        return PlaylistIterator(self._songs)

# Custom iterator
class PlaylistIterator(Iterator[str]):
    def __init__(self, songs: dict[int, str]):
        self._songs = songs
        self._keys = iter(songs.keys())  # Iterate over sorted keys

    def __next__(self) -> str:
        key = next(self._keys)
        return self._songs[key]

# Client code
def main():
    playlist = Playlist()
    playlist.add_song("Bohemian Rhapsody")
    playlist.add_song("Hotel California")
    playlist.add_song("Hey Jude")

    # Iterate using the iterator
    for song in playlist:
        print(f"Playing: {song}")

if __name__ == "__main__":
    main()

Output:

Playing: Bohemian Rhapsody
Playing: Hotel California
Playing: Hey Jude

State Pattern

Purpose: Allow an object to alter its behavior when its internal state changes. The object will appear to change its class.

Use Case: A vending machine with states like “idle,” “selecting item,” and “dispensing.” Each state defines different behavior for user actions (e.g., inserting coins, selecting items).

Python Example:

from abc import ABC, abstractmethod

# Context: Vending Machine
class VendingMachine:
    def __init__(self):
        self._state: VendingMachineState = IdleState()

    def set_state(self, state: "VendingMachineState") -> None:
        self._state = state

    def insert_coin(self) -> None:
        self._state.insert_coin(self)

    def select_item(self) -> None:
        self._state.select_item(self)

    def dispense(self) -> None:
        self._state.dispense(self)

# State interface
class VendingMachineState(ABC):
    @abstractmethod
    def insert_coin(self, machine: VendingMachine) -> None:
        pass
    @abstractmethod
    def select_item(self, machine: VendingMachine) -> None:
        pass
    @abstractmethod
    def dispense(self, machine: VendingMachine) -> None:
        pass

# Concrete states
class IdleState(VendingMachineState):
    def insert_coin(self, machine: VendingMachine) -> None:
        print("Coin inserted. Please select an item.")
        machine.set_state(SelectingState())

    def select_item(self, machine: VendingMachine) -> None:
        print("Insert a coin first!")

    def dispense(self, machine: VendingMachine) -> None:
        print("Insert a coin and select an item first!")

class SelectingState(VendingMachineState):
    def insert_coin(self, machine: VendingMachine) -> None:
        print("Coin already inserted. Select an item.")

    def select_item(self, machine: VendingMachine) -> None:
        print("Item selected. Dispensing...")
        machine.set_state(DispensingState())

    def dispense(self, machine: VendingMachine) -> None:
        print("Select an item first!")

class DispensingState(VendingMachineState):
    def insert_coin(self, machine: VendingMachine) -> None:
        print("Please wait, dispensing item...")

    def select_item(self, machine: VendingMachine) -> None:
        print("Please wait, dispensing item...")

    def dispense(self, machine: VendingMachine) -> None:
        print("Item dispensed!")
        machine.set_state(IdleState())

# Client code
def main():
    machine = VendingMachine()
    machine.select_item()  # Insert a coin first!
    machine.insert_coin()  # Coin inserted. Please select an item.
    machine.insert_coin()  # Coin already inserted. Select an item.
    machine.select_item()  # Item selected. Dispensing...
    machine.dispense()     # Item dispensed!
    machine.insert_coin()  # Coin inserted. Please select an item.

if __name__ == "__main__":
    main()

Output:

Insert a coin first!
Coin inserted. Please select an item.
Coin already inserted. Select an item.
Item selected. Dispensing...
Item dispensed!
Coin inserted. Please select an item.

Structural vs. Behavioral: Key Differences

To summarize, here’s a comparison of Structural and Behavioral patterns:

AspectStructural PatternsBehavioral Patterns
FocusObject/class composition and relationshipsObject interaction and responsibility delegation
Primary GoalForm larger, flexible structures from objectsDefine algorithms, workflows, and communication
Key Concern”How to build” (structure)“How to behave” (interaction)
ExamplesAdapter, Decorator, Composite, Proxy, FacadeObserver, Strategy, Command, Iterator, State
Use Case ScenarioIntegrating incompatible interfaces, adding features dynamically, simplifying complex systemsEvent handling, algorithm selection, undo/redo, state management

When to Use Which?

  • Structural Patterns are ideal when:

    • You need to adapt existing code to a new interface (Adapter).
    • You want to add features to objects dynamically (Decorator).
    • You’re working with tree-like hierarchies (Composite).
    • You need to control access to expensive resources (Proxy).
    • You want to simplify a complex subsystem (Facade).
  • Behavioral Patterns are ideal when:

    • Objects need to react to state changes (Observer).
    • You want to switch algorithms at runtime (Strategy).
    • You need to queue or undo requests (Command).
    • You want to iterate over a collection without exposing its structure (Iterator).
    • An object’s behavior depends on its state (State).

Conclusion

Structural and Behavioral design patterns solve distinct but complementary problems in software design. Structural patterns focus on composing objects into flexible structures, while Behavioral patterns manage interactions and responsibility delegation. By understanding their differences and use cases, you can select the right pattern to build robust, maintainable, and scalable Python applications.

Remember: Design patterns are tools, not rules. Always prioritize simplicity—use a pattern only if it solves a specific problem in your codebase.

References