Skip to content

Structural Design Patterns#

Start here

Read the Design Patterns overview. For interview design data structure problems in Python, see Challenge Yourself.

Structural patterns deal with object composition and how large structures are formed by combining classes and objects. These patterns focus on simplifying the structure by identifying relationships.

Adapter Pattern#

Purpose: Convert the interface of a class into another interface clients expect, letting incompatible classes work together without changing their source code.

When to use:

  • Integrating a third-party library whose API does not match your domain interfaces
  • Wrapping legacy code behind a modern contract
  • Reusing existing classes when only their interface differs from what you need
from abc import ABC, abstractmethod
import json


class WeatherService(ABC):
    @abstractmethod
    def get_weather_data(self) -> str:
        pass


class LegacyWeatherService(WeatherService):
    """Returns weather as XML — the format the rest of the app expects."""

    def __init__(self, temperature: str, condition: str) -> None:
        self.temperature = temperature
        self.condition = condition

    def get_weather_data(self) -> str:
        return (
            f"<weather><temperature>{self.temperature}</temperature>"
            f"<condition>{self.condition}</condition></weather>"
        )


class NewWeatherService:
    """Third-party service that exposes JSON instead of the WeatherService interface."""

    def __init__(self, temperature: str, condition: str) -> None:
        self.temperature = temperature
        self.condition = condition

    def fetch_weather(self) -> str:
        return json.dumps(
            {"temperature": self.temperature, "condition": self.condition}
        )


class NewWeatherServiceAdapter(WeatherService):
    """Adapts NewWeatherService to the WeatherService interface."""

    def __init__(self, service: NewWeatherService) -> None:
        self._service = service

    def get_weather_data(self) -> str:
        payload = json.loads(self._service.fetch_weather())
        return (
            f"<weather><temperature>{payload['temperature']}</temperature>"
            f"<condition>{payload['condition']}</condition></weather>"
        )


def demo_adapter() -> None:
    legacy = LegacyWeatherService("72", "Sunny")
    print("Legacy:", legacy.get_weather_data())

    adapted = NewWeatherServiceAdapter(NewWeatherService("68", "Cloudy"))
    print("Adapted:", adapted.get_weather_data())


if __name__ == "__main__":
    demo_adapter()

Explanation:

  1. WeatherService defines the target interface the client code depends on.
  2. LegacyWeatherService already implements that interface and needs no changes.
  3. NewWeatherService is incompatible — it exposes fetch_weather() and returns JSON.
  4. NewWeatherServiceAdapter wraps the new service, converts JSON to XML, and implements WeatherService.
  5. Clients call get_weather_data() on both services without knowing which implementation is behind the interface.

Interview note: Adapter is the go-to when you hear "wrap an existing API" or "make two libraries talk to each other." Do not confuse it with Decorator (adds behavior) or Facade (simplifies many classes into one entry point).


Decorator Pattern#

Purpose: Attach new responsibilities to an object dynamically by wrapping it, instead of subclassing every combination of features.

When to use:

  • Adding optional features at runtime (logging, compression, pricing add-ons)
  • Avoiding a combinatorial explosion of subclasses (CoffeeWithMilkAndSugar, etc.)
  • Extending behavior while keeping the same public interface
from abc import ABC, abstractmethod


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

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


class BasicCoffee(Coffee):
    def get_description(self) -> str:
        return "Basic Coffee"

    def get_cost(self) -> float:
        return 3.00


class CoffeeDecorator(Coffee):
    def __init__(self, coffee: Coffee) -> None:
        self._coffee = coffee

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

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


class Milk(CoffeeDecorator):
    def get_description(self) -> str:
        return f"{self._coffee.get_description()}, Milk"

    def get_cost(self) -> float:
        return self._coffee.get_cost() + 0.50


class Sugar(CoffeeDecorator):
    def get_description(self) -> str:
        return f"{self._coffee.get_description()}, Sugar"

    def get_cost(self) -> float:
        return self._coffee.get_cost() + 0.30


class WhippedCream(CoffeeDecorator):
    def get_description(self) -> str:
        return f"{self._coffee.get_description()}, Whipped Cream"

    def get_cost(self) -> float:
        return self._coffee.get_cost() + 0.70


def demo_decorator() -> None:
    coffee: Coffee = BasicCoffee()
    coffee = Milk(coffee)
    coffee = Sugar(coffee)
    coffee = WhippedCream(coffee)

    print(f"Order: {coffee.get_description()}")
    print(f"Total: ${coffee.get_cost():.2f}")


if __name__ == "__main__":
    demo_decorator()

