Engineering Atlas › Low-Level Design

Proxy pattern

12 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

  1. Some objects are expensive, remote or sensitive, and most callers should not care which.
  2. Keeping the target's interface lets the proxy slot in without changing callers.
  3. One wrapper on the only path is safer than trusting every caller to check.
  4. Hand-writing proxies per interface is tedious, so reflective languages generate them, at the cost of slower dispatch and surprising identity.
  5. 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

OptionStrengthsWeaknessesChoose when
Eager creationSimple; failures at startupPays for objects never usedCheap or always-used objects
Virtual proxyPay only on useFirst-call latency; N+1 risk; lazy init racesExpensive, often unused objects
Protection proxyOne enforcement pointBypassed if the target is reachable directlySensitive objects with many callers
Remote proxy (RPC stub)Network call looks localHides latency and partial failureTyped clients for services
Dynamic proxy (reflection, JS Proxy)One handler for any interfaceSlower dispatch; identity and self-call surprisesFrameworks: AOP, ORMs, reactivity, mocks

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. Why does calling a @Transactional method 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.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →