Francesco Sannicola

Machine Learning | Software Engineering

./articles/ Software Engineering Essentials: Structural Design Patterns

Last modified:

Structural design patterns organize objects and classes into larger structures while keeping those structures flexible. They focus on composition boundaries: adapting interfaces, separating variation axes, treating parts and wholes uniformly, wrapping behavior, simplifying subsystem access, sharing state, and controlling access.

This chapter covers all seven Structural patterns in the Gang of Four catalog. Each section moves from a concrete composition problem to a creation or delegation flow, then provides independent Python and Java examples with matching demo output. For object creation and object collaboration, see the Creational and Behavioral chapters.

Project source: OOP Design Patterns Handbook: Structural Patterns.

Run each Python example independently with Python 3.10 or later; only the standard library is required. Each Java example is a separate source file named in its first comment. Compile and run its public class, for example javac -encoding UTF-8 AdapterDemo.java followed by java AdapterDemo. The Java examples target Java 8 or later and use only JDK APIs. Do not combine the snippets into one file.

Repository verification: the linked source could not be retrieved during preparation. These are independently tested Python reference examples, not verified reproductions of the repository's APIs or naming. No specific upstream discrepancy is asserted without inspecting that source.

Quick Reference

PatternCore IntentKey Benefit
AdapterConvert an existing interface into one a client expectsIntegrates incompatible code without changing either side
BridgeSeparate an abstraction from its implementationAvoids a class for every combination of two variation axes
CompositeTreat individual objects and containers uniformlySimplifies operations across part-whole hierarchies
DecoratorAttach responsibilities by wrapping an objectCombines optional behavior without subclass explosion
FacadeOffer one simplified entry point to subsystemsCoordinates a common workflow and reduces client coupling
FlyweightShare immutable state across many objectsReduces memory use when state repeats at scale
ProxyControl access through a same-interface substituteAdds protection, caching, logging, or deferred access

The diagrams emphasize composition and delegation rather than exhaustive class relationships. On narrow screens, diagrams scroll horizontally to keep labels readable. Code and output remain visible if JavaScript is unavailable.

1. Adapter

Intent

Adapter converts one interface into another that a client expects. It lets existing code collaborate without changing either side.

The Problem It Solves

A checkout service works with integer cents and a charge() contract. A legacy gateway uses floating-point amounts and make_payment(). Rewriting the gateway may be impossible, while changing every checkout call would spread legacy details through the application. An adapter can translate between the two contracts at one boundary.

Structure

Adapter translates a legacy gateway interface The checkout workflow depends on the PaymentGateway interface. The adapter converts cents and currency into the legacy call, validates the response, and returns a normalized reference. checkout() uses PaymentGateway LegacyGatewayAdapter translates calls LegacyGateway make_payment() 1. charge(2499, EUR) 2. make_payment(24.99, EUR) 3. Normalize the response and return a payment reference
The adapter preserves the legacy implementation while giving new client code a stable interface.
From Target Call to Legacy Response
  1. The client calls charge(2499, "EUR") through the adapter.
  2. The adapter validates the amount, converts it to 24.99, and calls the legacy gateway.
  3. It checks the legacy status and returns the payment reference as a string.

The demo uses an in-memory response rather than a network call. The adapter performs only format and protocol translation; it does not add payment authorization.

Python Implementation

from __future__ import annotations
from abc import ABC, abstractmethod


class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount_cents: int, currency: str) -> str:
        pass


class LegacyGateway:
    def make_payment(self, value: float, currency_code: str) -> dict[str, object]:
        return {"status": "APPROVED", "reference": "LEG-421"}


class LegacyGatewayAdapter(PaymentGateway):
    def __init__(self, gateway: LegacyGateway):
        self._gateway = gateway

    def charge(self, amount_cents: int, currency: str) -> str:
        if amount_cents < 0:
            raise ValueError("Amount must be non-negative")
        result = self._gateway.make_payment(amount_cents / 100, currency)
        if result["status"] != "APPROVED":
            raise RuntimeError("Payment was not approved")
        return str(result["reference"])


def checkout(gateway: PaymentGateway) -> str:
    return f"Payment reference: {gateway.charge(2499, 'EUR')}"


