Skip to content
ExponaBLOG
GO PROGRAMMING & PRODUCTION APIS · DAY 020 / 100

Middleware in Go: a practical guide for developers

Wrap handlers for logging, auth and recovery.

Expona Admin6 min read

Middleware is the backbone of production API servers. It's where you handle cross-cutting concerns—logging, authentication, error recovery—without cluttering your business logic. Middleware in Go: a practical guide for developers will show you how to chain handlers cleanly and keep your codebase maintainable as complexity grows.

The shape — A function that takes and returns an http.Handler

At its core, middleware in Go follows a predictable pattern: a function that accepts an http.Handler and returns a new http.Handler. This is sometimes called the adapter pattern, and it's elegant because it composes nicely.

Let's start with a logging middleware example. When a request arrives, we want to know what path was hit, how long it took, and ideally capture any errors or status codes.

go
package main

import (
	"log/slog"
	"net/http"
	"time"
)

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		slog.Info("req",
			"path", r.URL.Path,
			"method", r.Method,
			"dur", time.Since(start),
		)
	})
}

Notice the structure: logging takes next http.Handler and returns a new handler via http.HandlerFunc. Inside, we record the start time, call the inner handler, then log the result. The inner handler runs with the same ResponseWriter and Request, so it can modify headers and write a response body.

This pattern works because http.Handler is just an interface with a single method: ServeHTTP(ResponseWriter, *Request). Any function with that signature can be a handler. By wrapping handlers this way, we can inject behavior before and after the next layer.

Chain them — Recovery, request ID, logging, auth

Real production servers don't use a single middleware. You'll chain several: one for panic recovery, one for request tracing IDs, one for logging, and one for authentication. The order matters.

Consider this middleware stack for a typical API:

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

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = generateID()
		}
		r.Header.Set("X-Request-ID", id)
		w.Header().Set("X-Request-ID", id)
		next.ServeHTTP(w, r)
	})
}

func auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}
		// Validate token, extract user info
		next.ServeHTTP(w, r)
	})
}

Now chain them on your main route handler or multiplexer:

go
func main() {
	mux := http.NewServeMux()

	// Define your business logic handler
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"status":"ok"}`))
	})

	// Wrap with middleware, innermost first
	wrapped := recovery(requestID(logging(auth(handler))))

	mux.Handle("/api/status", wrapped)
	http.ListenAndServe(":8080", mux)
}

The order is crucial: recovery wraps everything, so panics are caught; requestID runs next and sets the tracing ID; logging records the request; auth validates credentials. If auth fails early, logging still fires for that failed request, which is what we want.

Context values — Store user info in the request context

Middleware often needs to pass data downstream to handlers. The request context is the idiomatic way to do this in Go. After validating a token, store the user information in the context so your handler can access it without globals or function parameters.

go
package main

import (
	"context"
	"log/slog"
	"net/http"
)

type User struct {
	ID    string
	Email string
	Role  string
}

func auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		if token == "" {
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
			return
		}

		// Parse and validate token (simplified)
		user := &User{
			ID:    "user123",
			Email: "user@example.com",
			Role:  "admin",
		}

		// Store in context
		ctx := context.WithValue(r.Context(), "user", user)
		r = r.WithContext(ctx)

		next.ServeHTTP(w, r)
	})
}

func statusHandler(w http.ResponseWriter, r *http.Request) {
	user := r.Context().Value("user").(*User)
	slog.Info("user accessed status", "user_id", user.ID, "role", user.Role)

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"status":"ok","user":"` + user.Email + `"}`))
}

This approach keeps your handler code clean. The middleware handles authentication and enrichment; the handler just pulls what it needs from the context. For type safety, consider defining custom context key types:

go
type contextKey string

const userKey contextKey = "user"

// In auth middleware:
ctx := context.WithValue(r.Context(), userKey, user)

// In handler:
user := r.Context().Value(userKey).(*User)

This prevents accidental key collisions from string literals used elsewhere.

Keep them small — One responsibility per middleware

A common pitfall is cramming too much into one middleware. A middleware should have a single, clear job: log requests, validate auth, add tracing, recover from panics. Not all four at once.

This approach makes testing easier and reasoning about your stack simpler. Here's what good decomposition looks like in a real service:

Middleware Responsibility
recovery Catch panics, return 500
requestID Assign or propagate trace ID
logging Record request/response metrics
auth Validate token, extract user
rateLimit Enforce rate limits per user

Each is testable in isolation. You can mock the next handler and verify that your middleware performs its duty correctly.

go
func TestAuthMiddleware(t *testing.T) {
	var capturedUser *User
	nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		capturedUser = r.Context().Value(userKey).(*User)
	})

	authMiddleware := auth(nextHandler)

	req := httptest.NewRequest("GET", "/", nil)
	req.Header.Set("Authorization", "Bearer valid-token")
	w := httptest.NewRecorder()

	authMiddleware.ServeHTTP(w, req)

	if capturedUser == nil || capturedUser.ID != "user123" {
		t.Fatalf("expected user in context, got %v", capturedUser)
	}
}

Common mistakes

Forgetting to call next.ServeHTTP(): If your middleware doesn't invoke the inner handler, the request chain stops. You'll silently drop requests.

Modifying the response after calling next.ServeHTTP(): Once next.ServeHTTP() writes to the response, setting headers won't work. Middleware that needs to set headers must do so before calling next. Use a response wrapper if you need to inspect or modify the status code after the fact.

Wrong middleware order: Placing auth before recovery means an auth panic won't be caught. Always think through the execution flow.

Storing too much in context: Context is convenient, but overusing it makes code harder to follow. Pass heavy objects as pointers and keep context values lean—IDs, user info, trace IDs.

Middleware that blocks indefinitely: If a middleware deadlocks waiting for a database or external service, all requests hang. Add timeouts and circuit breakers where needed.

Key takeaways

  • Middleware follows the adapter pattern: a function that takes and returns http.Handler.
  • Chain multiple middleware by wrapping handlers, with order mattering for control flow and error handling.
  • Use request context to pass user info and request-scoped data from middleware to handlers.
  • Keep each middleware focused on one job: logging, auth, recovery, tracing, or rate limiting—not all at once.
  • Test middleware independently by providing a mock http.Handler as next and verifying behavior with httptest.
  • Always call next.ServeHTTP() unless your middleware intentionally terminates the request chain.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles