Code Around Every Handler
Middleware is code that runs around your handlers: before the request reaches them, after the response leaves them, or instead of them. Logging, authentication, rate limiting, compression — all middleware.
The same idea travels under three names: web frameworks say middleware, gRPC says interceptor, some ecosystems say filter. One concept, three vocabularies.
In Go, middleware is a function that takes a handler and returns a wrapped handler — exactly the Decorator pattern from the design patterns group, and a stack of them behaves like a Chain of Responsibility.
A Logging Middleware
withLog wraps any handler: it prints the request coming in, lets the handler work, and prints again on the way out. The handler itself stays completely unaware.
Wrap once, log everything
The wrapped handler serves two different paths and both get logged — the logging is written once, in the wrapper, not in every handler.
Output: Go program and captured output
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
// Middleware: takes a handler, returns a wrapped handler.
func withLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("-> ", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
fmt.Println("<- done")
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Ana, Ben")
})
mux.HandleFunc("GET /notes", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "shopping, ideas")
})
server := httptest.NewServer(withLog(mux))
defer server.Close()
for _, path := range []string{"/users", "/notes"} {
resp, _ := http.Get(server.URL + path)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Println("body:", strings.TrimSpace(string(body)))
}
}
Middleware That Stops the Request
Middleware can also refuse: an auth check that finds no valid key writes 401 Unauthorized and never calls the next handler.
Chained as withLog(withAuth(handler)), the request passes through the layers in order — and the response bubbles back out through them.
An auth layer in the chain
The request without a key is stopped by the auth layer with 401 — the handler never runs. With the key, the request passes the whole chain and reaches the data.
Output: Go program and captured output
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
)
func withLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Println("->", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
// Auth middleware: can stop the request before the handler.
func withAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-API-Key") != "secret123" {
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprintln(w, "missing or wrong API key")
return
}
next.ServeHTTP(w, r)
})
}
func main() {
data := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "the secret report")
})
server := httptest.NewServer(withLog(withAuth(data)))
defer server.Close()
client := &http.Client{}
for _, key := range []string{"", "secret123"} {
req, _ := http.NewRequest(http.MethodGet, server.URL+"/report", nil)
if key != "" {
req.Header.Set("X-API-Key", key)
}
resp, _ := client.Do(req)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Println(" status:", resp.StatusCode, "-", strings.TrimSpace(string(body)))
}
}