Explanation:

  1. Coffee is the component interface — description and cost.
  2. BasicCoffee is the concrete component with base price and label.
  3. CoffeeDecorator holds a wrapped Coffee and delegates by default.
  4. Concrete decorators (Milk, Sugar, WhippedCream) override only what they change.
  5. Wrapping stacks outward: each decorator adds its label and price on top of the inner object.

Interview note: Python's @functools.wraps decorators are syntactic sugar for the same idea. In OOD interviews, mention that Decorator preserves the interface while Proxy often controls access or adds lazy loading.


Proxy Pattern#

Purpose: Provide a surrogate or placeholder that controls access to another object — adding caching, lazy initialization, or access control without changing the real subject.

When to use:

  • Expensive object creation should be deferred until first use
  • Responses should be cached to avoid repeated network or disk calls
  • You need a lightweight stand-in (virtual proxy) or permission gate (protection proxy)
from abc import ABC, abstractmethod


class NetworkService(ABC):
    @abstractmethod
    def fetch_data(self, key: str) -> str:
        pass


class RealNetworkService(NetworkService):
    def fetch_data(self, key: str) -> str:
        return f"Data fetched from remote server for input: {key}"


class NetworkServiceProxy(NetworkService):
    def __init__(self) -> None:
        self._real_service: RealNetworkService | None = None
        self._cache: dict[str, str] = {}

    def fetch_data(self, key: str) -> str:
        if key in self._cache:
            print("Fetching data from cache")
            return self._cache[key]

        if self._real_service is None:
            self._real_service = RealNetworkService()

        data = self._real_service.fetch_data(key)
        self._cache[key] = data
        return data


def demo_proxy() -> None:
    service: NetworkService = NetworkServiceProxy()
    print(service.fetch_data("user:42"))   # hits remote
    print(service.fetch_data("user:42"))   # hits cache


if __name__ == "__main__":
    demo_proxy()

Explanation:

  1. NetworkService is the shared interface for both the real subject and the proxy.
  2. RealNetworkService performs the expensive remote fetch.
  3. NetworkServiceProxy implements the same interface and sits in front of the real object.
  4. On a cache miss, the proxy lazily creates RealNetworkService, fetches, and stores the result.
  5. On a cache hit, the proxy returns immediately without touching the real service.

Interview note: LRU Cache (Challenge Yourself §1) is a specialized proxy over storage with eviction — same "control access + cache" idea at interview scale.


Composite Pattern#

Purpose: Compose objects into tree structures so clients treat individual objects and groups of objects uniformly through a shared interface.

When to use:

  • Representing part-whole hierarchies (files/folders, org charts, UI component trees)
  • Clients should not distinguish between a leaf node and a container
  • You need recursive operations (size, render, search) over an entire tree
from abc import ABC, abstractmethod


class FileSystemNode(ABC):
    def __init__(self, name: str) -> None:
        self.name = name

    @abstractmethod
    def display(self, indent: int = 0) -> None:
        pass

    @abstractmethod
    def get_size(self) -> int:
        pass


class File(FileSystemNode):
    def __init__(self, name: str, size: int) -> None:
        super().__init__(name)
        self.size = size

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

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


class Folder(FileSystemNode):
    def __init__(self, name: str) -> None:
        super().__init__(name)
        self._children: list[FileSystemNode] = []

    def add(self, node: FileSystemNode) -> None:
        self._children.append(node)

    def display(self, indent: int = 0) -> None:
        print(f"{'  ' * indent}+ {self.name}/")
        for child in self._children:
            child.display(indent + 1)

    def get_size(self) -> int:
        return sum(child.get_size() for child in self._children)


def demo_composite() -> None:
    root = Folder("project")
    src = Folder("src")
    src.add(File("main.py", 12))
    src.add(File("utils.py", 8))
    root.add(src)
    root.add(File("README.md", 4))

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


if __name__ == "__main__":
    demo_composite()

Explanation:

  1. FileSystemNode is the component interface shared by leaves and composites.
  2. File is a leaf — it has a fixed size and no children.
  3. Folder is a composite — it stores children and delegates operations to them.
  4. add builds the tree; display and get_size work recursively on any node.
  5. The client calls the same methods on root, src, or a single File without type checks.

Interview note: Composite pairs naturally with Iterator (traverse the tree) and often appears in "design a file system" or "nested JSON" questions. Think dict/list nesting in Python — same uniform treatment of atoms and containers.


Facade Pattern#

Purpose: Provide a single, simplified interface to a complex subsystem, hiding the coordination details behind one entry point.

When to use:

  • A subsystem has many interdependent classes and callers only need a few high-level operations
  • You want to decouple client code from internal refactoring of the subsystem
  • Bootstrapping or orchestrating multiple services (API + DB + cache) in one call
