net/http and the Middleware Pattern

Strip any Go web framework far enough and you hit net/http. Once http.Handler and ServeHTTP click, you can see what a framework adds on top, and what it hides.

http.Handler and ServeHTTP

The whole net/http server abstraction is a single interface:

type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

Anything with a ServeHTTP method is a Handler — http.ServeMux, third-party frameworks, your own handlers, all of them. The common shortcut is http.HandlerFunc: a function type carrying a ServeHTTP method, so a plain function can be a Handler without a struct:

type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r) // the method just calls itself
}

“A method on a function type” is the Go idiom the whole middleware pattern rests on.

Middleware is a Handler wrapping a Handler

Middleware is no magic; it’s a function that takes a Handler and returns one:

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 %v", r.Method, r.URL.Path, time.Since(start))
    })
}

The signature func(http.Handler) http.Handler is the entire pattern. It injects behaviour around the call to next — one wrap, one responsibility.

Chaining with closures

To stack several, wrap one inside another. A closure-based helper:

func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h) // wrap in reverse so the first listed runs outermost
    }
    return h
}

handler := Chain(mux, Logging, Auth) // a request hits Logging, then Auth

It’s an onion: the outermost layer runs first. A framework (gortex included) just generalises this and bolts routing and a context onto it.

defer and recover

A panic in a handler takes down that request’s goroutine by default. defer + recover turns it into a 500 so one request can’t crash the server:

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

recover only works when called inside a deferred function. defer runs on function exit — including panic unwinding — so it’s perfectly placed to catch it.

From Effective Go: Functions, defer

The middleware pattern feeds on Go treating functions as first-class values: pass them, return them, store them in a slice. Effective Go’s Functions also covers multiple return values and named results.

defer is the cleanup idiom Effective Go singles out: schedule “close the file, unlock the mutex, finish up” at the function’s exit, written next to where you opened the resource so no early return can skip it. Two things to remember: multiple defers run LIFO, and arguments are evaluated at the defer statement, not when the call finally runs.

Takeaways

  • net/http’s core is one interface: http.Handler / ServeHTTP, and every framework is built on it.
  • Middleware is just func(http.Handler) http.Handler — one wrap, one behaviour.
  • Chain with closures; the order is an onion, outermost first.
  • defer + recover turns a panic into a 500 — don’t let one request topple the server.
  • Next up, A2 (Struct Tags and reflect) lays the groundwork for declarative routing.

Further reading: the series goes on to dissect the gortex framework built on these ideas — source at yshengliao/gortex.


Outline by Sheng, drafted with Claude · Go 1.23 (latest release at the time) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.