Loading learning content...
In software systems, we often face a fundamental tension: clients need access to objects, but direct access creates problems. Whether it's a resource-intensive object that shouldn't be created until absolutely necessary, a sensitive object that requires authorization before access, or an object residing on a remote server, direct instantiation and interaction often prove inadequate or dangerous.
Consider these scenarios from real production systems:
A document editor loads high-resolution images. Users scroll through a 50-page document with hundreds of images. Loading all images upfront would consume gigabytes of memory and take minutes to open.
An enterprise application connects to critical database servers. Not every user should have the ability to execute destructive queries—access must be controlled based on roles and permissions.
A distributed system communicates with services on remote machines. The client code shouldn't be burdened with network protocols, connection management, or failure handling.
A caching layer wraps expensive computation. Before invoking the real computation, the system should check if a cached result exists.
In each case, placing logic between the client and the real object provides the solution.
By the end of this page, you will understand the core problem that the Proxy pattern solves—why direct access to objects becomes problematic and what types of access control are commonly needed. You'll recognize proxy-eligible scenarios in your own systems and understand why a separate intermediary object provides the cleanest solution.
When clients directly instantiate and interact with objects, they become tightly coupled to:
This direct coupling works fine for simple scenarios, but breaks down as systems grow in complexity, scale across networks, or require security controls.
Let's examine a concrete example:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// A resource-intensive document rendererclass DocumentRenderer { private documentData: Uint8Array; private parsedPages: Map<number, PageContent> = new Map(); constructor(private documentPath: string) { // PROBLEM: Constructor loads entire document immediately // For a 500MB PDF, this blocks for seconds console.log(`Loading document from ${documentPath}...`); this.documentData = this.loadEntireDocument(documentPath); this.parseAllPages(); } private loadEntireDocument(path: string): Uint8Array { // Simulates reading entire file into memory // For large files, this is expensive! return readFileSync(path); } private parseAllPages(): void { // Parses every page upfront // User might only view page 1! for (let i = 0; i < this.getPageCount(); i++) { this.parsedPages.set(i, this.parsePage(i)); } } renderPage(pageNumber: number): Canvas { return this.renderFromParsed(this.parsedPages.get(pageNumber)!); }} // Client code - tightly coupled to eager loadingclass DocumentViewer { private renderers: Map<string, DocumentRenderer> = new Map(); openDocument(path: string): void { // PROBLEM: Every document is fully loaded when "opened" // User might just be browsing thumbnails! const renderer = new DocumentRenderer(path); this.renderers.set(path, renderer); } viewPage(path: string, page: number): Canvas { return this.renderers.get(path)!.renderPage(page); }} // Usage: Opening 10 documents loads 5GB of data immediatelyconst viewer = new DocumentViewer();const documents = getRecentDocuments(); // Returns 10 pathsdocuments.forEach(doc => viewer.openDocument(doc)); // Blocks for 30+ seconds!The problems in this design are severe:
You might think 'just make loading lazy with optional parameters'. But this pollutes the interface, requires all clients to understand the lazy loading protocol, and couples optimization concerns to business logic. We need a solution that keeps the real object unchanged while providing controlled access.
Access control needs fall into several distinct categories, each presenting unique challenges. Understanding these categories helps us recognize when a proxy-based solution is appropriate.
Some objects are expensive to create but may never be used, or their creation should be deferred until absolutely necessary.
Not all clients should have equal access to all operations. Access must be validated, logged, and potentially denied.
| Scenario | Protection Need | Without Proxy |
|---|---|---|
| Admin dashboard | Only admin users can access sensitive operations | Security checks scattered throughout codebase |
| Banking transactions | Amounts above threshold require additional verification | Business logic mixed with security logic |
| Document sharing | Read vs write permissions per user | Permission checks duplicated in every method |
| Multi-tenant system | Tenant A cannot access Tenant B's data | Tenant isolation logic everywhere |
| Rate limiting | Users limited to N requests per minute | Rate limiting code in every endpoint |
In distributed systems, objects may reside on different machines. Clients shouldn't need to know or care about object location.
1234567891011121314151617181920212223242526272829303132333435363738
// Without location transparency, clients handle networking directlyclass OrderServiceClient { async placeOrder(order: Order): Promise<OrderResult> { // Client is burdened with: // 1. URL construction const url = `https://${this.host}:${this.port}/api/orders`; // 2. Serialization const body = JSON.stringify(order); // 3. Network call with timeout/retry const response = await fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, signal: AbortSignal.timeout(5000), }); // 4. Error handling if (!response.ok) { throw new NetworkError(`Order failed: ${response.status}`); } // 5. Deserialization return await response.json() as OrderResult; } // Every method repeats this pattern! async cancelOrder(orderId: string): Promise<void> { /* ... */ } async getOrderStatus(orderId: string): Promise<OrderStatus> { /* ... */ }} // Ideal: Client shouldn't know OrderService is remoteinterface OrderService { placeOrder(order: Order): Promise<OrderResult>; cancelOrder(orderId: string): Promise<void>; getOrderStatus(orderId: string): Promise<OrderStatus>;}Sometimes we need to add behavior when objects are accessed—logging, caching, reference counting, synchronization—without modifying the object itself.
A reasonable question emerges: Why not simply add lazy loading, access control, and caching directly to the original class?
This approach seems simpler at first glance, but it violates fundamental design principles and creates long-term maintenance problems.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// ANTI-PATTERN: Mixing concerns in original classclass ImageRenderer { private image: ImageData | null = null; private cache: Map<string, Canvas> = new Map(); private accessLog: AuditLog; private rateLimiter: RateLimiter; constructor( private imagePath: string, private user: User, // Access control concern private cacheEnabled: boolean, // Caching concern private loggingEnabled: boolean, // Logging concern ) { this.accessLog = new AuditLog(); this.rateLimiter = new RateLimiter(100); } render(width: number, height: number): Canvas { // Logging concern mixed in if (this.loggingEnabled) { this.accessLog.record(this.user, 'render', { width, height }); } // Access control concern mixed in if (!this.user.hasPermission('render:images')) { throw new UnauthorizedError('User cannot render images'); } // Rate limiting concern mixed in if (!this.rateLimiter.allow(this.user.id)) { throw new RateLimitError('Too many requests'); } // Caching concern mixed in const cacheKey = `${width}x${height}`; if (this.cacheEnabled && this.cache.has(cacheKey)) { return this.cache.get(cacheKey)!; } // Lazy loading concern mixed in if (!this.image) { this.image = this.loadImage(); } // FINALLY: Actual business logic const result = this.doRender(this.image, width, height); // More caching logic if (this.cacheEnabled) { this.cache.set(cacheKey, result); } return result; } private doRender(image: ImageData, width: number, height: number): Canvas { // The actual rendering - 10 lines buried in 50+ lines of concerns }}When a class handles too many concerns, it becomes a 'god class'—difficult to understand, impossible to test in isolation, and terrifying to modify. Every change risks breaking unrelated functionality. The proxy pattern provides an escape hatch from this trap.
The Proxy pattern is built on a powerful idea: interposition. Instead of clients accessing objects directly, we insert an intermediary that:
This interposition creates a seam in the system—a point where behavior can be modified without touching either the client or the real object.
The proxy becomes a gatekeeper, but one that speaks the same language as the object it guards. Clients continue using familiar interfaces; they simply don't realize they're talking to a representative rather than the principal.
This concept appears throughout computing:
A well-designed proxy is indistinguishable from the real object to client code. If clients must know whether they're using a proxy or the real object, the abstraction has leaked. Strive for substitutability—any place expecting the real object should accept the proxy.
Let's examine detailed real-world scenarios where uncontrolled object access creates significant problems. These scenarios will reappear throughout this module as we apply the Proxy pattern to solve them.
Scenario: Video Streaming Platform
A video streaming service displays a catalog of thousands of videos. Each video has associated metadata, thumbnails, and the actual video stream.
123456789101112131415161718192021222324
// Problem: Video object loads stream on constructionclass Video { private stream: VideoStream; private metadata: VideoMetadata; constructor(videoId: string) { this.metadata = fetchMetadata(videoId); // Quick this.stream = loadVideoStream(videoId); // SLOW - 100MB+ } getTitle(): string { return this.metadata.title; } play(): void { this.stream.start(); }} // Catalog loads ALL videos - disaster!class VideoCatalog { private videos: Video[] = []; constructor() { for (const id of getAllVideoIds()) { // 10,000 videos this.videos.push(new Video(id)); // 1TB loaded! } }}How do you recognize when a Proxy pattern would benefit your design? Look for these diagnostic questions and code smells:
Code smells that suggest proxy need:
| Code Smell | Symptom | Proxy Type |
|---|---|---|
| Repeated null checks | if (this.heavyObject === null) { this.heavyObject = createHeavy(); } | Virtual Proxy |
| Permission checks in every method | if (!user.can('read')) throw UnauthorizedError; | Protection Proxy |
| Network code scattered everywhere | const response = await fetch(url); | Remote Proxy |
| Identical caching logic repeated | const cached = this.cache.get(key); if (cached) return cached; | Caching Proxy |
| Logging statements everywhere | console.log('Entering method X'); | Logging Proxy |
If you find yourself adding code BEFORE or AFTER method calls that isn't part of the core business logic, you're a candidate for the Proxy pattern. This cross-cutting code should live in a proxy, not pollute the original class or every call site.
We've established that direct object access, while simple, creates significant problems as systems grow in complexity:
What's Next:
Now that we understand the problem space, the next page introduces the Proxy pattern's solution: the surrogate with the same interface. We'll see how creating a stand-in object that implements the same interface as the real object provides the control point we need while maintaining complete transparency to client code.
You now understand why direct object access becomes problematic and the types of access control commonly needed. Next, we'll explore how the Proxy pattern structures its solution using a surrogate that shares the real object's interface.