Every time you call a method on a Hibernate entity, open a page behind a CDN, or hit a service through an Envoy sidecar, something answered on behalf of something else. You did not notice, and that was the point. The Proxy pattern is the oldest trick in the book for putting a decision between a caller and a thing: should this call happen at all, should it happen now, and should it happen here?
The definition fits in a sentence. A proxy is an object that implements the same interface as another object, holds a reference to it, and controls access to it. The caller cannot tell the difference. The Proxy entry on Refactoring Guru lists the standard variants: virtual proxies that delay creation of an expensive object, protection proxies that check permissions, remote proxies that hide a network hop, and caching proxies that remember results. That list is accurate, and it is also why people confuse Proxy with its neighbors.
This post argues one thing: a proxy is defined by the decision it makes, not by the wrapping it does. If your wrapper makes no decision, it is a pass-through and you should delete it. If it decides whether, when or where, it is a proxy, and it comes with a bill: it can lie about types, hide latency, and swallow errors. We will build the three useful jobs, then spend the second half on the bill.
Proxy, Decorator and Adapter are three different bets
The wrapper family is where most design pattern confusion lives, so pin it down first. An Adapter changes the interface so two incompatible things can talk. A Decorator keeps the interface and adds behavior, and the caller usually builds the stack on purpose. A Proxy keeps the interface and controls access, and the caller often does not know it is there.
The tell is lifecycle. A decorator is composed by the code that wants the extra behavior: you wrap the client in retries because you want retries. A proxy is usually handed to you by whoever owns the real thing: the ORM gives you a lazy collection, the framework gives you a transactional service. You never asked for the stand-in, and that is exactly why its behavior needs to be boring and predictable.
Job one: the caching proxy
Start with the one most teams already write by accident. A report service is slow and deterministic for a given account. Callers should not care that results are cached.
import time
class ReportService:
def __init__(self): self.calls = 0
def generate(self, account_id):
self.calls += 1
time.sleep(0.05)
return f"report:{account_id}"
class CachingProxy:
def __init__(self, real, ttl=60, clock=time.monotonic):
self._real, self._ttl, self._clock, self._cache = real, ttl, clock, {}
def generate(self, account_id):
now = self._clock()
hit = self._cache.get(account_id)
if hit and now - hit[0] < self._ttl:
return hit[1]
value = self._real.generate(account_id)
self._cache[account_id] = (now, value)
return value
Note the injected clock. A proxy that decides based on time is untestable if it reads the wall clock directly, so the clock is a constructor argument. I drove it with a fake clock: two calls for the same account at t=0 hit the real service once, and a third call at t=61 with a 60 second TTL hit it again. Final count on the real object: two calls for three requests. The cache did its job, and the caller code did not change a single line.
Here is the first thing the proxy decides that a plain wrapper would not: what counts as the same request. In this toy the key is the account id. In a real system the key is where bugs live. If the report depends on the caller's locale or permissions and your key ignores them, you have built a data leak that passes every test. Caches that infer their own keys are the most common way a stand-in object turns into an incident, and the fix is always the same: make the key an explicit, reviewed part of the proxy, never something derived from whatever arguments happened to be passed.
Job two: the protection proxy
The second job is refusing calls. A protection proxy checks a rule and either forwards the call or raises.
class AuthProxy:
def __init__(self, real, allowed):
self._real, self._allowed = real, allowed
def generate(self, account_id, *, user):
if account_id not in self._allowed.get(user, ()):
raise PermissionError(f"{user} may not read {account_id}")
return self._real.generate(account_id)
ap = AuthProxy(CachingProxy(ReportService()), {"ann": {"a1"}})
ap.generate("a1", user="ann") # report:a1
ap.generate("a2", user="ann") # PermissionError: ann may not read a2
I ran both calls: the first returns the report, the second is refused before the real service is touched. The interesting design point is the order of the stack. The auth proxy sits outside the caching proxy. Reverse them and an unauthorized caller can be served a cached result that an authorized caller paid to generate, because the cache answers first. Ordering is part of the contract, and it is the reason a security check belongs at the outermost layer, as close to the caller as you can put it.
Notice also that the signature changed: generate gained a user argument. That is a small honesty problem. A proxy that needs extra information the real object does not take has already stopped being transparent. Real systems solve this with ambient context (a request-scoped identity, a thread-local, a middleware) rather than widening every method, and the cost is that the dependency becomes invisible. Pick which kind of invisible you prefer.
Job three: the virtual proxy
The third job is deferral. Creating the real object is expensive, and many instances are never used. A virtual proxy is a placeholder that builds the real thing on first touch.
class LazyImage:
loads = 0
def __init__(self, path): self._path, self._data = path, None
def _load(self):
if self._data is None:
LazyImage.loads += 1
self._data = f"<bytes of {self._path}>"
return self._data
def __getattr__(self, name):
return getattr(self._load(), name)
def __len__(self):
return len(self._load())
imgs = [LazyImage(f"p{i}.png") for i in range(1000)]
# loads so far: 0
len(imgs[7]); len(imgs[7])
# loads so far: 1
Creating a thousand proxies loaded nothing. Touching one of them twice loaded exactly one payload. This is how ORMs give you a related entity without a query until you read a field, how list views render a thousand rows with images that fetch as they scroll into view, and how dependency injection containers delay building a heavy service until a request needs it.
Python gives you __getattr__ for this, and it is worth knowing why it works: the interpreter calls it only for attributes that were not found the normal way, so the proxy's own fields stay fast and everything else is forwarded. The data model documentation states that rule plainly. It also hides a trap: special methods such as __len__ are looked up on the type, not the instance, so __getattr__ never sees them. That is why LazyImage defines __len__ by hand. A generic forwarding proxy in Python silently fails to forward operators and protocol methods unless you list each one.
Proxies you already depend on
Three production examples show the pattern at different scales.
In Go, the standard library ships a protection and routing proxy as a type. httputil.ReverseProxy takes an incoming request, rewrites it through a Rewrite function, forwards it to a backend, and streams the response back. The decision it owns is where: which backend, which headers, which path. Nginx, Envoy, Traefik and every API gateway are the same object with a configuration file attached. That is why the API Gateway entry in this series is a proxy story as much as a routing story.
In JavaScript, the language made the pattern a first-class feature. The Proxy object lets you intercept property reads, writes and deletes through handler traps. Vue 3's reactivity system is built on it: wrap the state in a proxy, record which properties a render function reads, and re-run the render when those properties are written. The proxy's decision there is whether, and the "whether" is "should anyone be told about this change".
In Python's standard library, weakref.proxy returns an object that forwards to a referent without keeping it alive, and raises ReferenceError once the referent is collected. It is a proxy that decides based on the lifetime of the real object, and it shows a theme worth keeping in mind: proxies introduce a new failure mode that the real object never had.
The bill: three ways the disguise leaks
Every proxy lies a little. The question is whether the lie is cheap.
Identity and type. In my test, isinstance(CachingProxy(ReportService()), ReportService) returned False. The proxy has the same methods but not the same type, so any code that checks types, uses the object as a dictionary key by identity, or compares with is sees two different things. Languages that implement proxies by subclassing, such as the class-generating proxies in ORMs and the Java dynamic proxy machinery, avoid the type problem and inherit a new one: the proxy class must be able to subclass the real one, which is why final classes and entity classes without a no-argument constructor make some lazy-loading libraries fail at startup. Decide early whether your callers depend on the concrete type. If they do, a proxy is the wrong tool.
Hidden latency and hidden failure. A virtual or remote proxy makes a method call that looks local into one that can block, time out or fail. A lazy-loaded field that triggers a database query inside a template loop is the classic version: a thousand harmless-looking attribute reads and a thousand queries. A remote proxy that hides a network hop invites callers to call it in a loop, because nothing in the call site says "this costs 40 ms". The pattern that made the interface convenient is the same pattern that stops anyone from seeing the cost. The mitigation is cheap and unglamorous: give the proxy a way to say what it is, with a visible name, a log line on first load, or an explicit method for callers who want to force or avoid the real call.
Generic forwarding that forwards too much. The __getattr__ trick makes a proxy for any class in ten lines.
import functools
class LoggingProxy:
def __init__(self, real, log):
self._real, self._log = real, log
def __getattr__(self, name):
attr = getattr(self._real, name)
if not callable(attr):
return attr
@functools.wraps(attr)
def wrapper(*a, **k):
self._log.append(name)
return attr(*a, **k)
return wrapper
Wrapped around a ReportService, calling generate records ['generate'], and reading the calls attribute passes straight through to the real object. It works, and it is a trap, because it forwards everything including the methods you never meant to expose. A protection proxy built this way is a default-allow system: add a new sensitive method to the real class and the proxy happily forwards it. An explicit proxy with one method per method it intends to expose is longer and safer, because adding a method to the real object requires a decision at the proxy. Write generic proxies for logging and tracing, where over-forwarding is harmless, and explicit ones for anything that enforces a rule.
The yes, but
The fair objection is that most of these jobs have a better home now. Caching belongs in a cache library with eviction and stampede protection, authorization belongs in middleware, and lazy loading belongs in the framework. That is correct, and in practice you will usually reach for those first. But look at what those tools are underneath: a decorator stack, a middleware chain, a generated subclass. They are proxies with better engineering. Understanding the pattern tells you what they cost, which is the same bill every time: a type that is not quite the type, a call that is not quite local, and an ordering that matters.
The other objection is that a hand-written proxy duplicates the interface and drifts. It does. Every method on the real object needs a matching method on the proxy, and a proxy that forgets one is a bug that only appears when someone calls it. Protocols and interfaces help, and so does a test that compares the public method set of the proxy and the real class.
What to do on Monday
Find the stand-in objects already in your codebase: lazy collections, cached clients, wrapped HTTP sessions, anything an ORM or framework hands you that is not quite the type you asked for. For each one, answer three questions. What does it decide? Where in the stack does it sit relative to your security checks? And what does a caller see when it fails or is slow?
If you cannot answer the first question, delete the wrapper. If the second answer is "inside the cache", fix the order. If the third answer is "the caller cannot tell", add a log line on the first real call. A proxy earns its place when the decision it makes is worth the disguise. Everything else is a layer you are paying for.

