py4u guide

Python Design Patterns: A Structural Approach

In the realm of software development, writing code that is **flexible**, **maintainable**, and **scalable** is a universal goal. Design patterns are proven solutions to common architectural challenges, providing a shared vocabulary and best practices for structuring code. Among the three main categories of design patterns—**creational**, **structural**, and **behavioral**—structural patterns focus on how objects and classes are composed to form larger, more complex structures. Structural design patterns address relationships between entities, ensuring that changes to one part of the system have minimal impact on others. They help optimize object composition, simplify interfaces, and promote code reuse. In this blog, we’ll dive deep into structural design patterns, exploring their intent, real-world problems they solve, Python implementations, use cases, and tradeoffs. Whether you’re a seasoned developer or just starting with design patterns, this guide will equip you with the knowledge to apply these patterns effectively in your Python projects.

Table of Contents

  1. What Are Structural Design Patterns?
  2. Adapter Pattern
  3. Bridge Pattern
  4. Composite Pattern
  5. Decorator Pattern
  6. Facade Pattern
  7. Flyweight Pattern
  8. Proxy Pattern
  9. Comparing Structural Patterns
  10. Conclusion
  11. References

What Are Structural Design Patterns?

Structural design patterns are concerned with how objects and classes interact to form larger structures while keeping these structures flexible and efficient. They focus on:

  • Simplifying object relationships: Reducing dependencies between classes.
  • Reusability: Enabling components to be reused across different contexts.
  • Flexibility: Allowing the system to evolve without major overhauls.

Common structural patterns include Adapter, Bridge, Composite, Decorator, Facade, Flyweight, and Proxy. Each solves a specific problem related to object composition, and we’ll explore them one by one.

Adapter Pattern

Intent

Convert the interface of a class into another interface that clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.

Problem

You have a legacy component (e.g., an old library) with a useful feature, but its interface doesn’t match the one your new code expects. Rewriting the legacy code is costly, and you need to integrate it without breaking existing functionality.

Solution

The Adapter pattern acts as a wrapper around the legacy component, exposing the interface your new code expects. It translates calls from the new interface to the legacy interface.

Python Example

Suppose you’re building a payment processing system that expects a process_payment(amount) method. You need to integrate a legacy LegacyPaymentGateway that uses make_payment(amount) instead.

# Legacy component with incompatible interface
class LegacyPaymentGateway:
    def make_payment(self, amount: float) -> str:
        return f"Legacy gateway processed ${amount}"

# Target interface (what the new system expects)
class PaymentProcessor:
    def process_payment(self, amount: float) -> str:
        raise NotImplementedError

# Adapter: Wraps LegacyPaymentGateway to match PaymentProcessor interface
class PaymentAdapter(PaymentProcessor):
    def __init__(self, legacy_gateway: LegacyPaymentGateway):
        self.legacy_gateway = legacy_gateway

    def process_payment(self, amount: float) -> str:
        # Translate new interface to legacy interface
        return self.legacy_gateway.make_payment(amount)

# Client code
def main():
    legacy_gateway = LegacyPaymentGateway()
    adapter = PaymentAdapter(legacy_gateway)
    print(adapter.process_payment(100.0))  # Output: "Legacy gateway processed $100.0"

if __name__ == "__main__":
    main()

Use Cases

  • Integrating third-party libraries with incompatible interfaces.
  • Reusing legacy code in modern systems.
  • Supporting multiple data formats (e.g., JSON to XML adapters).

Pros & Cons

ProsCons
Enables reuse of existing components.Adds a layer of indirection, which may slightly slow down calls.
Decouples client code from legacy implementations.Can complicate the codebase if overused.

Bridge Pattern

Intent

Separate an abstraction from its implementation so that the two can vary independently.

Problem

You have two orthogonal dimensions of variation in your code (e.g., abstractions like Shape and implementations like Color). If you combine them directly (e.g., RedCircle, BlueSquare), you end up with a “class explosion” (n abstractions × m implementations = n×m subclasses).

Solution

Split the code into two hierarchies:

  1. Abstraction: Defines the high-level interface (e.g., Shape).
  2. Implementation: Defines the low-level interface (e.g., Color).

The abstraction holds a reference to an implementation object, delegating concrete work to it. This lets you vary abstractions and implementations independently.

Python Example

Let’s model shapes and colors. Instead of creating RedCircle, BlueSquare, etc., we’ll separate Shape (abstraction) and Color (implementation).

# Implementation hierarchy: Color
class Color:
    def fill(self) -> str:
        raise NotImplementedError

class Red(Color):
    def fill(self) -> str:
        return "red"

class Blue(Color):
    def fill(self) -> str:
        return "blue"

