What is it? #
A Proxy stands in for another object, implementing the same interface and controlling access to it.
It looks very like a Decorator, and the code often is similar. The difference is intent. A decorator adds behaviour to something you already have; a proxy controls whether, when or how the real object is used at all.
Three common kinds. A virtual proxy delays creating an expensive object until it is actually needed. A protection proxy checks permissions before allowing a call. A remote proxy makes a call to another machine look like a local one.
The caller sees the same interface throughout and never learns which it is holding.
Think of it like this #
A personal assistant. Calls to the executive go through them. Some are handled without ever disturbing the executive, some are refused, and some are put through.
The caller dials the same number either way. What changes is what happens behind it.
Simple example #
A report object is expensive to build — it queries several tables. Many pages display only the report title, so building the full report every time is wasteful. Separately, some reports may only be opened by managers.
Code #
import time
from abc import ABC, abstractmethod
class Report(ABC):
@abstractmethod
def title(self) -> str: ...
@abstractmethod
def rows(self) -> list[dict]: ...
class RealReport(Report):
def __init__(self, report_id: str):
self.report_id = report_id
print(f"building report {report_id} (expensive)")
time.sleep(0.2) # heavy queries
self._rows = [{"month": "Sep", "total": 120000}]
def title(self): return f"Sales report {self.report_id}"
def rows(self): return self._rows
# --- Virtual proxy: build the real thing only when rows are needed ---
class LazyReport(Report):
def __init__(self, report_id: str):
self.report_id = report_id
self._real: RealReport | None = None
def _load(self) -> RealReport:
if self._real is None:
self._real = RealReport(self.report_id)
return self._real
def title(self):
return f"Sales report {self.report_id}" # no loading needed
def rows(self):
return self._load().rows() # loads on first use
# --- Protection proxy: check permission before delegating ---
class RestrictedReport(Report):
def __init__(self, inner: Report, user_role: str):
self._inner, self._role = inner, user_role
def title(self):
return self._inner.title()
def rows(self):
if self._role not in {"manager", "admin"}:
raise PermissionError("you are not allowed to view report data")
return self._inner.rows()
listing = LazyReport("R-2026-09")
print(listing.title()) # no expensive build happens
secured = RestrictedReport(listing, user_role="analyst")
print(secured.title()) # fine
try:
secured.rows() # PermissionError, and still no build
except PermissionError as exc:
print(exc)
manager_view = RestrictedReport(listing, user_role="manager")
print(manager_view.rows()) # now the real report is built, once
Proxy vs Decorator
decorator adds behaviour around an object you already created
proxy controls access to, creation of, or location of the real object
Both implement the same interface as what they wrap.
How it works #
LazyReport can answer title() from the ID alone, so listing twenty reports costs nothing. RealReport is constructed only when rows() is first called, and self._real caches it so it is built once.
That is the virtual proxy: the expensive work is deferred until it is genuinely needed, and skipped entirely if it never is.
RestrictedReport wraps any Report and checks the role before exposing data. Titles stay visible; data does not. Because the check sits in the proxy, no caller can forget it.
Stacking the two gives a useful property: an unauthorised request fails before the expensive report is ever built. The order of wrapping produced that behaviour.
Both classes implement Report, so anything expecting a report works with either. The caller cannot tell it is holding a proxy.
The proxy-versus-decorator distinction is about intent rather than mechanics, and in real code the line is sometimes blurry. What matters is that the reader can tell which question the class answers: "what extra happens?" or "should this happen at all?"
Real-world use #
ORMs use virtual proxies extensively. Accessing order.customer may trigger a query the first time — lazy loading — which is convenient and also the cause of the N+1 query problem when it happens inside a loop.
Protection proxies show up as permission wrappers around resources, and API gateways are protection proxies at network scale: authentication, rate limiting and routing in front of your services.
Remote proxies make a network service look like a local object. gRPC and similar frameworks generate exactly this from a service definition.
Caching proxies sit in front of expensive services, and reverse proxies such as Nginx are the same idea at the infrastructure level — covered in the System Design and VPS tracks.
Common mistakes #
- Hiding expensive work behind an innocent-looking property access, causing N+1 queries.
- Forgetting to cache the loaded object, so it is rebuilt on every call.
- Letting the proxy diverge from the interface it is standing in for.
- Using a proxy for access control while leaving a direct path to the real object.
- Confusing readers by combining unrelated concerns in one proxy.
Practice #
Write a CachedApiClient proxy around a client with fetch(path), caching responses for sixty seconds. Then add a RateLimitedClient proxy allowing at most five calls per minute. Stack them and describe what each ordering does differently.