Proxy Pattern: Access Control and Lazy Loading

The Proxy pattern is one of those structural patterns that seems simple on the surface but unlocks powerful architectural capabilities. Defined by the Gang of Four, its purpose is straightforward:...

Key Insights

  • The Proxy pattern controls access to objects through a surrogate that shares the same interface, enabling lazy loading, access control, and caching without modifying the original class.
  • Virtual proxies defer expensive operations until absolutely necessary, dramatically improving startup performance and memory usage in applications dealing with large resources.
  • Protection proxies cleanly separate authorization logic from business logic, making security concerns explicit and testable rather than scattered throughout your codebase.

Introduction to the Proxy Pattern

The Proxy pattern is one of those structural patterns that seems simple on the surface but unlocks powerful architectural capabilities. Defined by the Gang of Four, its purpose is straightforward: provide a surrogate or placeholder for another object to control access to it.

Think of a proxy like a security guard at a building entrance. The guard isn’t the building, but they control who gets in, when they get in, and what they can do once inside. The visitor interacts with the guard using the same basic protocol they’d use with the building’s services—they just go through an intermediary first.

This indirection layer enables several valuable behaviors: deferring expensive operations, enforcing access rules, adding logging, caching results, and abstracting remote resources. The beauty is that clients remain unaware they’re talking to a proxy rather than the real object.

Anatomy of a Proxy

Every proxy implementation follows the same structural blueprint with three participants:

  1. Subject: The interface that both the real object and proxy implement
  2. RealSubject: The actual object that does the real work
  3. Proxy: The surrogate that controls access to the RealSubject

The proxy maintains a reference to the real subject and forwards requests to it—but only after performing its control logic.

// Subject interface
interface DataService {
  fetchData(id: string): Promise<Record<string, unknown>>;
  saveData(id: string, data: Record<string, unknown>): Promise<void>;
}

// RealSubject - the actual implementation
class DatabaseService implements DataService {
  async fetchData(id: string): Promise<Record<string, unknown>> {
    console.log(`Executing database query for ${id}`);
    // Actual database operation
    return { id, name: "Sample Data", timestamp: Date.now() };
  }

  async saveData(id: string, data: Record<string, unknown>): Promise<void> {
    console.log(`Writing to database: ${id}`);
    // Actual database write
  }
}

// Proxy skeleton
class DataServiceProxy implements DataService {
  private realService: DatabaseService | null = null;

  private getRealService(): DatabaseService {
    if (!this.realService) {
      this.realService = new DatabaseService();
    }
    return this.realService;
  }

  async fetchData(id: string): Promise<Record<string, unknown>> {
    // Pre-processing, validation, caching, etc.
    return this.getRealService().fetchData(id);
  }

  async saveData(id: string, data: Record<string, unknown>): Promise<void> {
    // Pre-processing, validation, logging, etc.
    return this.getRealService().saveData(id, data);
  }
}

The client code works with the DataService interface and doesn’t know—or care—whether it’s talking to the real service or a proxy.

Virtual Proxy: Lazy Loading in Practice

Virtual proxies defer the creation of expensive objects until they’re actually needed. This is invaluable when dealing with resources that are costly to initialize but might not be used in every execution path.

Consider an image gallery application. Loading all high-resolution images upfront would consume massive memory and delay startup. A virtual proxy solves this elegantly:

interface Image {
  display(): void;
  getMetadata(): ImageMetadata;
}

interface ImageMetadata {
  width: number;
  height: number;
  format: string;
}

class HighResolutionImage implements Image {
  private imageData: Buffer;
  private metadata: ImageMetadata;

  constructor(private filepath: string) {
    // Expensive operation - loads entire image into memory
    console.log(`Loading high-res image from ${filepath}...`);
    this.imageData = this.loadFromDisk(filepath);
    this.metadata = this.extractMetadata();
  }

  private loadFromDisk(path: string): Buffer {
    // Simulate expensive I/O operation
    return Buffer.alloc(50 * 1024 * 1024); // 50MB image
  }

  private extractMetadata(): ImageMetadata {
    return { width: 4000, height: 3000, format: "RAW" };
  }

  display(): void {
    console.log(`Rendering ${this.filepath}`);
    // Render the image data
  }

  getMetadata(): ImageMetadata {
    return this.metadata;
  }
}

class ImageProxy implements Image {
  private realImage: HighResolutionImage | null = null;
  private cachedMetadata: ImageMetadata | null = null;

  constructor(private filepath: string) {
    // Only store the path - no expensive loading yet
    console.log(`Created proxy for ${filepath}`);
  }

  display(): void {
    // NOW we load the real image
    if (!this.realImage) {
      this.realImage = new HighResolutionImage(this.filepath);
    }
    this.realImage.display();
  }

  getMetadata(): ImageMetadata {
    // Could load lightweight metadata without full image
    if (!this.cachedMetadata) {
      this.cachedMetadata = this.loadMetadataOnly();
    }
    return this.cachedMetadata;
  }

  private loadMetadataOnly(): ImageMetadata {
    // Read just the header, not the full image
    return { width: 4000, height: 3000, format: "RAW" };
  }
}

// Usage - gallery loads instantly
const gallery: Image[] = [
  new ImageProxy("/photos/vacation1.raw"),
  new ImageProxy("/photos/vacation2.raw"),
  new ImageProxy("/photos/vacation3.raw"),
];

// No images loaded yet - just proxies created
// Image only loads when user actually views it
gallery[0].display(); // NOW the first image loads

