Skip to content
Go back

Wrapping and Connecting Objects

The four patterns at the beginning of this article — Adapter, Facade, Proxy, and Decorator — look very similar in code.

They often follow a structure like this:

class Wrapper:
    def __init__(self, inner):
        self._inner = inner

The outer object then delegates calls to the inner object.

So, if you still confuse these four patterns after learning them, that’s completely normal. The main difference lies in their purpose, not in how the code is written.

Here’s a quick way to tell them apart:

PatternExternal interfacePurpose
AdapterDifferent from the wrapped objectConvert one interface into another
FacadeSimpler than the underlying systemHide complexity
ProxyAlmost identical to the wrapped objectControl access to the object
DecoratorIdentical to the wrapped objectAdd behavior to the object

You can remember them with two questions:

1. Does the outer object preserve the interface of the inner object?

  • No → usually Adapter or Facade
  • Yes → usually Proxy or Decorator

2. If it preserves the interface, what is it doing?

  • Controlling access → Proxy
  • Adding behavior and potentially stacking multiple layers → Decorator

The remaining three patterns — Composite, Bridge, and Flyweight — focus on different problems rather than wrapping objects, so we’ll discuss them separately.


Adapter

Adapter is useful when your code expects one interface, but an external object provides a different one.

This situation is common when integrating third-party SDKs or working with legacy code that you cannot modify.

For example, our code expects a charge() method:

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

But the legacy library provides:

class LegacyBillingClient:
    def submit_transaction(
        self,
        amount: float,
        card_ref: str,
        currency: str,
    ) -> dict:
        ...

We can write an adapter in between:

class LegacyBillingAdapter:
    def __init__(self, client: LegacyBillingClient):
        self._client = client

    def charge(self, amount_vnd: int, token: str) -> str:
        result = self._client.submit_transaction(
            amount=amount_vnd,
            card_ref=token,
            currency="VND",
        )

        if result["status"] != "OK":
            raise PaymentFailed(result.get("error", "unknown"))

        return result["txn_id"]

The adapter accepts the way our code wants to call the service, then translates it into the way the external library expects to be called.

This translation is not limited to parameters. An adapter often needs to translate error handling as well.

For example, the external library might return:

{"status": "FAILED", "error": "Insufficient balance"}

While our code expects a PaymentFailed exception.

If we let the provider’s exceptions or data formats leak into the rest of our system, other parts of the code become dependent on that provider. When we switch to another provider, we may have to change many places.

That’s why an adapter should create a clear boundary between our code and external code.

A simple rule:

If you control both sides, you usually don’t need an Adapter. Changing the interface directly is often simpler.


Facade

Facade is useful when a simple operation requires many steps behind the scenes.

For example, placing an order might require:

  1. Reserve inventory.
  2. Process payment.
  3. Create the order.
  4. Schedule delivery.
  5. Send a notification.
  6. Record an audit log.

If every caller has to perform these steps individually, the code will quickly become long and error-prone.

A Facade groups them behind a simple API:

class OrderPlacement:
    def __init__(
        self,
        inventory,
        payments,
        shipping,
        notifications,
        audit,
    ):
        ...

    def place(self, cart: Cart, customer: Customer) -> Order:
        self._inventory.reserve(cart.items)

        try:
            txn = self._payments.charge(
                cart.total,
                customer.payment_token,
            )

            order = Order.create(cart, customer, txn)

            self._shipping.schedule(order)
            self._notifications.order_confirmed(order)
            self._audit.record("order.placed", order.id)

            return order

        except Exception:
            self._inventory.release(cart.items)
            raise

The caller only needs to write:

order_placement.place(cart, customer)

It doesn’t need to know how many services are involved or the order in which they must be called.

This is the key difference from Adapter:

  • Adapter: The existing interface doesn’t fit → translate it into the interface we need.
  • Facade: The system has too many things to call → combine them behind a simpler interface.

An Adapter usually sits between one interface and another.

A Facade usually sits in front of multiple underlying components.

Be careful with God Objects

Facades can easily grow out of control.

At first, the Facade may only coordinate:

reserve → charge → create order → ship

But over time, people may start putting all kinds of business logic into it.

When a Facade starts performing calculations and making many business decisions, it can become a God Object.

A Facade should primarily coordinate operations. Business logic should live in the appropriate components.


Proxy

Proxy also preserves the interface of the wrapped object, but its purpose is to control access to that object.

For example, suppose we have a UserRepo and want to add caching:

class CachingUserRepo:
    def __init__(self, inner: UserRepo, ttl: int = 60):
        self._inner = inner
        self._ttl = ttl
        self._cache: dict[int, tuple[float, User]] = {}

    def get(self, user_id: int) -> User:
        hit = self._cache.get(user_id)

        if hit and time.monotonic() - hit[0] < self._ttl:
            return hit[1]

        user = self._inner.get(user_id)
        self._cache[user_id] = (time.monotonic(), user)

        return user

