Understanding Middleware in Go: The Onion Model
Core Concept: Middleware as Onion Layers
Middleware doesn't form a chain; instead, it wraps around the core handler like layers of an onion. Each middleware layer can process requests before passing them inward and handle responses on the way back out.
┌──────────────────────────────────────┐
│ Auth Middleware │
│ ┌─────────────────────────────────┐ │
│ │ Logging Middleware │ │
│ │ ┌───────────────────────────┐ │ │
│ │ │ Your Handler │ │ │
│ │ │ (Core Logic) │ │ │
│ │ └───────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└──────────────────────────────────────┘
In this form, a request flows inward (Auth → Logging → Handler), and the response flows outward (Handler → Logging → Auth). Each middleware layer can modify the request or response as needed. We will discuss this inward and outward flow in more detail later.
Go: Three-Layer Function Structure
Every middleware in Go follows this pattern:
// Phase 1: Registration/Setup - called once at startup
func SomeMiddleware(config) func(http.Handler) http.Handler {
// Phase 2: Chain building - called once per middleware during setup
return func(next http.Handler) http.Handler {
// Phase 3: Request handling - called on every HTTP request
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){
// do work before next layer
projectId := chi.URLParam(r, "projectId")
// add to context (immutable, create new every time)
newContext := context.WithValue(r.Context(), SomeKey, someValue)
// Pass to next layer
next.ServeHTTP(w, r.WithContext(newContext))
})
}
}
Context is an immutable 'bag' that is carried by chi as a request progresses down the 'onion'. This allows middleware implementations to send data down to later layers without modifying the original request object.
Phase 1 is called once at application startup. Its purpose is to accept required configuration; this can be anything that the middleware requires for its operation. It can be a Firebase app, a validator instance, a database connection pool, or any other resource. The function returns a middleware constructor function with signature func(http.Handler) http.Handler. We use this outermost phase function to register a middleware to a router. This is the only phase that is manually invoked by the developer; all the remaining phases are called by the chi framework internally.
r := chi.NewRouter()
r.Use(SomeMiddleware(config)) // Phase 1 called here
Phase 2 is the chain builder. It is called once per middleware during the setup of the router (by chi). Its purpose is to receive the next handler in the onion, and return a new handler that wraps around it. This phase is responsible for creating the actual request handler function that will be invoked on each HTTP request.
wrappedhandler := Auth(firebaseApps)(nextHandler) // called by chi internally
Phase 3 is the request handler; its called on every HTTP request. Its purpose is executing the handler's logic, and called by chi under the hood:
wrappedhandler.ServeHTTP(w, r) // called by chi internally
When multiple middleware layers are stacked, the onion structure is formed by chi by executing a sequence of these phases:
r.Use(Auth(firebase))
r.Use(Logging())
r.Post("/endpoint", myHandler)
This definition results in the following internal process:
- Call
Auth(firebaseApps)which returnsauthMiddleware func(Handler) Handler - Call
Logging()which returnsloggingMiddleware func(Handler) Handler - Build the onion:
authMiddleware(loggingMiddleware(myHandler))
Auth wraps {
Logging wraps {
myHandler
}
}
Therefore, the execution of a request flows as:
// 1. HTTP request arrives
// 2. Chi calls outermost layer
Auth.ServeHTTP(w, r)
↓ verify token, add user to context
↓ call next.ServeHTTP(w, newContext)
↓
Logging.ServeHTTP(w, r)
↓ log request, start timer
↓ call next.ServeHTTP(w, r)
↓
myHandler.ServeHTTP(w, r)
↓ do actual work
↓ write response
↓ return
← return to Logging
← log response time
← return to Auth
← cleanup if needed
← response sent to client
It should be noted that each layer only knows about its immediate next layer; there is no direct knowledge of the entire stack. This encapsulation allows middleware to be composed flexibly.
http.Handler Interface
This is the foundation of Go's HTTP server. It defines a single method:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Thanks to Go's implicit interfaces, any type with a ServeHTTP method automatically implements the http.Handler interface.
Without any helper functions or syntactic sugar, a handler would look like this:
type SomeHandler struct {}
func (h *SomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// handle request
}
This means that every handler requires an empty strcut. However, Go uses its clever trick of being able to attach functions to function types, and provides the http.HandlerFunc type:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
This means we can now just write any function with the correct signature, and convert it to a handler using http.HandlerFunc(myFunction). This is exactly what we do in Phase 3 of our middleware:
func myHandler(w http.ResponseWriter, r *http.Request) {
// handle request
}
http.HandlerFunc(myHandler) // converts to http.Handler
// now we can call
handler.ServeHTTP(w, r) // this calls myHandler(w, r)
This clever use of function types allows Go to maintain simplicity and avoid boilerplate code.
On the three-layer function example above, we even simplify things further by using an anonymous function for Phase 3:
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request
){
// do work
next.ServeHTTP(w, r)
})
This avoids the need to define a separate named function for the request handler, keeping the middleware code concise.
All the 'complexity' above is actually for writing more concise middleware, if its processing logic is relatively simple. For more complex middleware, you can always define named types and methods as needed:
type SomeMiddleware struct {
next http.Handler
config SomeConfigType
}
func (m *SomeMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// do work
m.next.ServeHTTP(w, r)
}
// setup
func NewSomeMiddleware(next http.Handler, config SomeConfigType) *SomeMiddleware {
return &SomeMiddleware{
next: next, config: config
}
}
This approach is more verbose, but allows for more complex state and behavior within the middleware.
Using explicit structs:
- Better suited for complex middleware with many states
- Better when methods beyond
ServeHTTPare needed - Easier testing in isolation
- More boilerplate code
http.HandlerFunc:
- Better suited for simpler middleware
- Has less boilerplate code
- More idiomatic Go
- Closures capture variables naturally
- Can become unwieldy for complex logic
The Full Journey: Request Flows In, Response Flows Out
HTTP Request arrives
↓
┌──────────────────────────────────────────────────┐
│ Auth.ServeHTTP(w, r) CALLED │
│ ├─ verify token (CODE BEFORE next) │
│ ├─ ctx = context.WithValue(...) │
│ │ │
│ ├─ next.ServeHTTP(w, r) ──────┐ │
│ │ │ │
│ │ ┌──────────────────────────▼───────────────┐│
│ │ │ Logging.ServeHTTP(w, r) CALLED ││
│ │ │ ├─ start = time.Now() (BEFORE next) ││
│ │ │ │ ││
│ │ │ ├─ next.ServeHTTP(w, r) ─────┐ ││
│ │ │ │ │ ││
│ │ │ │ ┌─────────────────────────▼────────┐││
│ │ │ │ │ Handler.ServeHTTP(w, r) CALLED │││
│ │ │ │ │ ├─ do work │││
│ │ │ │ │ ├─ w.Write("response") │││
│ │ │ │ │ └─ RETURN ──────────┐ │││
│ │ │ │ └───────────────────────│──────────┘││
│ │ │ │ │ ││
│ │ │ │ Handler returned ◄──────┘ ││
│ │ │ │ ││
│ │ │ └─ log.Printf() (CODE AFTER next) ││
│ │ │ └─ RETURN ───────────┐ ││
│ │ └────────────────────────│─────────────────┘│
│ │ │ │
│ │ Logging returned ◄───────┘ │
│ │ │
│ └─ cleanup (CODE AFTER next) │
│ └─ RETURN ──────────┐ │
└───────────────────────│──────────────────────────┘
│
Auth returned ◄───────┘
↓
Response sent to client
Each middleware is actually called once, but they have two execution phases:
func Auth(...) {
return http.HandlerFunc(func(w, r) {
verify() // ← Runs on the way IN
next.ServeHTTP() // ← Go deeper
cleanup() // ← Runs on the way OUT
})
}
- Going IN (before next.ServeHTTP)
- Coming OUT (after next.ServeHTTP)
- Handler writes response and returns
- Control returns to logging
- Logging's post-processing runs
- Control returns to auth
- Auth's post-processing runs
- Auth returns
- HTTP server sends buffered response
One important thing to note here is that these operations are completely synchronous - just regular functions calls; no async/await, no promises, no callbacks. This makes reasoning about middleware flow straightforward. For the above example, when Auth calls next.ServeHTTP():
- Thread blocks (waits synchronously)
- Executes
Logging.ServeHTTP() - Which blocks and executes
Handler.ServeHTTP() - Handler finishes, returns
- Back to
Logging, finishes and returns - Back to
Auth, finishes and returns - HTTP response sent to client
What does this mean for multiple requests?
Each HTTP request gets its own goroutine (Go's lightweight thread):
// When you do:
http.ListenAndServe(":8080", router)
// Go's server doe sthis internally for EACH incoming request:
go func() {
router.ServeHTTP(w, r) // each request handled in its own goroutine
}()
Therefore, multiple requests can be handled concurrently, each with its own call stack and middleware flow. The synchronous nature of middleware applies per request, but many requests can be processed in parallel thanks to goroutines.
Goroutines vs Threads
- OS Thread: Heavy (~1-2 MB stack), expensive to create, limited (thousands per process)
- Goroutine: Lightweight (~2 KB stack initially), cheap to create, millions per process
Common Patterns
Early Return (Stop the Onion)
if !authorized {
http.Error(w, "Forbidden", 403)
return // Don't call next
}
next.ServeHTTP(w,r)
Post-Processing (After Next Returns)
start := time.Now()
next.ServeHTTP(w, r)
duration := time.Since(start)
log.Printf("Request took %s", duration)
Error Recovery
defer func() {
if r := recover(); r != nil {
http.Error(w, "Internal Server Error", 500)
}
}()
next.ServeHTTP(w, r)
Key Takeaways
- Onion, not chain: Each layer wraps the next
- Three-function layers: Registration -> Chain Building -> Request Handling
- Immutable context: Always create a new context with previous ones added
HandlerFuncis syntactic sugar: It is a function type with a method that implements theHandlerinterface.- Implicit implementation: Any type (struct or function) with a method matching the
ServeHTTPsignature implicitly implements theHandlerinterface. - Each layer is isolated: Each layer only knows about its immediate next, nothing more.
- Each request is handled synchronously within its own goroutine, allowing efficient concurrency without traditional threading overhead.
- Request flows in, response flows out: The request travels down the onion layers, and the response travels back up. The middleware can act on both directions.