---
title: Proxy
source: 'https://academia.sh/en/courses/design-patterns/proxy'
course: 'Design Patterns'
language: en
updated: '2026-08-23T07:01:15+00:00'
license: 'CC BY-SA 4.0'
---

# Proxy

Controlling access by stepping in: counting the calls reaching a tariff source in a no-proxy context and in a caching-proxy context, having the virtual proxy keep the source from being built until the first request, intervening at the object level with a protection proxy, and measuring the cost in a level of indirection and stale-read count.

In the flyweight, one object was shared by many records, but access was unrestricted: a
record read the `tariff` field and used it directly. This lesson's problem is access
itself.

Tariff tables no longer live inside the library — they sit at a separate source, and
reaching it is expensive. Three requirements arise at once: skip building the source if it
never gets used, do not refetch the same table on a second request, and make access to
certain tariffs closable. All three share one thing: they leave the real object's body
untouched. A **proxy** is an object offering the **same interface** as the real object,
stepping in while passing the call through to it. The measure is calls reaching the source;
the cost is a level of indirection and stale-read count.

## Problem: Every Record Goes to the Source

The source counts its own construction and every call. The counters are the measurement's
instrument; on a real source, these numbers would mean a network request or a file read.

```js
// source/tariff-source.mjs — the real source: its construction and every call are counted
const TABLES = {
  standard: { tiers: [[1, 4990], [5, 8490], [15, 14990], [30, 24990]], coefficient: { 34: 100, "06": 118, 35: 120, 65: 145 } },
  economy: { tiers: [[1, 3990], [5, 6990], [15, 11990], [30, 19990]], coefficient: { 34: 100, "06": 118, 35: 120, 65: 145 } },
  express: { tiers: [[1, 6490], [5, 10990], [15, 18990], [30, 31990]], coefficient: { 34: 100, "06": 118, 35: 120, 65: 145 } },
};

let built = 0, calls = 0;

export const counters = () => ({ built, calls });
export const resetCounters = () => { built = 0; calls = 0; };

export function buildSource() {
  built += 1;
  return {
    get(code) {
      calls += 1;
      const t = TABLES[code];
      if (t === undefined) throw new RangeError(`unknown tariff: ${code}`);
      return t;
    },
  };
}

export function updateSource(code, increaseCoefficient) {
  TABLES[code] = {
    tiers: TABLES[code].tiers.map(([u, f]) => [u, Math.round(f * increaseCoefficient)]),
    coefficient: TABLES[code].coefficient,
  };
}
```

```js
// data.mjs — raw shipment rows; values are derived from the index, no randomness
const POSTAL = ["34100", "06500", "35400", "65100"];
const TARIFF = ["standard", "economy", "express"];

export function* rawRows(n) {
  for (let i = 0; i < n; i += 1) {
    yield {
      code: `G-${i}`,
      weight: 0.5 + (i % 59) / 2,
      postalCode: POSTAL[i % POSTAL.length],
      tariffCode: TARIFF[i % TARIFF.length],
    };
  }
}
```

The client takes the source from outside and does not know its type. **The same file** is
used in every measurement in this lesson; the only thing that changes is which object gets
passed to it.

```js
// client.mjs — the same file in both contexts: takes the source from outside, does not know its type
export function charge(source, rows) {
  let total = 0;
  for (const s of rows) {
    const t = source.get(s.tariffCode);
    const tier = t.tiers.find(([cap]) => s.weight <= cap) ?? [0, 24990];
    total += Math.round((tier[1] * (t.coefficient[s.postalCode.slice(0, 2)] ?? 165)) / 100);
  }
  return total;
}
```

## Solution: Three Proxies Offering the Same Interface

The caching proxy goes to the source once for the same code. Its interface is identical to
the source's: `get(code)`.

```js
// proxy/cache.mjs — caching proxy: same interface, the same code does not go to the source twice
export const cachingProxy = (inner) => {
  const cache = new Map();
  return {
    inner,
    get(code) {
      if (!cache.has(code)) cache.set(code, inner.get(code));
      return cache.get(code);
    },
    cacheSize: () => cache.size,
  };
};
```

```js
// proxy/lazy.mjs — virtual proxy: the source is not built until the first request
export const lazyProxy = (sourceFactory) => {
  let inner = null;
  return {
    get inner() { return inner ?? undefined; },
    get(code) {
      inner ??= sourceFactory();
      return inner.get(code);
    },
  };
};
```

```js
// proxy/guard.mjs — protection proxy: intervenes at the object level, checks an allow-list of codes
export const guardProxy = (inner, allowed) => ({
  inner,
  get(code) {
    if (!allowed.includes(code)) throw new RangeError(`access closed: ${code}`);
    return inner.get(code);
  },
});
```

What the protection proxy does is **intervene at the object level**: it decides which
object passes which call through. The rules deciding who can access which tariff —
identity, role, permission model — are covered in the Authentication and Authorization
course; the list here is data carrying the outcome of that decision, not the rule itself.

## Measuring Call Count

```js
// run.mjs — no-proxy and cached contexts: calls reaching the source, and the total fee
import { rawRows } from "./data.mjs";
import { buildSource, counters, resetCounters } from "./source/tariff-source.mjs";
import { cachingProxy } from "./proxy/cache.mjs";
import { charge } from "./client.mjs";

const N = 20000;
const depth = (n) => (n?.inner === undefined ? 1 : 1 + depth(n.inner));

resetCounters();
const directSource = buildSource();
const a = charge(directSource, rawRows(N));
const s1 = counters();

resetCounters();
const proxiedSource = cachingProxy(buildSource());
const b = charge(proxiedSource, rawRows(N));
const s2 = counters();

console.log(`no proxy: total=${a}  source calls=${s1.calls}  depth=${depth(directSource)}`);
console.log(`proxied : total=${b}  source calls=${s2.calls}  depth=${depth(proxiedSource)}  ` +
  `cache=${proxiedSource.cacheSize()}`);
console.log(`total difference = ${a - b}  shipments = ${N}`);
```

```
no proxy: total=463093345  source calls=20000  depth=1
proxied : total=463093345  source calls=3  depth=2  cache=3
total difference = 0  shipments = 20000
```

Calls reaching the source dropped from 20,000 to 3; the total fee matches to the cent. The
three corresponds to three distinct tariff codes: cache size equals the intrinsic-state
variety. The entire gain came without changing a **single line** in the client file —
`client.mjs` is the same file in both runs, the `get` call is the same too. The only thing
that changes is which object gets passed in the composition root, which would not be
possible if the proxy's interface were not identical to the source's — the pattern's one
mandatory rule.

## Three Proxies Stacked

Since proxies also return the same interface, they can wrap each other. The chain below
is, from outside in, a protection proxy, a caching proxy, and a virtual proxy; at the
innermost point is the source itself, not yet built.

```js
// chain.mjs — three proxies stacked: guard -> cache -> lazy source
import { rawRows } from "./data.mjs";
import { buildSource, counters, resetCounters } from "./source/tariff-source.mjs";
import { cachingProxy } from "./proxy/cache.mjs";
import { lazyProxy } from "./proxy/lazy.mjs";
import { guardProxy } from "./proxy/guard.mjs";
import { charge } from "./client.mjs";

const depth = (n) => (n?.inner === undefined ? 1 : 1 + depth(n.inner));

resetCounters();
const chain = guardProxy(cachingProxy(lazyProxy(buildSource)), ["standard", "economy", "express"]);
console.log(`before first call: source built=${counters().built}  depth=${depth(chain)}`);

const total = charge(chain, rawRows(20000));
console.log(`after first call: source built=${counters().built}  calls=${counters().calls}  ` +
  `depth=${depth(chain)}  total=${total}`);

const closed = guardProxy(cachingProxy(lazyProxy(buildSource)), ["standard"]);
try {
  charge(closed, rawRows(3));
  console.log("closed chain: all codes passed");
} catch (e) {
  console.log(`closed chain: ${e.message}`);
}
```

```
before first call: source built=0  depth=3
after first call: source built=1  calls=3  depth=4  total=463093345
closed chain: access closed: economy
```

Before the first call, the source was **not built at all**: the counter shows 0. Chain
depth went from 3 to 4 after the first call, since the source got built at that moment and
joined the chain. The total fee matches the single-proxy run. Narrowing the allow-list
closed access on the second tariff code and surfaced the error all the way to the client;
the protection proxy decided without ever reaching the source.

