skip to content

When composing a slice of func(http.Handler) http.Handler wrappers in a loop, which one ends up outermost?

level: middleimportance: should knowfreq 60%

answer

  1. a(b(h)) puts a on the outside
  2. each application nests the previous result
  3. last applied wins the outer position
  4. loop backwards to keep slice order
  5. unwinding is LIFO, like nested defers

basics

~20 s

Whichever wrapper is applied last ends up outermost, because each application nests the previous result inside a new handler. To make the first slice element outermost, iterate the slice backwards, starting from the final handler.

solid answer

~40 s

Composition nests: `a(b(h))` puts `a` outside `b`. So in a helper that loops over a slice of wrappers reassigning `h = mw(h)`, the **last** wrapper applied is the outermost one. A forward `for _, mw := range mws` loop therefore makes `mws[len-1]` outermost, which is almost never what a reader expects; iterating backwards with `for i := len(mws) - 1; i >= 0; i--` makes `mws[0]` outermost, so the chain reads in the order it executes. On the way in, the outermost wrapper's pre-`next` code runs first; on the way out, the post-`next` code unwinds innermost-first, exactly like nested defers. Because the ordering bug is silent -- everything still compiles and responds -- I pin it with a test that asserts the sequence of log lines each layer emits.

code

go · 10 lines
go
type Middleware func(http.Handler) http.Handler

// Chain applies mws so that mws[0] is outermost: it sees the request
// first and its post-next code runs last. A forward loop would reverse it.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

go deeper

for a junior

Know that a(b(h)) runs a first and that wrapping is just nesting. Being able to read a two-line chain and say which layer sees the request first is enough at this level.

for a middle

Explain the loop mechanics: reassigning h = mw(h) means the last application ends up outermost, so a backwards loop preserves slice order. Give the way-in and way-out orders and say why the way-out is LIFO.

for a senior

Show how you keep the order honest across a shared package: a test asserting the exact entry and exit sequence, doc comments stating relational ordering requirements, and awareness that a reversed chain fails silently rather than loudly.

for a principal

Own the convention itself. One documented direction for the shared Chain helper, ordering stated as constraints rather than positions, and a test template services inherit, is what stops every team relearning this by outage.

## Composition is nesting, and nesting decides order A Go middleware chain is built by repeatedly applying wrappers to a handler. Written out by hand: ``` h := logging(auditing(final)) ``` `logging` wraps `auditing`, which wraps `final`. When a request arrives, `logging`'s handler runs first, calls `next.ServeHTTP`, which is `auditing`'s handler, which calls `next.ServeHTTP`, which is `final`. Two orders fall out of this and they are mirror images: - **On the way in**, outermost to innermost: logging, auditing, final. - **On the way out**, innermost to outermost: final returns, then auditing's post-`next` code, then logging's post-`next` code. The way-out order is LIFO, the same unwinding a stack of deferred calls gives you, and for the same reason: each layer's post-`next` code is on a stack frame that cannot complete until the frames beneath it have. ## The loop, and the direction that bites A shared middleware package almost always offers a variadic helper rather than making callers nest calls by hand: ``` func Chain(h http.Handler, mws ...Middleware) http.Handler ``` The body is a two-line loop, and the direction of that loop is the whole question. Start from the final handler and reassign: - **Backwards** (`for i := len(mws) - 1; i >= 0; i--`) applies the last element first and the first element last, so `mws[0]` finishes as the outermost layer. Written this way, `Chain(h, a, b, c)` executes `a`, then `b`, then `c`, then `h` -- the call reads top to bottom in execution order. - **Forwards** (`for _, mw := range mws`) applies `mws[0]` first, so it is buried innermost and `mws[len-1]` becomes outermost. `Chain(h, a, b, c)` then executes `c`, `b`, `a`, `h`. Both are correct code. Only one matches what a reader assumes when they read the argument list. Whichever direction a shared package picks, the choice must be documented on the helper, because callers in other services cannot see the loop. ## Why the ordering bug is easy to ship Getting the direction wrong is not a compile error, not a panic, and usually not a failed request. The chain still serves traffic; the layers simply run in the wrong sequence. The symptoms are second-order and show up later: a layer that was supposed to see every request only sees the ones that got past an earlier layer, a value one layer attaches is read by a layer that now runs before it, a measurement that was meant to cover the whole chain now covers only part of it. In review, the reasonable questions are: which element of this slice is outermost, does the loop match the doc comment, and is there a test that would notice if someone flipped it. ## Pin the order with a test The cheap, decisive test builds a chain of marker wrappers that append to a slice (or emit a log line) on entry and on exit, drives one request through it with `httptest`, and asserts the exact sequence. For two markers `a` and `b` composed so `a` is outermost, the expected sequence is `enter a, enter b, exit b, exit a` -- and that single assertion catches a reversed loop, a wrapper accidentally dropped from the slice, and a wrapper that forgets to call `next.ServeHTTP`. ## Wrappers whose order actually matters Order is not always significant, but where it is, it is significant in one direction only, and the reasons are worth naming explicitly: - A layer that must observe **every** request has to sit outside any layer that can reject a request, because a rejecting layer returns without calling `next.ServeHTTP` and everything inside it is skipped. - A layer that **reads** a value another layer attached to the request must sit inside the layer that attached it. - A layer that measures the whole chain must sit outside the layers it is measuring; move it inwards and it silently measures less. A useful discipline in a shared package is to make each wrapper state its ordering requirement in its doc comment, in terms of what must be outside or inside it, rather than as an absolute position number. Absolute positions rot the moment a service adds one wrapper of its own. ## Build the chain once The chain is assembled at start-up and the resulting `http.Handler` is what you hand to `http.Server`. Rebuilding it inside a handler, per request, does the same nesting work again on every request for no benefit, and it also means the wrappers' one-time setup runs per request. Compose once, store the result, serve from it.

  • In what order does the code after each next.ServeHTTP call run?
    Innermost first. Each layer's post-`next` statements sit on a stack frame that cannot finish until the frames it called have returned, so the unwinding is LIFO: the layer nearest the final handler logs or measures first, the outermost layer last. It is the same shape as a stack of deferred calls.
  • How would a reviewer catch a chain whose order was reversed?
    Not by reading the responses -- a reversed chain still serves traffic. Build marker wrappers that record entry and exit, run one request through `httptest`, and assert the exact sequence. That one test catches a flipped loop, a dropped wrapper, and a wrapper that forgot to call `next.ServeHTTP`.
  • Should a wrapper document an absolute position in the chain?
    No -- state the requirement relationally: what must be outside it and what must be inside it. Absolute positions break the moment a service inserts a wrapper of its own, whereas "must sit outside anything that can reject a request" stays true regardless of how long the chain grows.

Packing boxes: the box you put on last is the one the courier opens first, and the one you sealed first is unwrapped last.

saying these in an interview costs you the question

  • Assumes a forward range loop keeps slice order
  • Thinks composition order only affects readability
  • Believes post-next code unwinds outermost first
  • Rebuilds the chain inside a handler per request
  • Documents a wrapper by absolute chain position