backend / theory foundations / 06_structural_patterns.md

Structural Design Patterns (GoF)

7 interview angles 12 min read source

Structural Design Patterns (GoF)

The seven GoF structural patterns deal with how classes and objects are composed to form larger structures, keeping the structures flexible and efficient.

Reference: refactoring.guru / Design Patterns — illustrated explanations of every GoF pattern; cross-reference each pattern below against the canonical descriptions there.

The seven structural patterns:

Pattern Intent refactoring.guru
Adapter Convert incompatible interface https://refactoring.guru/design-patterns/adapter
Bridge Decouple abstraction from implementation https://refactoring.guru/design-patterns/bridge
Composite Tree of part-whole objects https://refactoring.guru/design-patterns/composite
Decorator Add behavior via wrappers https://refactoring.guru/design-patterns/decorator
Facade Simplified interface to a subsystem https://refactoring.guru/design-patterns/facade
Flyweight Share state to support many objects https://refactoring.guru/design-patterns/flyweight
Proxy Surrogate / placeholder for another object https://refactoring.guru/design-patterns/proxy

For creational patterns see 05_creational_patterns.md; for behavioral 07_behavioral_patterns.md.

Adapter

Intent: Convert the interface of a class into another interface clients expect. Lets classes work together that couldn’t otherwise because of incompatible interfaces. Reference: https://refactoring.guru/design-patterns/adapter

Problem: You have a class with the data you need but the wrong API. The client expects method request(), your class has specific_request(). You can’t modify the class (third-party library, legacy code).

Solution: Wrap the incompatible class in an adapter that translates calls.

# Existing class with the wrong interface
class LegacyXMLParser:
    def parse_xml(self, xml_str: str) -> dict:
        return {"parsed": xml_str}                  # imagine real XML parsing

# Client expects this interface
class JSONParser:
    def parse(self, json_str: str) -> dict: ...

# Adapter
class XMLToJSONParserAdapter(JSONParser):
    def __init__(self, legacy: LegacyXMLParser):
        self.legacy = legacy

    def parse(self, json_str: str) -> dict:
        # Convert JSON → XML → call legacy → return result
        xml = self._json_to_xml(json_str)
        return self.legacy.parse_xml(xml)

    def _json_to_xml(self, j): return f"<root>{j}</root>"

# Client code is unchanged
def process(parser: JSONParser, data: str):
    return parser.parse(data)

result = process(XMLToJSONParserAdapter(LegacyXMLParser()), '{"key": "value"}')

Two flavors:

  • Object Adapter (composition — above example): adapter holds a reference to the adaptee.
  • Class Adapter (multiple inheritance): adapter inherits from both interfaces. Python supports but rarely needed.

When to use:

  • Integrating third-party libraries with mismatched APIs.
  • Legacy code modernization without rewriting.
  • Anti-corruption layer in DDD (translate external models to domain models).

Common pitfalls:

  • “Just expose the legacy method directly” — caller now depends on the wrong abstraction. Use an adapter to keep the boundary clean.
  • Adapter accumulating logic beyond translation — turns into a god class.

Bridge

Intent: Decouple an abstraction from its implementation so that the two can vary independently. Reference: https://refactoring.guru/design-patterns/bridge

Problem: A class hierarchy has two orthogonal dimensions (e.g., shape × color). Trying to express both via inheritance produces a Cartesian explosion: RedCircle, BlueCircle, RedSquare, BlueSquare…

Solution: Split the hierarchy into two: one for the abstraction (Shape), one for the implementation (Color). The abstraction holds a reference to the implementation.

from abc import ABC, abstractmethod

# Implementation hierarchy
class Color(ABC):
    @abstractmethod
    def fill(self) -> str: ...

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

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

# Abstraction hierarchy
class Shape(ABC):
    def __init__(self, color: Color):
        self.color = color
    @abstractmethod
    def draw(self) -> str: ...

class Circle(Shape):
    def draw(self): return f"Circle painted {self.color.fill()}"

class Square(Shape):
    def draw(self): return f"Square painted {self.color.fill()}"

print(Circle(Red()).draw())     # "Circle painted red"
print(Square(Blue()).draw())     # "Square painted blue"

Adding a new color: one new class. Adding a new shape: one new class. Without Bridge: each combination is a class.

When to use:

  • Two orthogonal axes of variation.
  • Avoiding exponential class explosion from multiple-inheritance hierarchies.
  • Need to switch the implementation at runtime.

Bridge vs Adapter:

  • Adapter retrofits incompatible interfaces; designed-in after the fact.
  • Bridge separates two dimensions of variation upfront; designed-in from the start.

