Mustafa Can Yücel
blog-post-34

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:
  1. Call Auth(firebaseApps) which returns authMiddleware func(Handler) Handler
  2. Call Logging() which returns loggingMiddleware func(Handler) Handler
  3. Build the onion: authMiddleware(loggingMiddleware(myHandler))
This results in a final handler structure of:
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 ServeHTTP are needed
  • Easier testing in isolation
  • More boilerplate code
Using 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
    })
}
  1. Going IN (before next.ServeHTTP)
  2. Coming OUT (after next.ServeHTTP)
    1. Handler writes response and returns
    2. Control returns to logging
    3. Logging's post-processing runs
    4. Control returns to auth
    5. Auth's post-processing runs
    6. Auth returns
    7. 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():

  1. Thread blocks (waits synchronously)
  2. Executes Logging.ServeHTTP()
  3. Which blocks and executes Handler.ServeHTTP()
  4. Handler finishes, returns
  5. Back to Logging, finishes and returns
  6. Back to Auth, finishes and returns
  7. HTTP response sent to client
One thread, synchronous execution, normal call stack.

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
Go's runtime multiplexes goroutines onto a small pool of OS threads, allowing efficient concurrency. This means that even though each request runs synchronously in its own goroutine, the overall server can handle many requests concurrently without the overhead of traditional threading models.

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
  • HandlerFunc is syntactic sugar: It is a function type with a method that implements the Handler interface.
  • Implicit implementation: Any type (struct or function) with a method matching the ServeHTTP signature implicitly implements the Handler interface.
  • 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.