Database connections are another classic use case. Connection pooling aside, you often want to defer the actual connection until the first query:

interface DatabaseConnection {
  query<T>(sql: string, params?: unknown[]): Promise<T[]>;
  close(): Promise<void>;
}

class LazyDatabaseConnection implements DatabaseConnection {
  private connection: DatabaseConnection | null = null;
  private connectionPromise: Promise<DatabaseConnection> | null = null;

  constructor(private connectionString: string) {}

  private async getConnection(): Promise<DatabaseConnection> {
    if (this.connection) {
      return this.connection;
    }

    if (!this.connectionPromise) {
      this.connectionPromise = this.establishConnection();
    }

    this.connection = await this.connectionPromise;
    return this.connection;
  }

  private async establishConnection(): Promise<DatabaseConnection> {
    console.log("Establishing database connection...");
    // Actual connection logic here
    return new RealDatabaseConnection(this.connectionString);
  }

  async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
    const conn = await this.getConnection();
    return conn.query(sql, params);
  }

  async close(): Promise<void> {
    if (this.connection) {
      await this.connection.close();
      this.connection = null;
      this.connectionPromise = null;
    }
  }
}

Protection Proxy: Access Control Implementation

Protection proxies enforce access rules before delegating to the real object. This cleanly separates authorization concerns from business logic.

interface Document {
  id: string;
  content: string;
  ownerId: string;
}

interface DocumentService {
  read(documentId: string): Promise<Document>;
  write(documentId: string, content: string): Promise<void>;
  delete(documentId: string): Promise<void>;
}

interface User {
  id: string;
  roles: string[];
}

interface AuthContext {
  getCurrentUser(): User;
}

class ProtectedDocumentService implements DocumentService {
  constructor(
    private realService: DocumentService,
    private authContext: AuthContext
  ) {}

  async read(documentId: string): Promise<Document> {
    const user = this.authContext.getCurrentUser();
    const document = await this.realService.read(documentId);

    if (!this.canRead(user, document)) {
      throw new Error(
        `Access denied: User ${user.id} cannot read document ${documentId}`
      );
    }

    return document;
  }

  async write(documentId: string, content: string): Promise<void> {
    const user = this.authContext.getCurrentUser();
    const document = await this.realService.read(documentId);

    if (!this.canWrite(user, document)) {
      throw new Error(
        `Access denied: User ${user.id} cannot write to document ${documentId}`
      );
    }

    return this.realService.write(documentId, content);
  }

  async delete(documentId: string): Promise<void> {
    const user = this.authContext.getCurrentUser();
    const document = await this.realService.read(documentId);

    if (!this.canDelete(user, document)) {
      throw new Error(
        `Access denied: User ${user.id} cannot delete document ${documentId}`
      );
    }

    return this.realService.delete(documentId);
  }

  private canRead(user: User, document: Document): boolean {
    return (
      document.ownerId === user.id ||
      user.roles.includes("admin") ||
      user.roles.includes("reader")
    );
  }

  private canWrite(user: User, document: Document): boolean {
    return document.ownerId === user.id || user.roles.includes("admin");
  }

  private canDelete(user: User, document: Document): boolean {
    return document.ownerId === user.id || user.roles.includes("admin");
  }
}

The real DocumentService remains focused purely on document operations. Security logic lives entirely in the proxy, making both components easier to test and modify independently.

Remote Proxy and Caching Proxy

Remote proxies abstract network communication, making remote services appear local. gRPC stubs and REST client wrappers are modern examples. The client calls methods on the proxy; the proxy handles serialization, network transport, and error handling.

Caching proxies store results to avoid repeated expensive operations:

interface ApiClient {
  fetch<T>(endpoint: string): Promise<T>;
}

interface CacheEntry<T> {
  data: T;
  timestamp: number;
}

class CachingApiProxy implements ApiClient {
  private cache = new Map<string, CacheEntry<unknown>>();

  constructor(
    private realClient: ApiClient,
    private ttlMs: number = 60000
  ) {}

  async fetch<T>(endpoint: string): Promise<T> {
    const cached = this.cache.get(endpoint) as CacheEntry<T> | undefined;

    if (cached && Date.now() - cached.timestamp < this.ttlMs) {
      console.log(`Cache hit: ${endpoint}`);
      return cached.data;
    }

    console.log(`Cache miss: ${endpoint}`);
    const data = await this.realClient.fetch<T>(endpoint);
    this.cache.set(endpoint, { data, timestamp: Date.now() });
    return data;
  }
}

Proxy vs. Decorator vs. Adapter

These patterns look similar but serve different intents:

  • Proxy: Controls access to an object. The proxy and real subject share the same interface, and the proxy decides if/when/how to delegate.
  • Decorator: Adds behavior to an object. Decorators enhance functionality while always delegating to the wrapped object.
  • Adapter: Converts one interface to another. Adapters bridge incompatible interfaces without controlling access.

A proxy might refuse to call the real object entirely. A decorator always calls through. An adapter changes the shape of calls.

When to Use (and Avoid) Proxies

Use proxies when:

  • Object creation is expensive and might not be needed
  • Access control should be separate from business logic
  • You need transparent caching or logging
  • Remote resources should appear local

Avoid proxies when:

  • The indirection adds complexity without clear benefit
  • Middleware or AOP handles the cross-cutting concern better
  • The “control” logic is trivial and could live in the client

Proxies add a layer of indirection. That’s their power and their cost. Use them when that indirection provides clear architectural value—not just because you can.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.