if __name__ == "__main__":
    print(checkout(LegacyGatewayAdapter(LegacyGateway())))

Java Implementation

// AdapterDemo.java

interface PaymentGateway {
    String charge(int amountCents, String currency);
}

final class LegacyGateway {
    public java.util.Map<String, String> makePayment(double value, String currencyCode) {
        return java.util.Map.of("status", "APPROVED", "reference", "LEG-421");
    }
}

final class LegacyGatewayAdapter implements PaymentGateway {
    private final LegacyGateway gateway;

    public LegacyGatewayAdapter(LegacyGateway gateway) {
        this.gateway = gateway;
    }

    public String charge(int amountCents, String currency) {
        if (amountCents < 0) throw new IllegalArgumentException("Amount must be non-negative");
        java.util.Map<String, String> result = gateway.makePayment(amountCents / 100.0, currency);
        if (!"APPROVED".equals(result.get("status"))) {
            throw new IllegalStateException("Payment was not approved");
        }
        return result.get("reference");
    }
}

public class AdapterDemo {
    static String checkout(PaymentGateway gateway) {
        return "Payment reference: " + gateway.charge(2499, "EUR");
    }

    public static void main(String[] args) {
        System.out.println(checkout(new LegacyGatewayAdapter(new LegacyGateway())));
    }
}

Output

Payment reference: LEG-421

This is an object adapter: it holds the adaptee rather than inheriting from it. That keeps the legacy class replaceable and avoids exposing its public surface through inheritance. A class adapter, available in languages with multiple implementation inheritance, would inherit from both sides but couples the adapter more tightly to the adaptee.

Keep translation rules explicit. Here, cents become a decimal amount and an approved legacy status becomes a reference. Silently mapping every unexpected status or currency can hide an integration failure.

When to Use It

2. Bridge

Intent

Bridge splits one abstraction from one or more implementations so the two hierarchies can change independently. The abstraction holds a reference to an implementation interface rather than creating a concrete implementation itself.

The Problem It Solves

Shapes need vector and raster rendering. Inheritance alone produces a class for every combination: vector circle, raster circle, vector square, raster square, and so on. Bridge keeps shape variants in one hierarchy and renderer variants in another, then connects them through composition.

Structure

Bridge separates shape abstraction from rendering A Shape holds a Renderer and delegates drawing to it. Circle and Square can be combined with VectorRenderer or RasterRenderer without creating a class for every combination. Shape hierarchy Circle, Square Renderer composition boundary Renderer hierarchy Vector, Raster 1. draw() 2. render(shape, area) Shape variants and rendering variants evolve independently.
The abstraction depends on the Renderer contract; concrete renderers remain independently replaceable.
Combining Two Independent Variations
  1. Create a shape with the renderer selected at the composition boundary.
  2. draw() calculates the shape-specific area and passes it to the renderer.
  3. The renderer formats the result without knowing whether the shape is a circle or square.

The demo combines a vector circle and a raster square. Adding a triangle or an SVG renderer does not require a multiplication of shape-renderer subclasses.

Python Implementation

from __future__ import annotations
from abc import ABC, abstractmethod


class Renderer(ABC):
    @abstractmethod
    def render(self, shape: str, area: float) -> str:
        pass


class VectorRenderer(Renderer):
    def render(self, shape: str, area: float) -> str:
        return f"Vector {shape}: area={area:.2f}"


class RasterRenderer(Renderer):
    def render(self, shape: str, area: float) -> str:
        return f"Raster {shape}: area={area:.2f}"


class Shape(ABC):
    def __init__(self, renderer: Renderer):
        self._renderer = renderer

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

    def draw(self) -> str:
        return self._renderer.render(type(self).__name__, self.area())


class Circle(Shape):
    def __init__(self, renderer: Renderer, radius: float):
        super().__init__(renderer)
        self.radius = radius

    def area(self) -> float:
        return 3.141592653589793 * self.radius ** 2


class Square(Shape):
    def __init__(self, renderer: Renderer, side: float):
        super().__init__(renderer)
        self.side = side

    def area(self) -> float:
        return self.side ** 2


if __name__ == "__main__":
    shapes = [Circle(VectorRenderer(), 2), Square(RasterRenderer(), 3)]
    for shape in shapes:
        print(shape.draw())

