GUIDE · Structural
Proxy
Provide a surrogate or placeholder for another object to control access to it.
At a glance
- INTENT
- Provide a surrogate or placeholder for another object to control access to it.
- PROBLEM
- The real object is expensive to create (virtual/lazy proxy), lives elsewhere (remote proxy), must be guarded (protection proxy), or its answers should be reused (caching proxy) — and callers shouldn't know or care.
- SAY
- Participants: Subject (shared interface), RealSubject (does the work), Proxy (same interface; holds/creates the RealSubject; decides when/whether to forward), Client. Pitch: "same interface as the real thing; it stands in front and controls access."
- SEEN IN
- ES
Proxyobject (Vue 3 reactivity, MobX observables, Immer drafts);Promiseas a placeholder for a future value (proxy-ish); ORM lazy relations (user.postsloads on first access); dynamicimport()/ lazyrequire(virtual proxy for a module); CDN & service-worker caches (caching proxy); gRPC/REST client stubs (remote proxy);http-proxy-middleware. - VS
- Decorator ADDS behavior and stacks freely; Proxy CONTROLS access (lazy/guard/cache/remote) and usually manages the subject's lifecycle itself. Adapter changes the interface; Proxy keeps it.
The idea#
Picture a coat check. You hand over a blue coat and you get a ticket with your name. When you want the coat, you talk to the attendant. You do not walk into the rack. The attendant reads the ticket, refuses the wrong person, and goes to the hook the first time you are allowed to ask. The second time, they can hand you the coat they already pulled. You still say "my coat." The words did not change. The attendant stands in front and decides.
A proxy is that attendant. It has the same interface as the real object. It holds the real object, or it creates it, and it decides when and whether to forward the call. The real report service might be expensive to build, living on another machine, closed to some users, or worth caching. Callers should not care which of those is true. They call getReport.
The shared interface is the subject. The object that does the work is the real subject. The proxy is the stand-in. The client holds the proxy.
Why it matters#
The bug this pattern prevents is every caller inventing its own guard. One screen forgets the role check and builds the expensive service anyway. Another caches in a place the rest of the app cannot see. A third reaches across the network with a raw client. Put the decision in the attendant and the callers stay boring.
You already stand in front of objects this way. The language's Proxy is how Vue 3, MobX, and Immer watch reads and writes. A promise is a placeholder for a value that is not here yet. An ORM that loads user.posts on first touch is a virtual proxy. A dynamic import is a virtual proxy for a module. A CDN or a service worker that reuses a response is a caching proxy. A generated client for a remote API is a remote proxy. Middleware that forwards HTTP is the same job at the network edge.
A decorator adds behavior and stacks freely. Each jacket assumes the call will go through, and it does a little extra around that call. A proxy controls access: lazy, guard, cache, or remote. It usually manages the real object's life itself, and you typically want one attendant, not a pile of them. An adapter changes the interface. A proxy keeps it. If the attendant invented a new method name, callers would know they were not holding the coat.
A small example#
function coatCheck(owner) { let coat = null; let pulls = 0; return { claim(name) { if (name !== owner) throw new Error('wrong ticket ' + name); if (coat === null) { pulls += 1; coat = 'blue coat on hook 4'; } return coat; }, pulls() { return pulls; }, };} const desk = coatCheck('ada');const first = desk.claim('ada');const again = desk.claim('ada');first and again are the same coat. desk.pulls() is 1. The hook is visited on the first allowed claim. A wrong name throws before any pull.
What the code below is doing#
The sheet shows the same idea twice.
The first shape uses classes so the book names are visible. ReportService is the real subject. Constructing it increments a counter. getReport increments a call counter and returns the account id plus a row pair: the length of the id, and the constant 42. ReportServiceProxy is the attendant. It keeps a private real service and a private cache, both starting empty. getReport refuses anyone whose roles do not include analyst, and it throws before it builds anything. On an allowed call it returns the cached report when it has one. Otherwise it creates the real service on first use, calls it, stores the report, and returns it.
A viewer named bob asks for acct-1 and is refused. The real-instance counter is still 0. The refusal was free. An analyst named ada asks for acct-1. One real service now exists. The report's rows are 6 and 42, because acct-1 has six characters. The same ask again returns that same object, and the real call counter is still 1. A different account, acct-2, makes a second real call. The instance counter stays 1. One attendant, one real service, many reports.
The second shape is the built-in Proxy, three times. Traps are the points where you intercept. The first wraps a config object. Setting port only forwards when the value is an integer from 1 to 65535. Setting 8080 lands on the real object. Setting 70000 throws a RangeError and leaves the port alone. Deleting any key throws. The second is a virtual proxy around a user. Reading name does nothing extra. Reading orders the first time records a load and stores one order with a total of 999. Reading orders again does not load again. The third records every property read. Adding a and c yields 4, and the read set is a then c. Nobody touched b. That is how a reactive library learns which fields a computation depends on.
When you talk about it, say this: same interface as the real thing. It stands in front and controls access.
Everything below is the demonstration. Same idea, written as code you can run.
'use strict';const assert = require('node:assert/strict');Classic shape (classes, so the participant names are visible)#
// Subject interface: getReport(accountId) -> { accountId, rows }let realInstances = 0;let realCalls = 0;class ReportService { // RealSubject — expensive to build and to call constructor() { realInstances += 1; } getReport(accountId) { realCalls += 1; return { accountId, rows: [accountId.length, 42] }; }}class ReportServiceProxy { // Proxy — protection + lazy + caching in one #real = null; #cache = new Map(); constructor(user) { this.user = user; } getReport(accountId) { if (!this.user.roles.includes('analyst')) throw new Error(`forbidden: ${this.user.name}`); // protection if (this.#cache.has(accountId)) return this.#cache.get(accountId); // caching this.#real ??= new ReportService(); // lazy init const report = this.#real.getReport(accountId); this.#cache.set(accountId, report); return report; }} const analyst = new ReportServiceProxy({ name: 'ada', roles: ['analyst'] });const viewer = new ReportServiceProxy({ name: 'bob', roles: ['viewer'] }); const r1 = analyst.getReport('acct-1');const r2 = analyst.getReport('acct-1');analyst.getReport('acct-2');Verified by 9 asserts
assert.equal(realInstances, 0); // lazy: nothing built yetassert.throws(() => viewer.getReport('acct-1'), /forbidden: bob/);assert.equal(realInstances, 0); // access control blocked before any costassert.equal(realInstances, 1); // built on first authorized useassert.deepEqual(r1, { accountId: 'acct-1', rows: [6, 42] });assert.equal(r2, r1);assert.equal(realCalls, 1); // caching: second call never hit the RealSubjectassert.equal(realCalls, 2);assert.equal(realInstances, 1); // still exactly one real instanceIdiomatic JS shape: the built-in Proxy object (traps = interception points)#
// 1) Protection/validation proxy over a config objectconst config = { port: 3000, env: 'dev' };const guardedConfig = new Proxy(config, { set(target, key, value) { if (key === 'port' && !(Number.isInteger(value) && value > 0 && value < 65536)) { throw new RangeError(`invalid port ${value}`); } target[key] = value; return true; }, deleteProperty() { throw new Error('config keys cannot be deleted'); },});guardedConfig.port = 8080; // 2) Virtual proxy: lazy, memoized property (ORM-style lazy relation)const loads = [];const lazyUser = new Proxy({ id: 7, name: 'Ada' }, { get(target, key, receiver) { if (key === 'orders' && !('orders' in target)) { loads.push('orders'); // the "db query" happens on first access only target.orders = [{ id: 1, total: 999 }]; } return Reflect.get(target, key, receiver); },}); // 3) Tracing proxy: observe reads (how Vue/MobX know which fields a computation depends on)const reads = new Set();const tracked = new Proxy({ a: 1, b: 2, c: 3 }, { get: (t, k) => { reads.add(k); return t[k]; } });Verified by 10 asserts
assert.equal(config.port, 8080); // forwarded to the real targetassert.throws(() => { guardedConfig.port = 70000; }, RangeError);assert.throws(() => { delete guardedConfig.env; }, /cannot be deleted/);assert.equal(lazyUser.name, 'Ada');assert.deepEqual(loads, []); // untouched until someone asksassert.equal(lazyUser.orders.length, 1);assert.equal(lazyUser.orders[0].total, 999);assert.deepEqual(loads, ['orders']); // loaded exactly onceassert.equal(tracked.a + tracked.c, 4);assert.deepEqual([...reads], ['a', 'c']); // dependency tracking; `b` never readSee also