## Cost: Indirection and Stale Reads

The chain-depth measurement is the first half of the cost: the number of bodies to trace
for one table request went from 1 to 4. The second half lies in the cache's nature — if the
source changes, the proxy keeps giving the old value.

```js
// stale.mjs — the cache's cost: once the source changes, the proxy keeps giving the old table
import { rawRows } from "./data.mjs";
import { buildSource, updateSource, resetCounters } from "./source/tariff-source.mjs";
import { cachingProxy } from "./proxy/cache.mjs";
import { charge } from "./client.mjs";

const N = 20000;
resetCounters();
const proxied = cachingProxy(buildSource());
const direct = buildSource();

console.log(`before increase : proxied=${charge(proxied, rawRows(N))}  ` +
  `direct=${charge(direct, rawRows(N))}`);

updateSource("standard", 1.1);

const afterProxied = charge(proxied, rawRows(N));
const afterDirect = charge(direct, rawRows(N));
const affected = [...rawRows(N)].filter((s) => s.tariffCode === "standard").length;
console.log(`after increase: proxied=${afterProxied}  direct=${afterDirect}`);
console.log(`stale reads = ${affected} shipments, undercalculated = ${afterDirect - afterProxied} cents`);
```

```
before increase : proxied=463093345  direct=463093345
after increase: proxied=463093345  direct=478135357
stale reads = 6667 shipments, undercalculated = 15042012 cents
```

After a ten-percent increase on the standard tariff, the proxied calculation kept using the
old table: 6667 shipments were read stale, and the total was undercalculated by 15,042,012
cents, with no error raised. Gain and cost come from the same mechanism: what drops the
call count from 20,000 to 3 also ties freshness to those 3 reads. A caching proxy needs a
validity period or an invalidation path as part of the design; without one, it silently
breaks correctness.

The distinction from the decorator is clear here too. Both preserve the same interface and
step in, but the decorator adds a **behavior** on top of the result — a fuel surcharge, tax
— and the chain's order changes the result. The proxy does not change the result; it
manages **access**: when to go, how many times, whether to go at all. This is why the
proxied and unproxied runs' totals must match — where they do not, as in the staleness
measurement above, that is evidence of a defect.

It does not apply in two situations. If access is cheap and there is no repetition — the
table already sits in memory, every code is requested once — the caching proxy only adds a
map and a level of indirection. If the source must be fresh and there is no invalidation
path, caching is forbidden; in that case the gain expected from the proxy is paid for with
an unmeasurable correctness risk.

## Summary

- The problem the proxy solves is controlling access without touching the real object's
  body; the mandatory rule is that the proxy offers the exact same interface as the real
  object.
- The caching proxy dropped calls reaching the source from 20,000 to 3; the total fee
  stayed the same, and not a single line changed in the client file — what changed was the
  composition root.
- The virtual proxy did not build the source at all until the first request (construction
  counter 0); the protection proxy made its decision on a closed tariff code without
  reaching the source.
- The cost was measured with two numbers: the number of bodies to trace for one request
  went from 1 to 4, and when the source changed, 6667 shipments were read stale and the
  total was undercalculated by 15,042,012 cents.
- The proxy manages access, the decorator adds behavior: the proxied and unproxied runs
  must give the same result. The pattern does not pay off when access is cheap or
  freshness is mandatory.

## Next Step

Structural patterns organized how objects get combined: the adapter translated two
incompatible interfaces into the same contract, the bridge separated two independent axes,
the composite handled a tree uniformly, the decorator layered behavior, the facade put a
single door in front of the subsystem, the flyweight shared the unchanging, and the proxy
stepped in and passed the call on with **the same interface**. That last point hides a
boundary: the intervening object's job was to pass the call through — the calculation
itself never changed, and it was never supposed to.

The next question is not combination but **selecting behavior**: can the algorithm behind
the same interface be swapped at run time? In the fee library, this is concrete — pricing
the same shipment set with the standard tariff, the express tariff, and a volume-derived
chargeable-weight tariff in the same run. The next lesson writes the strategy pattern that
takes the algorithm into an object and passes it as a parameter, and measures the number of
algorithms standing side by side in a single run against the files edited when a new one is
added.