Java Implementation

// BridgeDemo.java

interface Renderer {
    String render(String shape, double area);
}

final class VectorRenderer implements Renderer {
    public String render(String shape, double area) {
        return String.format(java.util.Locale.ROOT, "Vector %s: area=%.2f", shape, area);
    }
}

final class RasterRenderer implements Renderer {
    public String render(String shape, double area) {
        return String.format(java.util.Locale.ROOT, "Raster %s: area=%.2f", shape, area);
    }
}

abstract class Shape {
    protected final Renderer renderer;

    protected Shape(Renderer renderer) {
        this.renderer = renderer;
    }

    public abstract double area();

    public String draw() {
        return renderer.render(getClass().getSimpleName(), area());
    }
}

final class Circle extends Shape {
    private final double radius;

    public Circle(Renderer renderer, double radius) {
        super(renderer);
        this.radius = radius;
    }

    public double area() {
        return Math.PI * radius * radius;
    }
}

final class Square extends Shape {
    private final double side;

    public Square(Renderer renderer, double side) {
        super(renderer);
        this.side = side;
    }

    public double area() {
        return side * side;
    }
}

public class BridgeDemo {
    public static void main(String[] args) {
        Shape[] shapes = {new Circle(new VectorRenderer(), 2), new Square(new RasterRenderer(), 3)};
        for (Shape shape : shapes) {
            System.out.println(shape.draw());
        }
    }
}

Output

Vector Circle: area=12.57
Raster Square: area=9.00

Bridge differs from Adapter in intent. Adapter reconciles incompatible interfaces that already exist; Bridge is designed in advance to keep two variation axes separate. In this example, a shape may hold any renderer, but it does not replace it after construction. That policy keeps the geometry and rendering configuration stable for the object's lifetime.

The shared draw() method uses the concrete class name only as a display label. A more complete design would pass an explicit shape descriptor rather than relying on the class name.

When to Use It

3. Composite

Intent

Composite represents part-whole hierarchies through a common component interface. Clients can invoke the same operation on a leaf, a container, or an entire tree without branching on the object's structure.

The Problem It Solves

A course bundle contains a video product and a books bundle, which itself contains two products. Calculating the course price by manually traversing every level duplicates tree knowledge in every caller. A uniform total_price() operation lets each node contribute according to its role.

Structure

Composite treats products and bundles uniformly A bundle can contain products and other bundles. Calling total_price() on the outer bundle recursively collects prices from nested items and returns one total. Course bundle CatalogItem Video lessons Product Books bundle CatalogItem Products leaf nodes 1. total_price() recurses 2. Leaf prices are summed
The client does not need to distinguish a leaf product from a composite bundle when calculating a total.
Recursive Price Calculation
  1. The client calls total_price() on the outer course bundle.
  2. The bundle asks each child for its total; the nested books bundle repeats the process.
  3. Leaf products return their own prices, and the sums propagate back to the caller.

The example uses non-negative integer cents to avoid floating-point money arithmetic. It does not model quantities, discounts, or duplicate-child detection.

Python Implementation

from __future__ import annotations
from abc import ABC, abstractmethod


class CatalogItem(ABC):
    @abstractmethod
    def total_price(self) -> int:
        pass


class Product(CatalogItem):
    def __init__(self, name: str, price_cents: int):
        if price_cents < 0:
            raise ValueError("Price must be non-negative")
        self.name = name
        self.price_cents = price_cents

    def total_price(self) -> int:
        return self.price_cents


class Bundle(CatalogItem):
    def __init__(self, name: str):
        self.name = name
        self._items: list[CatalogItem] = []

    def add(self, item: CatalogItem) -> Bundle:
        self._items.append(item)
        return self

    def total_price(self) -> int:
        return sum(item.total_price() for item in self._items)


if __name__ == "__main__":
    books = Bundle("Books").add(Product("Design Patterns", 4200)).add(Product("Refactoring", 3800))
    course = Bundle("Course").add(Product("Video lessons", 9900)).add(books)

    print(f"Books: {books.total_price()} cents")
    print(f"Course: {course.total_price()} cents")

Java Implementation

// CompositeDemo.java

import java.util.ArrayList;
import java.util.List;

interface CatalogItem {
    int totalPrice();
}

final class Product implements CatalogItem {
    private final String name;
    private final int priceCents;

    public Product(String name, int priceCents) {
        if (priceCents < 0) throw new IllegalArgumentException("Price must be non-negative");
        this.name = name;
        this.priceCents = priceCents;
    }

    public int totalPrice() { return priceCents; }
}

final class Bundle implements CatalogItem {
    private final String name;
    private final List<CatalogItem> items = new ArrayList<>();

    public Bundle(String name) { this.name = name; }

    public Bundle add(CatalogItem item) {
        items.add(item);
        return this;
    }

    public int totalPrice() {
        int total = 0;
        for (CatalogItem item : items) total += item.totalPrice();
        return total;
    }
}

public class CompositeDemo {
    public static void main(String[] args) {
        Bundle books = new Bundle("Books")
                .add(new Product("Design Patterns", 4200))
                .add(new Product("Refactoring", 3800));
        Bundle course = new Bundle("Course")
                .add(new Product("Video lessons", 9900))
                .add(books);

        System.out.println("Books: " + books.totalPrice() + " cents");
        System.out.println("Course: " + course.totalPrice() + " cents");
    }
}

Output

Books: 8000 cents
Course: 17900 cents

Composite is useful when clients should treat individual objects and groups uniformly. It is not automatically a tree library: this example permits the same item to appear in multiple bundles and does not detect cycles. A cycle such as adding a bundle to itself would make recursive calculation non-terminating.

Child management methods can live on the composite only, as shown here, or on the shared component interface. The latter is more uniform but forces leaf products to reject meaningless operations such as add().

When to Use It

4. Decorator

Intent

Decorator attaches responsibilities to an object dynamically by wrapping it in another object with the same interface. Each wrapper delegates to the object it contains and may add behavior before or after that call.

The Problem It Solves

A coffee ordering system starts with espresso and later needs milk, an extra shot, and other optional features. Creating a subclass for every combination grows quickly, while putting every option into one class makes that class difficult to change. Decorators keep each feature separate and let clients compose only the options they need.

Structure

Decorator wraps a base coffee with optional features The client sees only the Coffee interface. ExtraShot delegates to Milk, which delegates to Espresso. Each wrapper contributes its cost and description before returning the combined result. Client depends on Coffee ExtraShot CoffeeDecorator Milk CoffeeDecorator Espresso base Coffee 1. cost_cents() 2. delegate Each wrapper adds its own cost and description, then delegates inward.
Wrappers can be composed at runtime without creating a subclass for every feature combination.
Wrapping From Outside Inward
  1. The client constructs ExtraShot(Milk(Espresso())).
  2. Calling cost_cents() starts at the outermost decorator and delegates inward.
  3. Each layer adds its contribution, producing one combined cost and description.

The resulting object is still a Coffee, so it can be passed to code that does not know which options were added. The order of wrappers can matter when a feature changes behavior rather than only adding data.

Python Implementation

from __future__ import annotations
from abc import ABC, abstractmethod


class Coffee(ABC):
    @abstractmethod
    def cost_cents(self) -> int:
        pass

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


class Espresso(Coffee):
    def cost_cents(self) -> int:
        return 250

    def description(self) -> str:
        return "Espresso"


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

    def cost_cents(self) -> int:
        return self._coffee.cost_cents()

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


class Milk(CoffeeDecorator):
    def cost_cents(self) -> int:
        return self._coffee.cost_cents() + 50

    def description(self) -> str:
        return f"{self._coffee.description()} + milk"


class ExtraShot(CoffeeDecorator):
    def cost_cents(self) -> int:
        return self._coffee.cost_cents() + 80

    def description(self) -> str:
        return f"{self._coffee.description()} + extra shot"


if __name__ == "__main__":
    order: Coffee = ExtraShot(Milk(Espresso()))
    print(f"{order.description()}: {order.cost_cents()} cents")

Java Implementation

// DecoratorDemo.java

interface Coffee {
    int costCents();
    String description();
}

final class Espresso implements Coffee {
    public int costCents() { return 250; }
    public String description() { return "Espresso"; }
}

