Proxy Pattern
Proxy stands in for a real object and intercepts every call — same interface, different responsibilities.
Introduction
Proxy stands in for a real object and intercepts every call — same interface, different responsibilities. Four canonical variants: Remote (RPC stub), Virtual (lazy creation), Protection (access control), Smart Reference (logging, ref-counting). Modern equivalents: JPA lazy proxies, Spring @Transactional CGLIB proxies, ES6 Proxy, gRPC client stubs.
The story
A B2B API had 3× latency on customer-detail endpoints. Profiling showed each request loading entire customer aggregate eagerly. ORM lazy proxies — created on first access — dropped p95 by 70%. Domain code never knew.
The business problem
Eager loading and uncontrolled access waste resources and expose data:
- Over-fetching: loading relations never used on this code path.
- Expensive creation: building heavy objects when caller may not need them.
- Missing gates: no authorization or rate-limit at object boundary.
- Cross-cutting duplication: tx/logging/caching reimplemented per call site.
The problem teams faced
Proxy fits when:
- Creating real object is expensive and not always needed (Virtual).
- Real object lives remotely (Remote / RPC stub).
- Access must be authorized or rate-limited (Protection).
- Lazy loading, caching, or transactions without modifying real subject (Smart).
Understanding the topic
Intent: Provide a surrogate controlling access to another object.
- Subject — interface shared by RealSubject and Proxy.
- RealSubject — actual object Proxy stands in for.
- Proxy — implements Subject, intercepts, delegates when appropriate.
- Transparent — client treats Proxy as RealSubject.
Internal architecture
Virtual proxy for lazy customer aggregate:
interface Customer {getId(): stringgetOrders(): Promise<Order[]>}class CustomerProxy implements Customer {private real?: Customerconstructor(private id: string,private loader: (id: string) => Promise<Customer>,) {}getId() { return this.id }async getOrders() {if (!this.real) this.real = await this.loader(this.id)return this.real.getOrders()}}
Visual explanation
Three diagrams cover proxy variants, lazy virtual proxy, and vs Decorator:
Informative example
Before / after — eager load to lazy proxy:
// ❌ Eager — loads orders, invoices, addresses every requestasync function getCustomer(id: string) {const row = await db.customers.find(id)const orders = await db.orders.where({ customerId: id })const invoices = await db.invoices.where({ customerId: id })return { ...row, orders, invoices }}// ✅ Lazy proxy — load relations on first accessclass CustomerProxy implements Customer {private orders?: Order[]async getOrders() {if (!this.orders) this.orders = await db.orders.where({ customerId: this.id })return this.orders}}// Protection proxyclass AuthorizedCustomerProxy implements Customer {constructor(private inner: Customer, private auth: AuthContext) {}async getOrders() {if (!this.auth.can("read:orders")) throw new ForbiddenError()return this.inner.getOrders()}}
Execution workflow
Client calls Subject
Receives Proxy via DI or factory.
Real-world use
Hibernate/JPA lazy relations, Spring AOP @Transactional, gRPC/Thrift/OpenAPI stubs, Java Proxy.newProxyInstance, Vue 3 reactive Proxy, Nginx reverse proxy, service mesh sidecars.
Production case study
Customer detail lazy proxy:
- Scenario: B2B API loaded full customer aggregate every request.
- Symptom: 3× latency vs endpoints using subset of data.
- Decision: ORM lazy proxies on relations; explicit fetch joins for export path.
- Outcome: p95 down 70%; export path still uses batched fetch — proxies are defaults, not laws.
Trade-offs
- Pro: defer expensive work; uniform access control; transparent to clients.
- Pro: remote/local swap without client changes.
- Con: N+1 queries if lazy proxies misused (serialization, loops).
- Con: debugging through proxy layers adds indirection.
Decision framework
- Use Virtual proxy when creation cost high and access selective.
- Use Protection proxy at sensitive domain boundaries.
- Use Remote proxy for RPC/client stubs — standard pattern.
- Avoid lazy proxies on code paths that always traverse full graph.
Best practices
- Know your access patterns — lazy default, eager for known full-graph paths.
- Protection proxy at domain boundary — not scattered if-checks.
- Log proxy creation in dev to catch N+1 early.
- Explicit fetch/join for batch operations that need full graph.
Anti-patterns to avoid
- Lazy proxy + JSON serialization = N+1 disaster.
- Protection checks duplicated instead of centralized proxy.
- Proxy that changes interface — that's Adapter.
Common mistakes
- Hibernate export serializing lazy collections — 14-minute N+1 (classic).
- Proxy holding per-request state — wrong scope.
- Infinite recursion in smart reference counting.
Debugging tips
- Enable SQL logging — count queries per request when proxies involved.
- Compare query count lazy vs explicit fetch for same endpoint.
Optimization strategies
- Batch-fetch known relation sets in dedicated read paths.
- Cache Protection proxy results with TTL for read-heavy auth checks.
Common misconceptions
- Proxy ≠ Decorator. Proxy controls access to subject; Decorator adds behaviour.
- Nginx reverse proxy is Proxy at network scale.
- Sidecar is Proxy — transparent interception of service traffic.
Advanced interview questions
Interview Prep
Practice concise answers, then expand each card for the explanation.
1IntermediateQuestionFour proxy types?+
Answer
Follow-up
2AdvancedQuestionProxy vs Decorator?+
Answer
Follow-up
3AdvancedQuestionLazy proxy N+1 trap?+
Answer
Follow-up
4IntermediateQuestionES6 Proxy use case?+
Answer
Follow-up
Summary
You can pick proxy variant, implement lazy loading safely, and avoid N+1 traps. Tell the customer aggregate p95 story — if you can do that, you own Proxy.