Structural Design Patterns
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:
WeatherService defines the target interface the client code depends on.
LegacyWeatherService already implements that interface and needs no changes.
NewWeatherService is incompatible — it exposes fetch_weather() and returns JSON.
NewWeatherServiceAdapter wraps the new service, converts JSON to XML, and implements WeatherService.
- 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:
Coffee is the component interface — description and cost.
BasicCoffee is the concrete component with base price and label.
CoffeeDecorator holds a wrapped Coffee and delegates by default.
- Concrete decorators (
Milk, Sugar, WhippedCream) override only what they change.
- 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:
NetworkService is the shared interface for both the real subject and the proxy.
RealNetworkService performs the expensive remote fetch.
NetworkServiceProxy implements the same interface and sits in front of the real object.
- On a cache miss, the proxy lazily creates
RealNetworkService, fetches, and stores the result.
- 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:
FileSystemNode is the component interface shared by leaves and composites.
File is a leaf — it has a fixed size and no children.
Folder is a composite — it stores children and delegates operations to them.
add builds the tree; display and get_size work recursively on any node.
- 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:
- Subsystem classes (
VideoDecoder, AudioMixer, etc.) each handle one low-level concern.
VideoConversionFacade owns instances of all subsystem classes.
convert_and_stream orchestrates decode → mix → encode → publish in the correct order.
- Clients depend only on the facade, not on four separate classes and their call sequence.
- 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:
TreeType holds intrinsic state (name, color, texture) that many trees share.
TreeTypeFactory acts as a flyweight pool — one TreeType instance per unique combination.
Tree stores extrinsic state (x, y) plus a reference to a shared TreeType.
Forest.plant requests a shared type from the factory instead of duplicating metadata.
- 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.