The onion model for web servers
You write a handler for /api/users. It returns a JSON list. You write a handler for /api/posts. It returns a JSON list. Both work. Then you realize every endpoint needs logging. You copy the log line into both handlers. Then you add authentication. You copy the auth check. Then you add CORS headers. Your handlers are bloated with boilerplate. The business logic is buried under plumbing.
Middleware solves this. It lets you extract shared behavior into reusable layers. You wrap your handlers in middleware. The middleware runs before the handler, runs the handler, and runs after the handler. You chain as many layers as you need. The result is clean handlers that do one thing, wrapped in layers that handle cross-cutting concerns.
Concept in plain words
Middleware is a wrapper. It sits between the client and your handler. It can inspect the request, modify it, run the handler, inspect the response, and modify that too. In Go, middleware is a function that takes an http.Handler and returns a new http.Handler.
Think of an onion. The request is a knife cutting through the layers. It hits the outer layer first, then the next, then the core. The response is the knife pulling back out. It hits the core first, then the next layer, then the outer layer. Each layer can do work on the way in and work on the way out.
The http.Handler interface is the contract. It has one method: ServeHTTP(http.ResponseWriter, *http.Request). Middleware returns a new handler that satisfies this interface. The new handler does some work, calls the original handler's ServeHTTP, and then does more work.
Middleware is plumbing. Keep it thin.
Minimal example
Here's the skeleton. A middleware function accepts the next handler in the chain and returns a wrapper that runs code before and after.
package main
import (
"log"
"net/http"
)
// LoggingMiddleware wraps a handler to log requests
func LoggingMiddleware(next http.Handler) http.Handler {
// Return a new handler that satisfies the http.Handler interface
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Pre-processing: log the incoming request details
log.Printf("Request: %s %s", r.Method, r.URL.Path)
// Pass control to the next handler in the chain
next.ServeHTTP(w, r)
// Post-processing: log after the handler finishes
log.Println("Response sent")
})
}
func main() {
// Define the core 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
log.Fatal(http.ListenAndServe(":8080", wrapped))
}
The http.HandlerFunc type is an adapter. It lets you use a function as a handler. The function signature matches ServeHTTP. The middleware returns this adapter. When the server calls the middleware, the adapter runs the function. The function logs, calls next, and logs again.
gofmt handles the indentation of the nested closure. Don't argue about formatting. Let the tool decide. The code looks deep, but the tool keeps it readable.
Walkthrough of the chain
When a request arrives, the server calls the outermost handler. That handler is the middleware. The middleware runs its pre-processing logic. Then it calls next.ServeHTTP. That call invokes the next handler in the chain. If there are multiple middlewares, the next handler is another middleware. The chain continues until it hits the core handler.
The core handler runs. It writes the response. It returns. Control goes back to the middleware that called it. That middleware runs its post-processing logic. It returns. Control goes back to the previous middleware. The response flows back out through the layers.
The order matters. If you chain A(B(handler)), A is the outer layer. A runs first. B runs second. The handler runs last. On the way back, the handler returns to B. B returns to A. A returns to the server.
The request flows in. The response flows out. Don't break the chain.
Realistic example: capturing status codes
The http.ResponseWriter interface doesn't expose the status code. You can write headers, but you can't read them back. Middleware often needs the status code for logging. You need to wrap the response writer to capture it.
Here's a wrapper struct that records the status code.
// statusRecorder wraps http.ResponseWriter to capture the status code
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
// WriteHeader captures the status code before delegating
func (rec *statusRecorder) WriteHeader(code int) {
// Store the status code for later inspection
rec.statusCode = code
// Delegate to the underlying writer
rec.ResponseWriter.WriteHeader(code)
}
The struct embeds http.ResponseWriter. This gives it all the methods of the interface by default. You only override WriteHeader because that's where the status code is set. The wrapper stores the code and calls the original writer.
Here's middleware that uses the wrapper.
// StatusLoggingMiddleware logs the final status code
func StatusLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Wrap the response writer to capture the status
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
// Call the next handler with the wrapped writer
next.ServeHTTP(rec, r)
// Log the captured status code
log.Printf("Status: %d", rec.statusCode)
})
}
The middleware creates the wrapper. It passes the wrapper to next. The handler writes to the wrapper. The wrapper captures the status. The middleware logs the status.
Wrap the writer. Capture the status. Log the truth.
Context injection
Middleware is the best place to inject values into the request context. The context travels with the request through the chain. Handlers can read values from the context. Middleware can write values to the context.
Here's middleware that extracts a user ID from a header and puts it in the context.
// UserIDMiddleware extracts user ID and stores it in context
func UserIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract the user ID from the header
userID := r.Header.Get("X-User-ID")
// Create a new context with the user ID
ctx := context.WithValue(r.Context(), "userID", userID)
// Clone the request with the new context
r = r.WithContext(ctx)
// Pass the modified request to the next handler
next.ServeHTTP(w, r)
})
}
The middleware reads the header. It creates a new context with context.WithValue. It clones the request with r.WithContext. The new request has the updated context. The next handler sees the new context.
context.Context always goes as the first parameter in functions. Middleware respects this convention. The context carries deadlines, cancellation signals, and request-scoped values.
Context is plumbing. Run it through every long-lived call site.
Panic recovery
Handlers can panic. A panic crashes the goroutine. The server might crash. Middleware can catch panics and return a safe error response.
Here's a panic recovery middleware.
// RecoveryMiddleware catches panics and returns a 500 error
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Defer a function to recover from panics
defer func() {
if err := recover(); err != nil {
// Log the panic details
log.Printf("Panic: %v", err)
// Return a 500 Internal Server Error
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
// Call the next handler
next.ServeHTTP(w, r)
})
}
The defer runs after the handler returns. If the handler panics, recover() catches it. The middleware logs the panic and writes a 500 response. The server stays alive.
The worst goroutine bug is the one that never logs.
Pitfalls and compiler errors
Middleware has traps. Watch for these.
Forgetting to call next. If the middleware doesn't call next.ServeHTTP, the request hangs. The client waits forever. The server holds the connection. The middleware must call the next handler.
Modifying the request. You can modify http.Request, but be careful. If you change r.URL, the next handler sees the change. If you change headers, the next handler sees the change. Use r.WithContext for context changes. Clone the request if you need to modify mutable fields safely.
Response writer methods. http.ResponseWriter has Header(), Write(), and WriteHeader(). If you wrap the writer, you must delegate all methods. If you forget Header(), the handler can't set headers. If you forget Write(), the handler can't write the body. Embed the interface to get the methods for free.
Compiler errors. If you pass a function directly to http.ListenAndServe, the compiler rejects it with cannot use func(...) as type http.Handler in argument. You need the http.HandlerFunc adapter. If you forget to import context, you get undefined: context. If you use a variable without declaring it, you get undefined: variable.
Don't fight the type system. Wrap the value or change the design.
Decision matrix
Middleware is powerful. Don't overuse it.
Use middleware when you need cross-cutting concerns like logging, authentication, tracing, or panic recovery. Use middleware when the behavior applies to many handlers. Use middleware when you need to modify the request or response before or after the handler.
Use a struct handler when the logic is complex and needs state. Use a struct handler when the behavior is specific to one endpoint. Use a struct handler when you need to implement http.Handler directly for clarity.
Use inline code when the behavior is unique to one handler and won't be reused. Use inline code when the logic is simple and adding middleware adds unnecessary complexity.
Use the simplest thing that works.