GoNetworking

Go Middleware Patterns: Constructing Robust Handlers

TT
TopicTrick Team
Go Middleware Patterns: Constructing Robust Handlers

Go Middleware: The Interceptor Mirror

When building a production web server, you'll quickly realize that you need the same logic applied to many different routes. You want to log every request, check if a user is authenticated, and recover from unexpected panics without crashing the whole server.

In Go, Middleware is achieved through a simple but powerful pattern: wrapping one http.Handler inside another.

What is Go Middleware?

Go middleware is a function that accepts an http.Handler and returns a new http.Handler, injecting cross-cutting logic — such as logging, authentication, or rate limiting — without modifying individual route handlers. This pattern keeps business logic clean and behaviour consistent across all endpoints.

Middleware works like the layers of an onion. Each layer can process the request before passing it to the "inner" handler, and then process the response again as it "bubbles up" back to the client.


1. The Interceptor Mirror: Handler Decoration Physics

Go's middleware is a masterclass in the Decorator Pattern, mapping perfectly to the CPU's execution stack.

The Wrapping Physics

  • The Stack Mirror: Each middleware "wraps" the inner handler. When a request arrives, the CPU enters the outermost "interceptor" first.
  • The Execution Path: Control flows down the chain until it hits the "Domain Mirror" (the business logic), then moves back up through the post-processing logic in reverse order.
  • The Memory Cost: Every middleware layer adds a small frame to the goroutine's stack. In Go, these stacks are lightweight (2KB), but a chain of 50+ middlewares can trigger stack growth.
  • The Result: You achieve true Separation of Concerns, where the hardware handles the complex interception logic while your code remains linear and readable.

2. The Middleware Signature

A Go middleware is simply a function that takes an http.Handler as an argument and returns a new http.Handler.

go
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Pass control to the NEXT handler in the chain
        next.ServeHTTP(w, r)
        
        // Log the results after the inner handler is done
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

Chaining Middleware

To apply middleware, you "wrap" your main handler. This can become nested and difficult to read if you have many layers.

go
func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", dataHandler)

    // Apply layers manually
    wrappedMux := LoggingMiddleware(RecoveryMiddleware(mux))

    http.ListenAndServe(":8080", wrappedMux)
}

Practical Example: Authentication Middleware

Middleware is the standard place to enforce security. It checks for a valid token or session before allowing the request to reach your business logic.

go
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token != "secret-token" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return // Stop the chain here!
        }
        next.ServeHTTP(w, r)
    })
}

Common Middleware Use Cases

No data available
Task / FeatureStandard HandlerMiddleware Handler
No comparison data available

Building a Middleware Chain Helper

Manually nesting middleware gets unwieldy once you have four or more layers. A simple Chain helper function solves this elegantly.

go
type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", dataHandler)

    // Clean, readable chain — outermost middleware listed first
    handler := Chain(mux, LoggingMiddleware, RecoveryMiddleware, AuthMiddleware)
    http.ListenAndServe(":8080", handler)
}

This pattern is used by popular routers such as Chi and Gorilla Mux internally. You get all the benefits without any external dependency.

Passing Data Between Middleware with Context

Middleware often needs to pass computed values — such as an authenticated user ID — downstream to handlers. Go's context.Context is the idiomatic way to do this.

go
type contextKey string
const UserIDKey contextKey = "userID"

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        userID := validateToken(token) // your validation logic
        
        // Store user ID in the request context
        ctx := context.WithValue(r.Context(), UserIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func dataHandler(w http.ResponseWriter, r *http.Request) {
    // Retrieve the user ID set by AuthMiddleware
    userID := r.Context().Value(UserIDKey).(string)
    fmt.Fprintf(w, "Hello, user %s", userID)
}

This approach avoids global state and keeps each request's data fully isolated, which is critical for concurrent servers.

Panic Recovery Middleware

One of the most important middleware functions in any production Go server is panic recovery. Without it, a single nil-pointer dereference in a handler will crash the entire process.

go
func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic recovered: %v", err)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Notice how this leverages defer and recover() — the same language features covered in our guide on Go panic, recover, and defer. Panic recovery middleware is best placed as the outermost wrapper in your chain so it catches panics from all inner middleware too.

Rate Limiting Middleware

Rate limiting protects your API from abuse. The golang.org/x/time/rate package provides a production-quality token bucket limiter.

go
import "golang.org/x/time/rate"

var limiter = rate.NewLimiter(rate.Every(time.Second), 10) // 10 req/s

func RateLimitMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !limiter.Allow() {
            http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
            return
        }
        next.ServeHTTP(w, r)
    })
}

