skip to content

When a web framework's request object is read-only, how can a pre-handler hook change what the handler sees?

level: middleimportance: should knowfreq 44%

answer

  1. decorate, do not mutate
  2. wrapper delegates, overrides one thing
  3. a change must be propagated
  4. override every accessor exposing the value
  5. local view, not what arrived

basics

~20 s

By wrapping rather than mutating: the hook builds a delegating object that forwards every accessor to the original except the ones it overrides, then invokes the next stage with the wrapper instead of the original request.

solid answer

~40 s

You do not edit the request, you **decorate** it. The hook builds a wrapper that holds the original, forwards every accessor to it, and overrides only what it wants to change — a normalised header value, a rewritten target, a replayable payload — and then calls the next stage with the wrapper. From there on that wrapper *is* the request, and the handler cannot tell the difference. Frameworks differ in how strict they are: some expose a request you can set values on in place, others make every change produce a new request object. The recurring bug is the same in both styles — the hook builds the modified request and then invokes the next stage with the original, so nothing downstream ever sees the change.

code

pseudocode · 4 lines
pseudocode
handle(request, next):
    resolved = lookupTenant(request.header("tenant"))
    wrapped  = wrapRequest(request, override = { header("tenant"): resolved })
    return next(wrapped)        // passing `request` here would discard the change

go deeper

for a junior

Remember that you change what the next stage sees, not what arrived. The pattern is to build a modified copy or wrapper and pass that along instead of the request you were given.

for a middle

Explain the delegating wrapper: it forwards everything it does not override, and it only matters if it is the object handed to the next stage. Name both propagation and partial-override failures.

for a senior

Keep the overridden surface small and consistent, and know the limits: wrapping cannot re-open a consumed payload, cannot alter upstream records, and does not outlive the exchange.

for a principal

Decide how much rewriting is allowed in shared hooks at all. Every silent rewrite makes the request a handler reads differ from the one the edge recorded, which is a debugging cost paid by everyone.

## Why the request behaves as read-only The request object is shared: a chain of hooks, the binding step, the handler and often an error stage all hold the same reference, and in some execution models those stages do not all run on the same thread. A value that any of them could change underneath the others is hard to reason about, so frameworks push you towards a discipline where a stage receives exactly what its caller handed it. Some enforce that literally, by exposing no setters at all; others allow changes but treat them as a smell. There is a second reason: some of what the request exposes is not stored data at all. The payload is a stream, and connection facts are properties of the transport. "Setting" them has no meaning; the only thing you can change is **what the next stage is shown**. ## Decorating instead of mutating The general mechanism is a **wrapper**: an object that holds the original request, forwards every accessor to it, and overrides only what it wants to change. To the stage that receives it, the wrapper *is* the request — there is no way to tell, and no reason to care. Typical uses: - normalising or injecting a header value that later stages read; - rewriting the request target before route resolution; - presenting a captured, replayable payload instead of the one-shot stream; - stripping a value that must not reach application code. ## The styles frameworks offer | Style | How you express a change | How it becomes visible | Typical risk | |---|---|---|---| | Mutable request | set the value in place | immediately, to everyone holding the request | invisible action at a distance | | Copy on change | a change returns a **new** request | only to stages given the new object | forgetting to pass it on | | Explicit wrapper | build a delegating object | only to stages given the wrapper | partial overrides | The important point for an interview is that the last two behave identically in the one way that matters: a change is worth nothing until it is **propagated**. ## The bug that eats the change The recurring failure is mechanical. A hook builds the modified request and then invokes the next stage with the original, because the original is the variable that was already in scope and the code still compiles and still runs. Nothing downstream sees the change, no error is raised, and the symptom is "my hook does not work". When someone reports that a value they set never arrives, two causes cover almost every case: 1. the modified request was never handed on; or 2. the later stage read through an accessor the wrapper did not override. ## Partial overrides and split views A wrapper that overrides the single-value header accessor but leaves the multi-value accessor delegating to the original produces an object that answers two different questions inconsistently. Anything that enumerates all headers, or reads the same name through a different route, sees the old value. Two rules keep this out of trouble: - override **every** accessor that can surface the value you changed, not just the one your own code happens to call; - prefer overriding at the narrowest point — one name, one accessor family — so the surface you must keep consistent stays small. ## What wrapping does not do - It does not change **what the client sent**. The original message is unchanged; anything that recorded it before your hook ran still shows the original. - It does not reach **upstream**. A proxy, an access log written at the edge, or a component that ran earlier in the chain is out of reach. - It does not un-consume a stream. If the payload has already been read, a wrapper can only replay bytes it captured; it cannot recover bytes nobody kept. - It does not survive the request. The wrapper exists for this exchange and is discarded with it. ## How to talk about it A complete answer says: requests are effectively immutable, so you decorate rather than mutate; the decorated object must be passed to the next stage or the change is invisible; overrides must be consistent across accessors that expose the same value; and the change is a **local view**, not a rewrite of what arrived.

  • A hook overrides a header on a wrapper, yet a later component still reads the old value. What are the two usual causes?
    Either the wrapper was never handed on and the next stage was invoked with the original request, or the later component reads through an accessor the wrapper did not override — the multi-value form, or a bulk view of all fields that still delegates to the original. Overrides have to cover every accessor that can surface the value.
  • Why do frameworks express a request change as a new object rather than a setter?
    Because the request is shared across hooks, binding, the handler and sometimes an error stage, occasionally on different threads. A value nobody can change underfoot is far easier to reason about: each stage sees exactly what its caller passed. The price is that a change is invisible unless the caller propagates the new object.

saying these in an interview costs you the question

  • Thinks setting a header on the request changes what the client actually sent
  • Builds a modified request and then calls the next stage with the original
  • Assumes every framework lets the request be mutated in place
  • Overrides one accessor and leaves the multi-value form showing the old value
  • Believes wrapping also corrects what upstream components already logged