skip to content

What changes when a net/http middleware chain wraps the whole ServeMux instead of individual routes?

level: seniorimportance: nice to knowfreq 38%

answer

  1. the mux is itself just a handler
  2. one side of it has not routed yet
  3. who sees the requests that match nothing
  4. wildcard values need a matched pattern
  5. per-route wiring fails by omission

basics

~20 s

Wrapping the ServeMux puts the chain before routing, so it sees every request including ones that match nothing, but it cannot know which pattern matched. Wrapping individual routes runs after matching, with the pattern's path values available, but misses unmatched requests.

solid answer

~50 s

The mux is just another `http.Handler`, so `Chain(mux, ...)` places the whole chain **outside** routing. Those wrappers run before `ServeMux` has matched anything: they see every request, including ones that will end in a 404 and the mux's own trailing-slash redirects, which makes that the right place for anything that must cover the entire surface uniformly. The cost is that routing has not happened yet, so `r.PathValue("id")` is empty and a wrapper cannot label its output by route template, nor can a single route opt out. Registering `mux.Handle("GET /items/{id}", Chain(itemsHandler, ...))` puts wrappers **inside** routing: path values are populated and each route chooses its own layers, but unmatched requests never reach them and a new route added later silently gets no wrappers. Most services run both: a short outer chain for whole-surface concerns, and per-route chains where the route is the unit.

code

go · 9 lines
go
mux := http.NewServeMux()
mux.Handle("GET /items/{id}", Chain(itemHandler, withItemPolicy))
mux.HandleFunc("GET /items", listItems)

srv := &http.Server{
	Addr: ":8080",
	// runs before routing: also sees requests that will 404
	Handler: Chain(mux, accessLog),
}

go deeper

for a junior

Know that http.ServeMux is itself an http.Handler, so a chain can wrap the whole mux or a single registered handler, and that those are different places.

for a middle

Explain what each position knows: outside the mux nothing has been routed yet, so wildcard path values are unavailable; inside, the pattern has matched and r.PathValue works.

for a senior

Make the placement call and defend it: whole-surface concerns outside so 404s are covered, route-specific policy inside, a registration helper so nobody forgets a layer, and a test that a request to an unknown path still reaches the outer chain.

for a principal

Set the pattern once for a shared package: which concerns are non-negotiable and therefore live outside the mux, and how per-route wiring is funnelled so a new service or a new route cannot silently opt out of them.

## The mux is a handler, which is why there is a choice at all `http.ServeMux` implements `http.Handler` like anything else. Its `ServeHTTP` looks at the method and path, finds the registered pattern that matches, and calls that pattern's handler. Nothing about it is special to `net/http`'s wrapping model, so a chain can sit on either side of it: ``` srv := &http.Server{Handler: Chain(mux, outerWrappers...)} // outside routing mux.Handle("GET /items/{id}", Chain(h, innerWrappers...)) // inside routing ``` The difference is not stylistic. It changes *which requests* a wrapper sees and *what it knows* when it sees them. ## Outside the mux: everything, but blind A wrapper outside the mux runs on every request the server accepts and dispatches, which includes: - requests that match no registered pattern and will be answered with 404 by the mux's not-found handling, - requests whose method does not match any pattern registered for that path, - the mux's own trailing-slash redirect responses, - requests to routes registered after the chain was built, automatically. That completeness is the reason to be out there. A wrapper meant to produce one access-log line per request is worth nothing if the requests that 404 are invisible to it -- those are frequently the interesting ones. The same applies to anything whose value comes from being unconditional. What that position gives up is knowledge. At that point `ServeMux` has not matched anything, so: - `r.PathValue("id")` returns the empty string, because no pattern has been matched to bind wildcards from, - the wrapper knows the raw path (`/items/42`) but not the template it will match (`/items/{id}`), so grouping output by route means guessing from the raw path, - there is no way to say "except for this one route", short of the wrapper re-implementing a path check of its own, which duplicates the routing rules and drifts from them. ## Inside the mux: informed, but partial Registering a chain per route inverts every one of those properties. The wrappers run only after the mux matched, so wildcard values from the pattern are available through `r.PathValue`. Each route chooses exactly the layers it needs, which is the natural way to express a policy that genuinely differs per route. The costs are equally real. Unmatched requests never reach any per-route chain, so nothing registered this way can be relied on for whole-surface coverage. And the wiring is repeated at every registration site, which means the failure mode is omission: someone adds a route in six months, copies the neighbouring line, forgets one wrapper, and nothing complains. A shared registration helper that takes the route pattern and the handler and applies the standard chain is the usual defence -- it makes the wrappers the default rather than something each caller remembers. ## The shape most services end up with A short outer chain for the concerns that must be unconditional and route-agnostic, wrapped around the mux; plus per-route or per-group chains for policies that vary by route, applied through one helper so no registration can quietly skip them. Groups are expressed by building the group's chain once and reusing it across the routes that share it -- Go has no router-level group concept in the standard library, so the composition helper is the grouping mechanism. ## Reviewing it The questions that decide whether a chain is in the right place: does this wrapper need to see requests that match nothing? Then it belongs outside the mux. Does it need to know which pattern matched, or does it apply to only some routes? Then it belongs inside. Does it claim to cover everything but sit at a route? That is the bug -- and it is invisible until somebody asks why the 404s never appear in its output. A test that drives a request to an unregistered path through the assembled server, and asserts that the outer wrappers still emitted their lines, is the cheapest way to keep that property from eroding.

  • Why is r.PathValue empty in a wrapper that sits outside the ServeMux?
    Wildcard values come from matching a registered pattern, and outside the mux no pattern has been matched yet -- the wrapper has only the raw path. `r.PathValue("id")` therefore returns the empty string. Any wrapper that needs the matched template or its wildcards has to be registered on the route instead.
  • What is the failure mode of applying wrappers only per route?
    Omission. Every registration repeats the wiring, so a route added later can silently miss a layer, and requests matching no route never reach any per-route chain at all. Funnel registrations through one helper that applies the standard chain, so the wrappers are the default rather than something each caller has to remember.
  • How do you group routes that share a chain, given the standard library has no router groups?
    Build the group's chain once as a value and reuse it: `api := func(h http.Handler) http.Handler { return Chain(h, a, b) }`, then register `mux.Handle(pattern, api(handler))` for each member. Composition is the grouping mechanism; there is no group concept in `http.ServeMux` to configure.

saying these in an interview costs you the question

  • Expects r.PathValue to work before the mux matched
  • Puts whole-surface logging on individual routes
  • Thinks unmatched requests reach per-route wrappers
  • Re-implements path matching inside an outer wrapper
  • Assumes the mux is exempt from the chain it sits in