PYTH-270 · Lesson 5 of 12 · 25 min
Structural patterns
▶ Video coming soon A video version of this lesson is on the way.
Structural patterns are about how objects and classes are combined into larger structures, and in particular about joining pieces that were not designed to fit together. Ridgeline meets this problem constantly. The green-coffee importer publishes prices in a format nobody at Ridgeline chose, the new payment provider's library has its own method names, the gift boxes nest inside each other, and the price lookup service is slow enough that calling it on every page view makes the shop crawl.
This lesson covers the structural patterns that come up most in Python: adapter, facade, decorator, composite and proxy, with brief notes on bridge and flyweight. Each one wraps or combines objects so that the rest of the code can stay simple. As in lesson 4, the classic form is shown alongside the Pythonic one, which is often shorter because of duck typing.
Adapter: making incompatible interfaces fit
An adapter converts the interface of one class into the interface that the rest of your code expects. It is the software version of a travel plug adapter. Ridgeline's checkout code expects every payment provider to have a charge(cents) method that returns a receipt string. LumenPay fits. Northwind Payments, the wholesale provider, offers submit_invoice(amount, currency) with the amount as a decimal string in dollars, and returns a dictionary. Rather than teaching checkout about Northwind's quirks, an adapter wraps the Northwind client and presents the expected charge method.
from decimal import Decimal
class NorthwindClient:
"""Third-party library: we cannot change this class."""
def submit_invoice(self, amount, currency):
return {"status": "accepted", "invoice": "NW-5531", "amount": amount, "currency": currency}
class NorthwindAdapter:
"""Presents Northwind as a provider with charge(cents)."""
def __init__(self, client, currency="CAD"):
self._client = client
self._currency = currency
def charge(self, cents):
dollars = str(Decimal(cents) / 100)
result = self._client.submit_invoice(dollars, self._currency)
if result["status"] != "accepted":
raise RuntimeError("payment refused")
return f"invoice {result['invoice']} for {result['amount']} {result['currency']}"
def checkout(provider, cents):
return provider.charge(cents) # checkout knows only charge(cents)
print(checkout(NorthwindAdapter(NorthwindClient()), 20400))invoice NW-5531 for 204 CAD
The adapter holds the client as an attribute and translates each call. This is composition, not inheritance: the adapter does not subclass NorthwindClient, so changes to that library's internals cannot leak into checkout, and the translation rules (dollars as strings, the status check) live in exactly one place. Adapters are also the natural place to translate errors, turning the library's exceptions into Ridgeline's own PaymentError so that callers handle one kind of failure.
Try it yourself
Adapt the supplier feed
Ridgeline's reorder code expects a supplier object with a method price_per_kg(origin) that returns a float price in dollars per kilogram. The green-coffee importer's client, ImporterClient, instead offers quote(region_code), which takes an upper-case region code and returns the price per pound in cents as an int.
Write ImporterAdapter(client) with price_per_kg(origin): upper-case the origin, call quote, convert cents per pound to dollars per kilogram (one kilogram is 2.20462 pounds) and round to 2 decimal places.
class ImporterAdapter:
def __init__(self, client):
self.client = client
def price_per_kg(self, origin):
return self.client.quote(origin)
print(ImporterAdapter(ImporterClient()).price_per_kg("ethiopia"))Facade: one simple door into a complicated subsystem
A facade provides a simple interface to a group of classes that work together. Placing an online order at Ridgeline involves the inventory, pricing rules, the payment provider, the shipping calculator, the order repository and the notifier, called in the right order with the right error handling. Every caller that places orders, whether the web shop, the kiosk or a wholesale import script, should not have to know that choreography. A CheckoutService facade offers one method, place_order, and handles the rest.
A facade does not hide the subsystem completely; code that needs the details can still use the individual classes. Its job is to make the common case easy and to keep the choreography in one place, so that a change such as reserving stock before charging the card is made once. Lesson 8 will recognise this facade as the application service layer of a layered architecture.
Decorator: adding behavior by wrapping
The decorator pattern adds responsibilities to an object by wrapping it in another object with the same interface. The wrapper forwards each call to the wrapped object and adds something before or after: logging, timing, retries, caching, access checks. Because the wrapper has the same interface, wrappers can be stacked, and callers cannot tell the difference. Ridgeline wraps its payment provider in a LoggingProvider during the first weeks with Northwind, so every charge is recorded for Anita's reconciliation.
The pattern and Python's @decorator syntax share a name and an idea but are not the same thing. The @ syntax wraps a function at definition time; the design pattern wraps an object at run time. Both add behavior without changing the original code, and both are applications of the open-closed principle. Lesson 7 covers function decorators in detail.
class LumenPay:
def charge(self, cents):
return f"LumenPay receipt for {cents} cents"
class LoggingProvider:
"""Same interface as any provider; records each charge, then delegates."""
def __init__(self, inner, log):
self._inner = inner
self._log = log
def charge(self, cents):
self._log.append(f"charging {cents}")
receipt = self._inner.charge(cents)
self._log.append(f"done: {receipt}")
return receipt
class LimitProvider:
"""Refuses charges over a limit, then delegates."""
def __init__(self, inner, max_cents):
self._inner, self._max = inner, max_cents
def charge(self, cents):
if cents > self._max:
raise ValueError(f"charge of {cents} exceeds limit {self._max}")
return self._inner.charge(cents)
log = []
provider = LoggingProvider(LimitProvider(LumenPay(), 50000), log)
print(provider.charge(4595))
try:
provider.charge(90000)
except ValueError as e:
print("refused:", e)
print(log)LumenPay receipt for 4595 cents refused: charge of 90000 exceeds limit 50000 ['charging 4595', 'done: LumenPay receipt for 4595 cents', 'charging 90000']
Look at what the log shows for the refused charge: the logging wrapper recorded the attempt, but not a completion, because the limit wrapper raised. The order in which wrappers are stacked changes the behavior, so choose it deliberately.
Wrapping without boilerplate
Real interfaces have more than one method. A payment provider might offer charge, refund, status and close, and a wrapper that only cares about charge would still need four forwarding methods to keep the same interface. Python offers a shortcut: __getattr__ is called only when normal attribute lookup fails, so a wrapper can define the methods it changes and let __getattr__ forward everything else to the wrapped object automatically.
class Provider:
def charge(self, cents):
return f"charged {cents}"
def refund(self, cents):
return f"refunded {cents}"
def status(self):
return "online"
class AuditedProvider:
"""Overrides charge; forwards every other attribute to the wrapped provider."""
def __init__(self, inner):
self._inner = inner
self.audit = []
def charge(self, cents):
self.audit.append(cents)
return self._inner.charge(cents)
def __getattr__(self, name): # only called for names not found on self
return getattr(self._inner, name)
p = AuditedProvider(Provider())
print(p.charge(1200))
print(p.refund(300))
print(p.status())
print(p.audit)charged 1200 refunded 300 online [1200]
Use this with care. Automatic forwarding hides which methods the wrapper really supports, so tools such as type checkers and editors cannot see them, and a misspelt attribute produces an error from the wrapped object rather than from the wrapper. For small interfaces, writing the forwarding methods out is clearer; for large ones, a typing.Protocol (lesson 7) documents the expected methods while __getattr__ does the forwarding.
Composite: treating groups and items the same way
The composite pattern lets you treat individual objects and groups of objects uniformly. Ridgeline's gift boxes, which you priced recursively with dictionaries in PYTH-201, are a natural composite: a box contains products and other boxes, and both a single bag and a whole box can answer what is your price? and how many items do you contain? Client code asks the question once and never checks whether it is holding a box or a bag.
Try it yourself
Gift boxes as a composite
Write two classes with the same interface. Item(name, price) represents one product; its price() returns the price and its count() returns 1. Box(name, contents) holds a list of Item and Box objects; its price() is the sum of its contents' prices and its count() is the total number of items inside, at every level.
Boxes themselves are not items, so an empty box has price 0 and count 0.
class Item:
def __init__(self, name, price):
self.name, self._price = name, price
def price(self):
return self._price
def count(self):
return 1
class Box:
def __init__(self, name, contents):
self.name, self.contents = name, contents
def price(self):
return sum(c._price for c in self.contents)
def count(self):
return len(self.contents)Proxy: a stand-in that controls access
A proxy is an object with the same interface as another object that stands in for it and controls access to it. A caching proxy remembers results so the real object is called less often; a lazy proxy delays creating an expensive object until it is first used; a protection proxy checks permissions; a remote proxy hides the fact that the real object lives on another machine. Structurally a proxy looks like a decorator; the difference is intent. A decorator adds behavior; a proxy manages access to the real thing.
Ridgeline's web shop asks the pricing service for every coffee on every page view, and each call takes a noticeable fraction of a second. Prices change at most once a day, so a caching proxy in front of the service is a large win with a few lines of code. The proxy must also offer a way to clear the cache when Rosa updates prices.
Try it yourself
A caching price proxy
The setup provides SlowPrices, whose price(sku) returns a price and records each call in self.calls. Write CachedPrices(service) with the same price(sku) method: the first request for a SKU asks the service, and later requests for the same SKU return the remembered value.
Add clear(), which forgets every remembered price so the next request asks the service again.
class CachedPrices:
def __init__(self, service):
self.service = service
def price(self, sku):
return self.service.price(sku)
def clear(self):
passBridge and flyweight, briefly
Two more structural patterns are worth recognising. The bridge pattern separates an abstraction from its implementation so the two can vary independently: Ridgeline's reports (daily sales, monthly wholesale) and their output formats (text, CSV, HTML) form two dimensions, and rather than a class for every combination, each report holds a formatter object. You will notice this is composition over inheritance again, applied deliberately to two independent axes of change.
The flyweight pattern shares common data among many small objects to save memory. If the web shop showed hundreds of thousands of product tiles, each could refer to one shared, immutable record for its coffee instead of copying the name, description and image for every tile. In Python, interned strings, small cached integers and immutable value objects shared by reference are everyday flyweights, and __slots__ reduces the memory each object needs.
Worked problem
Adapter, decorator or proxy?
Three wrappers appear in Ridgeline's code review. Sam is unsure what to call each one, since all three wrap another object.
Show solution
- TaxServiceWrapper changes the province-code argument and converts the tax service's response into Ridgeline's TaxResult: it changes the interface, so it is an adapter.
- TimedProvider has exactly the same charge(cents) interface and records how long each charge took: it adds behavior, so it is a decorator.
- LazyReportEngine has the same interface as the report engine but creates the engine, which loads large templates, only on the first call: it controls access to the real object, so it is a proxy.
- The structure is the same in all three (hold an object, forward calls); the intent decides the name.
Answer: An adapter changes the interface, a decorator keeps the interface and adds behavior, and a proxy keeps the interface and controls access.
Common mistakes
MistakeSpreading the translation of a third-party library's interface across the codebase.
InsteadWrite one adapter that presents the interface your code expects.
MistakeSubclassing a third-party class to adapt it.
InsteadWrap it with composition, so its internals cannot leak into your code.
MistakeLetting callers repeat a multi-step choreography of subsystem calls.
InsteadOffer a facade with one method for the common case.
MistakeStacking decorators without thinking about their order.
InsteadDecide which wrapper should see each call first; the order changes behavior.
MistakeChecking isinstance(x, Box) everywhere in code that handles gift boxes.
InsteadGive items and boxes the same interface and let each answer for itself (composite).
MistakeCaching results that can change without a way to clear the cache.
InsteadGive a caching proxy an explicit way to invalidate entries.
Summary and key terms
- Structural patterns combine objects into larger structures, often by wrapping.
- An adapter translates one interface into the one your code expects, usually by composition.
- A facade gives a simple entry point to a subsystem and keeps the choreography in one place.
- A decorator wraps an object with the same interface to add behavior; wrappers can be stacked, and order matters.
- A composite lets individual items and groups be treated the same way through a shared interface.
- A proxy stands in for an object to control access: caching, lazy creation, protection or remote access.
- Bridge separates two dimensions of variation; flyweight shares immutable data among many objects.
- structural pattern
- A pattern about how objects and classes are combined into larger structures.
- adapter
- A wrapper that converts one interface into another that clients expect.
- facade
- A simple interface to a set of classes that work together.
- decorator pattern
- Wrapping an object in another with the same interface to add behavior.
- composite
- A structure in which individual objects and groups share one interface.
- proxy
- A stand-in object that controls access to another object.
- caching proxy
- A proxy that remembers results to avoid repeated expensive calls.
- lazy initialization
- Delaying the creation of an expensive object until it is first needed.
- bridge
- Separating an abstraction from its implementation so both can vary.
- flyweight
- Sharing common immutable data among many small objects.
Practice problems
Work each problem on paper first, then reveal the answer to check.
What distinguishes an adapter from a decorator?
Show answer
An adapter changes the interface; a decorator keeps it and adds behavior.
Why should an adapter wrap a library object instead of subclassing it?
Show answer
Composition keeps the library's internals out of your code and limits coupling to one class.
Which pattern does CheckoutService.place_order represent?
Show answer
A facade over inventory, pricing, payments, shipping, storage and notification.
In the composite exercise, why must Box call c.price() rather than read c._price?
Show answer
A child might itself be a box, which has no single stored price; calling price() lets each child compute its own.
Give two kinds of proxy.
Show answer
For example a caching proxy and a lazy proxy (also protection and remote proxies).
Name an everyday flyweight in Python.
Show answer
Interned strings or small cached integers, or one immutable record shared by many objects.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.