Appearance
9.4.10 — Proxy
What the original Gang of Four book says: Provide a surrogate or placeholder for another object to control access to it.
What that means when you are actually writing code: Put a stand-in in front of the real object — with the same interface — that decides whether, when, and how the real call actually happens: create it lazily, cache its results, check permission, rate-limit it, or reach it across a network.
A proxy is a stand-in. It looks exactly like the object it stands in front of — the same methods, the same return types, so calling code cannot tell the difference — and it sits in the middle of every call so it can decide something before letting the call through.
What it decides varies, and this page covers each case. It might decide when: do not build the expensive object until somebody actually uses it. It might decide whether: this caller is not allowed to do that. It might decide again: I already have the answer from last time, here it is without asking. Or it might decide where: the real object lives on another machine, so turn this call into a network request.
The same idea works at every scale. A stand-in object in your process is one end of it. At the other end, the program sitting in front of your web servers deciding which one gets the next request is doing the same job for a whole datacentre.
1. The story: the object that cost a database query to look at
An Order has a customer and a list of lineItems, each with a product. You load an order:
typescript
const order = await orders.findById(id); // one query? or seven?If loading an order eagerly pulls the customer, every line item, and every product, then a single order view fires a cascade of joins and queries — most of which the current screen does not need. The order-summary page shows the total and the customer name; it never touches products. You are paying for data you will not use, on every request.
The opposite extreme — making callers load everything explicitly — is worse: now the summary page, the detail page, the invoice, and the packing slip each hand-assemble a different subset, the loading logic scatters, and adding a field means finding every caller. The domain object stops being an object and becomes a bag of manual queries.
What you want is for order.customer to look like a plain property access but only hit the database if and when it is actually read — and then remember the result. That is a lazy-loading proxy: a stand-in for the Customer that holds the id, presents the Customer interface, and fetches the real one on first access.
typescript
order.total; // no query — already loaded
order.customer.name; // ← first access triggers ONE query, then caches; looks like a field
order.customer.email; // no query — the proxy already has itThe client code reads like plain object navigation. The proxy is what makes "looks like a property, behaves like a controlled fetch" true.
2. How you arrive at the pattern
Step 1 — Start naive. The client uses the real object directly. This is correct when the object is cheap to create, always needed, always local, and freely accessible.
Step 2 — Wait for the force. One of those assumptions fails, and access to the real object has to be controlled:
- it is expensive to create or hold (large, slow to load, or remote) and not always needed → a virtual/lazy proxy;
- its results are worth caching to avoid repeated work → a caching proxy;
- access must be authorized first → a protection proxy;
- it lives on another machine and calls must be sent over the wire → a remote proxy;
- calls must be throttled, logged, or counted → a smart/monitoring proxy.
Step 3 — Draw the line between what varies and what stays fixed.
| What varies | when, whether, and under what conditions the real object is reached |
| What stays fixed | the interface — the proxy is indistinguishable from the real object to the client |
The fixed, identical interface is the definition of Proxy: the client must not be able to tell it is talking to a stand-in. That substitutability (9.3.7, Liskov) is why the client code stays clean — it never learns that access is controlled.
Step 4 — Decide when the choice is made. Composition: the proxy holds a reference to (or the means to create) the real subject and delegates under its own policy. Often the client receives the proxy thinking it is the real thing (via a factory or the framework).
Step 5 — Name the pattern and say what it costs. The name is Proxy. The costs are specific and biting: lazy loading hides latency and failure — a plain-looking order.customer.name can trigger a slow query or throw, at a line that does not look like I/O, and in a loop it becomes the N+1 query catastrophe (section 6); caching proxies raise staleness and invalidation questions; protection proxies scattered as the only access control are easy to bypass; and every proxy adds indirection that can obscure where work actually happens. The benefit is that the client stays simple while a real access concern is handled transparently.
3. The mental model
In one sentence: a proxy is a personal assistant who answers the executive's phone — same number, same voice on the line — but decides which calls get through, schedules them, remembers frequent answers, and connects long-distance calls, all without the caller knowing they never reached the executive directly.
The analogy that makes it stick — the ATM. An ATM is a proxy for the bank vault: it presents the interface "withdraw money," but it does not hand you the vault — it checks your PIN (protection), it may cache your balance (caching), and it talks to the bank's core over a network (remote). You interact with the ATM exactly as if it were the bank, and it controls, on every operation, whether and how you actually reach the money.
When to reach for it. The signals: "only load this when it's actually used" (lazy) · "cache the results of this expensive/remote call" (caching) · "check permission before every call to this" (protection) · "this object is actually on another service" (remote) · "rate-limit / log / count access to this" (smart). The umbrella phrasing: "same object, but access to it needs to be controlled."
The single distinction interviewers press — Proxy versus Decorator. Structurally identical: a wrapper with the same interface as what it holds. The difference is intent and relationship to the real object. A Decorator adds behaviour the client deliberately composed (retry, logging), and the client knows it is stacking layers. A Proxy controls access to a subject, and the client typically does not know a proxy is there at all — it thinks it has the real object; the proxy often manages the subject's lifecycle (creating it lazily) rather than just wrapping an already-existing one. Decorator = "I chose to add this behaviour." Proxy = "access to the real thing is being managed for me, transparently." (9.4.8 section 11 states the same boundary from the other side.)
4. Structure
The participants: the Subject (the shared interface), the RealSubject (the real object doing the work), the Proxy (implements Subject, holds or creates the RealSubject, applies the access policy, delegates when appropriate), and the Client (uses Subject, unaware which one it holds).
5. The implementation, by proxy kind
5.1 Virtual (lazy-loading) proxy — the most common
typescript
interface Customer { readonly id: CustomerId; name: string; email: string; }
class CustomerProxy implements Customer { // same interface as the real Customer
#real: Customer | null = null; // (1) not loaded yet
constructor(readonly id: CustomerId, private readonly load: (id: CustomerId) => Customer) {}
#resolve(): Customer { // (2) load-once, on first real access
return (this.#real ??= this.load(this.id)); // ??= caches after the first fetch
}
get name() { return this.#resolve().name; } // (3) property access triggers the load
get email() { return this.#resolve().email; }
}
const order = { id, total, customer: new CustomerProxy(customerId, loadCustomer) };
order.total; // no load
order.customer.name; // ← first access: one load, cached; reads like a plain property(1) The proxy starts holding only the id and a way to load — not the object. This is the "expensive thing not yet created" state that makes it virtual/lazy.
(2) #resolve() is the gate: the real object is created on first access and remembered. Every proxy kind has a method like this where the policy lives.
(3) The accessors delegate through the gate, so customer.name looks like a field read but is a controlled fetch. This is the power and the danger — the syntax hides the I/O (section 6).
5.2 Protection proxy — access control on every call
typescript
class SecureDocumentProxy implements Document {
constructor(private readonly real: Document, private readonly user: User) {}
read(): string {
if (!this.user.can("read", this.real.id)) throw new ForbiddenError(); // (1) gate BEFORE delegating
return this.real.read();
}
write(content: string) {
if (!this.user.can("write", this.real.id)) throw new ForbiddenError();
this.real.write(content);
}
}(1) The authorization check runs before every delegated call, transparently to the client. The caveat (section 10): a protection proxy is a convenience, not a security boundary, if the real object is reachable another way — the check has to be the only path, or it is bypassable.
5.3 Caching proxy — control access by short-circuiting
typescript
class CachingUserRepo implements UserRepo {
#cache = new Map<UserId, User>();
constructor(private readonly real: UserRepo) {}
async findById(id: UserId): Promise<User> {
const hit = this.#cache.get(id);
if (hit) return hit; // (1) return WITHOUT reaching the real repo
const user = await this.real.findById(id);
this.#cache.set(id, user);
return user;
}
}(1) A cache hit never touches the real subject — the proxy controls access by answering itself. (Framed as "I added caching behaviour" this is a Decorator; framed as "access to the expensive backend is mediated" it is a caching Proxy — section 11 and 9.4.8 section 11 both note this is a framing call.)
5.4 Remote proxy — a local stand-in for a remote object
typescript
class InventoryClient implements Inventory { // looks local; is remote
constructor(private readonly http: HttpClient, private readonly baseUrl: string) {}
async check(sku: Sku): Promise<number> {
const res = await this.http.get(`${this.baseUrl}/stock/${sku}`); // send the call over the wire
return (await res.json()).available; // unpack the result
}
}Every API client, gRPC stub, and RPC binding is a remote proxy: it presents a local interface and hides marshalling, transport, retries, and network errors. Recognising your InventoryClient as a Proxy is the point — and it explains why remote calls need timeouts and error handling that local calls do not (10.9).
5.5 JavaScript's built-in Proxy — the language feature
typescript
const audited = new Proxy(target, {
get(obj, prop, receiver) { // trap: intercept EVERY property read
log.debug(`read ${String(prop)}`);
return Reflect.get(obj, prop, receiver); // delegate to the real object
},
set(obj, prop, value) {
if (prop === "id") throw new Error("id is immutable"); // control access to writes
return Reflect.set(obj, prop, value);
},
});JavaScript ships the Proxy pattern as a runtime primitive: new Proxy(target, handler) steps in on fundamental operations (get, set, has, delete, apply…) through traps. This is what powers the "the screen updates by itself when I change this object" behaviour in modern front-end frameworks. They wrap your plain object in a Proxy, notice every read and every write, and re-render the parts of the page that read the thing you just changed. This is the pattern promoted to a language feature (9.4.1's "patterns dissolve into features"), and Reflect is the standard way a trap delegates to the real behaviour.
6. The deep dive that gets people fired — N+1 and hidden latency
The lazy-loading proxy's transparency is its trap. Because order.customer.name looks like a field access, nothing warns you that it is a database query — and inside a loop, the disaster is silent:
typescript
const orders = await orders.findRecent(100); // 1 query
for (const o of orders) {
console.log(o.customer.name); // ← 100 more queries. One per iteration.
}
// Total: 101 queries. This is the N+1 query problem, and it is the #1 ORM performance bug.The code reads innocently; the proxy turned a loop into 101 round trips. The fix is not to remove the proxy but to defeat its laziness where you know you need the data — eager loading or batching:
typescript
const orders = await orders.findRecent(100, { include: ["customer"] }); // 1 or 2 queries total
for (const o of orders) console.log(o.customer.name); // no queries — pre-loadedThe lessons to carry, because this is a career-shaping bug class:
Lazy proxies hide two things every engineer must stay aware of: latency and failure. A plain-looking access can be slow or can throw. Treat any property that might be a proxy as potential I/O.
In a loop, lazy loading is a landmine. When you are going to iterate and touch a lazy relation, eager-load or batch it first (include, a JOIN, DataLoader-style batching).
This is the "proxy's dark side" referenced across the book (10.11, 9.7.30): the same transparency that makes the client clean makes the cost invisible. Know your ORM's default (lazy versus eager) and override it deliberately.
7. Variants
| Kind | Controls access by… | Standard use |
|---|---|---|
| Virtual / lazy | deferring creation until first use | ORM relations, heavy objects, images |
| Caching | short-circuiting with stored results | repositories, remote-call memoization |
| Protection | authorizing before delegating | permission checks, read-only wrappers |
| Remote | sending calls over a network | API clients, gRPC stubs, RPC |
| Smart / monitoring | logging, counting, rate-limiting, ref-counting | telemetry, quotas, resource management |
| Firewall / reverse proxy | filtering and routing at the network edge | nginx, service-mesh sidecars, WAF |
| Copy-on-write | deferring a copy until first write | 9.4.5's COW; large-object sharing |
One object, several proxy jobs at once. Real proxies often combine kinds — an ORM relation is virtual (lazy) and caching (loads once); an ATM is protection + caching + remote. That is fine: "Proxy" names the role (controlling access), and a given proxy may control it in several ways.
8. Where you already use it
| What you have used | What the stand-in does |
|---|---|
JavaScript's built-in new Proxy(target, handler) | runs your code on every property read and write |
| An API client object whose methods are network calls | looks local, is remote |
| A database library that loads a related record only when you read it | delays the query until it is truly needed |
Object.freeze(obj) | a view of the object that refuses writes |
| A caching layer in front of a slow call | answers from memory when it can, forwards when it cannot |
The second row is the one worth sitting with, because it is where the pattern is most invisible. When you write await api.getUser(id), that looks exactly like calling a method on an object you own. It is not. The object is a stand-in whose getUser builds an HTTP request, sends it, waits, and turns the reply back into a user. The call site was written to look like a local one on purpose, and that convenience is also the trap: this call can time out, fail halfway, or take a full second, and none of that is visible in the way it is written. That is the leaky abstraction problem from 9.2.3 in its most common form.
9. Ways to get it wrong
N+1 / hidden latency. Lazy access in a loop means a query per iteration (section 6).
The fix: eager-load or batch when you will iterate; know your ORM's default.
A bypassable protection proxy. If the real subject is reachable without going through the proxy, the "security" is decorative.
The fix: make the proxy the only path, and enforce authorization at a real trust boundary too — a protection proxy is defence in depth, not the boundary itself.
A stale caching proxy. No TTL or invalidation means the client reads outdated data forever.
The fix: an invalidation policy; treat it as the cache design it is.
Leaking the difference. A proxy that throws different errors, has different timing that breaks assumptions, or exposes proxy-specific methods is no longer transparent.
The fix: an identical interface and contract; surprises defeat the pattern.
A Proxy where a Decorator was meant (or vice versa). Calling a retry wrapper a "proxy", or an access-control gate a "decorator", muddies review.
The fix: control access → Proxy; add composed behaviour → Decorator (section 11).
Over-proxying. Wrapping cheap local objects in lazy proxies adds indirection and hides nothing worth hiding.
The fix: proxy only genuinely expensive, remote, or protected access.
Serialization surprises. A lazy proxy serialized (JSON, structured clone) may capture an unloaded or partial state, or trigger loads during serialization.
The fix: resolve deliberately before serializing; never let serialization walk lazy relations.
10. Proxy compared with its neighbours
| Compared with | The difference | Choose Proxy when |
|---|---|---|
| Decorator | same interface; Proxy controls access (often creates/manages the subject, client unaware); Decorator adds composed behaviour (client aware) | the concern is access, not enhancement |
| Adapter | Adapter changes the interface; Proxy keeps it identical | the interface is fine; access needs control |
| Facade | Facade fronts many subsystems with a new simpler interface; Proxy fronts one object with the same interface | one object, same interface, controlled access |
| Remote proxy vs client library | a client library is a remote proxy | you're calling a remote object as if local |
The Decorator/Proxy line, said cleanly: both wrap one object and keep its interface. Ask who knows, and what is the wrapper for? If the client deliberately composed the wrapper to add behaviour it wanted (retry, logging, metrics) → Decorator. If the wrapper exists to control access to the real object — deciding whether, when, and for whom the real call happens, often creating the object lazily, with the client believing it holds the real thing → Proxy. Structure cannot decide it; intent and the client's awareness do.
11. Interview calibration
The 45-second answer, in the order you would say it:
Proxy is a stand-in with the same interface as the real object that controls access to it — the client can't tell the difference. The kinds are the useful taxonomy: virtual/lazy (create on first use — ORM relations), caching (short-circuit with stored results), protection (authorize before delegating), and remote (marshal calls over the wire — every API client is one).
The mechanism is a gate method where the policy lives, and delegation only happens when the policy allows. The classic trap is the lazy proxy hiding latency:
order.customer.namelooks like a field but fires a query, and in a loop that's the N+1 problem — the fix is eager loading, not removing the proxy. Versus Decorator, which looks identical structurally: Decorator adds behaviour the client composed; Proxy controls access, usually transparently, often managing the real object's lifecycle.
Follow-up questions, with the seed of each answer:
- "Proxy versus Decorator?" — Same interface; Proxy controls access (client unaware, often creates the subject), Decorator adds composed behaviour (client aware).
- "What's the N+1 problem?" — A lazy relation accessed in a loop means one query per iteration; fix it with eager loading or batching.
- "Is an API client a Proxy?" — Yes, a remote proxy — a local interface hiding marshalling and transport, which is why it needs timeouts and error handling.
- "Is a protection proxy real security?" — Only if it's the sole access path; otherwise it's bypassable — enforce at a real boundary too.
- "Where does the language give you Proxy?" — JS
new Proxy(target, handler)with traps — the basis of Vue reactivity and immer.
Recall
- Proxy = a same-interface stand-in that controls access to a real object, so transparently that the client cannot tell. The mechanism is a gate method where the policy lives; the real subject is reached only when the policy allows. Fixed part: the interface (substitutable, Liskov). Varying part: whether, when, and how the real call happens.
- The kinds are the taxonomy: virtual/lazy (create on first use — ORM relations, heavy objects), caching (short-circuit with stored results), protection (authorize before delegating), remote (send over the network — every API client or gRPC stub), smart (log/count/rate-limit). One proxy often combines kinds.
- The career-bug: lazy proxies hide latency and failure.
order.customer.namelooks like a field read but fires a query; in a loop that is the N+1 problem (101 queries for 100 rows). Fix by eager-loading or batching where you will iterate — not by deleting the proxy. Know your ORM's lazy-versus-eager default and override it deliberately. This is the "proxy's dark side". - Language and scale forms: JavaScript
new Proxy(target, handler)with traps (get/set/…) delegating viaReflect— this is how a framework can notice that you read or wrote a field and re-render because of it. At scale the same shape appears as a reverse proxy: a server that sits in front of your servers and routes, filters, rate-limits and terminates encryption on their behalf — the same pattern from one object to one datacenter. - Versus Decorator (identical structure): Decorator adds behaviour the client composed (client aware); Proxy controls access (client unaware, often creates/manages the subject). Misuse: N+1, a bypassable protection proxy (not a real boundary alone), a stale caching proxy, leaking the difference (different errors/methods), over-proxying cheap objects.
Self-test: Give the four proxy kinds and what each controls. Why does order.customer.name in a loop cause N+1, and what fixes it? What single question separates Proxy from Decorator? Why is a protection proxy not by itself a security boundary? What are JavaScript Proxy traps, and name one library built on them.
Quiz Bank
FoundationalDerive Proxy and give its four main kinds with what each controls.
Naive: the client uses the real object directly — correct when it is cheap, always needed, local, and freely accessible.
The force: one of those assumptions fails and access must be controlled — the object is expensive or not always needed, its results are worth caching, access must be authorized, or it lives on another machine.
The varies/fixed line: what varies is when, whether, and how the real object is reached; what is fixed is the interface — the proxy must be indistinguishable from the real object, so the client stays clean and unaware.
The pattern: a stand-in implementing the same interface, holding (or able to create) the real subject, with a gate method where the access policy lives; delegation happens only when the policy permits.
The four kinds. First, virtual/lazy — controls access by deferring creation until first use (ORM relations, heavy objects, images below the fold); the gate creates-and-caches on first access. Second, caching — controls access by short-circuiting with a stored result, so a hit never reaches the real subject (repositories, memoized remote calls). Third, protection — controls access by authorizing before delegating (permission checks, read-only wrappers). Fourth, remote — controls access by marshalling the call over a network (API clients, gRPC stubs), hiding transport and serialization. A fifth, smart/monitoring, adds logging, counting, rate-limiting, or reference-counting.
The cost: lazy proxies hide latency and failure (the N+1 trap), caching proxies raise staleness and invalidation, protection proxies are bypassable if not the sole path, and all add indirection that can obscure where work happens. The gain: the client code stays simple while a real access concern is handled transparently.
FoundationalExplain the N+1 query problem as a consequence of the lazy-loading proxy, and how to fix it without removing the proxy.
The N+1 problem is the lazy-loading proxy's transparency turned into a performance catastrophe. A lazy proxy makes order.customer look like a plain property, but the first read of a property on it fires a database query to load the real Customer. That is fine for a single order. But const orders = await findRecent(100) loads 100 orders in one query (the "1"), and then a loop doing for (const o of orders) console.log(o.customer.name) triggers one query per iteration (the "N") — 101 queries total, silently, because the code reads like innocent object navigation with no visible I/O. It is the single most common ORM performance bug precisely because the proxy hid the cost: nothing at the call site looks like a query.
The fix is not to remove the proxy — laziness is correct for the many cases where you do not touch the relation, and eager-loading everything would reintroduce the over-fetching the proxy exists to avoid. The fix is to defeat the laziness deliberately where you know you will need the data: eager-load with an include/JOIN (findRecent(100, { include: ["customer"] }) → 1–2 queries), or batch the loads (DataLoader-style: collect the ids accessed in the loop and issue one WHERE id IN (...) query).
The general discipline: treat any property that might be a proxy as potential I/O, and whenever you will iterate over a collection and touch a lazy relation, pre-load or batch that relation before the loop. Know your ORM's default (Hibernate defaults to lazy, some ORMs to eager) and override it per query based on what the code path actually reads — the proxy gives you the option of laziness; using it in a loop without batching is the misuse.
AppliedProxy and Decorator are structurally identical. Give the discriminating questions and classify three borderline cases.
Both are wrappers implementing the same interface as the object they hold, so structure cannot distinguish them — only intent and relationship to the real object can.
Question one: what is the wrapper for? Adding behaviour the client wanted (retry, logging, metrics, timing) → Decorator. Controlling access to the real object (lazy creation, authorization, caching-as-gatekeeping, remoting, throttling) → Proxy. Question two: does the client know? A Decorator is deliberately composed by the client, which knows it is stacking layers. A Proxy is usually transparent — the client thinks it holds the real object and does not know access is mediated. Question three: who manages the real object's lifecycle? A Decorator wraps an already-existing object handed to it. A Proxy often creates and owns the real subject (a lazy proxy creates it on first use).
Three borderline cases. (a) A caching wrapper over a repository. Framed as "I chose to add caching as one of several behaviours" → Decorator; framed as "access to the expensive backend is mediated and short-circuited" → caching Proxy. State the framing; both are defensible, and the honest answer names it. (b) A rate-limiting wrapper. If it exists to protect the downstream by controlling access → Proxy (a smart proxy); if it is one composed concern in a resilience stack the caller assembled → Decorator. Again framing, leaning Proxy because "control access to a resource" is its purpose. (c) A logging wrapper. Almost always Decorator — it adds observability the client composed and does not control access at all; nothing about it gates or manages the real object.
The meta-answer for an interview: do not treat it as a trick with one right label — say "structurally identical, so I classify by intent: this controls access, so I call it a Proxy" or "this adds a behaviour I composed, so it's a Decorator," and note the genuinely ambiguous cases (caching, rate-limiting) where the framing decides. That demonstrates you understand both patterns are about intent, not shape.
InterviewHow is Proxy the same pattern from one object to one datacenter? Trace it from an ORM relation to a service mesh.
Proxy's essence — a same-interface stand-in that controls access to a real subject — recurs at every scale, and tracing it is a strong way to show you see patterns as scale-independent.
At object scale: an ORM lazy relation is a virtual proxy — order.customer stands in for a Customer, presenting its interface but deferring the database load until first access and caching it after. At process scale: JavaScript's new Proxy(target, handler) is a proxy over any object's fundamental operations, stepping in on every get and set — the basis of Vue 3 reactivity and immer, where the proxy controls access by reacting to it.
At service scale: an API client or gRPC stub is a remote proxy — a local object presenting the remote service's interface, hiding marshalling, transport, retries, and network errors, so callers write inventory.check(sku) as if it were local (which is exactly why it needs timeouts and error handling a local call does not — 10.9).
At the network edge: a reverse proxy (nginx, Envoy) stands in for a fleet of backends — clients connect to it thinking it is the service, and it controls access by routing, load-balancing, rate-limiting, caching, and terminating TLS (11.1).
At the mesh: a sidecar proxy (Envoy in Istio or Linkerd) sits beside every service instance and mediates all its inbound and outbound traffic — applying mTLS, retries, circuit breaking, and telemetry transparently, so the application code makes a plain local-looking call and the proxy controls how it actually crosses the network.
Each is the same three participants — client, proxy, real subject — with the same fixed identical interface and the same job (control access, transparently). The insight to state: learning Proxy at class scale is learning distributed-infrastructure vocabulary for free — a sidecar is an ORM lazy relation's cousin, and the transparency that causes N+1 at object scale is the same transparency that lets a service mesh add mTLS without changing application code. The trade-off flips with scale, though: at object scale transparency's danger is hidden latency (N+1); at mesh scale transparency's benefit is that cross-cutting network concerns need no application changes — the same property, opposite value, because the controlled access is exactly what you want at the edge and exactly what surprises you in a loop.
StaffDesign the data-access layer for a service where domain objects have rich relationships (an Order has a customer, line items, shipments, and a payment), most requests need only a subset, some code paths iterate over thousands of orders, and a recent incident was caused by an N+1 that took the database down. Use Proxy deliberately and say how you prevent the incident recurring.
The incident is the lazy-loading proxy's transparency weaponised at scale, and the design must keep laziness's benefit (don't over-fetch) while making its danger (hidden N+1) impossible to ship silently.
The layer. Domain objects expose relationships that are lazy proxies by default — order.customer, order.lineItems, order.shipments each stand in for the real data and load on first access — because most requests genuinely need only a subset, and eager-loading everything would fire the join cascade that motivates the pattern (section 1). So Proxy is the right default. But defaults are not enough — the incident proves that — so three deliberate mechanisms.
First, explicit fetch plans at the query boundary. Every repository method takes an explicit include specification (findRecent(100, { include: ["customer", "lineItems"] })) that eager-loads exactly the relations that code path will touch, in one or two batched queries. The lazy proxy remains for unplanned access, but planned access is eager — which means a well-written path never triggers per-row loads.
Second, batching for the genuinely dynamic cases — a DataLoader-style layer that, when lazy access does happen across many objects, collects the ids within a tick and issues one WHERE id IN (...) query instead of N. This turns an accidental N+1 into an N-to-1 automatically, a safety net beneath the fetch plans.
Third, make the N+1 loud in development and blocked in production — this is the part that actually prevents recurrence. Instrument the data layer to count queries per request; in development and CI, a request that fires more than a threshold of queries fails the test (a query-count assertion on hot paths), so an N+1 cannot merge. In production, emit db.queries_per_request as a metric and alert on outliers, and optionally enforce a per-request query budget that trips a circuit breaker before a runaway loop takes the database down — the specific failure that caused the incident. Additionally, for the paths that iterate thousands of orders, forbid lazy relation access entirely via a "strict mode" the ORM offers (Prisma and Hibernate can be configured to throw on lazy load rather than silently query), so those paths must declare their includes or fail fast — converting the silent catastrophe into a loud, local error at development time.
Why Proxy and not "just always eager": always-eager reintroduces the over-fetching that makes a summary page load a payment record it never shows, multiplying database load across every request to prevent a bug on some — a worse trade. The right posture is lazy by default, eager by explicit plan, batched as a safety net, and query-budget-enforced so laziness can never again silently become N+1.
The organizational fix, not just the technical one: the incident happened because a lazy access looked like a field read and no gate caught the query explosion; the durable fix is that the query count is now a tested, budgeted, alerted number — the transparency that hid the cost is undone by making the cost measurable and enforced.
The sentence for the postmortem: the proxy was not the bug — the missing budget was; we keep lazy loading for the cases it serves, add explicit fetch plans and batching for the cases it endangers, and make per-request query count a tested and alerted budget so the N+1 that took us down becomes a failed test instead of an outage.
Flashcards
FlashProxy in one line
A same-interface stand-in that controls access to a real object — transparently. The client can't tell it's not the real thing.
FlashThe four proxy kinds
Virtual/lazy (create on first use), caching (short-circuit), protection (authorize first), remote (marshal over the network). Smart = log/count/rate-limit.
FlashN+1 problem
A lazy relation accessed in a loop → one query per row (101 for 100 orders). Fix: eager-load or batch (include, JOIN, DataLoader) — not remove the proxy.
FlashProxy vs Decorator
Same interface. Proxy controls access (client unaware, often creates the subject); Decorator adds behaviour the client composed (client aware).
FlashJS Proxy
new Proxy(target, handler) with traps (get/set/has/…) delegating via Reflect. Powers Vue 3 reactivity, MobX, immer. The pattern as a language feature.
FlashProxy at scale
ORM relation (object) → JS Proxy (process) → API client (service) → nginx/Envoy sidecar (network). Same pattern, one object to one datacenter.
Scenario Drill
DrillDesign an access layer for a document platform where documents can be huge (100 MB+), are stored remotely (S3), require per-user permission checks, and are read far more than written. Combine the proxy kinds appropriately, decide their order, and handle the failure modes each introduces.
This is Proxy in its combined form — one access path that is simultaneously protection, caching, virtual, and remote — and the design work is layering the kinds in the right order with each one's failure mode handled.
The stack, outermost to innermost: ProtectionProxy → CachingProxy → VirtualProxy → RemoteProxy → S3.
Why that order (this is the graded part). Protection outermost: authorization must run before anything else, or a cache hit or a lazy load could serve document bytes to an unauthorized user — the exact caching-before-auth security bug from 9.4.8 section 6, here with confidential documents at stake. Caching next: an authorized read consults the cache before doing expensive work, so hot documents are served without touching S3 — but after auth, so the cache never bypasses permission. Virtual (lazy) next: a Document object exposes metadata (title, size, owner) cheaply, and the 100 MB content is a lazy proxy that loads only when actually read — critical because reads far outnumber writes and most reads (listings, permission checks, metadata) never need the bytes. Remote innermost: the actual byte fetch is a remote proxy marshalling a ranged S3 GET.
Handling each kind's failure mode — the honest part. Protection: the proxy is defence-in-depth, not the sole boundary — S3 objects also carry bucket policies and signed URLs so that even a bug bypassing the proxy cannot expose bytes (never trust a single application-layer check for confidential data, section 10).
Caching: huge documents must not be cached whole in memory (100 MB × many = OOM) — cache metadata and small documents in memory, cache large document bytes on local disk or a CDN with a size threshold and LRU eviction, and define invalidation on write (a document edit must evict or version the cached copy, or readers see stale content). Version the cache key by document version so invalidation is a no-op (immutable versions never go stale — 11.1's trick).
Virtual: the lazy content load hides latency and can throw (S3 down, object deleted) at a line that looks like a property read — so the content accessor must be an explicitly async method (await doc.content()), never a synchronous-looking getter, precisely so callers cannot forget it is I/O; and listing endpoints must never touch .content() (the N+1 lesson — iterating 1000 documents and lazily loading each is a bandwidth catastrophe, so listings use metadata only).
Remote: every S3 call needs a timeout, retry with backoff for transient errors, and a circuit breaker so an S3 outage degrades gracefully rather than hanging every request (10.9); ranged GETs let a 100 MB document stream rather than buffer (3.8.4).
Streaming versus loading — the size-driven decision: for 100 MB documents, the "load" should usually be a stream through the proxy stack to the response, not a full in-memory materialization — so the virtual proxy exposes both metadata (cheap) and a stream() (ranged, backpressured), and only small documents get a buffered content().
The design sentence: access is a proxy stack ordered auth → cache → lazy → remote, because authorization must gate everything, caching must never precede it, the 100 MB payload must load lazily and stream rather than buffer, and the remote fetch must fail fast — and each proxy kind's convenience (transparency) is paired with the control (size limits, timeouts, invalidation, a second S3-level auth boundary) that keeps its convenience from becoming its incident.