Proxy pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- A proxy is an object that stands in for another object behind the same interface and decides whether, when and how the real object is reached. The caller holds the proxy and believes it holds the real thing.
- Four classic kinds: a virtual proxy delays creating an expensive object until first use, a protection proxy checks permissions before forwarding, a remote proxy turns a method call into a network request, and a caching or smart-reference proxy adds bookkeeping around access.
- Structurally a proxy looks like a decorator. The difference is intent: a decorator adds behaviour to an object that exists; a proxy controls access to an object that may not exist yet, may live elsewhere, or may be off limits. This lesson is about object-level proxies; network forward and reverse proxies are covered in Proxy vs Reverse Proxy.
- Proxies break assumptions quietly: first-call latency, identity and equality checks, calls that bypass the proxy from inside the target, and lazy loads that fire after their database session has closed.
- Agents generate ORM code full of lazy relationships and dynamic proxies. In review, look for N+1 queries, lazy loads outside a transaction, and permission checks that live in the UI instead of a proxy in front of the data.
How it works
The problem
A CRM loads a Customer for every page. Each customer has an order history (often thousands of rows), a contract PDF in object storage and a credit report from a paid third-party API. The first version loads all three in the constructor. The customer list page, which shows only names, now takes 900 ms and costs money per view for credit reports nobody looks at.
The proxy answer: Customer.orders holds a proxy with the real history's interface that runs the query only on first iteration. The credit report sits behind a protection proxy that checks the viewer's role before the paid API is called. The page code does not change.
The four kinds
A virtual proxy holds a recipe for the real object (a factory, a primary key, a file path) and builds it on first use. Hibernate's session.getReference(Customer.class, id) returns one. Virtual memory is the same idea in hardware: a not-present page table entry, and a page fault that loads the page on first touch.
A protection proxy checks whether the caller may perform the operation and forwards or refuses, so no caller can forget the policy.
A remote proxy implements the interface by serialising arguments, sending them over the network and decoding the reply. A generated gRPC client stub is one: client.GetUser(ctx, req) looks like a method call and is an HTTP/2 request.
A smart-reference proxy adds bookkeeping: reference counting, access logging, copy-on-write, or change tracking. Vue 3's reactive() wraps an object in a JavaScript Proxy whose get trap records which component read which property and whose set trap schedules re-renders.
Proxy, decorator, adapter
All three wrap an object. An adapter changes the interface (see Adapter). A decorator keeps it and adds a concern while always forwarding (see Decorator). A proxy keeps it and may not forward at all: it may refuse, defer, or cross a network. The line blurs (a cache is called either), so ask which question the wrapper answers: "what else happens on this call?" or "should and can this call reach the target?"
Static and dynamic proxies
A static proxy is written or generated for one interface. Go has only these, since it cannot create types at runtime: proxies are hand-written structs or generated code (gRPC stubs, mockgen). Dynamic proxies are created at runtime for any interface. Java's java.lang.reflect.Proxy.newProxyInstance builds a class implementing the given interfaces and routes every call to one InvocationHandler.invoke(proxy, method, args). JavaScript's new Proxy(target, handler) intercepts thirteen operations, including get, set, has, deleteProperty and apply. Python has no proxy object, but __getattr__, called only when normal attribute lookup fails, makes a forwarding proxy a few lines long.
Reviewing agent-written code
Agents add lazy relationships by default because they keep the first query simple. Check every lazy collection read in a loop (an N+1), every lazy access after the transaction closes (serialisers and templates), and every permission check that is not in front of the data. In hand-written proxies, a forgotten method is an unguarded door.
The same shape elsewhere
A protection proxy is the trust but verify problem shape: the check sits on the only path to the resource. Operating systems do the same with system calls, where the kernel checks every request against the process's credentials before touching a file, and cloud storage does it with presigned URLs, where the proxy is replaced by a signature the storage service verifies on every request.
Down the stack
Layer 1The contract
A proxy promises the target's contract plus documented differences. A virtual proxy creates the target at most once, on first use, and reports a failed creation rather than caching a broken object. A protection proxy promises that a denied call has no effect on the target, including no side effects of creating it. A remote proxy cannot keep the local contract fully: it adds latency in milliseconds, new failure modes (timeouts, partial failure where the server acted but the reply was lost), and copies instead of shared references. What no proxy preserves is identity: proxy is target is false, and type checks see the proxy's class.
Layer 2The mechanism
Java's dynamic proxy generates a class named like $Proxy12 that extends java.lang.reflect.Proxy and implements the requested interfaces. Each method body boxes primitive arguments into an Object[] and calls the handler with a cached Method object. Spring AOP uses these for interfaces and CGLIB-generated subclasses otherwise; either way, a @Transactional method called from another method of the same bean goes through this, not the proxy, so no transaction starts. Hibernate's lazy entities are ByteBuddy-generated subclasses whose methods ask a lazy initializer to load the row on first access, and throw LazyInitializationException if the session is gone.
In JavaScript, a Proxy has internal slots [[ProxyTarget]] and [[ProxyHandler]], and each internal method checks for a trap and falls back to the target. The engine enforces invariants: a get trap cannot report a different value for a non-configurable, non-writable property. Private class fields (#balance) are not reachable through a proxy: a method called with this set to the proxy throws TypeError when it reads this.#balance, which breaks naive wrappers of classes.
Layer 3The machine
A hand-written proxy in Go costs one extra interface call, roughly 1 to 2 ns, and one pointer of memory. A virtual proxy saves the target's whole allocation until it is needed: an unopened 4 MB image costs a 48-byte proxy. In Java, a dynamic proxy call allocates the argument array and boxes primitives unless the JIT's escape analysis removes them, and Method.invoke dispatch is slower than a direct call, typically tens of nanoseconds. In V8, property access on a Proxy does not use the inline caches that make ordinary property reads a few nanoseconds, so code that reads proxied objects in tight loops is often an order of magnitude slower; Vue mitigates this by caching proxies per object in a WeakMap.
A remote proxy dwarfs all of this. A local call costs nanoseconds; a same-datacentre RPC costs 0.2 to 1 ms, five to six orders of magnitude more, plus serialisation. A loop of 1,000 calls that looked free costs a second.
Lazy initialisation is a concurrency hazard. Two threads that both see "not loaded" create the target twice, which wastes a connection or, for a non-idempotent factory, does something twice. Go's sync.Once or a mutex fixes it; Java uses double-checked locking with a volatile field; Python needs a threading.Lock around the check and the creation.
Why it's built this way
- Some objects are expensive, remote or sensitive, and most callers should not care which.
- Keeping the target's interface lets the proxy slot in without changing callers.
- One wrapper on the only path is safer than trusting every caller to check.
- Hand-writing proxies per interface is tedious, so reflective languages generate them, at the cost of slower dispatch and surprising identity.
- A proxy hides cost and location, so query logs, traces and review rules must make the cost visible again.
Diagram
sequenceDiagram
participant C as Caller
participant G as "Guarded (protection)"
participant L as "Lazy (virtual)"
participant F as Factory
participant S as "Real store"
C->>G: put("a", "1") as reader
G-->>C: forbidden: put a
C->>G: get("a") as reader
G->>L: get("a")
L->>F: create()
F-->>L: store
L->>S: get("a")
S-->>L: "1"
L-->>G: "1"
G-->>C: "1"Worked example
The CRM's customer list shows 50 customers per page and, after a product request, each row's open order count. The ORM maps customer.orders as a lazy collection. The template loops over customers and reads len(customer.orders), so the page runs 1 query for the list and 50 more, one per proxy: 51 round trips. At 1.2 ms each against a database in the same zone, the page spends 61 ms in the database before rendering, and at 300 page views a second that is 15,300 queries a second for a list page.
Two fixes, depending on the access pattern. If the page needs the orders themselves, eager-load them for this query only, with SQLAlchemy's selectinload(Customer.orders) or a fetch join in JPA: 2 queries, about 3 ms. If it needs only the count, do not load orders at all; add a grouped count query: 2 queries, 2.5 ms, and no thousands of order rows in memory. The lazy proxy stays as the default for pages that touch one customer.
A second incident was a protection gap: a new export job called the credit API client directly, skipping the proxy. The fix made the client constructor package-private so the factory hands out only the proxy.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Eager creation | Simple; failures at startup | Pays for objects never used | Cheap or always-used objects |
| Virtual proxy | Pay only on use | First-call latency; N+1 risk; lazy init races | Expensive, often unused objects |
| Protection proxy | One enforcement point | Bypassed if the target is reachable directly | Sensitive objects with many callers |
| Remote proxy (RPC stub) | Network call looks local | Hides latency and partial failure | Typed clients for services |
Dynamic proxy (reflection, JS Proxy) | One handler for any interface | Slower dispatch; identity and self-call surprises | Frameworks: AOP, ORMs, reactivity, mocks |
Pitfalls in production
- N+1 queries from lazy collections read in loops. Fix: eager-load per query where the access pattern is known, and fail tests that exceed a query count.
LazyInitializationExceptionor detached-instance errors when a serialiser touches a lazy field after the transaction ends. Fix: map to DTOs inside the transaction, or fetch what the response needs explicitly.- Self-invocation skipping the proxy, so
@Transactional,@Cacheableor security annotations silently do nothing. Fix: move the method to another bean, or call through the injected proxy. - Identity and equality:
proxy == entityfalse,getClass()returns a generated subclass, andequalsbased on class comparison fails. Fix: compare by ID and useinstanceof-style checks or the ORM's unproxy helper. - Wrapping classes with private fields in a JavaScript
Proxy, so methods throwTypeError. Fix: bind methods to the target in thegettrap, or proxy plain data objects only. - Remote proxies called in loops as if local. Fix: batch APIs, and make remote interfaces look remote (contexts, deadlines, explicit errors).
Leadership lens
- Ask what each proxy hides (cost, location or policy) and how it is made visible again: query-count assertions, traces, access logs.
- A protection proxy is only as strong as the guarantee that nothing else can reach the target. In review, check how the target is constructed and who can get a reference.
- Set a team rule for ORM loading: lazy by default with query-count tests, or explicit fetch plans per endpoint. Mixed habits produce N+1 regressions in every release.
- Prefer a framework's dynamic proxies over home-grown reflection, and teach new hires the self-invocation and identity surprises.
Check yourself
What distinguishes a proxy from a decorator when both keep the same interface?
Show answer
Intent and forwarding: a decorator always forwards and adds a concern, while a proxy controls whether, when and where the call reaches the target, and may not forward at all.
Why does calling a
@Transactionalmethod from another method of the same Spring bean not start a transaction?Show answer
The transaction logic lives in the proxy around the bean; an internal call goes through
this, the raw target, so the proxy never sees it.Why must a protection proxy be checked before a virtual proxy in
Guarded(Lazy(store))?Show answer
So a denied call has no effects at all, including the cost and side effects of creating the real object; with the order reversed, unauthorised callers could trigger expensive loads.
Why should a failed lazy creation not be remembered?
Show answer
A transient failure (a database restarting) would then become permanent for the process; reporting the error and retrying on the next call lets it recover.
Watch
- Proxy Pattern, Design Patterns ep 10 (Christopher Okhravi): the Gang of Four structure and the kinds of proxy.
- Proxy Design Pattern Tutorial (Derek Banas): a code walkthrough of a protection-style proxy.
Free resources
- Proxy (Refactoring Guru): the structure, the kinds of proxy, and relations to decorator and adapter.
- Proxy (MDN): JavaScript's dynamic proxy, its traps and invariants.
- java.lang.reflect.Proxy (Java docs): how dynamic proxy classes are generated and dispatched.
- Proxying mechanisms (Spring docs): JDK versus CGLIB proxies and the self-invocation problem.
- Lazy Load (Martin Fowler): virtual proxy, lazy initialisation, value holder and ghost as variants.
- Relationship loading techniques (SQLAlchemy docs): lazy, select-in and joined loading, and how to avoid N+1.