abstract class CoffeeDecorator implements Coffee {
    protected final Coffee coffee;

    protected CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }

    public int costCents() { return coffee.costCents(); }
    public String description() { return coffee.description(); }
}

final class Milk extends CoffeeDecorator {
    public Milk(Coffee coffee) { super(coffee); }

    public int costCents() { return coffee.costCents() + 50; }
    public String description() { return coffee.description() + " + milk"; }
}

final class ExtraShot extends CoffeeDecorator {
    public ExtraShot(Coffee coffee) { super(coffee); }

    public int costCents() { return coffee.costCents() + 80; }
    public String description() { return coffee.description() + " + extra shot"; }
}

public class DecoratorDemo {
    public static void main(String[] args) {
        Coffee order = new ExtraShot(new Milk(new Espresso()));
        System.out.println(order.description() + ": " + order.costCents() + " cents");
    }
}

Output

Espresso + milk + extra shot: 380 cents

Decorator differs from inheritance because responsibilities attach to an instance at composition time, not to a class at compile time. It also differs from a collection of options: each decorator preserves the component contract and can alter calls, not merely store a setting.

Deep chains can become hard to debug and may duplicate wrapper logic. Keep each decorator focused, avoid dependencies on a particular wrapping order unless that order is part of the domain rule, and do not use Decorator merely to add fields to a data record.

When to Use It

5. Facade

Intent

Facade provides a simplified entry point to a set of subsystems. It coordinates a common workflow behind one interface while leaving the subsystems available for callers that need finer control.

The Problem It Solves

Placing an order requires inventory reservation, shipping scheduling, and billing. A client that calls all three services directly must know their order and dependencies. A facade gives the common case one method while keeping each subsystem independently testable and reusable.

Structure

Facade coordinates subsystem calls behind one entry point The client calls place_order() on the facade. The facade invokes inventory, shipping, and billing in the required order and returns a combined confirmation. Client place_order() OrderFacade simple entry point Inventory reserve() Shipping schedule() Billing charge() 1. one call 2. coordinate subsystems
The facade simplifies the common workflow but does not prevent direct use of the subsystems when needed.
One Entry Point, Three Coordinated Steps
  1. The client calls place_order() with the order data.
  2. The facade validates the request and invokes inventory, shipping, and billing.
  3. It combines the subsystem responses into one confirmation string.

This demo prints the result of each step rather than implementing persistence, compensation, or distributed transactions. A facade is not a transaction manager unless that responsibility is explicitly added and handled.

Python Implementation

from __future__ import annotations


class Inventory:
    def reserve(self, sku: str, quantity: int) -> str:
        return f"Reserved {quantity} x {sku}"


class Shipping:
    def schedule(self, address: str) -> str:
        return f"Shipping scheduled to {address}"


class Billing:
    def charge(self, amount_cents: int) -> str:
        return f"Billing accepted {amount_cents} cents"


class OrderFacade:
    def __init__(self, inventory: Inventory, shipping: Shipping, billing: Billing):
        self._inventory = inventory
        self._shipping = shipping
        self._billing = billing

    def place_order(self, sku: str, quantity: int, address: str, amount_cents: int) -> str:
        if quantity <= 0 or amount_cents < 0:
            raise ValueError("Quantity and amount must be non-negative")
        return " | ".join((
            self._inventory.reserve(sku, quantity),
            self._shipping.schedule(address),
            self._billing.charge(amount_cents),
        ))


if __name__ == "__main__":
    facade = OrderFacade(Inventory(), Shipping(), Billing())
    print(facade.place_order("BOOK-42", 2, "Milan", 7600))

Java Implementation

// FacadeDemo.java

final class Inventory {
    public String reserve(String sku, int quantity) {
        return "Reserved " + quantity + " x " + sku;
    }
}

final class Shipping {
    public String schedule(String address) {
        return "Shipping scheduled to " + address;
    }
}

final class Billing {
    public String charge(int amountCents) {
        return "Billing accepted " + amountCents + " cents";
    }
}

final class OrderFacade {
    private final Inventory inventory;
    private final Shipping shipping;
    private final Billing billing;

    public OrderFacade(Inventory inventory, Shipping shipping, Billing billing) {
        this.inventory = inventory;
        this.shipping = shipping;
        this.billing = billing;
    }

