skip to content

Dynamic Proxies and Interception

Putting a stand-in between caller and target, a synthesized type, a subtype, or rewritten compiled code, and chaining handlers around the call. Interviewers ask where the wrapping stops working.

on this pageshow

explore

questions

19

A caching wrapper speeds up calls from outside an object but never fires for a call the object makes on itself, why?

level: middleimportance: must knowfreq 66%

answer

  1. interception is a boundary technique
  2. only calls that arrive are seen
  3. which reference carries the inner call
  4. self-reference points at the target
  5. no stand-in in the path, no behaviour

basics

~20 s

Interception only runs where a call crosses from one object into another. A wrapper sees the calls made through the reference it was handed; a call the object makes on itself travels through its own self-reference and never reaches the wrapper.

solid answer

~40 s

A wrapper adds the behaviour on **its own copy** of the member and then forwards to the target, leaving the target's code untouched. Outside callers are handed the wrapper, so their calls land on it first. But when the object invokes one of its own members, the receiver is its internal self-reference, which points at the target; the wrapper is not on that path and never had a chance to run. The bug is silent, because the call still succeeds and only the caching, retry or audit step is missing. It is also path-dependent, so a test that drives the member from outside passes while the production path through the self-call does not.

code

pseudocode · 14 lines
pseudocode
object Report:
    method summary():
        rows = expensive()          // receiver is the object itself
        return format(rows)
    method expensive():
        return slowQuery()

object CachingStandIn(target):
    method summary():   return cacheOrCall("summary", target.summary)
    method expensive(): return cacheOrCall("expensive", target.expensive)

handedOut = CachingStandIn(Report())
handedOut.expensive()   // arrives at the stand-in, cached
handedOut.summary()     // summary cached; the expensive() inside it is not

go deeper

for a junior

Recall that a wrapper is a separate object placed in front of the real one, and that it can only act on calls that are actually sent to it.

for a middle

Explain which reference carries an internal call, why the wrapper is not on that path, and why the symptom is a silent no-op rather than an error.

for a senior

Diagnose it in a running system: show the behaviour applies from outside, trace the path of the failing call, and name the reference it travelled through.

for a principal

Judge whether a mechanism whose failure mode is silence is acceptable for the concern involved, and what evidence you would demand that it actually applied.

## The parts: a target, a stand-in, and the reference in between A **stand-in** (a wrapper, an interceptor) is an object that satisfies the same contract as a **target** object and holds a reference to it. Every member on the stand-in does the same three things: run some added behaviour, forward the call to the target, and run more added behaviour on the way out. Caching, retrying, timing, auditing and permission checks are all built this way, and the appeal is that the target's own source never changes. That appeal is also the source of the blind spot. The added behaviour lives **on the stand-in's copy of the member**, not on the target's. So it runs exactly once per call that *arrives at the stand-in*, and not at all for a call that arrives anywhere else. ## Why the self-call never arrives When code outside the object asks for a collaborator, the wiring hands it the stand-in, so its calls land there first and the behaviour fires. Inside the target the situation is different. When one of the target's members calls another member of the same object, the receiver of that call is the object's **own reference to itself**, and that reference points at the target, because the target is the object the code is executing in. The target was never told that a stand-in exists, and nothing rewrote the call site. So the call passes straight from one of the target's members to another. Interception is a **boundary technique**: it can only act where a call crosses from one object to another, and between two members of the same object there is no boundary to stand on. | call | receiver | added behaviour runs? | |---|---|---| | outside caller through the handed-out reference | the stand-in | yes | | member to another member of the same object | the target itself | no | | outside caller holding a reference obtained before wrapping | the target | no | | member to a collaborator that is itself wrapped | that collaborator's stand-in | yes | The last row matters: an internal call is not doomed by being internal. It is doomed by having the target itself as its receiver. A call the target makes outward, to some other object that is wrapped, is intercepted normally. ## Three properties that make it a favourite interview trap - **It fails silently.** Nothing throws and no log line appears. A cache that is never consulted is indistinguishable from a cache that always misses, and an audit step that never ran leaves exactly the evidence you would expect from a call that never happened. - **It is invisible at the declaration.** The member carries whatever marker requests the behaviour, and that marker says nothing about who is allowed to call it. Reading the member tells you nothing about which of its callers are covered. - **It is path-dependent.** The same member behaves one way through the handed-out reference and another way through the self-reference, so coverage depends on the call graph rather than on the code you are looking at. ## The same rule wearing other faces 1. **A captured callback.** If the target hands one of its own members to something else to invoke later, what was captured is the target, so every later invocation enters the target directly. 2. **A reference that escaped.** Any reference to the raw target obtained before or around the wiring keeps working and keeps bypassing the stand-in. 3. **Construction-time work.** A wrapper is placed around an object that already exists, so whatever the target did while it was being built happened before there was anything to intercept it. All three are the same sentence: only calls whose receiver is the stand-in are intercepted. ## Reading the symptom The diagnosis runs in three steps. First, confirm the behaviour applies at all by exercising the member from outside; if it does not fire there either, the problem is the wiring, not the call path. Second, follow the path the failing call actually took and look for a member of the same object calling another. Third, ask which reference carried the failing call: the handed-out one, or the object's own. A stack trace helps here, because the frames belonging to the interception layers are present on a covered path and absent on a bypassed one. ## What this is not It is not a defect in how the stand-in was produced. Whatever technique built it, a call that does not leave the target cannot be seen. And it is not an ordering problem between several stacked handlers: with a self-call, no handler ran at all, so no rearrangement of them changes anything.

  • Does the same blind spot appear when the target hands one of its own members to something else as a callback?
    Yes. What is captured is the target, not the stand-in, so every later invocation through that callback enters the target directly and the added behaviour is absent. It is the same rule on a different path: the receiver decides whether interception happens, and the receiver there is the raw object.
  • Why does a test that calls the member directly fail to catch this?
    Because the test is an outside caller: it is handed the stand-in, so it takes the covered path. The failing path only exists when the object calls itself, so the test has to drive the outer member that contains the internal call, and assert on the effect of the added behaviour - a second call served from the cache, an audit record - rather than on the return value.

A receptionist who screens every call arriving at the front desk cannot screen the calls staff make to each other from inside the building.

saying these in an interview costs you the question

  • Claims the wrapper also rewrites calls the target makes internally.
  • Says the behaviour lives on the member, so every caller of it is covered.
  • Expects a loud failure when a call bypasses the wrapper.
  • Treats a passing test that calls from outside as proof the path is covered.
  • Blames handler ordering when in fact no handler ran at all.
open as a page

Which calls does a stand-in that intercepts by overriding members silently miss, even when every caller goes through it?

level: middleimportance: must knowfreq 50%

basics

~20 s

Anything that is not a dynamically dispatched call on the instance: members no subtype may replace, members resolved from the declared type or with no receiver at all, direct field reads, and work the target did while it was being constructed.

open as a page

When a request passes through a chain of interceptors around one call, in what order do their after-the-call parts run?

level: middleimportance: must knowfreq 62%

basics

~20 s

In reverse of the order they were entered. Handlers nest rather than queue: each one calls inward and waits, so the handler entered last is the first to resume, and the outermost handler finishes last.

open as a page

Which run-time stand-in works when the caller holds a concrete class rather than a declared contract, and what must that class allow?

level: middleimportance: must knowfreq 55%

basics

~20 s

Only a stand-in generated as a subtype of that class can be substituted there; in a nominally typed language a contract-only stand-in is not of that type. The subtype needs an extensible class, overridable members and construction that is safe to repeat.

open as a page

A chat client's remote-service handle is synthesized at run time from a declared contract: what does each generated member body do?

level: middleimportance: must knowfreq 60%

basics

~20 s

Every generated member body does the same thing: it packages a description of the member that was called together with the argument list, hands both to one handler object, and returns whatever that handler returns.

open as a page

Why does rewriting a compiled unit's own method body reach calls that inserting a stand-in in front of it cannot?

level: seniorimportance: must knowfreq 55%

basics

~20 s

Rewriting edits the method body itself, so the added code runs for every call that reaches that body, whatever reference the caller holds. A stand-in only sees calls routed through it, so anything holding the original object bypasses it entirely.

open as a page

A timing interceptor sits inside the retrying interceptor in one chain and outside it in another - what differs in what each records?

level: seniorimportance: must knowfreq 55%

basics

~20 s

Position changes both how often a handler runs and what interval it covers. Inside the retrying handler, timing is entered once per attempt and records each attempt alone. Outside it, timing is entered once and records every attempt plus the waits between them.

open as a page

What must a rewritten compiled unit still satisfy for the runtime to accept it and run the edited method?

level: middleimportance: should knowfreq 40%

basics

~20 s

A rewritten unit must stay well-formed: the declared surface intact so existing callers still resolve, and the body's bookkeeping - working space, local slots, exception ranges, types on every path - matching the instructions actually present.

open as a page

What happens to the rest of an interceptor chain when one handler returns a result without passing control inward?

level: middleimportance: should knowfreq 50%

basics

~20 s

Everything nested inside that handler is skipped - the inner handlers and the target never run. The handlers outside it already ran their before-parts and still run their after-parts, and they see the substituted value as though the target had produced it.

open as a page

A registry keyed by object identity stops finding an object once it is wrapped for interception, so what broke?

level: seniorimportance: should knowfreq 38%

basics

~20 s

The stand-in is a second object, not the target. Identity comparison, identity-keyed lookups and run-time type tests answer about the object the caller is holding, which is now the wrapper rather than the thing it represents.

open as a page

A wrapped member is also called by the object on itself, so which restructurings make that internal call go through the stand-in?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Make the internal call cross an object boundary. Either move the member onto a separate collaborator the target calls through a reference, or give the target a reference to its own stand-in and have it call through that.

open as a page

When adding timing inside a compiled dependency, what differs between rewriting after the build, at load, or in a live process?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Later moments buy reach and lose record. A post-build edit ships in a reproducible artifact; a load-time hook leaves the artifact untouched but misses units already loaded; a live replacement needs no restart and leaves nothing on disk.

open as a page

One interceptor in a chain catches the exception the target threw and returns a fallback value - what do the outer handlers see?

level: seniorimportance: should knowfreq 44%

basics

~20 s

An ordinary successful return of the fallback value. Handlers outside the catching one never learn a failure occurred, so their failure paths do not fire, while handlers between it and the target saw the failure travel through and had their after-work skipped.

open as a page

Why does building a stand-in as a subtype of a concrete chat-session class run that class's constructor, and what can that break?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Creating a subtype instance creates an instance of the class it extends, so the constructor chain usually runs. Every side effect inside it - a connection opened, a registration, a counter advanced - happens a second time, once for the target and once for the stand-in.

open as a page

Your platform adds cross-cutting behaviour by wrapping, and teams keep shipping paths it silently skips, so what do you standardise?

level: principalimportance: should knowfreq 30%

basics

~20 s

Standardise around the failure mode: wrapping fails silently. Classify each concern by what a silent miss costs, keep the ones that must never be absent off a mechanism that can quietly not apply, and make the guarantee checkable instead of assumed.

open as a page

Teams want to rewrite compiled dependencies in production; what standard do you set once running code stops matching reviewed source?

level: principalimportance: should knowfreq 35%

basics

~20 s

Make every rewrite reviewable, declared and loud: defined in the build alongside code, failing the build when its target stops matching, recorded in a manifest the artifact carries, and given an owner and an expiry.

open as a page

How would you govern ordering when many teams contribute interceptors to one shared chain around every request?

level: principalimportance: should knowfreq 30%

basics

~20 s

Publish ordering as a contract, not a setting: reserved bands for admission, observability and business handlers, relative constraints rather than hand-picked numbers, declared powers for stopping a call or rewriting values, and a resolved order that is printed and asserted in tests.

open as a page

What goes wrong when a handler re-enters the same interceptor chain while that chain is still running?

level: seniorimportance: nice to knowfreq 28%

basics

~20 s

The whole chain runs a second time inside the first run. Per-call accounting is charged twice, measurements nest and sum to more than elapsed time, handler state kept in a single per-handler slot is overwritten, and an unconditional re-entry recurses without bound.

open as a page

Your platform builds stand-ins for many teams' services at run time: do you mandate a declared contract, or generate subtypes of their concrete classes?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Mandating a contract makes every stand-in buildable by one mechanism and keeps the platform out of other teams' class shapes. Generating subtypes avoids that tax but makes applicability depend on code you do not own and cannot keep stable.

open as a page