Middleware & Decorator Patterns
Middleware in Go wraps an http.Handler with another http.Handler to add cross-cutting behavior: logging, authentication, recovery, and metrics.
It is the practical form of the decorator pattern in a language without inheritance.
Summary
Each middleware receives the next handler, returns a new handler, and decides whether to run logic before, after, or around the inner call.
The net/http server invokes the outermost handler; middleware nests inward until the route handler runs.
The same composition model appears in chi, gin, echo, and gRPC interceptors with framework-specific registration helpers.
Recipe
Quick-reference recipe card - copy-paste ready.
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("handled", "path", r.URL.Path, "ms", time.Since(start).Milliseconds())
})
}When to reach for this:
- Cross-cutting concerns shared across many routes
- Authn/z, request IDs, panic recovery, tracing propagation
- Response wrapping (capturing status code and bytes written)
Working Example
package main
import (
"context"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type ctxKey int
const userKey ctxKey = 1
func withUser(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
}
ctx := context.WithValue(r.Context(), userKey, "demo-user")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func greet(w http.ResponseWriter, r *http.Request) {
user, _ := r.Context().Value(userKey).(string)
w.Write([]byte("hello " + user))
}
func main() {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Use(withUser)
r.Get("/greet", greet)
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}What this demonstrates:
r.Useregisters middleware for routes registered on that router- Auth middleware short-circuits with
http.Errorbefore callingnext - Request-scoped user id flows through
context.Context - chi middleware composes with stdlib handlers
Deep Dive
How It Works
Request -->
RequestID middleware
--> Recoverer middleware
--> withUser middleware
--> greet handler
<-- withUser (after next returns)
<-- Recoverer
<-- RequestID
Response <--- Inbound: outer to inner; outbound: inner to outer (like onion layers)
- Middleware must call
next.ServeHTTPunless it fully handles the request - Wrapping
ResponseWritercaptures status and body size for metrics
ResponseWriter Wrapping
type statusWriter struct {
http.ResponseWriter
code int
}
func (w *statusWriter) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}- Embed
http.ResponseWriterto delegate most methods - Override
WriteHeaderandWriteto observe behavior - Use
http.ResponseController(Go 1.20+) for flush/hijack when needed
Framework Notes
| Framework | Registration | Handler type |
|---|---|---|
| net/http | Manual nesting | http.Handler |
| chi | r.Use(mw) | http.Handler |
| gin | engine.Use(mw) | gin.HandlerFunc |
| echo | e.Use(mw) | echo.MiddlewareFunc |
| gRPC | grpc.ChainUnaryInterceptor | interceptor func |
Go Notes
- Prefer unexported typed context keys over string keys to avoid collisions
- Do not store large objects in context - pass ids and load in handlers
- Keep middleware free of database and business rules beyond auth/logging
Gotchas
- Forgetting to call next - Request hangs or returns empty response. Fix: always call
next.ServeHTTPunless you wrote the full response. - String context keys - Collide with other middleware using
"user". Fix: private typed key constants per package. - Business logic in middleware - Hard to test; hidden control flow. Fix: middleware sets context; handlers and services decide outcomes.
- Multiple ResponseWriter wrappers - Flusher/Hijacker interfaces lost if wrapper does not implement them. Fix: use
middleware.NewWrapResponseWriterfrom chi or implement optional interfaces. - Middleware order bugs - Auth after handler that panics skips recovery. Fix: register
Recovereroutermost; auth before handlers that need identity. - Global state in middleware - Package-level rate limiters hide dependencies. Fix: close over dependencies when building middleware in
main.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Inline handler checks | One route needs auth | Many routes share concern |
| HTTP middleware chain | Cross-cutting transport concerns | Core business branching |
| Decorator structs embedding Handler | Custom servers with defaults | Simple static file server |
| Service mesh / gateway auth | Centralized edge policy | Local dev simplicity matters |
FAQs
Is middleware the same as interceptors?
Yes in role - gRPC unary/stream interceptors wrap RPC handlers the same way HTTP middleware wraps ServeHTTP.
How do I test middleware?
Use httptest.NewRecorder and httptest.NewRequest; pass a stub next handler that sets a flag when called.
Can middleware modify the request body?
Yes, but read and restore carefully; prefer limiting body size at server level (MaxBytesReader).
Where should auth live?
Middleware validates credentials and attaches identity to context; services enforce authorization rules on resources.
How many middleware layers are too many?
There is no fixed max - but if order is hard to reason about, group related concerns or document a standard stack in main.
Does middleware run for 404s?
On chi, yes for routes registered on the router; unmatched paths hit NotFound handler after the same Use stack.
How do I skip middleware for one route?
Register the route on a sub-router without that middleware, or no-op inside middleware based on path prefix.
Should middleware use slog or log?
Prefer slog with request-scoped attributes (request ID from context) for structured logs.
How does this relate to gin.Context?
gin wraps http.Request in Context; middleware still composes but uses gin's API instead of raw handlers.
Can I use middleware with http.ServeMux (Go 1.22+)?
Yes - wrap the mux: logging(mux) or register per-method handlers wrapped individually.
Related
- Go Patterns Basics - middleware chain example
- Repository & Service Layer Patterns - handlers delegate to services
- Go Idioms: Composition Over Inheritance - function composition model
- Constructor Functions & Package APIs - wiring middleware in main
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify at build).