    public String placeOrder(String sku, int quantity, String address, int amountCents) {
        if (quantity <= 0 || amountCents < 0) {
            throw new IllegalArgumentException("Quantity and amount must be non-negative");
        }
        return String.join(" | ",
                inventory.reserve(sku, quantity),
                shipping.schedule(address),
                billing.charge(amountCents));
    }
}

public class FacadeDemo {
    public static void main(String[] args) {
        OrderFacade facade = new OrderFacade(new Inventory(), new Shipping(), new Billing());
        System.out.println(facade.placeOrder("BOOK-42", 2, "Milan", 7600));
    }
}

Output

Reserved 2 x BOOK-42 | Shipping scheduled to Milan | Billing accepted 7600 cents

A facade reduces coupling for the common workflow, but it should not become the only route to every subsystem capability. Keeping subsystem interfaces accessible avoids turning the facade into a bottleneck or a "God object."

The example returns a formatted confirmation for clarity. Production code would more likely return a structured result, distinguish partial failures, and define what happens when one subsystem fails after another has succeeded.

When to Use It

6. Flyweight

Intent

Flyweight shares immutable state that repeats across many objects. Each object keeps only its unique state and refers to a shared flyweight for the common part.

The Problem It Solves

A forest may contain millions of trees. Storing the species name, color, and texture reference in every tree repeats the same data. A shared immutable TreeType reduces that repetition, while each tree retains only its own position and height.

Structure

Flyweight shares immutable intrinsic state The forest requests a TreeType by its name, color, and texture. The factory returns one shared immutable object for matching keys, while each Tree retains only its unique extrinsic position and height. Forest many Tree objects TreeTypeFactory shared lookup TreeType name, color, texture 1. request type 2. reuse cached type Each Tree stores only its unique x, y, and height.
Sharing reduces repeated state, but requires immutable shared data and a clear key policy.
Separating Shared and Unique State
  1. The forest requests a tree type using name, color, and texture.
  2. The factory returns the existing cached type or creates it once.
  3. Each tree stores a reference to that type plus its unique coordinates and height.

Three trees use only two shared types in the demo. The savings grow with the number of objects and the size of the shared state.

Python Implementation

from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar


@dataclass(frozen=True)
class TreeType:
    name: str
    color: str
    texture: str


class TreeTypeFactory:
    _types: ClassVar[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._types:
            cls._types[key] = TreeType(name, color, texture)
        return cls._types[key]


class Tree:
    def __init__(self, tree_type: TreeType, x: float, y: float, height: float):
        self.type = tree_type
        self.x = x
        self.y = y
        self.height = height

    def draw(self) -> str:
        return f"{self.type.name} at ({self.x:.1f}, {self.y:.1f}), height={self.height:.1f}"


class Forest:
    def __init__(self):
        self.trees: list[Tree] = []

    def plant(self, name: str, color: str, texture: str, x: float, y: float, height: float) -> Tree:
        tree = Tree(TreeTypeFactory.get(name, color, texture), x, y, height)
        self.trees.append(tree)
        return tree


if __name__ == "__main__":
    forest = Forest()
    forest.plant("Oak", "green", "oak.png", 10, 20, 4.5)
    forest.plant("Oak", "green", "oak.png", 15, 25, 3.8)
    forest.plant("Pine", "dark green", "pine.png", 30, 40, 6.0)

    print(f"Trees planted: {len(forest.trees)}")
    print(f"Shared types: {len(TreeTypeFactory._types)}")
    print(forest.trees[0].draw())
    print(forest.trees[2].draw())

Java Implementation

// FlyweightDemo.java

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;

final class TreeType {
    private final String name;
    private final String color;
    private final String texture;

    private TreeType(String name, String color, String texture) {
        this.name = name;
        this.color = color;
        this.texture = texture;
    }

    public static TreeType of(String name, String color, String texture) {
        return new TreeType(name, color, texture);
    }

    public String getName() { return name; }
}

final class TreeTypeFactory {
    private static final Map<String, TreeType> TYPES = new HashMap<>();

    public static TreeType get(String name, String color, String texture) {
        String key = String.join("|", name, color, texture);
        return TYPES.computeIfAbsent(key, ignored -> TreeType.of(name, color, texture));
    }

    public static int typeCount() { return TYPES.size(); }
}

final class Tree {
    private final TreeType type;
    private final double x;
    private final double y;
    private final double height;

    public Tree(TreeType type, double x, double y, double height) {
        this.type = Objects.requireNonNull(type);
        this.x = x;
        this.y = y;
        this.height = height;
    }

    public String draw() {
        return String.format(Locale.ROOT, "%s at (%.1f, %.1f), height=%.1f",
                type.getName(), x, y, height);
    }
}

final class Forest {
    private final List<Tree> trees = new ArrayList<>();

    public Tree plant(String name, String color, String texture, double x, double y, double height) {
        Tree tree = new Tree(TreeTypeFactory.get(name, color, texture), x, y, height);
        trees.add(tree);
        return tree;
    }

    public int treeCount() { return trees.size(); }
}

public class FlyweightDemo {
    public static void main(String[] args) {
        Forest forest = new Forest();
        forest.plant("Oak", "green", "oak.png", 10, 20, 4.5);
        forest.plant("Oak", "green", "oak.png", 15, 25, 3.8);
        forest.plant("Pine", "dark green", "pine.png", 30, 40, 6.0);

        System.out.println("Trees planted: " + forest.treeCount());
        System.out.println("Shared types: " + TreeTypeFactory.typeCount());
        System.out.println(forest.plant("Oak", "green", "oak.png", 50, 60, 5.0).draw());
    }
}

Output

Trees planted: 3
Shared types: 2
Oak at (10.0, 20.0), height=4.5
Pine at (30.0, 40.0), height=6.0

Intrinsic state must be safe to share. Here, TreeType is immutable and contains no identity-dependent behavior. Extrinsic state remains with each Tree, so two trees can share a type while occupying different positions.

The cache key must identify the shared state completely. Using only the species name would incorrectly share different colors or textures. A bounded or evictable cache may be appropriate when the set of flyweights is large or changes over time.

When to Use It

7. Proxy

Intent

Proxy provides a substitute with the same interface as another object. It can control, delay, cache, log, or protect access before forwarding a request to the real subject.

The Problem It Solves

A report service may expose confidential documents. Clients should use one reporting interface, but access rules should be enforced before the real service performs its work. A proxy can check the caller role and forward only authorized requests.

Structure

Proxy controls access before delegating The client uses the ReportService interface. The proxy checks the caller role first; authorized requests are forwarded to the real service, while unauthorized requests are rejected. Client read(report_id) AccessControlProxy same interface RealReportService actual data access 1. authorize role 2. forward request Rejected requests never reach the real service.
A proxy has the same interface as its subject and can add access control, caching, logging, or lazy initialization.
Authorize, Then Delegate
  1. The client calls read() on the proxy.
  2. The proxy checks whether the caller role is allowed.
  3. An authorized request reaches the real service; an unauthorized request raises an access error.

The Python proxy accepts a role argument for clarity. The Java proxy uses a fixed guest role to retain the same subject signature. Both make the access decision before touching the real service.

Python Implementation

from __future__ import annotations
from abc import ABC, abstractmethod


class ReportService(ABC):
    @abstractmethod
    def read(self, report_id: str) -> str:
        pass


class RealReportService(ReportService):
    def read(self, report_id: str) -> str:
        return f"Contents of {report_id}"


class AccessControlProxy(ReportService):
    def __init__(self, service: ReportService, allowed_roles: set[str]):
        self._service = service
        self._allowed_roles = allowed_roles

    def read(self, report_id: str, role: str = "guest") -> str:
        if role not in self._allowed_roles:
            raise PermissionError(f"Role '{role}' cannot read {report_id}")
        return self._service.read(report_id)


if __name__ == "__main__":
    service = AccessControlProxy(RealReportService(), {"analyst", "admin"})
    print(service.read("quarterly-report", "analyst"))
    try:
        service.read("quarterly-report", "guest")
    except PermissionError as error:
        print(error)

Java Implementation

// ProxyDemo.java

interface ReportService {
    String read(String reportId);
}

final class RealReportService implements ReportService {
    public String read(String reportId) {
        return "Contents of " + reportId;
    }
}

final class AccessControlProxy implements ReportService {
    private final ReportService service;
    private final java.util.Set<String> allowedRoles;

    public AccessControlProxy(ReportService service, java.util.Set<String> allowedRoles) {
        this.service = service;
        this.allowedRoles = java.util.Set.copyOf(allowedRoles);
    }

    public String read(String reportId) {
        String role = "guest";
        if (!allowedRoles.contains(role)) {
            throw new SecurityException("Role '" + role + "' cannot read " + reportId);
        }
        return service.read(reportId);
    }
}

public class ProxyDemo {
    public static void main(String[] args) {
        ReportService service = new AccessControlProxy(
                new RealReportService(), java.util.Set.of("analyst", "admin"));
        System.out.println(service.read("quarterly-report"));
        try {
            new AccessControlProxy(new RealReportService(), java.util.Set.of("admin"))
                    .read("quarterly-report");
        } catch (SecurityException error) {
            System.out.println(error.getMessage());
        }
    }
}

Output

Contents of quarterly-report
Role 'guest' cannot read quarterly-report

Protection, virtual, caching, logging, and remote proxies share the same structural idea but solve different access problems. The important contract is interface compatibility: a client should be able to use the proxy wherever it could use the real subject.

A proxy adds a layer and can change latency or failure behavior. Make its policy explicit, especially for caching, retries, and security decisions. This demo does not authenticate the caller; it assumes the supplied role is already trusted.

When to Use It

8. Detailed Comparison

Pattern Selection Guide

Identify the composition pressure before selecting a pattern. The table below distinguishes the boundary each pattern introduces and the contract that must be made explicit.

Design PressurePatternComposition BoundaryWhat Must Be DefinedAvoid It When
An existing interface does not match the clientAdapterAdapter translates between Target and AdapteeUnit, protocol, and error mappingsYou can change the incompatible interface directly
Two independent dimensions would multiply subclassesBridgeAbstraction holds an Implementation referenceWhich variations belong to each hierarchyThere is only one stable implementation
Clients should treat leaves and groups alikeCompositeComposite stores child ComponentsCycle policy and child-management APIThe structure is fixed and shallow
Optional behavior must compose at runtimeDecoratorDecorator wraps another ComponentDelegation order and added responsibilitiesA few constructor parameters express the options clearly
A common workflow spans several subsystemsFacadeFacade coordinates subsystem callsWorkflow order, validation, and failure policyClients need unrestricted access to every subsystem feature
Many objects repeat immutable stateFlyweightFactory caches shared Flyweight objectsCache key, sharing safety, and extrinsic stateThe repeated state is small or object identity matters
Access to a subject needs a controlled boundaryProxyProxy implements the Subject interfaceAuthorization, caching, retry, and failure behaviorDirect access is simpler and has no cross-cutting requirement
Resolve the Closest Choices

Common Pitfalls

PatternCommon PitfallBetter Approach
AdapterHiding incompatible semantics behind a renamed methodDocument conversions, validation, and error translation explicitly
BridgeCreating a hierarchy split without two genuine variation axesUse Bridge only when abstraction and implementation must evolve independently
CompositeAllowing cycles or treating every node as both leaf and containerKeep leaf and composite responsibilities clear; define cycle handling
DecoratorBuilding deep, order-dependent wrapper chainsKeep wrappers small and make any required ordering explicit
FacadeTurning the facade into a "God object" that owns every capabilityCoordinate common flows while preserving direct subsystem access
FlyweightSharing mutable state or using an incomplete cache keyMake shared state immutable and include every distinguishing field in the key
ProxyAssuming interface compatibility makes behavior transparentDocument latency, caching, security, and error-handling changes

9. Putting It All Together

A structural pattern earns its place when it clarifies a real composition boundary. It should make a variation, access rule, or hierarchy easier to understand, not merely add another layer of indirection.

Composition Contracts Worth Making Explicit

Prefer direct composition when it expresses the design clearly. Introduce a structural pattern when it isolates a change that would otherwise spread through client code, and keep the resulting boundary small, documented, and testable.

Pattern terminology and relationships can be explored in the Structural Patterns catalog; the examples here focus on explicit composition, delegation, and runtime behavior.