# Abstraction hierarchy: Shape (depends on Color)
class Shape:
    def __init__(self, color: Color):
        self.color = color  # Reference to implementation

    def draw(self) -> str:
        raise NotImplementedError

class Circle(Shape):
    def draw(self) -> str:
        return f"Drawing a {self.color.fill()} circle"

class Square(Shape):
    def draw(self) -> str:
        return f"Drawing a {self.color.fill()} square"

# Client code
def main():
    red = Red()
    blue = Blue()

    red_circle = Circle(red)
    blue_square = Square(blue)

    print(red_circle.draw())  # Output: "Drawing a red circle"
    print(blue_square.draw()) # Output: "Drawing a blue square"

if __name__ == "__main__":
    main()

Here, adding a new color (e.g., Green) or shape (e.g., Triangle) only requires a new class in one hierarchy, avoiding subclass explosion.

Use Cases

  • When both abstraction and implementation need to evolve independently (e.g., UI frameworks with themes and widgets).
  • When you want to hide implementation details from clients.

Pros & Cons

ProsCons
Reduces subclass proliferation.Increases complexity by introducing extra layers.
Abstraction and implementation can be developed independently.Requires careful design to avoid over-engineering.

Composite Pattern

Intent

Compose objects into tree structures to represent part-whole hierarchies. Composite lets clients treat individual objects and compositions of objects uniformly.

Problem

You need to work with tree-like structures (e.g., file systems, organization charts) where elements can be either “leaves” (individual items) or “composites” (collections of items). Clients should interact with leaves and composites the same way.

Solution

Define a common Component interface for all elements (leaves and composites). Leaves implement the interface directly, while composites implement it by delegating to their child components.

Python Example

Model a file system with File (leaf) and Directory (composite) components:

from abc import ABC, abstractmethod
from typing import List

# Component interface
class FileSystemComponent(ABC):
    @abstractmethod
    def display(self, indent: int = 0) -> None:
        pass

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

    def display(self, indent: int = 0) -> None:
        print("  " * indent + f"File: {self.name}")

# Composite: Directory (contains other components)
class Directory(FileSystemComponent):
    def __init__(self, name: str):
        self.name = name
        self.children: List[FileSystemComponent] = []

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

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

    def display(self, indent: int = 0) -> None:
        print("  " * indent + f"Directory: {self.name}")
        for child in self.children:
            child.display(indent + 1)  # Delegate to children

# Client code
def main():
    root = Directory("root")
    docs = Directory("docs")
    root.add(docs)
    docs.add(File("resume.pdf"))
    docs.add(File("notes.txt"))

    root.add(File("README.md"))
    root.display()

if __name__ == "__main__":
    main()

Output:

Directory: root
  Directory: docs
    File: resume.pdf
    File: notes.txt
  File: README.md

Clients can call display() on any FileSystemComponent (file or directory) without needing to distinguish between them.

Use Cases

  • Tree structures (file systems, organization charts, XML/JSON parsers).
  • UI components (e.g., buttons, panels containing other components).

Pros & Cons

ProsCons
Clients treat leaves and composites uniformly.Can make the design overly general (e.g., adding add/remove to leaves is unnecessary).
Easy to add new component types.Requires careful handling of composite-specific operations (e.g., remove on a leaf).

Decorator Pattern

Intent

Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality.

Problem

You need to add features to an object (e.g., logging, caching) without modifying its code (Open/Closed Principle). Subclassing would create a proliferation of classes (e.g., LoggedFile, CachedLoggedFile).

Solution

Wrap the original object in a series of “decorator” objects, each adding a specific behavior. Decorators implement the same interface as the original object, so clients can interact with them transparently.

Python Example

Let’s model pizza customization, where toppings (decorators) can be added to a base pizza (component):

from abc import ABC, abstractmethod

# Component interface
class Pizza(ABC):
    @abstractmethod
    def get_description(self) -> str:
        pass

    @abstractmethod
    def get_cost(self) -> float:
        pass

# Concrete Component: Base pizza
class Margherita(Pizza):
    def get_description(self) -> str:
        return "Margherita"

    def get_cost(self) -> float:
        return 8.99

# Decorator base class (implements Pizza interface)
class ToppingDecorator(Pizza):
    def __init__(self, pizza: Pizza):
        self.pizza = pizza  # Wrap the pizza

    @abstractmethod
    def get_description(self) -> str:
        pass

    @abstractmethod
    def get_cost(self) -> float:
        pass

# Concrete Decorators
class Cheese(ToppingDecorator):
    def get_description(self) -> str:
        return f"{self.pizza.get_description()}, Extra Cheese"

    def get_cost(self) -> float:
        return self.pizza.get_cost() + 1.50

class Mushrooms(ToppingDecorator):
    def get_description(self) -> str:
        return f"{self.pizza.get_description()}, Mushrooms"

    def get_cost(self) -> float:
        return self.pizza.get_cost() + 1.00

# Client code
def main():
    pizza = Margherita()
    print(f"Basic: {pizza.get_description()} - ${pizza.get_cost()}")

    pizza = Cheese(pizza)  # Add cheese
    pizza = Mushrooms(pizza)  # Add mushrooms
    print(f"Custom: {pizza.get_description()} - ${pizza.get_cost()}")

if __name__ == "__main__":
    main()

Output:

Basic: Margherita - $8.99
Custom: Margherita, Extra Cheese, Mushrooms - $11.49

Use Cases

  • Adding features dynamically (e.g., logging, validation, caching).
  • Implementing optional features (e.g., pizza toppings, UI theme extensions).

Pros & Cons

ProsCons
Add/remove responsibilities at runtime.Can lead to many small decorator classes.
Follows Open/Closed Principle.Decorators can obscure the original object’s behavior.

Facade Pattern

Intent

Provide a unified interface to a set of interfaces in a subsystem. Facade defines a higher-level interface that makes the subsystem easier to use.

Problem

A complex subsystem (e.g., a home theater system with an amplifier, DVD player, projector, and speakers) has many components with intricate interactions. Clients must understand and coordinate these components, leading to tight coupling and error-prone code.

Solution

Create a Facade class that encapsulates the subsystem’s complexity, exposing simple methods (e.g., watch_movie()) that handle all component interactions.

Python Example

Model a home theater system with multiple components and a facade to simplify movie watching:

# Subsystem components
class Amplifier:
    def on(self) -> None: print("Amplifier on")
    def set_volume(self, level: int) -> None: print(f"Volume set to {level}")

class DVDPlayer:
    def on(self) -> None: print("DVD Player on")
    def play(self, movie: str) -> None: print(f"Playing '{movie}'")

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

# Facade
class HomeTheaterFacade:
    def __init__(self, amp: Amplifier, dvd: DVDPlayer, projector: Projector):
        self.amp = amp
        self.dvd = dvd
        self.projector = projector

    def watch_movie(self, movie: str) -> None:
        print("\n=== Preparing to watch a movie ===")
        self.projector.on()
        self.projector.set_input("DVD")
        self.amp.on()
        self.amp.set_volume(10)
        self.dvd.on()
        self.dvd.play(movie)

# Client code
def main():
    amp = Amplifier()
    dvd = DVDPlayer()
    projector = Projector()
    theater = HomeTheaterFacade(amp, dvd, projector)
    theater.watch_movie("Inception")

if __name__ == "__main__":
    main()

Output:

=== Preparing to watch a movie ===
Projector on
Input set to DVD
Amplifier on
Volume set to 10
DVD Player on
Playing 'Inception'

Use Cases

  • Simplifying access to complex subsystems (e.g., databases, APIs with many endpoints).
  • Reducing coupling between clients and subsystems.

Pros & Cons

ProsCons
Reduces subsystem complexity for clients.Can become a “god object” if it grows to handle too many responsibilities.
Decouples clients from subsystem components.May hide useful subsystem features from advanced clients.

Flyweight Pattern

Intent

Use sharing to support large numbers of fine-grained objects efficiently. Flyweight minimizes memory usage by sharing common state between objects.

Problem

You need to create thousands of similar objects (e.g., characters in a text editor, trees in a game), each with mostly identical “intrinsic” state (e.g., font, color) and some unique “extrinsic” state (e.g., position). Storing duplicate intrinsic state wastes memory.

Solution

Separate intrinsic state (shared) from extrinsic state (unique). Create a Flyweight factory that caches objects with the same intrinsic state, reusing them instead of creating new ones.

Python Example

Model characters in a text editor where most share the same font and size (intrinsic state), but have unique positions (extrinsic state):

from typing import Dict

# Flyweight: Shared intrinsic state
class CharacterFlyweight:
    def __init__(self, font: str, size: int):
        self.font = font  # Intrinsic state (shared)
        self.size = size  # Intrinsic state (shared)

    def render(self, char: str, x: int, y: int) -> None:
        # Extrinsic state (position) passed as arguments
        print(f"Rendering '{char}' at ({x},{y}) with {self.font} {self.size}pt")

