Low-Level Design · lesson 10 of 18
Beginner
4Intermediate
10- 5 Design a Parking Lot: Allocating and Freeing Spots
- 6 Design an Elevator System: Scheduling Requests
- 7 Design a Library Management System: Borrowing and Returning Books
- 8 Design an Expense-Splitting App: Balances and Minimal Settlements
- 9 Design a URL Shortener: Sequential Codes vs Real Hash Collisions
- 10 Wrapping Objects: Adapter, Facade, and Proxy in Python
- 11 The Composite Pattern in Python: Sizing a File Tree Without isinstance
- 12 The State Pattern in Python: Refactoring a Vending Machine Into State Classes
- 13 Undo and Redo in Python: the Memento and Command Patterns Compared
- 14 Chain of Responsibility in Python: Expense Approvals Without an if/elif Ladder
Advanced
4- 15 Concurrency in LLD: A Real Race Condition in the Parking Lot, Then the Fix
- 16 The Observer Pattern in Practice: A Stock Ticker Notifying Multiple Displays
- 17 Design Patterns That Are Just Language Features: Strategy, Command, Iterator
- 18 Abstract Factory and Prototype in Python: Matching Families and Cloning Objects
Also part of Low-Level Design
12- + SOLID Principles in Java from Java
- + Composition vs Inheritance in Java from Java
- + Design Patterns in Java from Java
- + Python Design Patterns from Python
- + Design Patterns in TypeScript from TypeScript
- + JavaScript Design Patterns from JavaScript
- + Design Patterns in C# from C#
- + Design Patterns in C++ from C++
- + Design Patterns in Go from Go
- + Design Patterns in Rust from Rust
- + Abstract Classes in Java from Java
- + Sealed Classes and Interfaces in Java from Java
Wrapping Objects: Adapter, Facade, and Proxy in Python
Fix a mismatched payment SDK with an Adapter, hide a five-step order behind a Facade, and cache a slow service with a Proxy, with real Python output.
By SkillByExample · Published
Three patterns wrap one object in another. They look alike in code, and they solve different problems: an interface that doesn’t fit, a sequence that’s easy to get wrong, and a call that’s too slow or not always allowed.
The vendor’s method doesn’t match ours
Our checkout calls gateway.charge(rupees). The payment SDK we just bought has
make_payment(amount_paise, currency) instead, and we can’t edit it.
# pay_mismatch.py: our checkout expects charge(rupees); the vendor's client doesn't have it
class VendorPayClient:
"""Third-party SDK: we can't edit this class."""
def make_payment(self, amount_paise, currency):
return {"status": "ok", "charged": f"{amount_paise} {currency} paise"}
def checkout(gateway, total_rupees):
receipt = gateway.charge(total_rupees)
print("receipt:", receipt)
checkout(VendorPayClient(), 499)
$ python pay_mismatch.py
Traceback (most recent call last):
File "/work/pay_mismatch.py", line 11, in <module>
checkout(VendorPayClient(), 499)
File "/work/pay_mismatch.py", line 8, in checkout
receipt = gateway.charge(total_rupees)
^^^^^^^^^^^^^^
AttributeError: 'VendorPayClient' object has no attribute 'charge'
Python only checks that charge exists when the line runs, so the mismatch shows up as an
AttributeError at the first real payment. Renaming our call isn’t enough either: the vendor
also wants paise, not rupees, so a renamed call would charge a hundredth of the price.
Adapter: translate at one seam
An Adapter holds the vendor object and exposes the interface checkout already uses.
# pay_adapter.py: an Adapter gives the vendor client the interface checkout expects
class VendorPayClient:
"""Third-party SDK: we can't edit this class."""
def make_payment(self, amount_paise, currency):
return {"status": "ok", "charged": f"{amount_paise} {currency} paise"}
class VendorPayAdapter:
"""Speaks our charge(rupees) interface and translates it to the vendor's call."""
def __init__(self, client):
self._client = client
def charge(self, rupees):
reply = self._client.make_payment(round(rupees * 100), "INR")
return reply["charged"]
def checkout(gateway, total_rupees):
receipt = gateway.charge(total_rupees)
print("receipt:", receipt)
checkout(VendorPayAdapter(VendorPayClient()), 499)
checkout(VendorPayAdapter(VendorPayClient()), 12.5)
$ python pay_adapter.py
receipt: 49900 INR paise
receipt: 1250 INR paise
checkout didn’t change. The adapter is the only place that knows about paise, so 499 rupees
became 49900 paise and 12.5 became 1250. round() matters here: 12.5 * 100 is exact, but
amounts like 0.29 * 100 come out as 28.999999999999996 in floating point, and int() would
truncate that to 28. When the vendor changes its SDK, only the adapter moves.
Five calls in the right order
Placing an order touches five subsystems. Every handler has to repeat the sequence.
# order_steps.py: placing an order takes five calls, and one caller forgets a step
log = []
class Inventory:
def reserve(self, sku): log.append(f"reserved {sku}")
class Payments:
def charge(self, amount): log.append(f"charged {amount}")
class Shipping:
def book(self, sku): log.append(f"shipment booked for {sku}")
class Invoices:
def record(self, amount): log.append(f"invoice for {amount}")
class Email:
def send(self, msg): log.append(f"emailed: {msg}")
inv, pay, ship, bill, mail = Inventory(), Payments(), Shipping(), Invoices(), Email()
# the web handler does all five, in order
inv.reserve("BOOK-1"); pay.charge(499); ship.book("BOOK-1"); bill.record(499); mail.send("order placed")
print("web: ", log)
# the mobile handler was written later and forgot to book the shipment
log.clear()
inv.reserve("BOOK-1"); pay.charge(499); bill.record(499); mail.send("order placed")
print("mobile:", log)
$ python order_steps.py
web: ['reserved BOOK-1', 'charged 499', 'shipment booked for BOOK-1', 'invoice for 499', 'emailed: order placed']
mobile: ['reserved BOOK-1', 'charged 499', 'invoice for 499', 'emailed: order placed']
Both handlers ran without an error. The mobile log is one line shorter: the customer was charged and emailed, and no shipment was ever booked. Nothing crashed, which is why this kind of bug reaches production.
Facade: one call owns the sequence
A Facade is one class with one method that runs the steps in order.
# order_facade.py: a Facade owns the five-step sequence, so callers make one call
log = []
class Inventory:
def reserve(self, sku): log.append(f"reserved {sku}")
class Payments:
def charge(self, amount): log.append(f"charged {amount}")
class Shipping:
def book(self, sku): log.append(f"shipment booked for {sku}")
class Invoices:
def record(self, amount): log.append(f"invoice for {amount}")
class Email:
def send(self, msg): log.append(f"emailed: {msg}")
class OrderService:
"""The one entry point. The subsystems stay reachable for anyone who needs them."""
def __init__(self):
self.inv, self.pay, self.ship = Inventory(), Payments(), Shipping()
self.bill, self.mail = Invoices(), Email()
def place_order(self, sku, amount):
self.inv.reserve(sku)
self.pay.charge(amount)
self.ship.book(sku)
self.bill.record(amount)
self.mail.send("order placed")
orders = OrderService()
for handler in ("web", "mobile"):
log.clear()
orders.place_order("BOOK-1", 499)
print(f"{handler}: {len(log)} steps, third step: {log[2]}")
$ python order_facade.py
web: 5 steps, third step: shipment booked for BOOK-1
mobile: 5 steps, third step: shipment booked for BOOK-1
Both handlers now make the same single call, and both get all five steps with the shipment third. The subsystems are still there for the rare caller that needs them directly; a Facade adds a simple door, it doesn’t lock the others.
A slow call, made five times
Five widgets on one page each fetch the same user’s profile from a slow service.
# slow_profile.py: every widget on the page asks the slow profile service again
import time
class ProfileService:
calls = 0
def fetch_profile(self, user_id):
ProfileService.calls += 1
time.sleep(0.2) # stands in for a network round trip
return {"id": user_id, "name": f"user{user_id}"}
service = ProfileService()
start = time.perf_counter()
for widget in range(5): # five widgets on one page
service.fetch_profile(42)
print(f"backend calls: {ProfileService.calls}, took {time.perf_counter() - start:.1f}s")
$ python slow_profile.py
backend calls: 5, took 1.0s
Five identical requests cost five round trips, a full second here. The service is right to be slow (it’s a network call); the waste is asking it the same question five times.
Proxy: same interface, controlled access
A Proxy has the same fetch_profile method, so callers can’t tell they aren’t talking to the real
service. In between, it caches and checks permissions.
# profile_proxy.py: a Proxy with the same interface adds caching and an access check
import time
class ProfileService:
calls = 0
def fetch_profile(self, user_id):
ProfileService.calls += 1
time.sleep(0.2) # stands in for a network round trip
return {"id": user_id, "name": f"user{user_id}"}
class ProfileProxy:
"""Same fetch_profile(user_id) as the real service, so callers can't tell."""
def __init__(self, real, viewer_id):
self._real, self._viewer, self._cache = real, viewer_id, {}
def fetch_profile(self, user_id):
if user_id != self._viewer:
raise PermissionError(f"user {self._viewer} may not read profile {user_id}")
if user_id not in self._cache:
self._cache[user_id] = self._real.fetch_profile(user_id)
return self._cache[user_id]
service = ProfileProxy(ProfileService(), viewer_id=42)
start = time.perf_counter()
for widget in range(5):
service.fetch_profile(42)
print(f"backend calls: {ProfileService.calls}, took {time.perf_counter() - start:.1f}s")
try:
service.fetch_profile(7)
except PermissionError as e:
print("blocked:", e)
$ python profile_proxy.py
backend calls: 1, took 0.2s
blocked: user 42 may not read profile 7
One backend call instead of five, and 0.2 seconds instead of 1.0. The same proxy also refused a request for someone else’s profile before it reached the service. Caching, permission checks and lazy creation are the usual jobs of a Proxy.
Four wrappers compared
| Pattern | Interface | Why it exists |
|---|---|---|
| Adapter | changes it, to the one the caller expects | an existing object doesn’t fit |
| Facade | a new, simpler one in front of many objects | a sequence is easy to get wrong |
| Proxy | the same as the real object | control access: cache, permission, lazy load |
| Decorator | the same as the wrapped object | add behaviour, often several layers |
Bridge, another GoF pattern, isn’t covered separately. Structurally it’s composition with a swappable implementation, like Strategy; it differs in having two hierarchies, abstraction and implementation, that vary independently.
Practice
1. A second vendor's SDK has pay(amount_text), where the amount is a string like "499.00". Write an adapter so checkout works with it unchanged.
# p15_1.py: a second vendor wants the amount as a string with two decimals
class OtherVendorClient:
"""Another SDK: pay(amount_text) where amount_text is like '499.00'."""
def pay(self, amount_text):
return f"paid Rs {amount_text}"
class OtherVendorAdapter:
def __init__(self, client):
self._client = client
def charge(self, rupees):
return self._client.pay(f"{rupees:.2f}")
def checkout(gateway, total_rupees):
receipt = gateway.charge(total_rupees)
print("receipt:", receipt)
checkout(OtherVendorAdapter(OtherVendorClient()), 499)
checkout(OtherVendorAdapter(OtherVendorClient()), 12.5)
$ python p15_1.py
receipt: paid Rs 499.00
receipt: paid Rs 12.50
The format string :.2f does the unit conversion this time. Each vendor gets its own adapter,
and checkout stays the same for both.
2. Make the Facade undo the stock reservation when the card is declined, so a failed order leaves nothing reserved.
# p15_2.py: the Facade undoes the reservation when the charge fails
log = []
class Inventory:
def reserve(self, sku): log.append(f"reserved {sku}")
def release(self, sku): log.append(f"released {sku}")
class Payments:
def charge(self, amount):
if amount > 1000:
raise ValueError("card declined")
log.append(f"charged {amount}")
class OrderService:
def __init__(self):
self.inv, self.pay = Inventory(), Payments()
def place_order(self, sku, amount):
self.inv.reserve(sku)
try:
self.pay.charge(amount)
except ValueError as e:
self.inv.release(sku)
return f"failed: {e}"
return "placed"
orders = OrderService()
print(orders.place_order("BOOK-1", 499), log)
log.clear()
print(orders.place_order("LAPTOP-9", 54000), log)
$ python p15_2.py
placed ['reserved BOOK-1', 'charged 499']
failed: card declined ['reserved LAPTOP-9', 'released LAPTOP-9']
The rollback lives in one place. Without the Facade, every handler would need the same
try/except and the same release call.
3. Add an invalidate(user_id) method to the Proxy so a profile edit isn't hidden by a stale cache.
# p15_3.py: the Proxy can drop a cached profile after the user edits it
class ProfileService:
calls = 0
def fetch_profile(self, user_id):
ProfileService.calls += 1
return {"id": user_id, "version": ProfileService.calls}
class ProfileProxy:
def __init__(self, real):
self._real, self._cache = real, {}
def fetch_profile(self, user_id):
if user_id not in self._cache:
self._cache[user_id] = self._real.fetch_profile(user_id)
return self._cache[user_id]
def invalidate(self, user_id):
self._cache.pop(user_id, None)
service = ProfileProxy(ProfileService())
print(service.fetch_profile(42), service.fetch_profile(42))
service.invalidate(42) # the user just saved a new name
print(service.fetch_profile(42))
print("backend calls:", ProfileService.calls)
$ python p15_3.py
{'id': 42, 'version': 1} {'id': 42, 'version': 1}
{'id': 42, 'version': 2}
backend calls: 2
The first two reads share version 1 from the cache. After invalidate, the next read goes to the
backend and gets version 2, so the backend saw two calls for three reads.
4. Count cache hits and misses in the Proxy, then check the numbers for the user IDs 42, 42, 7, 42, 7, 99.
# p15_4.py: hit and miss counters on the Proxy show whether the cache is earning its keep
class ProfileService:
def fetch_profile(self, user_id):
return {"id": user_id}
class ProfileProxy:
def __init__(self, real):
self._real, self._cache = real, {}
self.hits = self.misses = 0
def fetch_profile(self, user_id):
if user_id in self._cache:
self.hits += 1
else:
self.misses += 1
self._cache[user_id] = self._real.fetch_profile(user_id)
return self._cache[user_id]
service = ProfileProxy(ProfileService())
for user_id in [42, 42, 7, 42, 7, 99]:
service.fetch_profile(user_id)
print(f"hits={service.hits} misses={service.misses}")
$ python p15_4.py
hits=3 misses=3
Three distinct users mean three misses; the repeats are the three hits. A hit rate this low on real traffic would suggest the cache isn’t worth its memory.