Go Middleware: HTTP Handler Composition
Middleware is a function that wraps an HTTP handler to add cross-cutting functionality like logging, authentication, or error recovery. In Go, this pattern leverages the `http.Handler` interface,...
Key Insights
- Go middleware follows a simple pattern of wrapping
http.Handlerfunctions, creating a composable chain where each layer can execute code before and after the next handler - The order of middleware matters significantly—authentication should run before authorization, logging before panic recovery, and CORS headers before authentication
- Custom middleware is straightforward to implement in Go’s standard library, but libraries like Chi and Echo provide convenient helpers for common patterns without sacrificing performance
Understanding HTTP Middleware in Go
Middleware is a function that wraps an HTTP handler to add cross-cutting functionality like logging, authentication, or error recovery. In Go, this pattern leverages the http.Handler interface, which requires a single method: ServeHTTP(ResponseWriter, *Request).
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.Path[1:])
}
func main() {
http.HandleFunc("/", helloHandler)
http.ListenAndServe(":8080", nil)
}
This works, but every cross-cutting concern needs to be manually added to each handler. Middleware solves this by creating reusable wrappers.
The Middleware Pattern
Go middleware follows a consistent signature: a function that takes an http.Handler and returns a new http.Handler. This wrapper can execute code before calling the next handler, after it returns, or both.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Code before the handler
fmt.Printf("Request: %s %s\n", r.Method, r.URL.Path)
// Call the next handler
next.ServeHTTP(w, r)
// Code after the handler
fmt.Println("Response sent")
})
}
func main() {
handler := http.HandlerFunc(helloHandler)
wrappedHandler := loggingMiddleware(handler)
http.Handle("/", wrappedHandler)
http.ListenAndServe(":8080", nil)
}
The execution flows like an onion: outer middleware runs first, calls the next layer, and regains control when the inner layers complete. This allows middleware to measure timing, modify responses, or handle errors from downstream handlers.
Building Common Middleware Functions
Let’s implement practical middleware you’ll use in real applications.
Request Timing and Logging:
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)
log.Printf("%s %s - %v", r.Method, r.URL.Path, duration)
})
}
Panic Recovery:
func recoveryMiddleware(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", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
CORS Headers:
func corsMiddleware(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 == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
These middleware functions are focused, testable, and composable. Each does one thing well.
Composing Multiple Middleware
Manually wrapping handlers gets unwieldy with multiple middleware layers. Create a helper function to chain them cleanly:
func chain(handler http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
handler = middleware[i](handler)
}
return handler
}
The reverse iteration ensures middleware execute in the order specified. Now you can compose cleanly:
func main() {
finalHandler := http.HandlerFunc(helloHandler)
wrappedHandler := chain(
finalHandler,
recoveryMiddleware,
timingMiddleware,
corsMiddleware,
)
http.Handle("/", wrappedHandler)
http.ListenAndServe(":8080", nil)
}
Execution order matters. Recovery should be outermost to catch panics from all other middleware. CORS headers must be set before authentication rejects requests. Logging typically goes near the outside to capture the full request lifecycle.
Advanced Patterns
Configurable Middleware:
Real middleware often needs configuration. Use closures to create middleware factories:
func authMiddleware(validToken string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token != "Bearer "+validToken {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
// Usage
wrappedHandler := chain(
finalHandler,
authMiddleware("secret-token-123"),
timingMiddleware,
)
Context-Based Data Passing:
Middleware can store values in the request context for downstream handlers:
type contextKey string
const userKey contextKey = "user"
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
// Validate token and extract user
user := validateAndGetUser(token)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Add user to context
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func protectedHandler(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userKey).(*User)
fmt.Fprintf(w, "Hello, %s!", user.Name)
}
Conditional Middleware:
Apply middleware only to specific routes or conditions:
func conditionalMiddleware(condition func(*http.Request) bool, mw func(http.Handler) http.Handler) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if condition(r) {
mw(next).ServeHTTP(w, r)
} else {
next.ServeHTTP(w, r)
}
})
}
}
// Apply auth only to /admin routes
adminOnly := func(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, "/admin")
}
wrappedHandler := chain(
finalHandler,
conditionalMiddleware(adminOnly, authMiddleware("admin-token")),
)
Popular Middleware Libraries
While custom middleware is straightforward, libraries provide helpful utilities. Chi is a lightweight router with excellent middleware support:
import "github.com/go-chi/chi/v5"
import "github.com/go-chi/chi/v5/middleware"
func main() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
r.Get("/", helloHandler)
http.ListenAndServe(":8080", r)
}
Chi’s middleware is compatible with standard library patterns—you can mix custom and library middleware freely. Echo and Gin take similar approaches but with framework-specific handler signatures.
Use libraries when you need battle-tested implementations of complex middleware (rate limiting, JWT validation) or when the router provides useful features. Stick with custom middleware for simple, application-specific logic where you want full control and zero dependencies.
Best Practices
Keep middleware focused. Each middleware should do one thing. Don’t combine authentication, logging, and validation in a single function.
Consider performance. Middleware runs on every request. Avoid expensive operations like database queries unless necessary. Use caching and request-scoped resources wisely.
Test middleware in isolation. Middleware is just a function—it’s easy to test:
func TestTimingMiddleware(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
wrapped := timingMiddleware(handler)
req := httptest.NewRequest("GET", "/test", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", rec.Code)
}
}
Document execution order. Make it clear which middleware must run first. Use comments or documentation to explain dependencies between middleware layers.
Go’s middleware pattern is elegant because it leverages the language’s simplicity. You’re just wrapping functions. No magic, no complex interfaces—just composition. This makes middleware easy to understand, test, and maintain. Start with the standard library, add focused custom middleware for your application’s needs, and reach for libraries only when they provide clear value over a few lines of custom code.