← Design patterns
Composition Access, delegation, and cached readings

Proxy

A stand-in with a decision at the door.

A municipal flood-watch console reads historical snapshots from river gauges. Mira can read the North Fork and Harbor stations; Lee can read the Harbor station. The collector stores the source records, and the console asks for a station reading. After one permitted reading is cached, the collector can be offline. Follow where the access rule has to live.

TypeScriptGoRust One reading contract in each language.

Definition

A proxy is a surrogate that stands in for another object, behind the same interface, so that it can control access to it.

After Gamma et al., Design Patterns (1994), Proxy

01Start with a direct reader

One request, one snapshot.

A direct collector reader is a good fit for trusted code: pass a station ID and receive a snapshot or an error. The console’s station page checks the operator’s grant and reads the collector itself.

station.ts
// The station page checks the grant, then reads the collector itself.
function stationPage(
  collector: GaugeCollector,
  rules: AccessRules,
  request: ReadRequest,
): ReadResult {
  if (!rules.canRead(request.operator, request.id)) {
    return { ok: false, error: 'forbidden' };
  }
  return collector.read(request);
}

That is clear while it is the only page. The pressure appears when several console actions need the same current access rule (the station page, the export, the alert panel), and repeated reads should reuse a successful historical snapshot, because the collector is slow and a reading taken at 08:00 does not change. Each action that copies the check is one more place to forget it.

A proxy is a stand-in that keeps the resource’s caller-facing contract while controlling access to the resource behind it. The console continues to call read(request) on a StationReader; it is handed the proxy instead of the collector, and the proxy decides whether the request may go on, answers from what it kept when it can, or forwards the request to the collector.

Our small model is synchronous and in memory. It makes grant checks, cached snapshots and collector reads observable; the operator identity represents trusted server context, never a field the browser sends.

02Put a stand-in in front

One contract, two objects that keep it.

The collector and the proxy both implement StationReader. The proxy holds the collector and the rules, and keeps a cache of snapshots by station. This is the whole program the lab runs: the contract, the rules, the collector, the proxy, and four reads of North Fork through it, with Mira’s grant revoked before the last.

proxy.ts
type GaugeSnapshot = { id: string; station: string; reading: string };
type ReadRequest = { operator: string; id: string };
type ReadResult =
  | { ok: true; snapshot: GaugeSnapshot }
  | { ok: false; error: string };

// The contract the console is given. The collector and the proxy both keep it.
interface StationReader {
  read(request: ReadRequest): ReadResult;
}

class AccessRules {
  private grants = new Map<string, Set<string>>();
  allow(operator: string, id: string) {
    this.grants.set(operator, (this.grants.get(operator) ?? new Set()).add(id));
  }
  revoke(operator: string, id: string) {
    this.grants.get(operator)?.delete(id);
  }
  canRead(operator: string, id: string): boolean {
    return this.grants.get(operator)?.has(id) ?? false;
  }
}

// The resource: historical snapshots. Each call is one collector read.
class GaugeCollector implements StationReader {
  reads = 0;
  constructor(private snapshots: GaugeSnapshot[]) {}
  read(request: ReadRequest): ReadResult {
    this.reads++;
    const snapshot = this.snapshots.find((s) => s.id === request.id);
    if (!snapshot) return { ok: false, error: 'not found' };
    return { ok: true, snapshot };
  }
}

// The stand-in: the same contract, a grant check, then a cache.
class GaugeProxy implements StationReader {
  private cache = new Map<string, GaugeSnapshot>();
  constructor(private origin: StationReader, private rules: AccessRules) {}
  read(request: ReadRequest): ReadResult {
    if (!this.rules.canRead(request.operator, request.id)) {
      return { ok: false, error: 'forbidden' };
    }
    const cached = this.cache.get(request.id);
    if (cached) return { ok: true, snapshot: cached };
    const result = this.origin.read(request);
    if (result.ok) this.cache.set(request.id, result.snapshot);
    return result;
  }
}

const collector = new GaugeCollector([
  { id: 'north-fork', station: 'North Fork', reading: '2.4 m, rising' },
  { id: 'harbor', station: 'Harbor', reading: '1.8 m, steady' },
]);
const rules = new AccessRules();
rules.allow('mira', 'north-fork');
rules.allow('mira', 'harbor');
rules.allow('lee', 'harbor');
// The console only ever sees a StationReader.
const reader: StationReader = new GaugeProxy(collector, rules);

const first = reader.read({ operator: 'mira', id: 'north-fork' });
const warm = reader.read({ operator: 'mira', id: 'north-fork' });
const lee = reader.read({ operator: 'lee', id: 'north-fork' });
rules.revoke('mira', 'north-fork');
const revoked = reader.read({ operator: 'mira', id: 'north-fork' });
const reads = collector.reads;

