Proxy¶
Intent¶
Give the client a stand-in that presents the real object's interface and decides what happens to each call: create the real object only when it is first needed, answer from a cache, refuse the call, or send it across a network. The calling code never changes, and usually never learns which one it holds.
When to use and when not to¶
Use it when
- Creation is expensive and often unnecessary — the virtual proxy. Building 1,000 compressed photos at ~200 KB each is ~200 MB; proxies holding paths cost nothing until someone scrolls.
- The same call keeps giving the same answer — the caching proxy, with the cache behind the interface, not in every caller.
- Authorisation belongs outside the domain object — the protection proxy.
TextDocumentshould not have to know what a role is. - The real object is in another process — the remote proxy. The stub makes the call look local, though a datacenter round trip is ~500 µs against ~100 ns for a memory reference: 5,000x.
Leave it out when
- The wrapper adds behaviour the client deliberately chose and stacks: that is a Decorator. If it changes the interface it is an Adapter — adapt first, then proxy.
- Nothing is ever skipped. A lazy layer that always resolves on the first call is one indirection and no benefit.
- Hiding the cost would hide a budget: an ORM lazy relationship is a virtual proxy, and N+1 is the bill — 100 rows one at a time is 100 round trips, ~50 ms of network.
Structure¶
The virtual and caching proxy: one Subject, a RealSubject whose constructor does the expensive work, and a stand-in that postpones it and remembers the answers.
classDiagram
class Image {
<<interface>>
+path: str
+render(width: int) str
}
class RealImage {
+path: str
+render(width: int) str
}
class CachedImageProxy {
-_real: RealImage
-_renders: Dict~int,str~
+path: str
+is_loaded: bool
+render(width: int) str
}
class ImageStore {
+paths: Tuple~str~
+load_count: int
+load(path: str) Tuple~int,int~
}
Image <|.. RealImage
Image <|.. CachedImageProxy
CachedImageProxy o-- RealImage : creates on first use
RealImage ..> ImageStore : loads from
The protection and remote proxies: the same Subject, two stand-ins that decide whether the call reaches the real document and how.
classDiagram
class Document {
<<interface>>
+doc_id: str
+read() str
+write(text: str) None
}
class TextDocument {
+doc_id: str
+read() str
+write(text: str) None
}
class AccessControlledDocument {
-_user: str
-_policy: AccessPolicy
+read() str
+write(text: str) None
}
class RemoteDocumentStub {
-_transport: Transport
+read() str
+write(text: str) None
}
class DocumentServer {
+handle(request: Message) Message
}
Document <|.. TextDocument
Document <|.. AccessControlledDocument
Document <|.. RemoteDocumentStub
AccessControlledDocument o-- Document : guards
RemoteDocumentStub ..> DocumentServer : one message per call
DocumentServer o-- Document : holds the real one
Every arrow into an interface is the point: a proxy is a Subject, so it stands where one was expected.
Canonical example in Python¶
The Subject and the expensive RealSubject first (code/patterns/proxy.py, tested by code/patterns/tests/test_proxy.py):
@runtime_checkable
class Image(Protocol):
"""The Subject: what the client calls, satisfied by the real image and by its proxy."""
@property
def path(self) -> str: ...
def render(self, width: int) -> str: ...
class ImageStore:
"""The disk or blob store: ``load`` is the expensive call a proxy postpones.
``_lock`` guards ``_loaded``; ``load_count`` proves how often the store was hit.
"""
def __init__(self, files: Mapping[str, tuple[int, int]]) -> None:
self._files = dict(files)
self._loaded: list[str] = []
self._lock = threading.Lock()
@property
def paths(self) -> tuple[str, ...]:
return tuple(sorted(self._files)) # listing is cheap; it is the pixels that cost
def load(self, path: str) -> tuple[int, int]:
"""Returns (width, height); a real store would return megabytes of pixels."""
dimensions = self._files.get(path)
if dimensions is None:
raise NotFoundError(f"no image at {path!r}")
with self._lock:
self._loaded.append(path)
return dimensions
@property
def load_count(self) -> int:
with self._lock:
return len(self._loaded)
class RealImage:
"""The RealSubject: the load happens in the constructor, which is exactly what a proxy delays."""
def __init__(self, path: str, store: ImageStore) -> None:
self._path = path
self._width, self._height = store.load(path)
@property
def path(self) -> str:
return self._path
def render(self, width: int) -> str:
if width <= 0:
raise ValidationError("width must be positive")
height = max(1, round(self._height * width / self._width))
return f"{self._path} {width}x{height}"
RealImage loads in its constructor, which is what makes it worth postponing; load_count turns "the proxy avoided work" into an assertion.
class CachedImageProxy:
"""A virtual proxy with a cache: the ``Image`` interface, the ``RealImage`` made on first ``render``.
``path`` is answered from the proxy's own field, so listing a gallery of a thousand
images touches the store zero times. ``_lock`` guards ``_real`` and ``_renders``,
without which two threads rendering a cold image would both load it. A render that
raises caches nothing, and a failed load leaves ``_real`` empty so the next retries.
"""
def __init__(self, path: str, store: ImageStore) -> None:
self._path = path
self._store = store
self._real: RealImage | None = None
self._renders: dict[int, str] = {}
self._lock = threading.Lock()
@property
def path(self) -> str:
return self._path
@property
def is_loaded(self) -> bool:
with self._lock:
return self._real is not None
def render(self, width: int) -> str:
with self._lock:
cached = self._renders.get(width)
if cached is None:
if self._real is None:
self._real = RealImage(self._path, self._store)
cached = self._renders[width] = self._real.render(width)
return cached
Three decisions to say out loud:
pathis answered locally. The proxy keeps the path it was constructed with, so listing a gallery reads zero pixels. A proxy that had to load before answering any question would be pointless.- The lock exists because the load must happen once. Two threads rendering a cold image would otherwise both build a
RealImage; the test drives 200 concurrent renders and assertsload_count == 1. - Failure is not cached, and the key is the width. A
renderthat raises stores nothing and a failed load leaves the proxy cold, so the next call retries; caching a failure forever is the classic bug.
The protection proxy moves the policy out of the document:
class Permission(StrEnum):
READ = "read"
WRITE = "write"
class PermissionDeniedError(HandbookError):
"""The proxy refused to forward the call; the real document never saw it."""
@runtime_checkable
class Document(Protocol):
"""The Subject shared by the real document, the protection proxy and the remote stub."""
@property
def doc_id(self) -> str: ...
def read(self) -> str: ...
def write(self, text: str) -> None: ...
class TextDocument:
"""The RealSubject: it knows nothing about users, roles or networks."""
def __init__(self, doc_id: str, text: str = "") -> None:
self._doc_id = doc_id
self._text = text
@property
def doc_id(self) -> str:
return self._doc_id
def read(self) -> str:
return self._text
def write(self, text: str) -> None:
self._text = text
type AccessPolicy = Callable[[str, Permission], bool]
class AccessControlledDocument:
"""A protection proxy: the same ``read`` and ``write``, each preceded by a policy check.
Bound to one user at construction, so the signatures stay identical to the subject's:
nobody passes a user into ``read``. The policy is consulted on every call, so a
revocation lands on the next call rather than the next login.
"""
def __init__(self, inner: Document, user: str, policy: AccessPolicy) -> None:
self._inner = inner
self._user = user
self._policy = policy
@property
def doc_id(self) -> str:
return self._inner.doc_id
def read(self) -> str:
self._check(Permission.READ)
return self._inner.read()
def write(self, text: str) -> None:
self._check(Permission.WRITE)
self._inner.write(text)
def _check(self, permission: Permission) -> None:
if not self._policy(self._user, permission):
raise PermissionDeniedError(f"{self._user} may not {permission} {self._inner.doc_id}")
Binding the user at construction keeps the signatures identical to the subject's — nobody passes a user into read — so the proxy stays substitutable. Consulting the policy on every call means a revoked permission lands on the next call, not the next login.
The remote proxy keeps the interface and nothing else:
class RemoteError(HandbookError):
"""The failure a local object never has: the call may not have happened at all."""
type Message = dict[str, str]
type Transport = Callable[[Message], Message]
class DocumentServer:
"""The far side of the wire: unpack, call the real document, pack the reply; a gRPC service in production."""
def __init__(self, documents: Mapping[str, Document]) -> None:
self._documents = dict(documents)
def handle(self, request: Message) -> Message:
document = self._documents.get(request.get("doc_id", ""))
if document is None:
return {"error": f"no document {request.get('doc_id')!r}"}
match request.get("method"):
case "read":
return {"result": document.read()}
case "write":
document.write(request.get("text", ""))
return {"result": ""}
case other:
return {"error": f"unknown method {other!r}"}
class RemoteDocumentStub:
"""A remote proxy: the ``Document`` interface, one message per call.
``doc_id`` is local state; ``read`` and ``write`` cross the transport. The interface
hides the network but not its cost or its failure modes: every call is a round trip,
and either side's failure arrives as ``RemoteError``.
"""
def __init__(self, doc_id: str, transport: Transport) -> None:
self._doc_id = doc_id
self._transport = transport
@property
def doc_id(self) -> str:
return self._doc_id
def read(self) -> str:
return self._call("read")
def write(self, text: str) -> None:
self._call("write", text=text)
def _call(self, method: str, **fields: str) -> str:
request: Message = {"method": method, "doc_id": self._doc_id, **fields}
try:
reply = self._transport(request)
except OSError as exc:
raise RemoteError(f"transport failed: {exc}") from exc
if "error" in reply:
raise RemoteError(reply["error"])
return reply.get("result", "")
doc_id is local state and costs nothing; read and write each become one message. Failures on either side of the wire arrive as one RemoteError — the thing a remote call has that a local one does not is a failure where you cannot tell whether it happened.
Running python -m patterns.proxy prints:
--- virtual proxy: list four images and load none; render one and load one ---
listed 4 paths, loads: 0
render 200: photos/a.jpg 200x133 loads: 1
render 200: photos/a.jpg 200x133 loads: 1 (cache hit)
render 400: photos/a.jpg 400x267 loads: 1 (new width, same image)
loaded 1 of 4 images
--- protection proxy: one document, two users, the policy outside the document ---
alice writes, bob reads: 'Loan policy: 5 items, 14 days'
bob writes: PermissionDeniedError: bob may not write doc-1
alice writes after revocation: PermissionDeniedError: alice may not write doc-1
--- remote proxy: the same interface, one message per call ---
stub.read() -> 'Loan policy: 5 items, 14 days' via {'method': 'read', 'doc_id': 'doc-1'}
unknown document: RemoteError: no document 'doc-9'
--- pythonic forms: __getattr__ delegation, cached_property, weakref.proxy ---
LazyProxy created; builds so far: 0
lazy.read() -> 'built on first attribute access'; builds: 1
thumbnail.image twice: photos/d.jpg 80x60, photos/d.jpg 40x30; loads: 1
weakref.proxy parent: root/docs
after the parent is collected: ReferenceError: weakly-referenced object no longer exists
Pythonic variant¶
Three smaller forms come before a class per subject: __getattr__ forwards every attribute, cached_property is a virtual proxy for one attribute, and weakref.proxy is the smart reference:
class LazyProxy[T]:
"""``__getattr__`` delegation: build the target on first use, forward every attribute after that.
``__getattr__`` runs only when normal lookup fails, so the proxy's own fields never
recurse into it. It never sees dunder lookups either: ``len(proxy)``, ``proxy == other``
and ``with proxy:`` go to ``type(proxy)``, and ``isinstance`` reports the proxy.
"""
def __init__(self, factory: Callable[[], T]) -> None:
self._factory = factory
self._target: T | None = None
self._lock = threading.Lock()
def __getattr__(self, name: str) -> Any:
if name.startswith("_"): # never forward private names; also ends recursion before __init__
raise AttributeError(name)
with self._lock:
if self._target is None:
self._target = self._factory()
return getattr(self._target, name)
class Thumbnail:
"""``cached_property`` is the virtual proxy for one attribute: computed on first access, then stored.
Since 3.12 it holds no lock: two threads racing on a cold attribute may both run it.
"""
def __init__(self, path: str, store: ImageStore) -> None:
self.path = path
self._store = store
@cached_property
def image(self) -> RealImage:
return RealImage(self.path, self._store)
class TreeNode:
"""A tree whose back-reference to the parent is a ``weakref.proxy``.
The parent owns its children; a child must not own its parent, or no subtree is ever
collected. The proxy forwards ``path`` and raises ``ReferenceError`` once it is gone.
"""
def __init__(self, name: str, parent: TreeNode | None = None) -> None:
self.name = name
self.children: list[TreeNode] = []
self.parent: TreeNode | None = None
if parent is not None:
self.parent = weakref.proxy(parent)
parent.children.append(self)
def path(self) -> str:
if self.parent is None:
return self.name
return f"{self.parent.path()}/{self.name}"
__getattr__runs only when normal lookup fails, so the proxy's own fields never recurse into it; guarding names that start with_stops an unfinished proxy from recursing during__init__.- It never sees dunder lookups.
len(proxy),proxy == otherandwith proxy:are looked up on the type, andisinstance(proxy, list)isFalse. Generic delegation suits services; write the class when the subject has protocol behaviour. cached_propertyholds no lock since Python 3.12, so two threads can both run a cold getter. When the load must happen exactly once, keep the explicit lock.
| Reach for | When |
|---|---|
functools.cached_property |
One expensive attribute per instance, single-threaded or idempotent |
functools.lru_cache |
The expensive thing is a pure function of its arguments |
weakref.proxy |
A back-reference that must not keep its target alive |
__getattr__ delegation |
Many forwarded methods, no dunder or isinstance requirement |
| An explicit proxy class | The checks are per method, or the wrapper must be named |
Real-world usage¶
types.MappingProxyTypeis a protection proxy in the standard library: a read-only view of a dict. Every class's__dict__is one, which is why you cannot assign into it.weakref.proxyforwards attribute access and raisesReferenceErroronce the referent is collected, so parent back-pointers do not leak whole subtrees.multiprocessing.Manager().dict()returns aBaseProxywhose every operation is an inter-process round trip, andxmlrpc.client.ServerProxyturns attribute access into a remote call.- Frameworks: Django's
SimpleLazyObject(whatrequest.useris) and its lazysettings; SQLAlchemy and Django relationships that query on first access; gRPC stubs; every API gateway and sidecar.
Related patterns and confusions¶
The structural patterns all wrap something; classify by two questions: does the interface change, and why does the wrapper exist?
| Looks like Proxy | How to tell them apart |
|---|---|
| Decorator | Same interface, but a decorator always forwards and adds, and the client stacks it deliberately. A proxy decides whether the real call happens, and the client usually did not choose it. |
| Adapter | Changes the interface and adds no behaviour. Adapt first, then proxy: CachingProxy(StripeAdapter(client)). |
| Facade | A new, simpler interface over a subsystem of several objects, so it is not interchangeable with what it hides and cannot be layered. |
| Cache-aside | Same cache, different owner. A caching proxy hides the cache behind the interface; cache-aside makes every caller check and populate it, and one that forgets is a bug. |
| Lazy initialisation | cached_property is a virtual proxy one field wide, not one object wide. |
| Ambassador or sidecar | The remote proxy on a deployment diagram: retries, TLS and routing in a process next to yours. |
Where it appears in LLD problems¶
- Design a library management system — a member's view of a record is a protection proxy: the same interface, with borrowing and fine-waiving checked against the role first.
- Design an in-memory file system — permission checks in front of a node, and listings that report sizes without materialising contents.
- Design a learning management system — course content behind an enrolment check, and lesson bodies loaded when a student opens them, not when the syllabus is listed.
Interview tips¶
Interview tip
Name which proxy you mean in the same breath as the word: "a virtual proxy, so listing the gallery loads nothing", or "a protection proxy, so TextDocument never learns what a role is". Then add the two details that mark an SDE2: the lock, because the expensive load must happen exactly once under concurrency, and the rule that failures are never cached.
Common mistake
Calling a Decorator a Proxy. If the client deliberately stacked the wrapper to add behaviour it is a decorator; a proxy exists to control access and is usually invisible. Runner-up: a proxy that hides latency it should expose — the lazy attribute that turns one list into N queries — and __getattr__ forwarding that silently drops len(), ==, with and isinstance, so code that worked with the real object breaks.
Related¶
- Decorator — same interface, adds behaviour the client chose
- Adapter — changes the interface; adapt first, then proxy
- Facade — a simpler interface over many objects, not a matching one over one
- Design a library management system — role checks in front of an entity
- Design an in-memory file system — permissions and lazy listings
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Proxy
- Python documentation:
weakref— Weak references - Python documentation:
functools.cached_property