The caller still uses:

repo.get(user_id)

It doesn’t need to know whether repo is the real repository or a CachingUserRepo.

This is the important idea behind Proxy:

The outer object looks like the inner object, but sits in between to control access.

Some common types of Proxy include:

  • Caching Proxy — checks the cache before calling the real object.
  • Lazy Proxy — creates or loads the object only when it is actually needed.
  • Remote Proxy — the object lives somewhere else, but the caller interacts with it as if it were local.
  • Protection Proxy — checks permissions before allowing access.

Proxy for authorization

Authorization is a good use case for Proxy.

Instead of scattering permission checks throughout the code:

if not user.can_read:
    raise PermissionError()

We can place the checks inside a Proxy:

class AuthorizedDocumentStore:
    def __init__(self, inner: DocumentStore, policy: Policy):
        self._inner = inner
        self._policy = policy

    def read(self, actor: Actor, doc_id: str) -> Document:
        self._policy.require(actor, "document.read", doc_id)

        doc = self._inner.read(actor, doc_id)
        audit.log(actor, "read", doc_id)

        return doc

    def delete(self, actor: Actor, doc_id: str) -> None:
        self._policy.require(actor, "document.delete", doc_id)

        self._inner.delete(actor, doc_id)
        audit.log(actor, "delete", doc_id)

This approach has two benefits.

First, the authorization logic lives in one place, making it easier to test and audit.

Second, if the architecture is designed correctly, callers can only obtain an AuthorizedDocumentStore, rather than accessing the underlying DocumentStore directly.

If callers can still easily obtain the original object and call it directly:

document_store.delete(...)

then the protection provided by the Proxy is almost useless.

Be careful

A Proxy can make the calling code look very simple:

repo.get(user_id)

But behind the scenes, it might be:

  • Calling a database.
  • Making a network request.
  • Waiting for a lock.
  • Checking permissions.
  • Performing an expensive operation.

So, if a Proxy significantly changes performance or behavior, consider giving it a clear name when creating it.


Decorator

Decorator also preserves the interface of the wrapped object, but its purpose is to add behavior to that object.

The strength of Decorator is that multiple behaviors can be combined.

For example, an HTTP client might need:

  • Retry.
  • Logging.
  • Rate limiting.

We can implement each behavior as a decorator:

class RetryingClient:
    def __init__(self, inner: HttpClient, attempts: int = 3):
        self._inner = inner
        self._attempts = attempts

    def get(self, url: str) -> Response:
        for i in range(self._attempts):
            try:
                return self._inner.get(url)

            except TransientError:
                if i == self._attempts - 1:
                    raise

                time.sleep(2 ** i)


class LoggingClient:
    def __init__(self, inner: HttpClient):
        self._inner = inner

    def get(self, url: str) -> Response:
        start = time.monotonic()

        resp = self._inner.get(url)

        log.info(
            "GET %s -> %s in %.0fms",
            url,
            resp.status,
            (time.monotonic() - start) * 1000,
        )

        return resp

Then we can combine them:

client = LoggingClient(
    RetryingClient(
        RateLimitedClient(
            RealHttpClient()
        )
    )
)

Each decorator does one thing, but they all share the same interface as HttpClient.

This lets us add or remove individual behaviors without creating a new class for every possible combination.

Decorator order matters

This is an easy detail to overlook.

For example:

LoggingClient(
    RetryingClient(
        RealHttpClient()
    )
)

Logging is outside Retry.

If a request fails and is retried three times, the outer logging layer may record one call, while Retry performs three actual calls.

If we reverse the order:

RetryingClient(
    LoggingClient(
        RealHttpClient()
    )
)

logging can record each retry attempt.

Both approaches can be correct. What matters is understanding which behavior you want.

In Python, function-level decorators are based on a similar idea:

@retry(attempts=3)
@log_calls
def fetch_prices(symbol: str) -> dict:
    ...

Be careful when stacking too many decorators

Decorators are convenient, but stacking too many layers can make debugging difficult.

A stack trace may pass through many wrapper classes. When a request is slow, you may have to trace through each layer to find the problem.

A few layers are usually fine.

But if you need to look through seven or eight decorators just to understand what a method does, the design may have become too complicated.


Composite

Composite is used for tree-like structures, where both leaf nodes and container nodes can be handled through the same interface.

Examples include:

  • Files and directories.
  • Nested UI components.
  • Products and product bundles.
  • Categories containing subcategories.

The easiest way to understand Composite is to calculate the size of a directory.

A file has its own size. A directory doesn’t have a separate size; its size is the total size of everything inside it — which can include files or other nested directories.

from dataclasses import dataclass, field
from typing import Protocol


class Node(Protocol):
    def size(self) -> int: ...