For IP-level rate limiting in real deployments, maintain a sync.Map of per-IP limiters.

Testing Middleware

Because middleware is just a function, it is straightforward to test in isolation using Go's built-in httptest package. See our dedicated guide on Go testing and benchmarks for a full walkthrough, but here is a quick example:

go
func TestLoggingMiddleware(t *testing.T) {
    handler := LoggingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    }))

    req := httptest.NewRequest("GET", "/test", nil)
    rr := httptest.NewRecorder()
    handler.ServeHTTP(rr, req)

    if rr.Code != http.StatusOK {
        t.Errorf("expected 200, got %d", rr.Code)
    }
}

Real-World Middleware Libraries

While writing your own middleware teaches the fundamentals, several well-maintained libraries add production features out of the box:

Understanding the middleware pattern also underpins Go security best practices, where security headers and input validation are applied globally via middleware rather than in each handler.

All the middleware techniques in this post are applied directly in our comprehensive Go REST API project guide, where we wire up logging, auth, and recovery middleware around CRUD endpoints. If you are also exploring the broader web server layer, review our Go web server with net/http tutorial first.


Phase 21: Middleware Architecture Mastery Checklist

  • Verify Flow Continuation: Ensure every code path in your middleware either calls next.ServeHTTP or writes a terminal response. Short-circuiting without a response leaves the request mirror hanging.
  • Audit Context Mutation: Use r.WithContext() to propagate values downstream. Never use global variables to pass per-request data, as it will break the concurrency mirror.
  • Implement Sovereign Recovery: Place your RecoveryMiddleware as the outermost layer to protect the entire execution chain from developer errors.
  • Test Post-Processing Logic: Confirm that logic running "after" next.ServeHTTP correctly inspects the response status or duration without trying to write new headers.
  • Use Standard Interface Mirrors: Avoid proprietary middleware signatures. Stick to func(http.Handler) http.Handler to remain compatible with the broader Go ecosystem.

Read next: Go Caching with Redis: The Latency Mirror →


Next Steps

Now that you can layer logic onto your handlers, it's time to put everything we've learned together. In our next tutorial, we will execute a Full REST API Project, building a complete tiered application from the database layer to the secure route handlers.

Common Go Middleware Mistakes

1. Not calling next.ServeHTTP(w, r) Middleware that forgets to call the next handler silently swallows all requests past it. Every middleware must either call next.ServeHTTP(w, r) to continue the chain or deliberately short-circuit with a response (e.g. returning 401 for failed auth).

2. Writing headers after calling next Response headers must be set before w.WriteHeader() or any body write. If middleware tries to set a header after next.ServeHTTP has already written the response, the header is silently ignored.

3. Modifying the request after the handler runs r is passed by pointer — modifications to the request are visible to the handler. But middleware runs around the handler: code after next.ServeHTTP sees the original request, not any changes the handler may have made internally.

4. Creating a new http.ResponseWriter without wrapping properly Custom response writers (to capture status codes or response bodies) must implement the full http.ResponseWriter interface including Header(), Write(), and WriteHeader(). Missing any method causes a panic. See the net/http package docs.

5. Applying middleware in the wrong order Middleware executes in the order it wraps the handler. Logging middleware should wrap everything (outermost) so it sees all requests. Auth middleware should run before business logic but after logging. Think of middleware as onion layers — order matters.

Frequently Asked Questions

What is the standard signature for Go HTTP middleware?

go
func MyMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // pre-handler logic
        next.ServeHTTP(w, r)
        // post-handler logic
    })
}

This pattern composes cleanly with any router that accepts http.Handler.

How do I pass values from middleware to handlers? Use context.WithValue to attach values to the request context in middleware, then read them with r.Context().Value(key) in the handler. Use a private key type to avoid collisions with other packages.

Which router has the best middleware support in Go? chi and gorilla/mux both have excellent middleware support. chi's Use method builds a middleware stack cleanly. For stdlib-only solutions, http.Handler chaining works without any third-party dependency.