Go Practice #5 Middleware Patterns

6 min read

If #4 DB Integration covered what lives inside, this post is about the outside again. Common processing between request and response.

Go’s middleware is — essentially the same as middleware in other languages, but distinctly not a separate language concept — expressed entirely through the http.Handler interface. Simple — and powerful for it.

The middleware shape — Handler → Handler #

middleware signature
type Middleware func(http.Handler) http.Handler

A function that — takes a handler and returns a new handler. Inside, it does work before/after calling the original handler.

The smallest example — logging #

logging middleware
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))
	})
}

Key points:

  • http.HandlerFunc adapter converts a function to a Handler
  • next.ServeHTTP(w, r) is the actual handler invocation — logic before/after that

Applying it #

registration
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", helloHandler)

handler := logger(mux)    // wrap the whole mux with logging

http.ListenAndServe(":8080", handler)

mux itself is an http.Handler — wrapping with middleware is natural.

Chaining #

Multiple middlewares — like function composition.

chain
handler := logger(recoverer(authMiddleware(mux)))

Order: logger → recoverer → auth → mux. The inner doesn’t run first — the outer enters ServeHTTP first. Call flow:

  1. logger.ServeHTTP starts (records start)
  2. recoverer.ServeHTTP starts (defer recover)
  3. auth.ServeHTTP starts (auth check)
  4. mux.ServeHTTP (actual routing)
  5. ← unwinds back through (logging, etc.)

Because it’s function composition — order matters. Usually outer to inner: log → recover → auth → routing.

Chain helper #

Hand-nesting hurts readability. One helper to flatten it.

chain function
type Middleware func(http.Handler) http.Handler

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

handler := chain(mux, logger, recoverer, authMiddleware)

The front of the list is the outermost layer — it runs first.

Standard middleware collection #

1) Recoverer — panic recovery #

Recoverer
func recoverer(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				log.Printf("panic: %v\n%s", err, debug.Stack())
				http.Error(w, "internal error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

When a handler panics — catch it so the server doesn’t die, and respond 500. One of the outermost middlewares that wraps every handler.

2) Request ID #

request ID
type ctxKey string

const reqIDKey ctxKey = "reqID"

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = uuid.New().String()
		}
		w.Header().Set("X-Request-ID", id)
		ctx := context.WithValue(r.Context(), reqIDKey, id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func RequestIDFrom(ctx context.Context) string {
	if v, ok := ctx.Value(reqIDKey).(string); ok {
		return v
	}
	return ""
}

In distributed systems — the standard for tracking a single request. Stash it in the context — and include it in logs.

The most common case where ctx.Value from Intermediate #5 fits.

3) Capturing response status — better access logs #

The default ResponseWriter — doesn’t remember the response status. To include status/size in logs, a small wrapper.

status capture
type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code
	r.ResponseWriter.WriteHeader(code)
}

func (r *statusRecorder) Write(b []byte) (int, error) {
	if r.status == 0 {
		r.status = http.StatusOK
	}
	n, err := r.ResponseWriter.Write(b)
	r.bytes += n
	return n, err
}

func accessLog(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		rec := &statusRecorder{ResponseWriter: w}
		start := time.Now()
		next.ServeHTTP(rec, r)
		log.Printf("%s %s %d %dB %s",
			r.Method, r.URL.Path, rec.status, rec.bytes, time.Since(start))
	})
}

This pattern — exists identically inside frameworks like chi or gin. Once you’ve written one, that code becomes familiar too.

4) Auth #

auth middleware
type userKey struct{}

func authMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		user, err := verifyToken(token)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		ctx := context.WithValue(r.Context(), userKey{}, user)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func UserFrom(ctx context.Context) (User, bool) {
	u, ok := ctx.Value(userKey{}).(User)
	return u, ok
}

Use a non-exported type as the ctx key. Won’t collide with a same-named string key from another package.

5) CORS #

simple CORS
func cors(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}

		next.ServeHTTP(w, r)
	})
}

Production use adds Origin verification, credentials handling, and more. The rs/cors package covers these cases and stays close to the spec.

6) Rate limit #

simple rate limit
import "golang.org/x/time/rate"

func limit(next http.Handler) http.Handler {
	limiter := rate.NewLimiter(10, 30)    // 10 req/sec, burst 30
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !limiter.Allow() {
			http.Error(w, "too many", http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Server-wide. To limit per user — a per-IP/user-ID limiter map. For high traffic, consider a Redis-based distributed limiter.

Per-route middleware — a limit of standard ServeMux #

Standard ServeMux — has no helpers for slotting middleware per route. Wrap by hand.

apply to individual handlers
mux.HandleFunc("GET /admin", authMiddleware(http.HandlerFunc(adminHandler)).ServeHTTP)

As routes grow, the boilerplate grates — a place where a router like chi’s r.Use becomes natural.

chi's per-route
r.Group(func(r chi.Router) {
	r.Use(authMiddleware)
	r.Get("/admin", adminHandler)
	r.Get("/me", meHandler)
})

The same router-choice tradeoff as in #2.

Standard pattern collection #

A typical server’s middleware stack:

real server config
handler := chain(mux,
	requestID,           // 1) attach request ID
	accessLog,           // 2) access log
	recoverer,           // 3) panic recovery
	cors,                // 4) CORS headers
	limit,               // 5) rate limit
	// auth lives per-route
)

For order — putting recoverer inside would be nice, but then you can’t catch panics from middlewares above it. log → recover → others is typical.

Pitfall — not passing r.WithContext #

common pitfall
func mw(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := context.WithValue(r.Context(), key, val)
		next.ServeHTTP(w, r)        // ✗ new ctx not passed
	})
}

You must build a new Request with r.WithContext(ctx) for the context value to propagate.

correctly
next.ServeHTTP(w, r.WithContext(ctx))

Pitfall — forgetting to call next.ServeHTTP #

In conditional auth — on auth failure, send a response and return. On success, call next.ServeHTTP. Confusing the two paths — a bug where the response is sent twice or not at all.

Good middleware design #

  • Single responsibility — one middleware, one job
  • Make order intent explicit — note it in comments
  • Minimize shared state — pass through ctx when possible
  • Express errors as responses — middleware itself shouldn’t panic
  • Test the middleware too — quick checks with httptest

Wrap-up #

What we covered:

  • Middleware = func(http.Handler) http.Handler
  • Logger, recoverer, request ID, auth, CORS, rate limit — standard building blocks
  • Chain helper — flat registration
  • Order — outer runs first; usually log → recover → others
  • Don’t forget r.WithContext
  • Capture response status/size with status recorder
  • Standard mux is weak on per-route middleware — chi smooths it out

In the next post (#6 Testing and Deployment) — testing handlers with httptest, Docker multistage builds, and the standard patterns for shipping a small server to production.

X