Common pitfalls:

  • Over-applying when one hierarchy suffices.
  • Confusion with Strategy (similar structure; Strategy is for interchangeable algorithms, Bridge for implementations).

Composite

Intent: Compose objects into tree structures to represent part-whole hierarchies. Lets clients treat individual objects and compositions of objects uniformly. Reference: https://refactoring.guru/design-patterns/composite

Problem: A tree-shaped structure (file system, UI widget hierarchy, organization chart) where leaves and branches need similar operations. You don’t want client code to check if isinstance(x, Leaf) everywhere.

Solution: A common interface for both leaves and composites; composites recurse on their children.

from abc import ABC, abstractmethod

class FileSystemNode(ABC):
    @abstractmethod
    def size(self) -> int: ...
    @abstractmethod
    def display(self, indent: int = 0) -> str: ...

class File(FileSystemNode):
    def __init__(self, name: str, size: int):
        self.name = name
        self._size = size

    def size(self): return self._size
    def display(self, indent=0):
        return " " * indent + f"{self.name} ({self._size} bytes)"

class Directory(FileSystemNode):
    def __init__(self, name: str):
        self.name = name
        self.children: list[FileSystemNode] = []

    def add(self, child): self.children.append(child)
    def size(self): return sum(c.size() for c in self.children)
    def display(self, indent=0):
        result = [" " * indent + f"{self.name}/"]
        for child in self.children:
            result.append(child.display(indent + 2))
        return "\n".join(result)

root = Directory("project")
root.add(File("README.md", 1024))
src = Directory("src")
src.add(File("main.py", 2048))
src.add(File("utils.py", 512))
root.add(src)

print(root.display())
print(f"Total: {root.size()} bytes")

When to use:

  • Genuine tree structures.
  • Operations apply uniformly to nodes and to subtrees (size, render, print).
  • Client code shouldn’t distinguish leaves from branches.

Common pitfalls:

  • The Component interface includes operations that don’t make sense for leaves (add, remove). The classic trade-off: type safety vs uniformity. Pythonic answer: leaves raise on add or use separate interfaces.

Decorator

Intent: Attach additional responsibilities to an object dynamically. Provides a flexible alternative to subclassing for extending functionality. Reference: https://refactoring.guru/design-patterns/decorator

Problem: You want to add behavior to specific instances of a class at runtime, not all instances via inheritance. You may want multiple combinations (logged + cached + retried, or just cached + retried).

Solution: Wrap the object in a “decorator” that has the same interface and forwards calls, adding behavior.

from abc import ABC, abstractmethod

class Coffee(ABC):
    @abstractmethod
    def cost(self) -> float: ...
    @abstractmethod
    def description(self) -> str: ...

class SimpleCoffee(Coffee):
    def cost(self): return 5.0
    def description(self): return "coffee"

class CoffeeDecorator(Coffee):
    def __init__(self, wrappee: Coffee):
        self._wrappee = wrappee
    def cost(self): return self._wrappee.cost()
    def description(self): return self._wrappee.description()

class Milk(CoffeeDecorator):
    def cost(self): return super().cost() + 1.5
    def description(self): return super().description() + ", milk"

class Sugar(CoffeeDecorator):
    def cost(self): return super().cost() + 0.5
    def description(self): return super().description() + ", sugar"

class WhippedCream(CoffeeDecorator):
    def cost(self): return super().cost() + 2.0
    def description(self): return super().description() + ", whipped cream"

# Stack decorators dynamically
order = WhippedCream(Sugar(Milk(SimpleCoffee())))
print(order.description())      # "coffee, milk, sugar, whipped cream"
print(f"${order.cost():.2f}")    # "$9.00"

Pythonic note: don’t confuse with Python decorator syntax (@functools.wraps). They share the name and concept (wrapping behavior), but Python decorators wrap functions/classes via syntax; the GoF Decorator wraps objects via composition.

When to use:

  • Adding responsibilities to objects without subclass explosion.
  • Behavior is opt-in per-instance.
  • Combining multiple optional behaviors (logging × caching × retry × encryption).

Common pitfalls:

  • Wrapping order matters for non-commutative operations.
  • Identity confusion: wrapped is original is False; isinstance(wrapped, Original) may be False.
  • Deeply nested decorators hide the underlying object’s behavior; harder to debug.

Facade

Intent: Provide a unified, simpler interface to a set of interfaces in a subsystem. Defines a higher-level interface that makes the subsystem easier to use. Reference: https://refactoring.guru/design-patterns/facade

Problem: A complex subsystem has many classes with intricate interactions. Client code shouldn’t deal with the internal complexity for common operations.

