Skip to content
academia.sh

Lesson 13 / 30

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.

Contents

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.

// 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,
  };
}
// 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.

// 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).

// 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,
  };
};
// 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);
    },
  };
};
// 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

// 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.

// 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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close