Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 022 / 100

Proxy pattern: Design Patterns best practices explained

A stand-in that controls access to the real object.

Expona Admin13 min read

When an object becomes expensive to create, or when you need to control how it's accessed, the proxy pattern gives you a powerful way to stand in between the caller and the real object. The Proxy pattern: Design Patterns best practices explained is about creating a surrogate or placeholder that intercepts requests, manages resource allocation, enforces permissions, and can even make remote services feel like local calls. In production systems handling millions of requests, this pattern prevents wasted computation, strengthens security boundaries, and simplifies distributed architecture.

What is the Proxy Pattern?

The Proxy pattern is a structural design pattern that provides a stand-in object—the proxy—to control access to another object, called the real subject. Instead of the client directly calling the real object, it calls the proxy, which decides what to do: it might create the real object on demand, validate permissions, cache results, or forward the call across a network.

The proxy and the real object typically share the same interface, so clients don't know they're working with a proxy. This transparency is key to the pattern's power in production code.

Lazy Loading — Create Expensive Objects Only When Used

In large applications, some objects are costly to instantiate: database connections, ML model weights, or complex graph structures. Lazy loading through a proxy delays creation until the object is actually needed. This strategy reduces startup time, saves memory, and can significantly improve perceived performance.

Consider a scenario in a SaaS platform where each user session loads a configuration object that pulls data from multiple microservices, transforms it, and prepares heavy compute graphs. Creating this for every session—even those that never reach the feature requiring it—wastes resources.

typescript
// Real expensive object
class UserConfigurationService {
  private data: Map<string, any>;

  constructor(userId: string) {
    console.log(`Initializing heavy config for user ${userId}`);
    // Simulates expensive initialization: DB calls, API requests, ML loading
    this.data = new Map();
    this.loadFromMultipleSources(userId);
  }

  private loadFromMultipleSources(userId: string): void {
    // Complex, slow initialization
    for (let i = 0; i < 1000000; i++) {
      this.data.set(`key_${i}`, Math.random());
    }
  }

  getConfig(key: string): any {
    return this.data.get(key);
  }

  updateConfig(key: string, value: any): void {
    this.data.set(key, value);
  }
}

// Proxy that delays creation
class LazyUserConfigurationProxy implements UserConfigurationService {
  private realService: UserConfigurationService | null = null;
  private userId: string;

  constructor(userId: string) {
    this.userId = userId;
    console.log(`Proxy created for user ${userId}, but not initialized yet`);
  }

  private ensureInitialized(): UserConfigurationService {
    if (!this.realService) {
      this.realService = new UserConfigurationService(this.userId);
    }
    return this.realService;
  }

  getConfig(key: string): any {
    return this.ensureInitialized().getConfig(key);
  }

  updateConfig(key: string, value: any): void {
    return this.ensureInitialized().updateConfig(key, value);
  }
}

// Usage
const configProxy = new LazyUserConfigurationProxy('user_42');
// At this point, the expensive service hasn't been created.
// Only when we call a method does initialization happen.
const value = configProxy.getConfig('some_key');

In production, this pattern is invaluable for platforms with thousands of concurrent users. Sessions created but rarely used don't trigger expensive initialization. The proxy remains lightweight until the real object is actually needed.

Access Control — Check Permissions Before Forwarding Calls

Security is paramount in modern applications. A protection proxy validates that the caller has permission to invoke an operation before delegating to the real object. This keeps authorization logic separate from business logic and makes the security model auditable.

Imagine a document management system where read access is common but write access requires elevated permissions. Rather than sprinkling permission checks throughout the document service, a proxy enforces the policy consistently.

typescript
// Real document service
interface DocumentService {
  read(docId: string): string;
  write(docId: string, content: string): void;
  delete(docId: string): void;
}

class RealDocumentService implements DocumentService {
  private docs: Map<string, string> = new Map();

  constructor() {
    this.docs.set('doc_1', 'Sensitive financial data');
    this.docs.set('doc_2', 'Public announcement');
  }

  read(docId: string): string {
    console.log(`[DocumentService] Reading ${docId}`);
    return this.docs.get(docId) || 'Not found';
  }

  write(docId: string, content: string): void {
    console.log(`[DocumentService] Writing to ${docId}`);
    this.docs.set(docId, content);
  }

  delete(docId: string): void {
    console.log(`[DocumentService] Deleting ${docId}`);
    this.docs.delete(docId);
  }
}

// User context
interface UserContext {
  userId: string;
  role: 'viewer' | 'editor' | 'admin';
}

// Protection proxy
class ProtectedDocumentServiceProxy implements DocumentService {
  private realService: DocumentService;
  private user: UserContext;
  private auditLog: string[] = [];

  constructor(realService: DocumentService, user: UserContext) {
    this.realService = realService;
    this.user = user;
  }

