core/design_patterns/structural.md

Structural Design Patterns: Adapter, Decorator, Facade, Proxy, Composite, Bridge, Flyweight

Cross-language guide to the classic structural Gang of Four patterns for composing objects and classes into larger structures, with modern Python, TypeScript, Go, and Rust idioms and honest trade-off analysis for 2026 codebases.

Introduction / Overview

Structural patterns describe how classes and objects are composed to form larger, more capable structures while keeping those structures flexible and efficient. Where creational patterns govern how objects come into existence, structural patterns govern how existing objects relate to and wrap one another. In modern codebases, several of these patterns show up so often that they are barely recognized as "patterns" anymore - middleware pipelines are Decorator, ORMs are Proxy, and most public SDK clients are Facade - which is exactly the point: a good pattern eventually becomes invisible infrastructure.

This document covers Adapter, Decorator, Facade, Proxy, Composite, Bridge, and Flyweight, with idiomatic 2026 implementations and clear guidance on when the formal pattern name is worth invoking versus when it is over-engineering a simple wrapper function.

When to use this guidance: When integrating a third-party or legacy interface, when adding cross-cutting behavior (caching, logging, access control) around existing objects, when simplifying a complex subsystem for callers, or when a class hierarchy is exploding due to conflating two independent dimensions of variation.

Core Concepts

  • Composition over inheritance: Every pattern in this document favors composing objects at runtime over building rigid class hierarchies at compile time - the same instinct behind preferring interfaces over concrete coupling.
  • Wrapping vs. translating vs. simplifying: Adapter translates one interface into another it was not designed for; Decorator adds behavior while preserving the interface; Facade simplifies a complex subsystem behind a narrow interface; Proxy controls access to another object while preserving its interface exactly.
  • Structure patterns often compose with each other: A real HTTP client might be an Adapter (translating a third-party SDK) wrapped in a Decorator (adding retry logic) exposed through a Facade (a simple ApiClient class used by the rest of the app).
  • Two independent axes of variation call for Bridge: When a class would otherwise need RedShape, BlueShape, RedCircle, BlueCircle, etc. (a combinatorial explosion), Bridge splits abstraction from implementation so each axis varies independently.

Best Practices

1. Adapter - Translate an Incompatible Interface Without Modifying It

Rationale: Legacy systems, third-party SDKs, and generated clients rarely match your application's internal interfaces exactly. Adapter isolates that mismatch in one place instead of letting the foreign interface's shape leak throughout the codebase.

from typing import Protocol

class PaymentGateway(Protocol):
    async def charge(self, amount_cents: int, token: str) -> str: ...

class LegacyStripeSDK:
    def create_charge(self, amount: float, currency: str, source: str) -> dict: ...

class StripeAdapter:
    def __init__(self, sdk: LegacyStripeSDK):
        self._sdk = sdk

    async def charge(self, amount_cents: int, token: str) -> str:
        result = self._sdk.create_charge(amount=amount_cents / 100, currency="usd", source=token)
        return result["id"]

Why this works well in production: Business logic depends on PaymentGateway, never on LegacyStripeSDK directly; replacing the SDK version (or the provider entirely) only touches the adapter, satisfying Dependency Inversion.

2. Decorator - Add Behavior Without Modifying the Original Class

Rationale: Cross-cutting concerns (caching, retries, logging, authorization checks) should be composable and independent of core logic, per Single Responsibility. Decorator lets you stack these concerns without subclass explosion.

interface Repository<T> {
  findById(id: string): Promise<T | null>;
}

class CachingRepositoryDecorator<T> implements Repository<T> {
  constructor(
    private readonly inner: Repository<T>,
    private readonly cache: Map<string, T> = new Map(),
  ) {}

  async findById(id: string): Promise<T | null> {
    if (this.cache.has(id)) return this.cache.get(id)!;
    const result = await this.inner.findById(id);
    if (result) this.cache.set(id, result);
    return result;
  }
}

// Usage: stack decorators freely
const repo: Repository<User> = new CachingRepositoryDecorator(new PostgresUserRepository());

Why this works well in production: Python's @decorator syntax, FastAPI/Express/ASGI middleware chains, and this object-based form are all the same pattern - adding behavior at a well-defined seam without touching the wrapped class, and each concern (caching, retry, logging) stays independently testable.

3. Facade - Provide a Simple Interface to a Complex Subsystem

Rationale: Client code should not need to understand the internal wiring of five collaborating subsystems (parsing, validation, persistence, notification) just to perform one coherent operation. A Facade exposes the operation, not the machinery.

class OrderCheckoutFacade:
    def __init__(self, inventory: InventoryService, payments: PaymentGateway,
                 shipping: ShippingCalculator, notifier: NotificationSender):
        self._inventory = inventory
        self._payments = payments
        self._shipping = shipping
        self._notifier = notifier

    async def checkout(self, cart: Cart, customer: Customer) -> OrderConfirmation:
        await self._inventory.reserve(cart.items)
        cost = self._shipping.calculate(cart, customer.address)
        await self._payments.charge(cart.total_cents + cost, customer.payment_token)
        await self._notifier.send(customer.email, "Order confirmed")
        return OrderConfirmation(order_id=cart.id)

Why this works well in production: A single call site (await checkoutfacade.checkout(cart, customer)) hides four collaborators from callers such as a FastAPI route handler, matching the "thin path operation, rich service" philosophy.

4. Proxy - Control Access to an Object Transparently

Rationale: When you need to add lazy initialization, access control, or remote-call semantics without changing the interface the caller sees, Proxy preserves the illusion of talking directly to the real object.

class LazyModelProxy:
    def __init__(self, loader: Callable[[], "MLModel"]):
        self._loader = loader
        self._model: "MLModel | None" = None

    def predict(self, features: list[float]) -> float:
        if self._model is None:
            self._model = self._loader()  # expensive load deferred until first use
        return self._model.predict(features)

Why this works well in production: Callers use proxy.predict(...) exactly as they would the real MLModel; the expensive load cost is deferred and paid only once, transparently. The same shape underlies ORM lazy-loaded relationships and remote-procedure-call stubs (gRPC clients are Proxies to a remote service).

5. Composite - Treat Individual Objects and Groups Uniformly

Rationale: Tree-shaped domains (file systems, UI component trees, organizational hierarchies, nested permission groups) are far easier to reason about when a leaf node and a group of nodes expose the same interface.

type FileSystemNode interface {
    Size() int64
}

type File struct{ size int64 }
func (f File) Size() int64 { return f.size }

type Directory struct{ children []FileSystemNode }
func (d Directory) Size() int64 {
    var total int64
    for _, child := range d.children {
        total += child.Size()
    }
    return total
}

Why this works well in production: Callers computing .Size() do not need to know or care whether they hold a single File or an arbitrarily deep Directory tree - recursion is encapsulated inside Directory itself.

6. Bridge - Decouple an Abstraction From Its Implementation

Rationale: When a hierarchy varies along two independent dimensions (e.g., what operation to perform and which backend performs it), Bridge prevents an (M × N) explosion of subclasses.

class RenderBackend(Protocol):
    def draw_pixel(self, x: int, y: int) -> None: ...

class SvgBackend:
    def draw_pixel(self, x: int, y: int) -> None: ...

class CanvasBackend:
    def draw_pixel(self, x: int, y: int) -> None: ...

class Shape:
    def __init__(self, backend: RenderBackend):
        self._backend = backend  # the "bridge" to the implementation

class Circle(Shape):
    def draw(self, radius: int) -> None:
        for x, y in circle_points(radius):
            self._backend.draw_pixel(x, y)

Why this works well in production: Adding a new shape (Square) or a new backend (WebGpuBackend) each requires exactly one new class, not a new class per combination.

7. Flyweight - Share Common State Across Many Fine-Grained Objects

Rationale: When an application needs to create very large numbers of similar objects (glyphs in a text renderer, tiles in a game map, tokens in a large parse tree), separating shared intrinsic state from per-instance extrinsic state can cut memory usage by orders of magnitude.

class GlyphStyle:  # intrinsic, shared, immutable
    __slots__ = ("font", "size", "color")
    def __init__(self, font: str, size: int, color: str):
        self.font, self.size, self.color = font, size, color

class GlyphStyleFactory:
    _cache: dict[tuple, GlyphStyle] = {}

    @classmethod
    def get(cls, font: str, size: int, color: str) -> GlyphStyle:
        key = (font, size, color)
        if key not in cls._cache:
            cls._cache[key] = GlyphStyle(font, size, color)
        return cls._cache[key]

# Each on-screen character stores only (char, x, y, style_reference) --
# thousands of characters share a handful of GlyphStyle instances.

Why this works well in production: Memory scales with the number of distinct styles, not the number of glyphs on screen - critical for rendering engines, large-scale simulations, and any system generating millions of small, mostly-identical objects.

Common Pitfalls & Anti-Patterns

Pitfall: Confusing Adapter With Facade

Problem: Adapter and Facade both "wrap" something, leading developers to use them interchangeably, which confuses intent for future readers.

Recommended approach: Adapter exists because the wrapped interface is incompatible (translation). Facade exists because the wrapped subsystem is complex (simplification), even if every underlying interface is already compatible. Name classes accordingly (StripeAdapter vs CheckoutFacade) so intent is unambiguous.

Pitfall: Decorator Chains That Obscure Control Flow

Problem: Stacking six decorators (RetryDecorator(CachingDecorator(LoggingDecorator(TimeoutDecorator(...))))) makes it hard to reason about ordering (does caching happen before or after retry?), especially when failures occur.

Recommended approach: Keep decorator order explicit and documented at the composition root; prefer a small, named number of standard "stacks" over ad hoc per-call-site compositions. Middleware frameworks (ASGI, Express) solve this by making ordering an explicit, visible list.

