Go Middleware Patterns: HTTP Handler Chains
Middleware solves the problem of cross-cutting concerns in web applications. Rather than repeating authentication checks, logging statements, and error handling in every route handler, middleware...
Key Insights
- Go’s middleware pattern leverages function closures to wrap
http.Handlerinstances, creating composable layers that execute in a predictable order—outer middleware runs first on the request and last on the response. - Effective middleware design separates cross-cutting concerns into isolated, reusable components that can be mixed and matched across different routes without duplicating logic.
- Context propagation through
context.Contextenables middleware to pass authentication data, request IDs, and other values downstream while maintaining type safety and avoiding global state.
Understanding HTTP Middleware in Go
Middleware solves the problem of cross-cutting concerns in web applications. Rather than repeating authentication checks, logging statements, and error handling in every route handler, middleware lets you define these behaviors once and apply them systematically. In Go’s HTTP ecosystem, middleware exploits the elegance of the http.Handler interface to create composable request processing pipelines.
Go’s standard library defines http.Handler as any type with a ServeHTTP(ResponseWriter, *Request) method. The http.HandlerFunc type adapter converts ordinary functions into handlers. This simple interface becomes the foundation for powerful middleware patterns.
Here’s a basic HTTP server without middleware:
package main
import (
"fmt"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", r.URL.Query().Get("name"))
}
func main() {
http.HandleFunc("/hello", helloHandler)
http.ListenAndServe(":8080", nil)
}
This works, but adding logging, authentication, or error recovery requires modifying every handler. Middleware provides a better approach.
The Core Middleware Pattern
The standard Go middleware signature follows a consistent pattern: a function that accepts an http.Handler and returns a new http.Handler that wraps the original. This wrapper executes code before and after calling the wrapped handler.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Code here runs before the handler
fmt.Printf("%s %s\n", r.Method, r.URL.Path)
// Call the next handler
next.ServeHTTP(w, r)
// Code here runs after the handler
fmt.Println("Request completed")
})
}
func main() {
handler := http.HandlerFunc(helloHandler)
wrappedHandler := loggingMiddleware(handler)
http.Handle("/hello", wrappedHandler)
http.ListenAndServe(":8080", nil)
}
The execution order follows an onion model: outer middleware executes first on the request path and last on the response path. Understanding this flow is critical for proper middleware composition.
Building Reusable Middleware Components
Production applications need several standard middleware components. Let’s build authentication, timing, and panic recovery middleware that you can reuse across projects.
Authentication middleware validates API keys and rejects unauthorized requests:
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
if apiKey == "" {
http.Error(w, "Missing API key", http.StatusUnauthorized)
return
}
if !isValidAPIKey(apiKey) {
http.Error(w, "Invalid API key", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func isValidAPIKey(key string) bool {
// In production, check against database or cache
validKeys := map[string]bool{
"secret-key-123": true,
"another-key-456": true,
}
return validKeys[key]
}
Request timing middleware measures and logs response times:
func timingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
duration := time.Since(start)
fmt.Printf("%s %s completed in %v\n", r.Method, r.URL.Path, duration)
})
}
Panic recovery middleware prevents crashes from propagating and crashing your server:
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
fmt.Printf("Panic recovered: %v\n", err)
debug.PrintStack()
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
Chaining Middleware Effectively
Applying multiple middleware layers requires composition. Manual nesting works but becomes unwieldy:
handler := recoveryMiddleware(
timingMiddleware(
loggingMiddleware(
authMiddleware(
http.HandlerFunc(helloHandler),
),
),
),
)
A custom chain helper improves readability:
func chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}
// Usage
handler := chain(
http.HandlerFunc(helloHandler),
authMiddleware,
loggingMiddleware,
timingMiddleware,
recoveryMiddleware,
)
The justinas/alice library provides a production-ready chaining implementation:
import "github.com/justinas/alice"
chain := alice.New(
recoveryMiddleware,
timingMiddleware,
loggingMiddleware,
authMiddleware,
).Then(http.HandlerFunc(helloHandler))
Alice’s fluent API makes middleware stacks explicit and maintainable. Order matters: recovery should be outermost to catch panics from all other middleware.
Configurable Middleware with Closures
Hardcoded middleware limits reusability. Parameterized middleware accepts configuration and returns a configured middleware function:
func rateLimitMiddleware(requestsPerSecond int) func(http.Handler) http.Handler {
limiter := rate.NewLimiter(rate.Limit(requestsPerSecond), requestsPerSecond)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !limiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage
handler := chain(
http.HandlerFunc(helloHandler),
rateLimitMiddleware(10), // 10 requests per second
authMiddleware,
)
This closure pattern captures configuration in the outer function while maintaining the standard middleware signature in the returned function.
Context-Aware Middleware
The context.Context package enables middleware to pass data to downstream handlers without global variables or custom response writer wrappers. Authentication middleware can store user information in the request context:
type contextKey string
const userContextKey contextKey = "user"
type User struct {
ID string
Username string
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-API-Key")
user, err := getUserByAPIKey(apiKey)
if err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Store user in context
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func protectedHandler(w http.ResponseWriter, r *http.Request) {
user, ok := r.Context().Value(userContextKey).(*User)
if !ok {
http.Error(w, "User not found in context", http.StatusInternalServerError)
return
}
fmt.Fprintf(w, "Hello, %s (ID: %s)", user.Username, user.ID)
}
Use unexported types for context keys to prevent collisions. Type assertions when retrieving values require careful error handling.
Best Practices and Production Patterns
Middleware ordering significantly impacts behavior. Apply this general order:
- Recovery (outermost) - catches panics from all layers
- Logging - records all requests, including errors
- Metrics/Timing - measures total request duration
- Authentication - identifies the user
- Authorization - checks permissions
- Business logic (innermost) - your actual handlers
Here’s a complete production-ready server:
package main
import (
"context"
"fmt"
"log"
"net/http"
"time"
"github.com/justinas/alice"
)
func main() {
// Common middleware for all routes
commonMiddleware := alice.New(
recoveryMiddleware,
loggingMiddleware,
timingMiddleware,
)
// Protected routes require authentication
protectedMiddleware := commonMiddleware.Append(authMiddleware)
mux := http.NewServeMux()
// Public endpoints
mux.Handle("/health", commonMiddleware.ThenFunc(healthHandler))
// Protected endpoints
mux.Handle("/api/users", protectedMiddleware.ThenFunc(usersHandler))
mux.Handle("/api/data", protectedMiddleware.Append(
rateLimitMiddleware(100),
).ThenFunc(dataHandler))
log.Println("Server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "OK")
}
func usersHandler(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userContextKey).(*User)
fmt.Fprintf(w, "User: %s", user.Username)
}
func dataHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Protected data")
}
Avoid middleware bloat by keeping concerns separated. Don’t create middleware that does too much—a middleware function that handles authentication, logging, and rate limiting becomes difficult to test and reuse. Instead, compose small, focused middleware functions.
Performance matters in high-traffic applications. Middleware executes on every request, so expensive operations like database queries or complex computations should use caching or move to background jobs. Profile your middleware stack to identify bottlenecks.
Go’s middleware pattern provides a clean, composable approach to handling cross-cutting concerns in web applications. By mastering handler wrapping, context propagation, and proper ordering, you can build maintainable HTTP services that scale from prototypes to production systems.