Skip to content
aipatterns.dev

Design patternStructural Patterns

Proxy

Put a stand-in with the same interface in front of an object to control access to it: create it lazily, check permissions, or reach it remotely.

Complexity
Medium
Seen in the wild
Low
Also known as
Surrogate, Stand-in

Intent

Give clients an object that implements the same interface as the real one but controls when, whether and how the real one is reached. The proxy can delay creating an expensive subject until first use, refuse calls the caller is not allowed to make, forward calls over a network, or count references. Clients cannot tell the proxy from the subject.

Forces

  • Expensive setup vs fast startup: loading a 400 MB model at boot slows every deploy, even though most requests never need it.
  • Callers want a plain object vs access needs rules: the code that classifies messages should not also manage loading, locking or permissions.
  • Location transparency vs honest failure modes: a remote object that looks local hides latency and network errors.
  • One access rule vs many call sites: when every caller checks "is it loaded yet" or "is this user allowed", one of them eventually forgets.

Therefore

Implement the subject's interface in a proxy class that holds (or knows how to obtain) the real subject, and put the access-control logic in the proxy before delegating each call.

The proxy usually manages the subject's lifecycle. It creates the subject, or receives a way to create it, rather than being handed a ready instance by the client. That is the practical line between a proxy and a Decorator, which the client builds by wrapping an object it already has.

Structure

Diagram source (Mermaid)
classDiagram
  class InboxFilter {
    -SpamClassifier classifier
    +isSpam(sender, body) boolean
  }
  class SpamClassifier {
    <<interface>>
    +score(message) double
  }
  class ModelClassifier {
    -Map weights
    +score(message) double
  }
  class LazyClassifier {
    -SpamClassifier real
    -Loader load
    +score(message) double
  }
  InboxFilter --> SpamClassifier
  SpamClassifier <|.. ModelClassifier
  SpamClassifier <|.. LazyClassifier
  LazyClassifier --> ModelClassifier : creates on first use

Participants

  • Subject (SpamClassifier): the interface shared by the proxy and the real object.
  • Real subject (ModelClassifier): does the actual work and is expensive, remote or sensitive.
  • Proxy (LazyClassifier): same interface; controls access, creation and lifetime of the real subject.
  • Client (InboxFilter): depends on the subject interface and is unaware which one it holds.

Common kinds of proxy: virtual (lazy creation, as in the example), protection (permission checks), remote (local stand-in for a network object, such as a gRPC stub), and smart reference (reference counting, locking, or releasing the subject when idle).

Use it when

  • An object is expensive to create and many code paths never use it.
  • Access needs a check (role, tenant, quota) that should be enforced in one place for every caller.
  • The real object lives in another process or service and you want callers to use a local interface (RPC client stubs).
  • You need to manage a resource's lifetime: open a connection on first use, close it after idle time, count users.
  • You need to intercept calls to an object you do not own, without changing its callers.

Do not use it when

  • The object is cheap to create. Construct it eagerly; lazy loading adds locking and makes the first request slow.
  • You only need lazy initialization of one field. Use the language's built-in: functools.cached_property in Python, sync.OnceValue in Go, a memoized Supplier or holder class in Java, ??= in TypeScript.
  • You want to intercept every property access on a dynamic object in JavaScript. Use the built-in Proxy object instead of a hand-written class. In Python, __getattr__ or descriptors cover the same ground.
  • The interface is wide and the proxy only acts on construction. Hand-forwarding thirty methods invites drift; use a dynamic proxy (java.lang.reflect.Proxy), code generation, or a lazy factory instead.
  • You are adding optional behavior such as caching, logging or retries that the client stacks freely. That is Decorator.
  • The remote call is slow or fallible, and pretending it is local hides that. Expose an explicit async client with timeouts and errors in its signature.

Application grill