from dataclasses import dataclass


@dataclass
class VideoFile:
    name: str


class VideoDecoder:
    def decode(self, file: VideoFile) -> str:
        return f"decoded:{file.name}"


class AudioMixer:
    def mix(self, track: str) -> str:
        return f"mixed:{track}"


class VideoEncoder:
    def encode(self, video: str, audio: str) -> str:
        return f"encoded[{video}|{audio}]"


class StreamingServer:
    def publish(self, payload: str) -> None:
        print(f"Streaming {payload}")


class VideoConversionFacade:
    """One-call API over decoder, mixer, encoder, and streaming server."""

    def __init__(self) -> None:
        self._decoder = VideoDecoder()
        self._mixer = AudioMixer()
        self._encoder = VideoEncoder()
        self._server = StreamingServer()

    def convert_and_stream(self, file: VideoFile) -> None:
        video = self._decoder.decode(file)
        audio = self._mixer.mix("default-audio-track")
        payload = self._encoder.encode(video, audio)
        self._server.publish(payload)


def demo_facade() -> None:
    facade = VideoConversionFacade()
    facade.convert_and_stream(VideoFile("interview-prep.mp4"))


if __name__ == "__main__":
    demo_facade()

Explanation:

  1. Subsystem classes (VideoDecoder, AudioMixer, etc.) each handle one low-level concern.
  2. VideoConversionFacade owns instances of all subsystem classes.
  3. convert_and_stream orchestrates decode → mix → encode → publish in the correct order.
  4. Clients depend only on the facade, not on four separate classes and their call sequence.
  5. Internal subsystem changes stay behind the facade as long as the public method signature is stable.

Interview note: Facade answers "simplify this messy API." Adapter wraps one incompatible interface; Facade coordinates many cooperating classes. Both show up in system-design discussions about service layers.


Flyweight Pattern#

Purpose: Share intrinsic (immutable) state among many fine-grained objects to cut memory use when thousands of instances differ only in extrinsic (context) data.

When to use:

  • Creating huge numbers of similar objects (game tiles, text glyphs, map markers)
  • Most object state can be extracted and shared; only a small part varies per instance
  • Memory or cache pressure matters more than object identity
from dataclasses import dataclass


@dataclass(frozen=True)
class TreeType:
    """Intrinsic state — shared, immutable."""
    name: str
    color: str
    texture: str


class TreeTypeFactory:
    _pool: dict[tuple[str, str, str], TreeType] = {}

    @classmethod
    def get(cls, name: str, color: str, texture: str) -> TreeType:
        key = (name, color, texture)
        if key not in cls._pool:
            cls._pool[key] = TreeType(name, color, texture)
        return cls._pool[key]

    @classmethod
    def pool_size(cls) -> int:
        return len(cls._pool)


class Tree:
    """Extrinsic state — position is unique per tree."""

    def __init__(self, x: int, y: int, tree_type: TreeType) -> None:
        self.x = x
        self.y = y
        self.tree_type = tree_type  # shared reference, not a copy

    def render(self) -> str:
        t = self.tree_type
        return f"{t.name} ({t.color}, {t.texture}) at ({self.x}, {self.y})"


class Forest:
    def __init__(self) -> None:
        self._trees: list[Tree] = []

    def plant(self, x: int, y: int, name: str, color: str, texture: str) -> None:
        tree_type = TreeTypeFactory.get(name, color, texture)
        self._trees.append(Tree(x, y, tree_type))

    def render(self) -> None:
        for tree in self._trees:
            print(tree.render())


def demo_flyweight() -> None:
    forest = Forest()
    for i in range(5):
        forest.plant(i, i * 2, "Oak", "Green", "Rough")
    for i in range(5):
        forest.plant(i + 10, i, "Pine", "Dark Green", "Smooth")

    forest.render()
    print(f"Trees planted: {len(forest._trees)}")
    print(f"Shared TreeType objects: {TreeTypeFactory.pool_size()}")


if __name__ == "__main__":
    demo_flyweight()

Explanation:

  1. TreeType holds intrinsic state (name, color, texture) that many trees share.
  2. TreeTypeFactory acts as a flyweight pool — one TreeType instance per unique combination.
  3. Tree stores extrinsic state (x, y) plus a reference to a shared TreeType.
  4. Forest.plant requests a shared type from the factory instead of duplicating metadata.
  5. Ten trees of the same species reuse one TreeType object, so memory scales with unique types, not tree count.

Interview note: Flyweight is less common in live coding but shows up in "optimize memory for millions of objects" system questions. In Python, interning small strings and reusing immutable tuples/dicts mirrors the same idea.