skip to content

In a web framework's middleware chain, when does an element mutate the request in place versus pass a replacement to the next element?

level: seniorimportance: should knowfreq 52%

answer

  1. attach in place, override by replacement
  2. a replacement is only visible where passed
  3. built but not passed changes nothing
  4. response wrappers install before delegating
  5. in place leaks outward, replacement is scoped

basics

~20 s

Mutate in place to attach something nobody else owns, such as a correlation value. Pass a replacement when overriding what the handler reads or wrapping the response, and remember a replacement is visible only to what you delegate with.

solid answer

~40 s

An element changes what the rest of the chain sees in one of two ways. Where the request object is mutable, it can set a field or attach an attribute in place, and every holder of that object sees the change, including elements further out once control returns. Where the request is immutable, or where scoping matters, it builds a modified copy or a wrapper and **delegates with that object instead of the original**. The second form has one dominant failure mode: constructing the replacement and then passing the original, which compiles, runs, and changes nothing. Response wrapping follows the same rule but is stricter about timing, because a wrapper that is meant to observe or transform the body must be installed before delegating; afterwards the bytes have already gone to their sink.

go deeper

for a junior

Remember the mechanism: whatever object you pass to the delegation call is what the rest of the chain sees, so a change you build but do not pass has no effect.

for a middle

Explain the difference in scope. In-place mutation is visible to every holder of the object, while a replacement reaches only the elements you delegated to.

for a senior

Show that you pick per case: attach in place, override by replacement, install response wrappers before delegating, and cap any buffering you introduce.

for a principal

Set the convention for the codebase. Namespaced attribute keys, a short list of elements allowed to replace rather than attach, and a rule about buffering keep a pipeline debuggable as teams add to it.

## Two ways to change what the rest of the chain sees An element that wants the handler to see something different has two mechanisms available, and which one is even offered depends on the framework's objects. - **Mutate in place.** The request or response object is mutable, often with a general-purpose attribute bag, and the element sets a value on it. Everyone holding a reference sees the change. - **Pass a replacement.** The element builds a new object, either a copy with modifications or a wrapper that delegates to the original, and hands *that* to the delegation call. Only the sub-chain it delegates to sees the replacement. Both are ordinary consequences of the wrapping model: an element controls the arguments of one call, so whatever it passes is what the remainder receives. ## The rule that makes replacement work A replacement is visible only where it is passed. This sounds obvious and is broken constantly: ``` # wrong - the modified request never leaves this frame modified = request.with_header("x-tenant", tenant) return next(request) # right return next(request.with_header("x-tenant", tenant)) ``` The first version has no error, no warning and no effect. It is the signature bug of copy-on-write request objects, and it is worth a code-review habit of its own: whenever an element builds a modified request, check what the delegation call actually receives. ## Scope and blast radius | | Mutate in place | Pass a replacement | |---|---|---| | Who sees the change | every holder of the object, including outer elements after control returns | only the elements and handler you delegated to | | Signature failure | action at a distance; two elements quietly fight over one field | a modification built but not passed, so it is silently ignored | | Concurrency | risky once the object is shared across threads or tasks | each holder keeps its own reference | | Reversibility | none, unless you save and restore the old value yourself | automatic, since the original is untouched once the call returns | | Natural use | attaching a value nobody else writes | overriding what the handler reads; wrapping the response | ## Wrapping the response has to happen on the way in To observe or transform what the handler writes, the element must hand the remainder a response object it controls, and it must do so **before** the delegation call. Typical reasons are compressing output, enforcing a size cap, computing a checksum or digest over the body, or buffering so that a header can still be decided at the end. After the call has returned, that opportunity is gone: the bytes went wherever the handler wrote them, and the after phase can only inspect the result it was handed. Buffering is the expensive version of this technique. It gives the element complete freedom after the call, at the cost of holding the whole body in memory and destroying streaming behaviour, so it wants a size cap and a deliberate decision rather than a default. Two rules keep response wrapping honest: - The wrapper must forward everything it does not care about, or the handler's output changes in ways nobody asked for. - Anything the wrapper buffers has to be flushed by the element that installed it, normally in its after phase, or the client waits for a response that is sitting in memory. ## Choosing, in practice 1. **Attach rather than rewrite.** A correlation value, a resolved principal, or a decision another element will read are all safe in place, provided the key is namespaced so two elements cannot collide on it. 2. **Override by replacement.** Changing something the handler reads as input, such as a header, a path value or the body, is clearer as a replacement: the change has a visible scope, and nothing outside the call is affected. 3. **Never mutate after the call and expect the handler to notice.** Once the delegation call has returned, the handler has already read everything it was going to read. 4. **Respect the framework's model.** If request objects are immutable, in-place mutation is not on offer and the whole discipline collapses to *pass what you built*. If they are mutable, replacement may still be the better choice for scoping. 5. **Keep wrappers shallow.** A wrapper that reports different values for many fields makes every later debugging session harder, because what the handler sees no longer matches what arrived on the connection. ## Responses are usually the mutable case The response an element receives from the delegation call is normally mutated in place, because replacing it would mean discarding what the inner work produced. That mutation is bounded by whether anything has already been sent: a response whose first bytes are on the wire will not accept new headers, which is another reason an element that needs guaranteed control over the response installs its own wrapper before delegating instead of improvising afterwards. ## Why interviewers ask The question separates candidates who have only registered elements from those who have debugged one. The two failure modes are opposites: in-place mutation fails by affecting more than intended, and replacement fails by affecting nothing at all. Naming both, and saying which one the framework in front of you makes easy, is the answer.

  • What is the symptom of building a modified request and delegating with the original?
    Nothing at all happens. The handler sees the unmodified request, there is no error, and the element's own logs look correct because it did build the change. The fix is to make the modified object the argument of the delegation call, and a test that asserts on what the inner callable received.
  • Why must a response wrapper be installed before the delegation call rather than after?
    Because after the call the handler has already written its output to whatever sink it was given. Wrapping is how the element becomes that sink, so the substitution has to be in place before the inner work runs; afterwards only the returned result is available to inspect.
  • What makes in-place mutation risky in a chain where elements run concurrently?
    The shared object becomes shared mutable state. Two tasks touching the same request, or an element mutating while inner work reads, produce ordering-dependent behaviour that tests rarely reproduce. Replacement avoids it, because each delegation passes its own object down one path.

saying these in an interview costs you the question

  • Builds a modified request then delegates with the original
  • Expects a replacement to be visible outside the call it was passed to
  • Wraps the response after the delegation call returns
  • Mutates the request after delegating and expects the handler to see it
  • Treats buffering the whole body as a free way to stay flexible