Go Practice #5 Middleware Patterns
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
#
type Middleware func(http.Handler) http.HandlerA function that — takes a handler and returns a new handler. Inside, it does work before/after calling the original handler.
The smallest example — logging #
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.HandlerFuncadapter converts a function to a Handlernext.ServeHTTP(w, r)is the actual handler invocation — logic before/after that
Applying it #
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.
handler := logger(recoverer(authMiddleware(mux)))Order: logger → recoverer → auth → mux. The inner doesn’t run first — the outer enters ServeHTTP first. Call flow:
logger.ServeHTTPstarts (records start)- →
recoverer.ServeHTTPstarts (defer recover) - →
auth.ServeHTTPstarts (auth check) - →
mux.ServeHTTP(actual routing) - ← 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.
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 #
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 #
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.
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 #
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 #
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 #
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.
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.
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:
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
#
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.
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.