← writing

Building a Middleware Chain in Go

Go’s net/http package ships with everything you need to build robust middleware chains, and the pattern is simple enough that reaching for a framework is rarely worth it.

At its core, middleware in Go is just a function that takes an http.Handler and returns an http.Handler. That’s the whole interface.

type Middleware func(http.Handler) http.Handler

Wrapping handlers

The canonical pattern wraps a handler, does something before and/or after calling the next handler in the chain:

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

Short-circuiting — returning early without calling next.ServeHTTP — is how you implement auth checks, rate limiting, or request validation. The middleware just writes a response and returns.

Composing a chain

A small helper makes chaining readable without nesting:

func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

The reversed iteration means the first middleware in the slice is the outermost — the first to run on a request and the last to see a response. Calling it looks like:

mux.Handle("/api/", Chain(apiHandler, Logger, Auth, RateLimiter))

Passing values through context

Middleware often needs to hand data downstream — a user ID from a JWT, a request trace ID, a parsed body. The right place for this is context.WithValue:

type ctxKey string

const userKey ctxKey = "user"

func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user, err := parseToken(r.Header.Get("Authorization"))
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), userKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Using an unexported type for the key avoids collisions with other packages doing the same thing.

The pattern scales well. Each middleware stays focused on one concern, and the chain is assembled at the routing layer where it’s easy to read and modify.