← Design patterns
Composition Access, delegation, and cached copies

Proxy

A familiar door. A decision before it opens.

Your project archive holds a brand guide and an unreleased launch plan. Ari can read both. Bo can read the guide. The document viewer asks for a document and renders the result.

Now add a cache. Ari opens the launch plan, leaving a copy ready for the next request. Bo asks for it. The archive no longer needs to be contacted—but does Bo get the document?

TypeScriptGoPythonOne reading contract · across the comparison

01 / Give the caller a stand-in

The viewer still asks to read.

A direct archive reader is a reasonable starting point for trusted internal code. It accepts a document ID and returns the document or an error. Once different readers need different access, a check before each call seems like a small addition.

Then a preview endpoint appears, followed by an export action. Each caller needs the same rule. Add caching at one call site and it becomes easy to return a saved document before reaching the permission check.

A proxy is a stand-in that offers the interface a caller expects while controlling access to another object or resource. Here, both the archive and its proxy implement DocumentReader.read(request). Give the viewer the proxy as its reader. The viewer keeps its ordinary read call; the proxy decides whether to refuse, return a cached copy, or ask the archive.

We will build a protection proxy, then add caching. The archive holds three local records, and reader identities and grants are simulated, so every permission check and archive read stays visible.

01 / CALLER → PROXY

Document viewer

Asks its reader for a document. Handles success or a returned error.

02 / PROXY → ARCHIVE

Archive proxy

Checks current access. Reuses a successful copy or forwards an allowed miss.

03 / ARCHIVE → PROXY → CALLER

Internal archive

Owns the source records. Returns a document copy or an archive error.

02 / Follow the order

Having the document does not answer who may read it.

The proxy checks the reader’s current grant before looking in its cache. A denial ends the request there. An allowed hit returns a copy. An allowed miss reaches the archive and saves the result only when it succeeds.

The cache belongs to this proxy instance and is shared across its readers. It uses the exact document ID as its key because every authorized reader receives the same content. Ari and Bo can reuse one cached brand guide; Bo still cannot read Ari’s cached launch plan.

Four launch-plan reads through one proxy, in order
RequestCurrent accessResultNew archive reads
Ari opens itAllowedFetch and cache the plan1
Ari opens it againAllowedCached plan0
Bo opens itDeniedForbidden, despite the cache0
Revoke Ari’s grant; Ari tries againDeniedForbidden, despite the cache0

Four permission checks. One cache hit. One archive read. Revoking access changes the next read even though the cached plan remains. It cannot erase a copy Ari already received.

“Same interface” gives the caller a stable way to ask and handle the answer. It does not promise identical timing, freshness, or permission outcomes to the raw archive. Those behaviors are part of the reader’s contract too.

03 / Read the shape

One reader contract, two implementations.

Start with the basic protection wrapper: deny or forward. In the practical form, follow the permission check past the cache lookup to the origin read. The call-site view shows the trusted owner wiring these objects together and passing the proxy to showDocument.

That caller prints either the document body or the returned error. It needs no “is this a proxy?” branch. Complete files include the records, mutable access rules, archive, and example invocation. Every comparison program runs the four requests from the table.

The archive and protection proxy satisfy one DocumentReader contract. The proxy either refuses the read or forwards the same request. Supporting records and policy types appear in the complete file.

TypeScriptReading
archive.ts
export interface DocumentReader {
	read(request: ReadRequest): ReadResult;
}
export class ProtectionProxy implements DocumentReader {
	#origin: DocumentReader;
	#rules: AccessRules;
	constructor(origin: DocumentReader, rules: AccessRules) {
		this.#origin = origin;
		this.#rules = rules;
	}
	read(request: ReadRequest): ReadResult {
		if (!this.#rules.canRead(request.actor, request.id)) return { ok: false, error: 'forbidden' };
		return this.#origin.read(request);
	}
}
GoAlongside
archive.go
type DocumentReader interface {
	Read(ReadRequest) (Document, error)
}
type ProtectionProxy struct {
	origin DocumentReader
	rules  *AccessRules
}

func (p *ProtectionProxy) Read(request ReadRequest) (Document, error) {
	if !p.rules.CanRead(request.Actor, request.ID) {
		return Document{}, errors.New("forbidden")
	}
	return p.origin.Read(request)
}
Reading the TypeScript

implements DocumentReader makes the shared shape explicit. TypeScript interfaces are structural: a compatible read method satisfies this contract. The ok field distinguishes a document from an expected failure before the caller reads the corresponding fields.

A cache entry is a document object, so an empty body still counts as a hit. Object spreads copy the record into and out of the cache. All fields here are strings; this shallow copy is sufficient for these records. Adding a nested mutable object would require revisiting that boundary.

The # fields, such as #cache and #rules, stay inside their instance. Type annotations and private fields do not turn an in-browser object into an authorization service; the real boundary still belongs on the server.

Reading the Go

DocumentReader is satisfied implicitly by a matching Read method. The caller accepts the interface; constructors return pointers to concrete implementations. Pointer receivers let repeated reads update the same cache and counters.

The map lookup’s ok reports presence independently of the body’s contents. Returning a Document copies its string fields, while the second return value carries an error. These maps and counters are used sequentially, without goroutine synchronization.

Reading the PythonProtocols, dataclasses, and explicit copies

Python’s Protocol describes the reader contract without a shared base class. The result is a small union of success and failure objects, so the caller checks which answer it received before reading a document or error.

The archive and cache copy the mutable document dataclass at their boundaries. The proxy returns an authorized copy, while a failed origin result is returned unchanged and is not retained. Python’s annotations do not add synchronization to the maps.

04 / Predict, change, observe

Warm the cache. Change the reader.

Watch the recorded reads and revocation, or step through their results. In Try it, start with Ari and the launch plan. Predict a document and read it twice. Then select Bo without clearing the cache. Predict again. The path below the controls shows which step handled the latest request; the counters accumulate until reset.

Switch the proxy order to the deliberately broken cache-first version. This starts a fresh archive. Let Ari warm the launch plan, then let Bo ask for it. A cold denial can look correct while a warm cache quietly skips the check.

Proxy

A warm copy still needs permission.

ari / launchCurrent grant: allowed
  1. 1 / PermissionNot calledGrant present
  2. 2 / CacheNot called0 retained
  3. 3 / ArchiveNot calledOnline
Retained copies
Empty
Returned to callerNo current response

Read to inspect the result.

Checks 0Hits 0Reads 0
01/ 05
An empty cache

Permission comes first.

Ari can read the launch plan. No copy has been cached yet.

Reduced motion: choose a scene to see its completed state.

Read this scene

Ari can read the launch plan. No copy has been cached yet.

Current grant: allowed. Cached: none. Permission checks 0, cache hits 0, archive reads 0. Result: no current response.

Try revocation too: warm a document as Ari, then uncheck Ari’s grant and read again. The guarded proxy denies the new request. The broken version keeps returning its copy. Changing permission need not delete content to stop future reads through a correctly ordered gate.

Finally, warm an allowed document and take the archive offline. The guarded cache can still serve that authorized read. Clear the cache and the next allowed read fails; a later attempt reaches the archive again because failures are not cached. The empty note is a successful document with an empty body, so it is cached normally.

The browser lab runs the TypeScript implementation above, plus a browser-only broken ordering for contrast; the other language files show the same reader contract natively.

05 / Review a decision

Trace the path that can return the document.

A check is useful only when every protected return path goes through it. Review the ordering, the references given to the caller, and the responsibility behind the wrapper.

Ari has read the launch plan. Bo has no grant, but the plan is now cached. What should run first on Bo’s read?

06 / Give it a real job

The owner chooses which reader leaves the room.

Imagine a server endpoint behind a document viewer’s Open action. Authentication supplies the actor; the endpoint supplies the requested document ID. A trusted composition root—the code that assembles the service—owns the internal archive, current policy, and retained proxy. The endpoint receives the proxy as its reader.

The actor string in our example represents that trusted request context. Accepting an unchecked actor from a browser would let the caller choose whose permissions to use. Putting the archive data and the check in the browser also gives the user both sides of the gate.

If the preview or export path receives the raw archive, it can skip the proxy entirely. Keep that reader internal and ensure every protected entry point applies the policy. OWASP’s authorization guidance calls for checking authorization on every request and denying by default. Our placement before every cache return is an application of that rule. Read the authorization guidance.

Now the launch plan changes. Our cached body stays stale until clear(); that method drops copies without changing grants or historical counters. Content freshness and permission freshness are separate decisions. This example reads the current in-memory grant every time, but a real policy service can have its own stale data and failure modes.

Before this becomes a shared service

Define the cache’s identity and lifetime. Document ID is enough for our three shared records. If a response varies by workspace, revision, locale, or reader, the key or cache scope must include every value that changes the content. Keep the permission check even with a more specific key. Add capacity and freshness policies before retaining an open-ended archive.

Decide how failures age. This proxy retains only successful reads. Offline and missing-document results pass through and are retried on later allowed requests. That is easy to explain, but repeated failures can repeatedly load a real service. Any negative caching or retry policy needs an explicit lifetime and must preserve access checks.

Make concurrent behavior explicit. Two asynchronous misses could duplicate a fetch; mutable maps need protection in concurrent native code. A grant could change while a network read is in flight. Sharing pending work and deciding when authorization takes effect need their own design.

Count decisions as well as reads. The counters record exact calls. A real service may record allowed and denied decisions independently of origin reads. An authorized cache hit is still a read worth accounting for.

Build UIs?$state hands you a Proxy and React holds the object itself, and one day an integration will need a view of your state it cannot change.

Where it already is in your components

In Svelte you write todo.done = true and the row updates. In React you never write that; you give the setter a new object. Both habits come from whether a stand-in sits between you and your state. Svelte’s docs say that “If $state is used with an array or a simple object, the result is a deeply reactive state proxy.” It is a JavaScript Proxy offering your object’s own interface. Your assignment, or a todos.push(…), reaches its set trap, which records the value and updates whatever on the page read it.

Its limits follow from where it stands. $state hands you the proxy, not your object: in a Svelte 5.57 mount, $state(original) === original was false, and the development build warned that “proxies and the values they proxy have different identities.” Writing to original after the page rendered changed nothing on screen, and reading through the proxy still gave the old value. The docs note the other direction: “When you update properties of proxies, the original object is not mutated.” An object you push is wrapped the same way, so writing to the todo you pushed changed nothing either. Class instances get no stand-in at all. “Class instances are not proxied.” $state(new Reminder(…)) returned the same instance, and setting its plain done field left the row saying To do; declared as done = $state(false), it updated.

React puts nothing in front of your object. Its guide to updating objects in state says React “does not need to hijack their properties, always wrap them into Proxies, or do other work at initialization,” and so, “without using the state setting function, React has no idea that object has changed.” Calling the setter with the object you mutated does not help: React will “ignore your update if the next state is equal to the previous state,” as determined by Object.is. In a React 19.1.0 development mount, toggling by mutation and calling setTodos(todos) rendered the list zero times across three clicks, with or without StrictMode, and the next unrelated update showed the mutated value. Replacing the array with a mapped copy rendered it. Copying state out of a proxy is Prototype’s question.

When you have to own it

Now your calendar app gets integrations. A travel-time widget, written by a vendor or another team, renders on the event page. It needs the start time and location. It has no business with the attendees’ email addresses or your private notes, and it should never move the meeting. Hand it a stand-in: a Proxy over the event that answers reads for the fields its permissions list and refuses every write. That is this lesson’s protection proxy, with a property read as the request.

Every way of looking has to get the same answer, and each is its own trap. In Chromium 153, get and ownKeys traps hid notes from view.notes, Object.keys, JSON.stringify, and spread, and a has trap made 'notes' in view false. Without a getOwnPropertyDescriptor trap, though, asking for the descriptor returned the notes. Without traps for them, Object.defineProperty, delete, and Object.setPrototypeOf went straight through to the event. A permitted array came back as the app’s own array, so the widget could push into it. The helper below traps each of those and wraps nested values too.

Its target is an empty object, not the event, because the engine checks a proxy’s answers against its target. Over frozen state, which Immer’s produce returns, a get trap that hid a field threw “'get' on proxy: property 'notes' is a read-only and non-configurable data property on the proxy target but the proxy did not return its actual value”, and filtering ownKeys threw as well.

plugin-view.ts
// A read-only view of app state for code you did not write, such as a calendar
// integration. It reads only the fields its permissions list, and every write throws.
export type PluginView<T, K extends keyof T> = { readonly [P in K]: T[P] };

const nestedViews = new WeakMap<object, object>();

export function createPluginView<T extends object, K extends keyof T & string>(
	state: T,
	fields: readonly K[]
): PluginView<T, K> {
	const allowed = new Set<PropertyKey>(fields);
	return readOnlyView(state, (key) => allowed.has(key)) as PluginView<T, K>;
}

function readOnlyView(source: object, allowed: (key: PropertyKey) => boolean): object {
	// The target is a fresh empty object or array, never the state. With frozen state
	// as the target, a trap that hides a field breaks a Proxy invariant and throws.
	const target = Array.isArray(source) ? [] : {};
	const visible = (key: PropertyKey) => allowed(key) && Object.hasOwn(source, key);
	const refuse = (): never => {
		throw new TypeError('Integrations can read this event but not change it.');
	};
	return new Proxy(target, {
		get(target, key, receiver) {
			if (visible(key)) return wrap(Reflect.get(source, key));
			// Hidden fields read as undefined; toString and map still come from the prototype.
			return key in target ? Reflect.get(target, key, receiver) : undefined;
		},
		has: (target, key) => visible(key) || key in target,
		// Object.keys, JSON.stringify, and spread all ask these two traps.
		ownKeys: () => Reflect.ownKeys(source).filter(visible),
		getOwnPropertyDescriptor(target, key) {
			if (!visible(key)) return undefined;
			const own = Reflect.getOwnPropertyDescriptor(source, key);
			if (!own) return undefined;
			// An array target has its own non-configurable length, and the report has to
			// agree with it. Everything else is reported as configurable, as the target allows.
			const fixed = Reflect.getOwnPropertyDescriptor(target, key);
			return fixed
				? { ...fixed, value: own.value }
				: { ...own, value: wrap(own.value), configurable: true };
		},
		// Without these traps a write would land on the empty target: the state would not
		// change, the write would seem to succeed, and the next read of that field would throw.
		set: refuse,
		defineProperty: refuse,
		deleteProperty: refuse,
		setPrototypeOf: refuse,
		preventExtensions: refuse
	});
}

// An allowed field can hold an array or object. Hand out a read-only view of that
// too, the same one each time, so an integration cannot push into the app's array.
function wrap(value: unknown): unknown {
	if (typeof value !== 'object' || value === null) return value;
	let view = nestedViews.get(value);
	if (!view) {
		view = readOnlyView(value, () => true);
		nestedViews.set(value, view);
	}
	return view;
}

In Svelte the view layers over $state without losing tracking. The event prop is the parent’s state proxy, and the widget’s reads go through the view into it: in a Svelte 5.57 mount, moving the meeting updated the widget, and a $derived view followed a replaced event where a view built once kept showing the old one. Wrap the state proxy, as the docs advise: “If you need to use your own proxy handlers in a state proxy, you should wrap the object after wrapping it in $state.” Wrapped the other way, a write through the state proxy never reached the inner set trap. React treats the view as any other object, so useMemo keyed on event builds a new one for each new event; keyed on nothing, the widget kept the first event. A write during render threw to the nearest error boundary in React and to <svelte:boundary> in Svelte.

A Proxy in the page shapes an interface; it is not a security boundary. Code running in the same page can reach around it. In Chromium, a widget that replaced Reflect.get before its next read was handed the raw event, attendees and all, by the helper’s own trap. It could also read the notes from the page. For code you do not trust, run it in a sandboxed iframe and post it only the permitted fields: a frame with sandbox="allow-scripts" that read parent.document got a SecurityError. It is the rule from this section again. A check protects only what the caller cannot reach without it, and whatever the page holds, the server must still decide what reaches the page.

A todo list toggled by mutating the todo. Svelte’s $state hands you a proxy that sees the write, while a class instance it does not wrap stays on To do; React holds the object itself, and setting the same array renders nothing.

ReactAlready in your code
TodoList.tsx
import { useState } from 'react';
import { initialTodos, type Todo } from './components/todos';

export function TodoList() {
	const [todos, setTodos] = useState(initialTodos);

	function toggle(todo: Todo) {
		// State holds the object itself, with no proxy in between, so nothing
		// notices this write.
		todo.done = !todo.done;
		// The same array as the last render. Object.is finds no change, React skips
		// the render, and the row keeps saying To do. Replace instead:
		// setTodos(todos.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)));
		setTodos(todos);
	}

	return (
		<ul>
			{todos.map((todo) => (
				<li key={todo.id}>
					<button type="button" onClick={() => toggle(todo)}>
						{todo.done ? 'Done' : 'To do'}
					</button>{' '}
					{todo.text}
				</li>
			))}
		</ul>
	);
}

07 / Recognize the family

The stand-in can do more than guard a document.

A protection proxy checks access. A caching proxy reuses a result. A remote proxy represents something reached elsewhere. A virtual proxy delays creating or loading its subject. These responsibilities can combine, and their order can change the result—as our cached launch plan demonstrates.

A reverse proxy forwards HTTP work.

Go’s httputil.ReverseProxy is an HTTP handler that forwards an incoming request to another server and sends its response back to the client. The client addresses an intermediary that reaches the origin on its behalf, with no document cache or permission policy of its own.

Read the ReverseProxy contract ↗

JavaScript also has a Proxy constructor.

JavaScript’s built-in Proxy can intercept operations such as reading or assigning a property. That mechanism lets an object stand in for a target at the language level. Our example uses ordinary methods and classes instead.

Read about JavaScript Proxy ↗

08 / Make the call

Choose the wrapper for the decision it owns.

Consider a proxy when callers should keep a stable interface while access, fetching, or representation needs a central owner. It is especially useful when a new caller should inherit those rules by receiving the same reader.

Keep a direct reader for trusted internal work when there is no access or indirection policy to centralize. For a service whose authorization already belongs in established middleware, use that boundary consistently. Adding a class is not what makes the checks complete.

Similar shapes, different reasons to introduce them
PressureUseful connection
Represent a resource while controlling how it is reachedProxy: preserve the reader contract and mediate access.
Make an incompatible API fit the callerAdapter: translate to the interface the caller needs.
Attach another responsibility around existing behaviorDecorator: compose additions through a compatible interface.
Offer a simpler entry point to several operationsFacade: organize a subsystem behind a focused surface.
Postpone setup until first demandLazy initialization: a timing decision a virtual proxy can use.

Proxy and Decorator can look almost identical in code, and a wrapper can serve more than one intent. Describe the actual contract: this reader stands in for the archive and decides whether the document may be returned. The name helps explain that decision; it does not replace it. One operational tie-breaker: a proxy may refuse, defer, or substitute the operation, so Bo asks for the launch plan and never receives it. A decorator always performs the operation it wraps and adds around it.

09 / Take the idea with you

Explain why “already here” still means “forbidden.”

Try it without saying “proxy”: the viewer gets a reader that checks current access before returning anything. A saved copy avoids an archive call. It never supplies permission.

Now transfer the idea. A preview becomes personalized for each workspace. Which cache key or owner must change? A teammate adds an export endpoint. Which reader should it receive? Those two changes test whether you can identify both the content boundary and the access boundary.

Connections to follow nextRelated lessons

Adapter changes how a caller speaks to something. Facade gives a workflow a smaller surface. Lazy initialization changes when setup happens. Proxy keeps the familiar request and puts a decision on its path.