Middleware
Some behavior doesn't belong to any one route — logging every request,
timing how long handlers take, rejecting requests without valid
credentials. You could copy that logic into every handler, but Go's HTTP
model gives you a cleaner option: middleware. Because a Handler is just an
interface, you can write a function that takes one Handler and returns
another, wrapping it with extra behavior.
The shape: func(http.Handler) http.Handler
That's the whole pattern. A middleware function accepts the handler it's
wrapping (conventionally named next) and returns a new handler that does
some work, then delegates:
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
})
}
http.HandlerFunc is doing the same job here it always does — adapting a
plain closure into something satisfying the Handler interface. The
closure captures next, so every request that flows through the returned
handler can run code before next.ServeHTTP, after it, or both.
Wrap and delegate
Most middleware follows this template: do some work, call
next.ServeHTTP(w, r), optionally do more work afterward. The timing
example above does both — it records a start time before, and logs the
elapsed duration after next returns.
Short-circuiting
Sometimes you don't want next to run at all — an auth check that rejects
the request, for instance. To block a request, write the response and
return before calling next.ServeHTTP. That's exactly what this
lesson's exercise does: check a header, and either let the request through
or stop it with a 401 and never touch next. The return is what makes
it a short-circuit rather than just an early write.
Chaining middlewares
Middlewares compose by wrapping each other, and the order you nest them in determines the order they run:
handler := Logging(RequireAPIKey(mux))
Reading from the outside in, Logging runs first — it sees every request,
even ones RequireAPIKey will reject, so you get a log line for rejected
requests too. RequireAPIKey runs next and decides whether mux ever sees
the request at all. The outermost wrapper always runs first on the way in,
and last on the way out.
Passing something down the chain
Middleware often needs to hand a value to the handlers below it — the authenticated user, a request ID. It cannot mutate the request in place, because the fields it would need to change are not meant to be written. Instead it derives a new request:
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), requestIDKey, newID())
next.ServeHTTP(w, r.WithContext(ctx)) // note: r.WithContext, not r
})
}
r.WithContext(ctx) returns a shallow copy of the request carrying the
new context. Forget it — pass the original r — and everything below sees
the old context and your value silently vanishes.
This is why the handler signature is (http.ResponseWriter, *http.Request)
and not the other way round. The request is a struct you may need to
replace, so you get a pointer and can pass a different one down. The
ResponseWriter is an interface you only ever write through, so there is
nothing to replace and no pointer to give.
Wrapping the whole mux
A *http.ServeMux is itself an http.Handler (you already relied on this
to pass it to http.ListenAndServe), so middleware doesn't need any
special integration with routing — you just wrap the mux like any other
handler:
mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthHandler)
handler := Logging(RequireAPIKey(mux))
http.ListenAndServe(":8080", handler)
Every request now passes through both layers before reaching a route, and every route gets the behavior for free — no need to touch individual handlers as you add more of them.
Why this scales
This is the same idea as decorators or filters in other languages, but Go
needs no framework to express it — just a function type and one adapter
from the standard library. Real services often stack several of these:
recovering from panics, adding request IDs, rate limiting, compressing
responses, then finally your auth gate and router. Each one stays small and
testable in isolation, exactly like the exercise below, because a
middleware function never needs anything more than the Handler it wraps
and the Handler it returns.
Practice
1. An API-Key Gate
IntermediateMiddleware is the cross-cutting layer of a web app — auth, logging, rate limiting — and in Go it's just a function: take an
http.Handler, return a new one that wraps it.Implement
RequireAPIKey(next http.Handler) http.Handler:- If the request header
X-API-Keyis exactlysecret, callnext.ServeHTTP(w, r)— let it through. - Otherwise respond
401 Unauthorizedand do not callnext.
Return
http.HandlerFunc(func(w, r) { ... })— that adapter turns your closure into anhttp.Handler. Read the header withr.Header.Get("X-API-Key"), and remember toreturnafter writing the 401 so a rejected request never reachesnext.The hidden tests wrap a handler that records whether it ran, so they check both the status code and that a blocked request truly stops here.
package main import ( "fmt" "net/http" ) // RequireAPIKey wraps next, allowing the request through only when the // X-API-Key header is exactly "secret". Middleware is just a function that // takes a handler and returns a new one. func RequireAPIKey(next http.Handler) http.Handler { // TODO: return a handler that 401s without the right X-API-Key, else calls next return next } func main() { var app http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "secret data") }) http.ListenAndServe(":8080", RequireAPIKey(app)) }Sign in to write and run your solution in the browser.
Sign in for hints and the solution.
- If the request header