# Flyweight Factory: Caches flyweights
class CharacterFactory:
    _flyweights: Dict[tuple, CharacterFlyweight] = {}

    @classmethod
    def get_flyweight(cls, font: str, size: int) -> CharacterFlyweight:
        key = (font, size)
        if key not in cls._flyweights:
            cls._flyweights[key] = CharacterFlyweight(font, size)
        return cls._flyweights[key]

# Client code: Manages extrinsic state
def main():
    factory = CharacterFactory()

    # Render 5 'A's with the same font/size (shared flyweight)
    flyweight = factory.get_flyweight("Arial", 12)
    for i in range(5):
        flyweight.render("A", x=i*20, y=100)  # Extrinsic state: x varies

    # Render a 'B' with different font/size (new flyweight)
    flyweight = factory.get_flyweight("Times New Roman", 14)
    flyweight.render("B", x=0, y=120)

    print(f"Total flyweights created: {len(factory._flyweights)}")  # Output: 2

if __name__ == "__main__":
    main()

Use Cases

  • Large object collections with shared state (e.g., game sprites, text rendering, map tiles).
  • Memory-constrained systems (e.g., mobile apps).

Pros & Cons

ProsCons
Reduces memory usage significantly.Adds complexity (separating intrinsic/extrinsic state).
Centralizes shared state management.Extrinsic state must be passed around, which can be cumbersome.

Proxy Pattern

Intent

Provide a surrogate or placeholder for another object to control access to it.

Problem

You need to restrict access to an object (e.g., for security), delay its creation (lazy initialization), or log interactions. Directly accessing the object would expose it to unintended use or inefficiency.

Solution

Create a Proxy object that implements the same interface as the target object. The Proxy intercepts calls to the target, adding behavior (e.g., lazy loading, access control) before delegating to the target.

Python Example

A ProxyImage that delays loading an image from disk until it’s needed (lazy initialization):

from abc import ABC, abstractmethod

# Subject interface
class Image(ABC):
    @abstractmethod
    def display(self) -> None:
        pass

# Real Subject: Loads image from disk (expensive)
class RealImage(Image):
    def __init__(self, filename: str):
        self.filename = filename
        self._load_from_disk()  # Heavy operation

    def _load_from_disk(self) -> None:
        print(f"Loading image: {self.filename}")

    def display(self) -> None:
        print(f"Displaying image: {self.filename}")

# Proxy: Controls access to RealImage (lazy loading)
class ProxyImage(Image):
    def __init__(self, filename: str):
        self.filename = filename
        self._real_image: RealImage | None = None  # Defer creation

    def display(self) -> None:
        if not self._real_image:
            self._real_image = RealImage(self.filename)  # Create only when needed
        self._real_image.display()

# Client code
def main():
    image = ProxyImage("vacation.jpg")
    print("Image created (but not loaded yet)")

    print("\nFirst display:")
    image.display()  # Loads and displays

    print("\nSecond display:")
    image.display()  # Uses cached RealImage

if __name__ == "__main__":
    main()

Output:

Image created (but not loaded yet)

First display:
Loading image: vacation.jpg
Displaying image: vacation.jpg

Second display:
Displaying image: vacation.jpg

Use Cases

  • Remote Proxy: Represent an object in a different address space (e.g., API clients).
  • Virtual Proxy: Lazy initialization (e.g., loading large images).
  • Protection Proxy: Control access (e.g., restricting sensitive operations).

Pros & Cons

ProsCons
Adds functionality without modifying the target object.Introduces indirection and potential latency.
Enables lazy initialization and access control.Proxies must mimic the target’s interface, which can be error-prone.

Comparing Structural Patterns

To choose the right structural pattern, consider their core goals:

PatternCore GoalKey Differentiator
AdapterMake incompatible interfaces compatibleTranslates between interfaces.
BridgeSeparate abstraction from implementationDecouples hierarchies to enable independent variation.
CompositeTreat individual objects and composites uniformlyTree structure with part-whole relationships.
DecoratorAdd responsibilities dynamicallyWraps objects to extend behavior.
FacadeSimplify a complex subsystemProvides a unified, high-level interface.
FlyweightReduce memory usage for large object setsShares intrinsic state between objects.
ProxyControl access to an objectActs as a surrogate for the target object.

Conclusion

Structural design patterns are powerful tools for building flexible, maintainable, and efficient systems. By focusing on object composition, they help simplify relationships between components, promote reuse, and enable systems to evolve gracefully.

Whether you need to integrate legacy code (Adapter), reduce subclass proliferation (Bridge), or add features dynamically (Decorator), there’s a structural pattern to solve the problem. The key is to understand the tradeoffs and apply patterns judiciously—over-engineering with patterns can complicate code just as much as under-engineering.

By mastering these patterns, you’ll write code that is not only functional but also elegant and adaptable to change.

References