The tangled handler problem
You are building a Go HTTP server. You write a handler for /api/users. It works. Then you add logging. You sprinkle log.Printf calls before and after the logic. Then you add authentication. You check a token header. If it's missing, you return 401. Then you add metrics. You record the start time, call the handler, calculate the duration, and push a histogram.
Your handler function has grown. The actual business logic is buried under boilerplate. You copy the auth check to /api/posts. You copy the timer logic to /api/orders. Then you realize you need to change the log format to include the request ID. Now you have to hunt down every file and update the logging code.
This is the cross-cutting concern problem. Logging, metrics, and auth cut across many handlers. They are not part of the business logic. They are infrastructure. Go solves this with middleware. Middleware wraps handlers to add behavior without modifying the handler code. You write the cross-cutting logic once. You apply it to any handler.
Middleware is a wrapper
Middleware is a function that takes an http.Handler and returns a new http.Handler. The returned handler wraps the original one. When a request arrives, the wrapper runs code, calls the wrapped handler, then runs more code. The wrapper can inspect the request, modify it, reject it, or modify the response.
Think of a security checkpoint at an airport. The security guard checks your ID. If you pass, you proceed to the gate. If you fail, you turn around. The gate agent never sees the ID check. The guard is middleware. The gate agent is your handler. The passenger is the request.
In Go, http.Handler is an interface with one method: ServeHTTP(http.ResponseWriter, *http.Request). Any type with that method is a handler. Middleware returns a type that implements ServeHTTP. The standard library provides http.HandlerFunc as an adapter. It lets you use a function as a handler. Middleware often returns an http.HandlerFunc that closes over the next handler.
Middleware is a chain. Break the chain and the request dies.
Minimal example
Here is the simplest middleware: a logger that prints the method and path.
package main
import (
"fmt"
"net/http"
)
// LoggingMiddleware wraps a handler to log requests.
func LoggingMiddleware(next http.Handler) http.Handler {
// Return a new handler that wraps the next one.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Log before calling the next handler.
fmt.Printf("Request: %s %s\n", r.Method, r.URL.Path)
// Call the wrapped handler.
next.ServeHTTP(w, r)
// Log after the handler returns.
fmt.Println("Response sent")
})
}
func main() {
// Create a simple handler.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello"))
})
// Wrap the handler with middleware.
wrapped := LoggingMiddleware(handler)
// Start the server.
http.ListenAndServe(":8080", wrapped)
}
The LoggingMiddleware function takes next. It returns a closure. The closure captures next. When the server calls ServeHTTP on the wrapper, the closure runs. It prints the request info. It calls next.ServeHTTP. It prints the response info. The request flows through the wrapper.
How the chain flows
You can stack middleware. LoggingMiddleware(AuthMiddleware(handler)) creates a chain. The request enters the outermost middleware first. It flows inward. The response flows back outward.
Order matters. If you put auth before logging, the log won't run for unauthorized requests. If you put logging before auth, the log runs for every request. The chain looks like this:
- Logging middleware starts.
- Auth middleware starts.
- Handler runs.
- Auth middleware finishes.
- Logging middleware finishes.
If auth rejects the request, it returns early. The handler never runs. The auth middleware finishes. The logging middleware finishes. The chain unwinds.
Trust gofmt. Argue logic, not formatting.
Capturing status codes
The default http.ResponseWriter does not expose the status code after writing. If you want to log the status code in middleware, you need to wrap the writer. Create a struct that embeds http.ResponseWriter. Override WriteHeader. Store the code. Pass the wrapper to the next handler.
package main
import (
"fmt"
"net/http"
)
// statusRecorder wraps http.ResponseWriter to capture status code.
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
// WriteHeader captures the status code.
func (rec *statusRecorder) WriteHeader(code int) {
// Store the code before delegating.
rec.statusCode = code
// Call the underlying writer.
rec.ResponseWriter.WriteHeader(code)
}
// MetricsMiddleware logs duration and status code.
func MetricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Wrap the writer to capture status.
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
// Call the next handler with the wrapper.
next.ServeHTTP(rec, r)
// Log the captured status code.
fmt.Printf("Status: %d\n", rec.statusCode)
})
}
func main() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate an error response.
http.Error(w, "Not Found", http.StatusNotFound)
})
wrapped := MetricsMiddleware(handler)
http.ListenAndServe(":8080", wrapped)
}
The statusRecorder embeds http.ResponseWriter. This gives it all the methods of the interface by default. We override WriteHeader to intercept the call. We store the code. We delegate to the underlying writer. The receiver name is rec, matching the type Recorder. This follows Go convention. Receiver names are usually one or two letters matching the type.
Context is plumbing. Run it through every long-lived call site.
Factory functions for configuration
Middleware often needs configuration. A logger instance, a database client, a list of allowed IPs. Use a factory function. The factory returns the middleware function. This keeps the middleware signature clean and allows dependency injection.
package main
import (
"log"
"net/http"
)
// NewLoggingMiddleware creates a middleware with a custom logger.
func NewLoggingMiddleware(logger *log.Logger) func(http.Handler) http.Handler {
// Return the middleware function.
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Use the injected logger.
logger.Printf("Request: %s", r.URL.Path)
next.ServeHTTP(w, r)
})
}
}
func main() {
// Create a logger.
logger := log.New(log.Writer(), "API: ", log.LstdFlags)
// Create middleware with the logger.
logging := NewLoggingMiddleware(logger)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
wrapped := logging(handler)
http.ListenAndServe(":8080", wrapped)
}
The factory function NewLoggingMiddleware takes a logger. It returns a function that matches the middleware signature. The returned function closes over the logger. You call the factory once to create the middleware. You apply the middleware to handlers. This pattern separates configuration from execution.
Pitfalls and errors
Middleware has traps. Forgetting to call next.ServeHTTP is the most common. If you forget, the request hangs. The client waits forever. The compiler will not catch this. It is a logic error. Always call next unless you are rejecting the request.
Writing to the response before calling next causes problems. If next tries to write headers, you get a runtime error. The standard library warns with http: superfluous response.WriteHeader call if you try to write headers twice. Check if headers are written before writing. Or use a wrapper that tracks the state.
Loop variables are another trap. If you generate handlers in a loop, be careful with closures. Go 1.22 fixed the loop variable capture. Older code might trigger loop variable i captured by func literal errors if compiled with newer tools. Pass the loop variable as an argument to the closure if you are on an older version. Or trust the fix in 1.22 and later.
Goroutine leaks happen when middleware spawns a goroutine that waits on a channel that never closes. Always have a cancellation path. Use context.Context to propagate cancellation. If the context is cancelled, stop work and return.
The worst goroutine bug is the one that never logs.
Decision matrix
Use middleware when you need to apply logic to multiple handlers, like logging, auth, or compression.
Use a wrapper struct when you need to maintain state across requests, such as a counter or a connection pool.
Use context.Context when you need to pass request-scoped values, deadlines, or cancellation signals to downstream functions.
Use plain handler code when the logic applies to only one endpoint and is simple enough to read inline.
Use a dedicated library like chi or gorilla/mux when you need advanced routing features like path parameters and method restrictions, though the standard library covers most cases.
Keep the chain short. Every layer adds latency and complexity.