const describe = (s: GaugeSnapshot) => `${s.station} ${s.reading}`;
const show = (name: string, result: ReadResult) => {
  const shown = result.ok ? describe(result.snapshot) : result.error;
  console.log(`${name}: ${shown}`);
};
show('first', first);
show('warm', warm);
show('lee', lee);
show('revoked', revoked);
console.log(`collector reads: ${reads}`);
Station reading path
RoleHereWhat it does
CallerFlood-watch consoleRequests a station snapshot through the reader contract; it never names the collector.
Stand-inProtection and cache proxyDecides whether the request may go on, and keeps the snapshots the collector returned.
ResourceGauge collectorOwns historical snapshots and reports outages or missing IDs.

03Predict a warm read

Move one lookup. Who gets the snapshot?

Experiment

Four reads through one proxy

proxy.ts
// The proxy's read method (lines 41–50) becomes, cache first:
  read(request: ReadRequest): ReadResult {
    const cached = this.cache.get(request.id);
    if (cached) return { ok: true, snapshot: cached };
    if (!this.rules.canRead(request.operator, request.id)) {
      return { ok: false, error: 'forbidden' };
    }
    const result = this.origin.read(request);
    if (result.ok) this.cache.set(request.id, result.snapshot);
    return result;
  }
Suppose the proxy’s read looked in its cache first, as above, to skip the grant lookup on a warm read. The same four reads run. What do Lee’s read and Mira’s read after the revocation return?
What a production proxy addsCopies, counters, clear, outages

The same GaugeProxy, ready for a server. It counts grant checks and cache hits, so an operator can see the cache working, and clear drops every copy when the collector’s records change, keeping grants and counters. It copies snapshots on the way into the cache and on the way out, so no caller holds the cached record and a caller changing what it received cannot change what the next one gets.

proxy.ts
export class GaugeProxy implements StationReader {
	#cache = new Map<string, GaugeSnapshot>();
	#checks = 0;
	#hits = 0;
	#origin: StationReader;
	#rules: AccessRules;
	constructor(origin: StationReader, rules: AccessRules) {
		this.#origin = origin;
		this.#rules = rules;
	}
	read(request: ReadRequest): ReadResult {
		this.#checks++;
		if (!this.#rules.canRead(request.operator, request.id))
			return { ok: false, error: 'forbidden' };
		// A cache hit is still a read, so check current access before using it.
		const cached = this.#cache.get(request.id);
		if (cached) {
			this.#hits++;
			return { ok: true, snapshot: { ...cached } };
		}
		const result = this.#origin.read(request);
		// Only successes are kept; an outage or a missing ID is asked again next time.
		if (!result.ok) return result;
		this.#cache.set(request.id, { ...result.snapshot });
		return { ok: true, snapshot: { ...result.snapshot } };
	}
	// Drops every copy (the collector changed), keeping grants and counters.
	clear(): void {
		this.#cache.clear();
	}
	stats() {
		return { checks: this.#checks, hits: this.#hits, entries: this.#cache.size };
	}
}

Revocation blocks later reads through this proxy, but cannot erase a copy already delivered. A successful empty reading is still a success; collector failures and missing IDs are passed through and retried rather than cached. Authorized warm reads can continue during a collector outage.

Cached readings are snapshots: collector changes stay invisible until the owner clears the cache. The cache key is the station ID because every authorized operator sees the same historical reading in this model.

Reading the TypeScriptStructural interfaces, tagged results, copies

Interfaces are structural, so the collector and proxy can both satisfy StationReader without sharing a base class; implements only asks the compiler to check it. constructor(private origin: StationReader, …) declares and assigns a field in one step.

The tagged result keeps a successful reading distinct from an expected failure: after if (result.ok), TypeScript knows result.snapshot exists. A map lookup distinguishes a stored record from no record, even when the reading body is empty.

The lesson’s program hands out the cached object itself, which is fine while nobody changes it. Object copies ({ ...snapshot }) protect the simple flat snapshot record at the cache boundary in production. A nested mutable field would need its own copy or immutable representation.

04Meet it in your language

Stand-ins you already use.

JavaScript has a built-in for this: new Proxy(target, handler) returns an object that stands in for target and runs a trap, such as get, for every property access. Vue’s reactive() and MobX’s observables are built on it, and it is a quick way to make a read-only view of an object you did not write.

The trap that forwards with Reflect.get(target, key, receiver) is the textbook one, and for a plain object it is right. For a Map it is not: its methods and its size getter read internal slots only the real map has, and the receiver is the stand-in, so viewBefore.get('harbor') throws “TypeError: Method Map.prototype.get called on incompatible receiver”. Set, Date and any class with #private fields fail the same way.

view.ts
// The dashboard gets a read-only view of the console's readings: a stand-in
// made with JavaScript's Proxy, which runs the get trap for every property.
const readings = new Map([['harbor', '1.8 m, steady']]);
const writes = new Set<PropertyKey>(['set', 'delete', 'clear']);

// Before: the trap hands back the Map's own methods, unbound.
const viewBefore = new Proxy(readings, {
  get(target, key, receiver) {
    if (writes.has(key)) throw new TypeError(`read-only: ${String(key)}`);
    return Reflect.get(target, key, receiver);
  },
});

// After: methods and getters run against the real Map, not the stand-in.
const view = new Proxy(readings, {
  get(target, key) {
    if (writes.has(key)) throw new TypeError(`read-only: ${String(key)}`);
    const value = Reflect.get(target, key, target);
    return typeof value === 'function' ? value.bind(target) : value;
  },
});

export { view, viewBefore };

The fix runs them against the real map: read with the target as the receiver, and bind functions to it. The stand-in still decides what gets through: the writes it refuses never reach the map. Reach for Proxy when you cannot list the methods ahead of time; when you can, a class like the lesson’s GaugeProxy is easier to read and to type.

05Decide when it helps

When is the extra reader worth keeping?

One trusted caller with no shared policy can read the collector directly. That is simpler and keeps failures visible. The proxy earns its place when separate console actions must apply the same access decision and reuse a successful snapshot. It also costs something: another object owns a cache, policy ordering, and a freshness rule.

What changes when another requirement arrives?
The changeEach caller reads the collectorCallers are handed the proxy
A third console action needs station readingsIt repeats the grant check, or it is the one that leaks.It is handed the StationReader and inherits the check.
Repeated reads should reuse a snapshotEach action keeps its own copies, each with its own staleness.One cache, behind the check, with one freshness rule.
What a reading contains starts to vary by operatorEach action’s copies must vary with it.The cache keys on that difference, or keeps only what is the same for everyone.
One trusted script reads one station, onceTwo lines, and failures stay visible.An extra object that owns a cache, an order of checks and a freshness rule for nothing.

The pattern leaves the hard choices to you: how fresh a cached copy must be and who clears it; what the cache is keyed by; how current the policy must be (this proxy asks the rules on every read); and what happens on failure (passed through, never cached). If what a response contains starts to vary by operator, station revision or requested format, the cache must either include that difference in its key or keep only what is the same for everyone and tailor each copy on the way out.

What goes where: the access decision and the cache belong to the proxy; the records, outages and missing IDs to the collector; the operator’s identity to trusted server context, never to the request the client sends.

Its neighbours: Adapter changes an incompatible interface; this proxy preserves the reader contract. Decorator commonly adds behavior around an operation; the proxy’s central job here is to stand in for the collector and control access to it. Their object shapes can overlap, so the responsibility is what distinguishes the design. Facade gives a subsystem a new, simpler interface rather than keeping one.

06Make the call

What does a proxy guarantee?

The console uses the proxy, but a second server endpoint can reach the raw collector. What does the proxy guarantee?
A developer’s script reads one station from a test collector running on their laptop, once. No one else calls it. What should it use?
Somewhere else: a document service caches rendered previews of shared files, and an owner can revoke a share link. What must the preview stand-in check before it returns a cached preview?

Before you open the answer below, say it in your own words: what does the proxy decide that the collector does not, why must it decide before the cache, and what can it not protect?

One way to say itThe door, the cache, and the paths around them

A proxy keeps the resource’s contract, so callers can be handed it in the resource’s place without knowing; that is what lets it decide things the resource does not: who may read, and when a kept copy can answer. The decision comes first on every request, because a cache hit is still a read. Put the cache in front and you get the failure reviews call an authorization bypass through the cache: a copy fetched for one person is served to another, or after their access was revoked, and every counter looks healthy. A proxy may cache what the resource said; it must not cache what it decided about the person asking. And it protects only the paths that go through it: any code that can still reach the raw resource needs the same door, or no reference to the resource at all. When there is no one to keep out and nothing to reuse, read the resource directly.

Practice in the lab

When the river rises, the city office that runs the flood-watch console also opens evacuation shelters. The county’s shelter registry is read by a public shelter map and by responders, and some shelters’ locations are confidential. Each level starts from the previous level’s finished code, so you can begin at any of them.

  • PROXY-101 (easy) The public map shows the safe house
  • PROXY-102 (medium) Every page view is a county call Pro
  • PROXY-103 (hard) Review: the cache remembers too much Pro