@dataclass
class File:
    name: str
    bytes: int

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


@dataclass
class Folder:
    name: str
    children: list[Node] = field(default_factory=list)

    def size(self) -> int:
        return sum(child.size() for child in self.children)

Now, a Folder can contain either a File or another Folder:

root = Folder("project", [
    File("README.md", 2_000),

    Folder("src", [
        File("main.py", 8_000),
        File("utils.py", 4_000),
    ]),
])

The important point is:

File   → Node
Folder → Node

Folder contains Node, but Folder is also a Node itself.

So the calling code only needs:

root.size()

It doesn’t need to know whether it is working with a single file or a deeply nested directory tree.

It also doesn’t need to write if isinstance(x, Folder) anywhere. Each type knows how to calculate its own part.

This is the key benefit of Composite:

Leaf and container nodes share the same interface, so the calling code doesn’t need to know which type it is working with.

Composite is not a good fit when your structure is already flat, or when leaf nodes and container nodes have completely different behaviors.

In that case, forcing them into the same interface may result in an interface with many methods that some types don’t use.

Another concern is tree depth. If the tree can be created from user input, consider limiting its depth. A very deep tree combined with recursion can lead to a stack overflow.


Bridge

Bridge is useful when a class has two dimensions that can change independently, and combining them would create too many classes.

For example, suppose we have:

  • 3 types of notifications.
  • 4 delivery channels.

If every notification type has to know about every channel, we might need:

3 × 4 = 12 classes

Adding another channel would require three more classes.

Bridge solves this by separating the two dimensions:

class Channel(Protocol):
    def send(
        self,
        to: str,
        subject: str,
        body: str,
    ) -> None:
        ...


class Notification:
    def __init__(self, channel: Channel):
        self._channel = channel


class PasswordReset(Notification):
    def notify(self, user, token):
        self._channel.send(
            user.contact,
            "Reset your password",
            render(token),
        )

Now we can use:

PasswordReset(EmailChannel()).notify(user, token)

PasswordReset(ZaloChannel()).notify(user, token)

Notification types and delivery channels can evolve independently.

Instead of:

3 notification types × 4 channels = 12 classes

We have:

3 notification types + 4 channels = 7 classes

But Bridge is not a pattern you need to use frequently.

Many situations may look like Bridge but actually have only one dimension that needs to change. In that case, adding Bridge only makes the code more complicated.

A good signal to use Bridge is:

You can clearly identify two dimensions that are likely to change independently in the future.

If you don’t have a real example of both dimensions changing independently, don’t rush to use Bridge just because the number of classes might grow.


Flyweight

Flyweight is useful when you have a very large number of objects, but most of their data is identical.

Instead of storing many copies of the same data, we keep the shared data in one place.

Imagine loading 5 million transactions from a database to run a report.

Each transaction has a currency. A currency carries several pieces of information: its code, name, symbol, number of decimal places, and rounding rules.

A naive implementation might create a separate Currency object for every transaction — 5 million objects, even though there are only around 150 currencies in the world.

Most of them would simply be duplicate copies of USD, VND, or EUR.

So we split the data into two parts.

The shared data that can be reused:

@dataclass(frozen=True)
class Currency:
    code: str
    name: str
    symbol: str
    decimals: int

The data specific to each transaction:

@dataclass
class Transaction:
    id: int
    amount: int          # stored in the smallest unit
    currency: Currency

Then we reuse Currency objects through a cache:

_currencies: dict[str, Currency] = {}


def currency(code: str) -> Currency:
    if code not in _currencies:
        _currencies[code] = load_currency(code)

    return _currencies[code]

When constructing the data:

transactions = [
    Transaction(row.id, row.amount, currency(row.currency_code))
    for row in rows
]

We now have 5 million Transaction objects, but only 150 Currency objects in memory.

Each transaction holds a reference to a shared object instead of storing its own copy of the currency data.

This is the idea behind Flyweight:

Separate shared data (intrinsic state) from instance-specific data (extrinsic state), then share the intrinsic state.

The same idea applies to text rendering.

A 50,000-character document doesn’t need 50,000 copies of font data. The character A rendered with the same font only needs one Glyph containing the outline. Each occurrence can store its own x and y coordinates.

Flyweight is especially useful in systems with many small objects, such as:

  • Text rendering.
  • Game engines.
  • Particle systems.
  • Map tiles.
  • Large-scale batch data processing.

In a typical CRUD web app, Flyweight is usually not worth using. If the data isn’t large enough for memory usage to become a real problem, the pattern only adds complexity.

One important rule:

Shared data should be immutable.

If a shared object is modified, every object using it is affected.

Changing the decimals value of a shared Currency object and accidentally affecting 5 million transactions would be an extremely difficult bug to find.

That’s also why the Currency class above is declared with frozen=True.

Previous Post
Next Post