Solution: A single class (the Facade) exposes the simplified operations clients need; internally it coordinates the subsystem.

# Complex subsystem
class VideoFile:
    def __init__(self, path): self.path = path

class CodecFactory:
    def extract(self, file: VideoFile): return "h264_codec" if "h264" in file.path else "mpeg4_codec"

class BitrateReader:
    @staticmethod
    def read(file, codec): return f"read {file.path} with {codec}"

    @staticmethod
    def convert(buffer, codec): return f"converted to {codec}"

class AudioMixer:
    def fix(self, output): return f"audio fixed in {output}"

# Facade — one-method API for converting video
class VideoConverter:
    def convert(self, filename: str, target_format: str) -> str:
        file = VideoFile(filename)
        source_codec = CodecFactory().extract(file)
        destination_codec = "ogg_codec" if target_format == "ogg" else "mpeg4_codec"
        buffer = BitrateReader.read(file, source_codec)
        result = BitrateReader.convert(buffer, destination_codec)
        result = AudioMixer().fix(result)
        return f"final: {result}"

# Client uses one simple call
converter = VideoConverter()
print(converter.convert("input.mp4", "ogg"))

Facade vs Adapter:

  • Facade: simplifies a complex subsystem (you control the subsystem).
  • Adapter: makes incompatible interface compatible (you adapt to an external API).

When to use:

  • Layered architecture — each layer’s API is a facade over the next.
  • Wrapping a complex third-party library with a project-specific API.
  • Microservice “API gateway” — facade over many internal services.

Common pitfalls:

  • Facade becoming a god object as more operations are added.
  • Hiding the subsystem too completely — power users need to bypass.

Flyweight

Intent: Use sharing to support large numbers of fine-grained objects efficiently. Reference: https://refactoring.guru/design-patterns/flyweight

Problem: You need many instances of similar objects (millions of game particles, every character in a document) and creating one per occurrence runs out of memory.

Solution: Separate the intrinsic state (shared, immutable) from the extrinsic state (varies per instance, supplied per call). Cache intrinsic state and share instances.

class TreeType:
    """Intrinsic state — shared across many trees."""
    def __init__(self, name: str, color: str, texture: str):
        self.name = name
        self.color = color
        self.texture = texture
    def draw(self, x: int, y: int):
        return f"Drawing {self.name} ({self.color}) at ({x}, {y})"

class TreeTypeFactory:
    _types: dict[tuple, TreeType] = {}
    @classmethod
    def get(cls, name: str, color: str, texture: str) -> TreeType:
        key = (name, color, texture)
        if key not in cls._types:
            cls._types[key] = TreeType(name, color, texture)
        return cls._types[key]                       # return shared instance

class Tree:
    """Each tree has unique position (extrinsic); shares its type."""
    def __init__(self, x: int, y: int, type_: TreeType):
        self.x = x
        self.y = y
        self.type = type_
    def draw(self):
        return self.type.draw(self.x, self.y)

# Forest: a million trees, but only a handful of TreeTypes
forest = []
for i in range(1_000_000):
    type_ = TreeTypeFactory.get("Oak", "green", "rough")    # all share one TreeType
    forest.append(Tree(x=i % 1000, y=i // 1000, type_=type_))

Without Flyweight: 1M trees × (name + color + texture + position) ≈ huge memory. With Flyweight: 1M positions + 1 shared TreeType. The expensive fields are stored once.

Python’s string interning is a built-in Flyweight: short strings and identifier-like strings are cached, so multiple "hello" literals share one object.

When to use:

  • Many similar objects (graphics, game entities, document characters, CSS styles).
  • Memory pressure is real.
  • Most of the object state is intrinsic (immutable, shareable).

When NOT to use:

  • The objects aren’t actually similar.
  • Memory isn’t the bottleneck.
  • Extracting extrinsic state is more complex than the savings.

Common pitfalls:

  • Mutating “intrinsic” state breaks the sharing assumption.
  • Hash key collisions in the factory cache.
  • Thread safety — the factory cache needs locking under concurrent access.

Proxy

Intent: Provide a surrogate or placeholder for another object to control access to it. Reference: https://refactoring.guru/design-patterns/proxy

Problem: You want to add behavior around accessing an object — lazy loading, access control, caching, logging — without modifying the object itself or its clients.

Solution: A proxy class with the same interface as the real object. The proxy delegates to the real object, adding its own logic before/after/instead.

Common proxy types:

Type Purpose
Virtual Proxy lazy-load expensive objects on first use
Protection Proxy enforce access control
Remote Proxy local stand-in for remote object (RPC clients)
Caching Proxy cache results
Logging / Smart Reference Proxy reference counting, logging, monitoring
from abc import ABC, abstractmethod
import time

class Image(ABC):
    @abstractmethod
    def display(self) -> str: ...

class RealImage(Image):
    def __init__(self, filename: str):
        self.filename = filename
        self._load()                                  # expensive: load from disk

    def _load(self):
        print(f"Loading {self.filename}...")
        time.sleep(0.1)                               # imagine 100ms disk I/O

    def display(self):
        return f"Displaying {self.filename}"

class ImageProxy(Image):
    def __init__(self, filename: str):
        self.filename = filename
        self._real_image: RealImage | None = None     # lazy

    def display(self):
        if self._real_image is None:
            self._real_image = RealImage(self.filename)
        return self._real_image.display()

# Constructing a proxy is cheap; loading deferred
images = [ImageProxy(f"photo_{i}.jpg") for i in range(1000)]
# Only loads when display() is called
print(images[5].display())

Protection proxy:

class AdminProxy:
    def __init__(self, real_service, user):
        self.real = real_service
        self.user = user

    def delete_user(self, user_id):
        if not self.user.is_admin:
            raise PermissionError("admin required")
        return self.real.delete_user(user_id)

Proxy vs Decorator:

  • Proxy controls access — same lifetime/identity boundary as the real object.
  • Decorator adds responsibilities — multiple decorators stack.
  • Structurally similar; intent differs.

Proxy vs Adapter:

  • Adapter: different interface, makes compatible.
  • Proxy: same interface, controls access.

When to use:

  • Lazy initialization for expensive objects.
  • Authorization/security wrapping.
  • Remote access (RPC clients are proxies).
  • Caching (memoization wrappers).
  • Logging and metrics injection.

Common pitfalls:

  • Proxy operations that mask the real object’s exceptions or behavior subtly.
  • Forgetting that proxy is real_object is False.
  • Deep proxy chains complicating debugging.

Choosing among structural patterns

Situation Pattern
Incompatible interfaces Adapter
Two orthogonal axes of variation Bridge
Tree-shaped data, uniform operations Composite
Add behavior to specific instances Decorator
Simplified interface to a complex subsystem Facade
Many similar objects, save memory Flyweight
Control access (lazy / cache / auth / remote) Proxy

Pattern relationships

  • Adapter + Facade: both wrap something; Adapter for compatibility, Facade for simplification.
  • Decorator + Proxy: structurally identical; Decorator stacks for behavior, Proxy controls access.
  • Composite + Decorator: composites use decorators internally; decorators can be composed.
  • Bridge + Strategy: structurally identical; Bridge is a design-time decision about abstraction/implementation split, Strategy is runtime algorithm selection (Strategy is behavioral, see 07_behavioral_patterns.md).

Common interview confusions

  • “Adapter and Facade are the same.” — overlap but differ in intent. Adapter changes one interface; Facade simplifies many.
  • “Python’s @decorator is the Decorator pattern.” — related but different. The syntax wraps functions; the GoF pattern wraps objects with classes.
  • “Proxy and Decorator are interchangeable.” — same structure, different intent. Don’t mix them up in design discussions.

Interview angle

  • “What are the GoF structural patterns?” — seven patterns about composing classes and objects: Adapter, Bridge, Composite, Decorator, Facade, Flyweight, Proxy.
  • “Adapter vs Facade?” — Adapter wraps a class with an incompatible interface to match what clients expect (single class). Facade provides a simplified interface over a complex subsystem (many classes).
  • “Decorator vs Proxy?” — same structure (wrapper with the same interface as the wrapped object); intent differs. Decorator adds behavior (stacks for combinations); Proxy controls access (one wrapper; lifetime/identity-bound to the real object).
  • “What’s Flyweight and when do you use it?” — share intrinsic (immutable) state across many similar objects; supply extrinsic (per-instance) state externally. Use for many similar objects under memory pressure (game particles, document characters, CSS styles). Python’s string interning is a built-in example.
  • “How does the Composite pattern work?” — leaves and composites share a common interface; composites recurse on children. Client code treats both uniformly. Common in file systems, UI hierarchies, expression trees.
  • “What is the Bridge pattern’s main benefit?” — separates two orthogonal axes of variation (e.g., shape × color) so each can vary independently. Avoids Cartesian-product class explosion from multi-dimensional inheritance.
  • “Real-world examples of Proxy?” — ORM lazy loading (loading a related object only on access), Django/Flask permission checks, gRPC client stubs (remote proxy), @lru_cache (caching proxy concept), HTTP reverse proxies (different but related concept).