Inspect first: the access being controlled (lazy construction, permissions, remote reach); the proxies already in the stack (HTTP clients, API gateways, service meshes, CDNs, the language's Proxy, lazy getters); whether the subject is truly remote or just far away in the codebase.

Missing pieces: in-process: the language's Proxy or a lazy getter - nothing to buy. Remote: reuse the existing mesh, gateway or service client before writing a custom remote proxy; a custom one is a network hop you now own.

Ask before you build:

  • Scale: does the remote proxy add a per-call network hop that the latency budget survives?
  • Performance: when is the subject actually constructed under lazy loading - does the first caller absorb the stall?
  • Reliability: when the subject is unreachable, what does the proxy return - timeout, fallback, error?
  • Budget: is configuring an existing gateway cheaper than hand-rolled proxy code?
  • Implementation: does the proxy keep the interface identical (the same test suite passes against proxy and subject)?

Implementation steps

  1. Make sure clients depend on an interface. Extract one from the real class if needed (Extract Interface).
  2. Write the proxy class implementing that interface. Give it what it needs to reach the subject: a loader function, an address, or a reference plus a policy.
  3. In each method, apply the control (create if missing, check permission, open connection), then delegate.
  4. Make the control thread-safe if the proxy is shared: use a lock, sync.Once, or a synchronized accessor.
  5. Wire the proxy at the composition root in place of the real subject.
  6. Test both paths: the proxy before the subject exists and after, and allowed and denied calls for a protection proxy.

Example

A mail service scores incoming messages with a spam model that takes several seconds and hundreds of megabytes to load. Most mail comes from trusted senders and never reaches the model, so the service should start without loading it.

interface SpamClassifier {
  score(message: string): number; // 0..1
}

// Real subject: expensive to construct.
class ModelClassifier implements SpamClassifier {
  private readonly weights: Map<string, number>;
  constructor(path: string) {
    console.log(`loading model from ${path} ...`); // stands in for seconds of I/O
    this.weights = new Map([["winner", 0.6], ["crypto", 0.5], ["free", 0.3]]);
  }
  score(message: string): number {
    const words = message.toLowerCase().split(/\W+/);
    return Math.min(1, words.reduce((sum, w) => sum + (this.weights.get(w) ?? 0), 0));
  }
}

// Virtual proxy: same interface, creates the subject on first use.
// Single-threaded runtime, so no lock is needed.
class LazyClassifier implements SpamClassifier {
  private real?: SpamClassifier;
  constructor(private readonly load: () => SpamClassifier) {}
  score(message: string): number {
    this.real ??= this.load();
    return this.real.score(message);
  }
}

class InboxFilter {
  constructor(private readonly classifier: SpamClassifier, private readonly trusted: Set<string>) {}
  isSpam(sender: string, body: string): boolean {
    if (this.trusted.has(sender)) return false; // most mail stops here
    return this.classifier.score(body) >= 0.7;
  }
}

const filter = new InboxFilter(
  new LazyClassifier(() => new ModelClassifier("models/spam-v3.bin")),
  new Set(["billing@acme.io", "ci@acme.io"]),
);
console.log("service ready"); // printed before any model load
console.log(filter.isSpam("billing@acme.io", "Your invoice is ready"));       // false, no load
console.log(filter.isSpam("promo@deals.biz", "WINNER: claim free crypto now")); // loads once, true
console.log(filter.isSpam("friend@mail.com", "Lunch tomorrow?"));              // false, already loaded

The output shows service ready before loading model, and the model loads exactly once. A protection proxy has the same shape: replace the ??= line with a permission check that throws before delegating.

Trade-offs

Gains

  • Clients get access control, laziness or remoteness without any code change.
  • The rule is enforced in one place, so no call site can skip it.
  • Startup is faster and memory is spent only when the subject is actually used.
  • The real subject stays focused on its job.

Costs

  • The first call pays the full setup cost, which can surprise latency budgets. Warm it in the background if that matters.
  • Lazy creation in concurrent code needs correct synchronization.
  • Errors move: a broken model file now fails on the first message, not at startup. Add a readiness check if you need fail-fast.
  • Remote proxies that look local hide latency and partial failure.

Relations

  • Decorator has the same structure. A decorator adds behavior and is stacked by the client around an object it already has; a proxy controls access and usually owns the subject's creation and lifetime.
  • Adapter gives its wrapped object a different interface. A proxy keeps the interface identical.
  • Facade presents a new, simpler interface over several objects. A proxy stands in for one object with the same interface.
  • Flyweight saves memory by sharing many objects. A virtual proxy saves memory by not creating one until needed.

Agent checklist

  • The subject is measurably expensive, remote, or needs access control; otherwise use it directly.
  • You checked for a language-native option first: cached_property, sync.OnceValue, JavaScript Proxy, dynamic proxies.
  • Clients depend on the interface, and the proxy implements all of it.
  • Lazy creation is thread-safe where the proxy is shared, and creation errors are surfaced, not swallowed.
  • Tests cover the first call (creation), later calls (reuse), and denied calls for a protection proxy.

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.