Pitfall: Proxy Used to Silently Hide Expensive or Failing Operations

Problem: A caching or remote Proxy that swallows errors or silently serves stale data can violate the "errors must never pass silently" rule and surprise callers who believe they are talking to the real object.

Recommended approach: Preserve the real object's error semantics faithfully; if the proxy changes behavior (e.g., returns stale cache on a timeout), that must be an explicit, documented policy - not an accidental side effect.

Pitfall: Composite Applied to Non-Recursive Data

Problem: Forcing a flat list of unrelated items into a Composite hierarchy "for consistency" adds indirection with no recursive structure to justify it.

Recommended approach: Reserve Composite for genuinely tree-shaped domains; a flat collection should just be a flat collection.

Testing Strategies

  • Adapter: Test against the adapter's own interface using a fake of the wrapped SDK; a separate contract test can verify the adapter still matches the real SDK's actual behavior (useful after third-party SDK upgrades).
  • Decorator: Test each decorator in isolation with a fake "inner" implementation, asserting only the added behavior (e.g., the caching decorator returns a cached value without calling the inner repository twice).
  • Facade: Integration-test the facade's public method with fakes/mocks for each collaborator; this is a natural boundary for integration tests as opposed to full end-to-end tests.
  • Proxy: Test lazy/deferred behavior explicitly (assert the real object is not constructed until first use) and test that error/timeout semantics match documented policy.
  • Composite: Property-based tests (e.g., Hypothesis) work well here - generate arbitrary trees and assert invariants (total size == sum of leaf sizes) hold regardless of shape.
  • See Unit Testing Principles for isolation techniques applicable across all of the above.

Performance, Security & Scalability Considerations

  • Decorator/Proxy indirection cost: Each layer adds a function call; in Python this is usually negligible next to I/O, but in tight numerical loops (Rust, Go), excessive layering can matter - profile before stacking many decorators in a hot path.
  • Facade as a security boundary: A well-designed Facade is a natural place to enforce authorization checks once, rather than scattering them across every subsystem call - reducing the chance of a missed check.
  • Proxy caching correctness under concurrency: Caching proxies must handle concurrent first-access races (multiple coroutines/goroutines triggering the expensive load simultaneously) with a lock or single-flight pattern to avoid duplicate work or thundering-herd effects.
  • Flyweight and memory scalability: Flyweight is one of the few patterns whose primary justification is a measured memory constraint; do not apply it speculatively - verify with a profiler that per-object overhead is actually a bottleneck before adding the shared-state indirection.
  • Adapter and third-party API changes: Isolating third-party SDKs behind an Adapter significantly reduces the blast radius of upstream breaking changes, a meaningful operational/scalability concern as external APIs evolve.

Edge Cases & Advanced Usage

  • Adapter chains for multi-version SDK support: When supporting two versions of an upstream API simultaneously (migration period), a version-selecting Adapter factory can route to V1Adapter or V2Adapter behind one stable internal interface.
  • Structural Proxy vs. dynamic language proxies: Python's __getattr__, JavaScript's Proxy object, and Go's interface embedding all offer language-level proxy mechanisms; prefer the explicit class-based Proxy pattern shown above when behavior (caching, access control) needs to be reasoned about clearly, and reserve dynamic proxies for generic tooling (e.g., ORMs, mocking libraries).
  • Composite with heterogeneous operations: When different node types need genuinely different behavior beyond the shared interface, combine Composite with Visitor to add operations without polluting every node class.
  • Bridge in cross-platform codebases: Bridge is the standard shape behind cross-platform abstractions (e.g., a shared UI abstraction with platform-specific rendering backends, or a storage abstraction with local/cloud backend implementations).

When Not to Use / Alternatives

SituationRecommendation
Wrapped interface is already compatible, just verboseA simple wrapper function may suffice; skip formal Adapter machinery
Only one cross-cutting concern, applied in one placeA plain function wrapper or single if check may be simpler than a Decorator class
Subsystem has only one or two collaboratorsFacade may be unnecessary ceremony; call collaborators directly
Object construction is cheap and side-effect-freeSkip Proxy's lazy-loading machinery
Data is genuinely flat, not hierarchicalSkip Composite; use a plain list/array
Only one implementation will ever exist for an abstractionSkip Bridge; a single class is simpler
Object count is small (thousands, not millions)Skip Flyweight; the memory savings do not justify the complexity

Glossary

  • Intrinsic State: Shared, immutable state factored out in the Flyweight pattern (e.g., font and color of a glyph).
  • Extrinsic State: Per-instance state that cannot be shared (e.g., a glyph's screen position).
  • Composition Root: The place where wrapped/decorated object graphs are assembled, typically near application startup.
  • Contract Test: A test verifying that an Adapter's behavior still matches the real external system it wraps, distinct from a unit test of the adapter's internal logic.
  • Single-Flight: A concurrency pattern ensuring only one concurrent caller triggers an expensive operation while others wait for its result, often used inside caching Proxies.