  private checkPermission(action: string): void {
    const allowedActions: Record<string, string[]> = {
      read: ['viewer', 'editor', 'admin'],
      write: ['editor', 'admin'],
      delete: ['admin'],
    };

    if (!allowedActions[action]?.includes(this.user.role)) {
      const msg = `[SECURITY] User ${this.user.userId} denied ${action} (role: ${this.user.role})`;
      this.auditLog.push(msg);
      console.log(msg);
      throw new Error(`Access denied: ${action}`);
    }

    this.auditLog.push(`[AUDIT] User ${this.user.userId} allowed ${action}`);
  }

  read(docId: string): string {
    this.checkPermission('read');
    return this.realService.read(docId);
  }

  write(docId: string, content: string): void {
    this.checkPermission('write');
    this.realService.write(docId, content);
  }

  delete(docId: string): void {
    this.checkPermission('delete');
    this.realService.delete(docId);
  }

  getAuditLog(): string[] {
    return this.auditLog;
  }
}

// Usage
const realService = new RealDocumentService();
const viewerUser: UserContext = { userId: 'user_1', role: 'viewer' };
const editorUser: UserContext = { userId: 'user_2', role: 'editor' };

const viewerProxy = new ProtectedDocumentServiceProxy(realService, viewerUser);
const editorProxy = new ProtectedDocumentServiceProxy(realService, editorUser);

// Viewer can read
console.log(viewerProxy.read('doc_1')); // Works

// Viewer cannot write
try {
  viewerProxy.write('doc_1', 'New content'); // Throws error
} catch (e) {
  console.error(e.message);
}

// Editor can write
editorProxy.write('doc_2', 'Updated announcement'); // Works

This approach centralizes authorization, making it easy to audit and modify permissions. The real service doesn't need to know about access control—that's the proxy's responsibility.

Remote Proxies — gRPC and HTTP Clients Look Like Local Objects

In distributed systems, calling a remote service over gRPC or HTTP is fundamentally different from calling a local function, yet clients want a simple, uniform interface. A remote proxy hides the complexity of serialization, network calls, error handling, and retries behind a familiar local-looking API.

Consider a microservice architecture where a web backend needs to query a recommendation engine service. The remote proxy makes the service look local, while handling all the networking plumbing.

typescript
// Service interface
interface RecommendationEngine {
  getRecommendations(userId: string, limit: number): Promise<string[]>;
  logInteraction(userId: string, itemId: string): Promise<void>;
}

// Real implementation (would be the remote service)
class RealRecommendationEngine implements RecommendationEngine {
  async getRecommendations(userId: string, limit: number): Promise<string[]> {
    // Simulate expensive computation
    await new Promise(resolve => setTimeout(resolve, 100));
    return [`item_1`, `item_2`, `item_3`].slice(0, limit);
  }

  async logInteraction(userId: string, itemId: string): Promise<void> {
    await new Promise(resolve => setTimeout(resolve, 50));
  }
}

// Remote proxy with caching, retries, and connection pooling
class RemoteRecommendationProxy implements RecommendationEngine {
  private cache: Map<string, { data: string[]; timestamp: number }> = new Map();
  private cacheTTL = 60000; // 60 seconds
  private maxRetries = 3;
  private retryDelay = 100;

  constructor(private remoteService: RecommendationEngine) {}

  async getRecommendations(userId: string, limit: number): Promise<string[]> {
    const cacheKey = `${userId}_${limit}`;
    const cached = this.cache.get(cacheKey);

    if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
      console.log(`[Cache HIT] Recommendations for ${userId}`);
      return cached.data;
    }

    console.log(`[Cache MISS] Fetching recommendations for ${userId}`);
    let lastError: Error | null = null;

    for (let attempt = 0; attempt < this.maxRetries; attempt++) {
      try {
        const result = await this.remoteService.getRecommendations(userId, limit);
        this.cache.set(cacheKey, { data: result, timestamp: Date.now() });
        return result;
      } catch (error) {
        lastError = error as Error;
        if (attempt < this.maxRetries - 1) {
          await new Promise(resolve => setTimeout(resolve, this.retryDelay * Math.pow(2, attempt)));
        }
      }
    }

    throw lastError || new Error('Failed to fetch recommendations after retries');
  }

  async logInteraction(userId: string, itemId: string): Promise<void> {
    // Fire-and-forget with best-effort retry
    let lastError: Error | null = null;

    for (let attempt = 0; attempt < 2; attempt++) {
      try {
        await this.remoteService.logInteraction(userId, itemId);
        return;
      } catch (error) {
        lastError = error as Error;
        if (attempt === 0) {
          await new Promise(resolve => setTimeout(resolve, this.retryDelay));
        }
      }
    }

    console.warn(`Failed to log interaction: ${lastError?.message}`);
    // Don't throw—interaction logging shouldn't break the user experience
  }
}

