The onion model of middleware
You are building a Go HTTP server. You have a handler that returns JSON. Now you need logging. Then authentication. Then rate limiting. You could paste the logic into every handler, but that creates a maintenance nightmare. You write middleware. Now you have three middleware functions. How do you combine them?
The order changes the behavior. If you log before auth, you record every unauthorized request. If you rate limit after auth, you might leak information about valid users. Wrapping them manually gets messy fast. You end up with a pyramid of parentheses that grows leftward and becomes unreadable. You need a way to chain them cleanly.
Middleware is just a wrapper
Middleware in Go follows a single signature: a function that takes an http.Handler and returns an http.Handler. The returned handler wraps the input. It does some work, calls the inner handler, then does more work.
Think of it like layers of an onion. The request enters the outermost layer, passes through each layer, hits the core handler, and the response travels back out through the layers in reverse order. Each layer can inspect the request, modify the context, or short-circuit the chain by returning an error response without calling the next layer.
The type signature is strict. The community convention is to accept interfaces and return structs. Middleware accepts the http.Handler interface and returns a concrete implementation, often an http.HandlerFunc or a custom struct. This keeps the chain flexible. You can swap implementations without changing the chain logic.
The chain function
Here's what happens when you wrap middleware by hand. The nesting grows leftward and becomes hard to read. The outermost middleware runs first on the request.
// Manual wrapping creates deep nesting that obscures the handler.
// The first function in the call chain executes first on the request.
handler := loggingMiddleware(authMiddleware(rateLimitMiddleware(myHandler)))
A chain function flattens the nesting. You pass middleware in order, and it builds the wrapper stack for you. The function returns a closure that captures the middleware list. The closure runs the wrapping logic once when you invoke it, not on every request.
// chainMiddleware takes a variadic list of middleware functions.
// It returns a single function that accepts a handler and returns the wrapped result.
func chainMiddleware(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler {
// Return a closure that captures the middleware slice.
return func(next http.Handler) http.Handler {
// Iterate backwards so the first middleware in the slice wraps the outermost layer.
for i := len(middlewares) - 1; i >= 0; i-- {
// Wrap the current 'next' with the middleware at index i.
next = middlewares[i](next)
}
return next
}
}
The reverse iteration is the trick. If you iterate forward, the last middleware runs first, which is usually wrong. Reverse iteration ensures the first middleware in your list executes first on the request. The loop runs during setup. The request path is just a series of function calls. No reflection, no dynamic dispatch overhead beyond the calls. Go functions are cheap. Closures are cheap. The chain function allocates a closure and captures a slice. That's it.
Reverse iteration saves your sanity.
Walkthrough: how the stack builds
When you call chainMiddleware(mw1, mw2, mw3)(handler), the chain function returns a closure. That closure holds the slice [mw1, mw2, mw3]. When you invoke the closure, the loop runs.
The variable next starts as handler. The loop index i starts at 2. The first iteration sets next = mw3(handler). Now next is mw3 wrapping handler. The second iteration sets next = mw2(mw3(handler)). Now next is mw2 wrapping mw3 wrapping handler. The third iteration sets next = mw1(mw2(mw3(handler))). The result is mw1 wrapping mw2 wrapping mw3 wrapping handler.
The request hits mw1 first. mw1 does its work and calls ServeHTTP on its inner handler, which is mw2. mw2 does its work and calls mw3. mw3 does its work and calls the final handler. The response bubbles back up.
This math guarantees that the order of arguments matches the order of execution. chainMiddleware(logging, auth)(handler) means logging runs before auth. The code reads left-to-right, and the execution flows left-to-right.
Realistic middleware with context
Real middleware often touches the context or checks headers. Context is plumbing. Pass it down the chain. If you add values to the context, use r.WithContext. Don't mutate the request directly. WithContext returns a new request with the updated context, preserving the original for safety.
Here's a logging middleware that respects context cancellation and a simple auth check. The logging middleware generates a request ID and stores it in the context. Downstream handlers can read the ID for tracing.
// LoggingMiddleware adds a request ID and logs duration.
// It respects context cancellation to avoid logging after the client disconnects.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate a unique ID for this request trace.
reqID := uuid.New().String()
// Store the ID in the context for downstream handlers to use.
ctx := context.WithValue(r.Context(), "reqID", reqID)
// Create a new request with the updated context.
req := r.WithContext(ctx)
// Record start time to calculate latency later.
start := time.Now()
// Call the next handler in the chain.
next.ServeHTTP(w, req)
// Log after the response is sent.
log.Printf("req=%s method=%s path=%s duration=%v", reqID, r.Method, r.URL.Path, time.Since(start))
})
}
The auth middleware checks a header. If the header is missing or invalid, it returns a 401 response and stops the chain. It never calls next. This short-circuiting is how middleware protects downstream handlers.
// AuthMiddleware checks for an API key in the header.
// It returns 401 immediately if the key is missing or invalid.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract the authorization header.
key := r.Header.Get("X-API-Key")
// Reject requests without a valid key.
if key == "" || key != "secret-key" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Pass valid requests to the next handler.
next.ServeHTTP(w, r)
})
}
Here's how you wire it up in a server. The order of arguments determines the execution order. Logging runs first, then auth. If auth fails, logging still runs because it wraps auth. You log the 401 response. If you want to skip logging for failed auth, swap the order.
// main sets up the handler chain and starts the server.
// The order of arguments determines the execution order.
func main() {
// Create the base handler.
base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
// Chain middleware: Logging runs first, then Auth.
handler := chainMiddleware(LoggingMiddleware, AuthMiddleware)(base)
// Start the server with the fully wrapped handler.
log.Fatal(http.ListenAndServe(":8080", handler))
}
Context flows down. Errors flow up.
Pitfalls and common mistakes
Order matters. Changing the order changes the semantics. If you put rate limiting before auth, you rate limit based on IP. If you put auth before rate limiting, you can rate limit based on user ID. Both are valid, but they protect different things.
Forgetting to call next leaves the client hanging. The request never completes. The connection stays open until the client times out. This is a silent bug that kills your server's concurrency. Always call next unless you are returning a response.
Calling next twice sends two responses. The client gets confused, and the server logs http: superfluous response.WriteHeader call. This error appears when you try to write headers after the response has already started. Middleware must call next exactly once.
Wrapping the response writer is common for status codes. The http.ResponseWriter interface does not expose the status code. If you want to log the status code in logging middleware, you need to wrap the writer to intercept WriteHeader. If you don't wrap it, you can't see the status code in the outer middleware. This is a design choice in the standard library. You trade simplicity for control.
The worst middleware bug is the one that swallows the response.
Decision: when to use each approach
Use manual wrapping when you have exactly two middleware functions and want to keep the code inline without a helper.
Use a chain function when you have three or more middleware and want a clean, readable list of layers.
Use a framework's built-in chain when you are using a router like Chi or Gin that provides middleware registration methods.
Use a single composite handler when the middleware logic is tightly coupled and belongs in one unit.
Middleware is wrapping. Order is law.