A proxy stands in for another object, implements the same interface, and controls access to it. The caller cannot tell the difference, which is the source of both its power and its most notorious failure mode.
You use proxies constantly without naming them: an ORM's lazy relationship, a generated RPC client, a service mesh sidecar, a caching layer, a mocking framework's stub.
Four kinds, one shape
classDiagram
class ImageSource {
<<interface>>
+render() Pixels
}
class RealImage {
-bytes: byte[]
+render() Pixels
}
class LazyImageProxy {
-path: string
-real: RealImage
+render() Pixels
}
class Client
ImageSource <|.. RealImage
ImageSource <|.. LazyImageProxy
LazyImageProxy --> RealImage : creates on first use
Client --> ImageSourceVirtual proxy defers creating an expensive object until it is actually used. An ORM returning an order whose customer is not loaded until you touch it.
Remote proxy makes a call over a network look like a local method call. Every generated RPC client is one.
Protection proxy checks whether the caller may proceed before forwarding.
Caching proxy returns a stored result instead of forwarding at all.
The structure is identical in each: same interface, holds the subject, decides what to do before or instead of delegating.
The distinction from Decorator
Both wrap an object of the same interface. Textbooks say the difference is intent — Decorator adds behaviour, Proxy controls access — and that is true but unsatisfying, because a caching decorator and a caching proxy are the same code.
The distinction that has practical consequences is who owns the subject's lifetime:
- A decorator receives an object that already exists and wraps it. The subject is constructed by someone else and could be used without the wrapper.
- A proxy often owns the subject's existence — creating it lazily, connecting to it remotely, or standing in for something that may never be created.
That is why a virtual proxy is unambiguously a proxy: there is no subject to decorate yet. It is also why the argument about a caching wrapper is not worth having.
The failure mode: transparency
A proxy's promise is that callers cannot tell. That is exactly what makes lazy loading dangerous.
const orders = await repo.findRecent(100);
for (const order of orders) {
console.log(order.customer.name); // each access: one more query
}One query became 101. Nothing at the call site suggests a network round trip is happening inside a property access — the proxy's transparency has hidden a hundredfold cost increase behind a dot.
This is the N+1 problem, and lazy proxies are its most common cause. The mitigations are all about making the invisible visible:
- Eager loading when you know you need it.
findRecent(100, { include: ['customer'] })— one query with a join. - Batching proxies. Collect the accesses that happen in one tick and resolve them in a single query. This is what a data-loader does, and it is a proxy that has been taught to be less transparent about timing.
- Instrumentation that counts queries per request and fails a test above a threshold. This catches the regression that code review will not.
Where proxies genuinely shine
Lazy loading of genuinely expensive things. A document's 40 MB attachment should not load because someone listed document titles.
Access control at a boundary. A protection proxy in front of a subsystem gives one place where authorisation is checked, rather than a check in every method.
Testing. Every mock and stub is a proxy. Frameworks that generate them at runtime are doing exactly this pattern.
Instrumentation without modification. A proxy in front of a third-party object can count calls, log arguments and inject faults without touching code you do not own.
Copy-on-write. A proxy that shares the underlying data until someone writes, then copies. It makes cheap "copies" of large structures with real isolation.
Check your understanding
Common mistakes
Lazy loading by default. It converts one query into N and the cost appears only under real data volumes.
Assuming a remote proxy makes distribution transparent. Latency, partial failure and timeouts do not disappear because the syntax hides them.
A protection proxy that some callers bypass. If the subject is reachable directly, the check is optional and therefore absent somewhere.
Proxies that change semantics silently. Returning a stale cached value where callers assumed freshness is a contract change with no interface change.
Deep proxy chains. A proxy wrapping a proxy wrapping a decorator makes stack traces useless and behaviour unpredictable.
Serialising a proxy. Passing a lazy object across a process boundary either fails or triggers loading at the worst possible moment.
Practice
An API endpoint that returns 50 items times out in production but is fast locally. Traces show 300 database queries. Walk through diagnosis and fixes, including what you would change to prevent recurrence.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Six queries per item is a clue about how many lazy relationships are being touched.
Show a strong answer
Read the arithmetic first. 300 queries for 50 items is six per item, which points at a serialiser touching roughly six lazy relationships per object — not one accidental access but a whole object graph being walked. Local speed with production timeouts is consistent with this: locally the database is on the same machine and 300 sequential queries at sub-millisecond latency is imperceptible, while at 2 ms of network round trip it is over half a second of pure waiting, before contention.
Confirm before fixing:
- Log the queries for one request and group them by shape. Six distinct repeated shapes confirms six relationships; one shape repeated 300 times means something different — a loop inside the mapping code.
- Find where the access happens. Frequently it is the serialiser, not the handler, which is why the handler looks innocent in review.
Fixes, in order of preference:
- Eager-load exactly what the response needs. The endpoint's response shape is known, so the query can be written to fetch it in one or two statements. This is the real fix and it usually reduces 300 queries to two.
- Introduce a batching loader where the graph is genuinely dynamic and eager loading cannot know what is needed. It collects accesses in a tick and resolves them in one query per relationship — 300 becomes six.
- Reduce the response. Six relationships per item is often a response returning more than any client uses, and trimming it is cheaper than optimising the fetch.
Preventing recurrence, which matters more than this fix:
- Assert a query budget in tests. Wrap the endpoint test in a counter and fail above, say, five queries. This is the control that actually works, because it catches the regression the next time someone adds a field to a serialiser — which is when this always comes back.
- Make the budget scale-independent: the test should use two items and ten items and assert the count does not change. A fixed threshold passes for a while and then quietly stops meaning anything; a "does not grow with N" assertion is the property you actually want.
- Consider disabling lazy loading by default in this codebase, so an unloaded relationship raises rather than silently querying. It is a disruptive change and it converts a whole category of production timeout into a loud local failure.
What I would report in the postmortem: the endpoint was never wrong, it was untested at production latency. The lesson is about the query-count assertion, not about the specific serialiser.
Design a protection proxy that enforces per-tenant data isolation in front of a repository. State what it cannot guarantee and what you would add to cover the gap.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Think about how the tenant is known, and about every path to the data that does not go through the proxy.
Show a strong answer
The design:
- The proxy implements the repository interface and holds the current tenant, supplied at construction from the authenticated request context. It must not read the tenant from a global or ambient store — that is how a background job ends up with whichever tenant last handled a request.
- Every read is narrowed by adding a tenant predicate, and every write is stamped and verified. Verification on write matters: an update to an entity belonging to another tenant must be rejected, not silently scoped away, because silence turns an attack into a no-op that nobody investigates.
- A missing tenant is an error, never a wildcard. The single most dangerous default in multi-tenant code is treating absent as "all".
What it cannot guarantee, which is the important half of the answer:
- Anything that bypasses it. Raw SQL, migrations, admin tools, reporting jobs, a second repository someone adds next quarter. The proxy protects one path, and data has many.
- Correctness of joins it does not control. A query joining to a table the proxy does not know about can pull other tenants' rows through the join even though the top-level predicate is right.
- In-memory leakage. Caches keyed without the tenant will serve one tenant's data to another — and this is the mirror of the decorator ordering problem: a cache above the proxy defeats it entirely.
- Aggregates and counts, which can leak information about other tenants' data volumes without returning a single row.
What I would add to cover the gap:
- Enforcement in the database, not only in the application — row-level security or per-tenant credentials, so the guarantee holds for every path including the ones nobody has written yet. This is the only control that is not bypassable by new code.
- Tenant included in every cache key, verified by a test, since this failure is invisible in review.
- A test that runs the whole suite as tenant A and asserts no query returns a tenant B row, using a seeded second tenant. Cross-tenant tests catch what per-method unit tests structurally cannot.
- Make the unscoped repository hard to reach — internal to a module, or named in a way that makes review flag it, since it will legitimately be needed by migrations.
The general lesson: a protection proxy is a good place to enforce a rule and a bad place to rely on it. It should be one layer of a defence that also exists where the data lives.
Key takeaways
- A proxy implements the same interface and controls access to its subject — virtual, remote, protection and caching are four intents with one structure.
- The practical distinction from Decorator is lifetime ownership: a decorator wraps something that exists; a proxy often decides whether it exists at all.
- Transparency is the pattern's power and its danger. Lazy proxies are the usual cause of N+1 query problems.
- Fix N+1 by eager loading what you know you need, batching loaders, and a query-count assertion that must not grow with N.
- Remote proxies hide latency and partial failure. Make timeouts and failure handling explicit at the boundary.
- A protection proxy is bypassable. Enforce isolation in the database too, and remember that a cache above the proxy defeats it.
- Prefer explicit over transparent wherever the hidden work can fail or be slow.
Further reading
- Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994).
- Martin Fowler, Patterns of Enterprise Application Architecture (Addison-Wesley, 2002) — lazy load, virtual proxy and the N+1 problem.
- Peter Deutsch and James Gosling, "The Eight Fallacies of Distributed Computing" (Sun Microsystems, 1994).
- Michael Nygard, Release It!, second edition (Pragmatic Bookshelf, 2018) — remote calls and their failure modes.
- Steve Freeman and Nat Pryce, Growing Object-Oriented Software, Guided by Tests (Addison-Wesley, 2009) — test doubles as proxies.