// Usage
const remote = new RealRecommendationEngine();
const proxy = new RemoteRecommendationProxy(remote);

// Looks like a local call
const recs = await proxy.getRecommendations('user_42', 5);
console.log('Recommendations:', recs);

// Second call hits cache
const recs2 = await proxy.getRecommendations('user_42', 5);

By wrapping the remote service in a proxy, we add caching, automatic retries, timeout handling, and even circuit breakers—all transparently. The calling code remains simple and focused on business logic.

JavaScript Proxy — Intercept Property Access for Validation or Reactivity

JavaScript's native Proxy object lets you intercept operations on an object at the meta level. This is powerful for validation, reactivity systems, and creating observable objects. Unlike the classical pattern, JavaScript Proxy operates on the language level.

In a reactive form validation system, a Proxy can validate data as it's set and automatically trigger validation messages or enable/disable buttons.

typescript
// Validation schema
interface FieldRules {
  [key: string]: (value: any) => string | null; // Returns error message or null
}

const formRules: FieldRules = {
  email: (value: string) => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return emailRegex.test(value) ? null : 'Invalid email';
  },
  password: (value: string) => {
    return value.length >= 8 ? null : 'Password must be 8+ characters';
  },
  age: (value: number) => {
    return value >= 18 ? null : 'Must be 18+';
  },
};

// Form data with validation
interface FormData {
  email: string;
  password: string;
  age: number;
  errors: Record<string, string | null>;
}

const formData: FormData = {
  email: '',
  password: '',
  age: 0,
  errors: {},
};

// Proxy that validates on write
const validatedForm = new Proxy(formData, {
  set(target: FormData, property: string | symbol, value: any): boolean {
    // Don't validate the errors object itself
    if (property === 'errors') {
      target.errors = value;
      return true;
    }

    // Check if this field has a validation rule
    const rule = formRules[property as string];
    if (rule) {
      const error = rule(value);
      target.errors[property as string] = error;

      if (error) {
        console.log(`[Validation] ${property}: ${error}`);
        return false; // Reject invalid assignments if needed, or allow and track error
      }

      console.log(`[Validation] ${property} is valid`);
    }

    target[property as keyof FormData] = value;
    return true;
  },

  get(target: FormData, property: string | symbol): any {
    if (property === 'isValid') {
      // Computed property: form is valid if no errors
      return Object.values(target.errors).every(e => e === null);
    }
    return target[property as keyof FormData];
  },
});

// Usage
validatedForm.email = 'user@example.com'; // Valid
validatedForm.email = 'not-an-email'; // Invalid, error tracked
validatedForm.password = 'short'; // Invalid
validatedForm.password = 'securepass123'; // Valid
validatedForm.age = 16; // Invalid
validatedForm.age = 25; // Valid

console.log('Form valid?', validatedForm.isValid); // false or true
console.log('Errors:', validatedForm.errors);

JavaScript Proxy is particularly useful in frontend frameworks where you need reactive updates. Libraries like Vue use proxies internally to track which data changes and automatically re-render components. You can also use Proxy for lazy evaluation, memoization, and creating observable objects that notify listeners when properties change.

Common Mistakes

  • Breaking the interface contract: If the proxy and real object don't expose the same interface, callers will need to know about both, defeating the purpose of transparency. Always ensure consistency.
  • Synchronous proxy for async operations: Forcing synchronous proxies to wrap async real objects, or vice versa, creates friction and confusion. Match the proxy behavior to the real object's nature.
  • Proxy overhead in hot paths: Every layer adds latency. Don't use proxies for operations called millions of times per second without profiling first. Cache, batch, or optimize before adding a proxy.
  • Cascading proxies: Stacking multiple proxies (lazy loading + access control + remote proxy) can become hard to debug. Keep the proxy stack shallow and well-documented.
  • Leaky abstractions in error handling: If errors from the real object aren't wrapped or translated by the proxy, callers might see implementation details they shouldn't. Handle and translate exceptions appropriately.

Key Takeaways

  • The Proxy pattern provides a stand-in object that controls access to a real subject, enabling lazy loading, access control, remote transparency, and reactivity.
  • Use lazy-loading proxies to defer expensive initialization until the object is actually needed, saving memory and startup time in large applications.
  • Protection proxies centralize authorization logic, making security auditable and separable from business logic.
  • Remote proxies hide network complexity—serialization, retries, caching, timeouts—behind a local-looking interface, simplifying distributed system development.
  • JavaScript's native Proxy object enables meta-level interception for validation, reactivity, and observable patterns in frontend and Node.js code.
  • Keep the proxy interface identical to the real object's to maintain transparency; avoid nested proxies and proxy stacks in performance-critical paths.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles