Middleware is usually the go-to pattern in Go HTTP servers for tweaking request behavior.
Typically, you wrap your base handler with layers of middleware—one might log every request,
while another intercepts specific routes like /special
to serve a custom response.
However, I often find the indirections introduced by this pattern a bit hard to read and debug. I recently came across the embedded delegation pattern while browsing the Gin1 repo. Here, I explore both patterns and explain why I usually start with delegation whenever I need to modify HTTP requests in my Go services.
Middleware stacking
Here’s an example where the logging middleware records each request, and the special
middleware intercepts requests to /special
:
package main
import (
"log"
"net/http"
)
// loggingMiddleware logs incoming requests.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("Middleware: received request for", r.URL.Path)
next.ServeHTTP(w, r)
})
}
// specialMiddleware intercepts requests for "/special" and handles them.
func specialMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/special" {
w.Write([]byte("Special middleware handling request"))
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, world!"))
})
// The middleware chain applies special handling then logs every request.
handler := loggingMiddleware(specialMiddleware(mux))
http.ListenAndServe(":8080", handler)
}
In this setup, every incoming request is first handled by the special middleware, which
checks for the /special
route, and then by the logging middleware that logs the request
details. We’re effectively stacking the middleware functions.
If you hit the server with:
curl localhost:8080/
curl localhost:8080/special
the server logs will look like this:
2025/03/06 21:24:44 Middleware: received request for /
2025/03/06 21:24:47 Middleware: received request for /special
Stacking middleware functions like middleware3(middleware2(middleware1(mux)))
can get
messy when you have many of them. That’s why people usually write a wrapper function to
apply the middlewares to the mux:
func applyMiddleware(
handler http.Handler,
middlewares ...func(http.Handler) http.Handler) http.Handler {
// Apply middlewares in reverse order to preserve LIFO.
for i := len(middlewares) - 1; i >= 0; i-- {
handler = middlewares[i](handler)
}
return handler
}
applyMiddleware
takes an http.Handler
and a variadic list of middleware functions
(...func(http.Handler) http.Handler
). It loops over the middleware in reverse order so
each one wraps the next properly. This avoids deep nesting like
middleware3(middleware2(middleware1(mux)))
and keeps the middleware chain tidy.
You’d then use it like this:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, world!"))
})
// The middleware chain applies special handling then logs every request.
// specialMiddleware is applied before loggingMiddleware, just like before.
handler := applyMiddleware(mux, loggingMiddleware, specialMiddleware)
http.ListenAndServe(":8080", handler)
}
This behaves just like the manual middleware stacking, but it’s a bit cleaner.
While this is the canonical way to handle request-response modifications in Go, it can sometimes be hard to reason about, especially when debugging or dealing with many middleware layers.
There’s another way to achieve the same result without dealing with a soup of nested functions. The next section talks about that.
Embedded delegation
Embedded delegation (or the delegation pattern) means you embed the standard HTTP
multiplexer inside your own struct and override its ServeHTTP
method.
It’s a bit like inheritance—overriding a method in a subclass to add extra functionality and then delegating the call to the original method. Although Go doesn’t have a class hierarchy, you can still delegate responsibilities to the embedded type’s method.
The following example implements the same behavior—logging every request and intercepting
the /special
route—directly within a custom mux:
package main
import (
"log"
"net/http"
)
// CustomMux embeds http.ServeMux to override ServeHTTP.
type CustomMux struct {
*http.ServeMux
}
// ServeHTTP logs the request and intercepts "/special" before
// delegating to the embedded mux.
func (cm *CustomMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Log all requests.
log.Println("CustomMux: received request for", r.URL.Path)
// Handle "/special" differently.
if r.URL.Path == "/special" {
w.Write([]byte("Special handling in CustomMux"))
return
}
cm.ServeMux.ServeHTTP(w, r)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, world!"))
})
// Wrap the standard mux with our custom delegation.
customMux := &CustomMux{ServeMux: mux}
http.ListenAndServe(":8080", customMux)
}
In this example, the custom mux centralizes both logging and special-case route handling
within one ServeHTTP
method. This approach cuts out the extra function calls in a
middleware chain and can simplify tracking the request flow. I find it a bit easier on the
eyes too.
If you have a bunch of extra functionality to add inside cm.ServeHTTP
, you can wrap them
in utility functions like this:
// logRequest logs incoming HTTP requests.
func logRequest(r *http.Request) {
log.Println("CustomMux: received request for", r.URL.Path)
}
// handleSpecialRequest handles requests to "/special"
// and returns true if handled.
func handleSpecialRequest(w http.ResponseWriter, r *http.Request) bool {
if r.URL.Path != "/special" {
return false // Not handled, continue processing.
}
w.Write([]byte("Special handling in CustomMux"))
return true // Handled; no further processing needed.
}
Then, simply call these functions inside your cm.ServeHTTP
method:
func (cm *CustomMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
logRequest(r)
if handleSpecialRequest(w, r) {
return
}
cm.ServeMux.ServeHTTP(w, r)
}
This keeps all the request modifications in a single ServeHTTP
method.
Mixing the two approaches
You can also mix both techniques. For example, you might use direct delegation for special route handling and then wrap the resulting handler with middleware for logging. Here’s how a hybrid solution might look:
package main
import (
"log"
"net/http"
)
// CustomMux embeds http.ServeMux and intercepts "/special".
type CustomMux struct {
*http.ServeMux
}
// ServeHTTP intercepts "/special" and delegates other routes.
func (cm *CustomMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/special" {
w.Write([]byte("Special handling in CustomMux"))
return
}
cm.ServeMux.ServeHTTP(w, r)
}
// loggingMiddleware logs incoming requests.
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println("Middleware: received request for", r.URL.Path)
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, world!"))
})
// Use direct delegation for special routing.
customMux := &CustomMux{ServeMux: mux}
// Wrap the custom mux with logging middleware.
handler := loggingMiddleware(customMux)
http.ListenAndServe(":8080", handler)
}
In this hybrid approach, the specialized behavior (intercepting the /special
path) is
handled via direct delegation, while logging stays modular as middleware. This gives you the
best of both worlds.
I usually start with the embedded delegation and gradually introduce the middleware pattern if I need it later. It’s easier to adopt the middleware pattern if you start with delegation than the other way around.
Recent posts
- Why does Go's io.Reader have such a weird signature?
- Go slice gotchas
- The domain knowledge dilemma
- Hierarchical rate limiting with Redis sorted sets
- Dynamic shell variables
- Link blog in a static site
- Running only a single instance of a process
- Function types and single-method interfaces in Go
- SSH saga
- Injecting Pytest fixtures without cluttering test signatures