05 - Middleware

📋 Jump to Takeaways

Middleware is code that runs before or after your handler: logging, authentication, panic recovery, CORS, rate limiting. In Go, middleware is just a function that wraps an http.Handler.

The Pattern

func middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // before
        next.ServeHTTP(w, r)
        // after
    })
}

That's it. Take a handler, return a handler. The next.ServeHTTP call passes control to the next handler in the chain.

Request Logger

Log every request with method, path, and duration:

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

This logs timing but not the response status code. Capturing the status requires wrapping http.ResponseWriter — that's more code than it's worth for now. If you need it later, search for "ResponseWriter wrapper" or use a library like httpsnoop.

Panic Recovery

A panic in a handler crashes the entire server. Recover from it:

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 recovered",
                    "error", err,
                    "path", r.URL.Path,
                )
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Always add this in production. Without it, one bad request takes down your server.

Request ID

Generate a unique ID for each request. Makes it easy to trace a request through logs:

type contextKey string

We define a custom type for the context key to avoid collisions. If two packages both use the plain string "request_id" as a context key, they'd overwrite each other. A custom type makes the key unique to this package — only code that has access to contextKey can read the value.

const requestIDKey contextKey = "request_id"

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()
        }

        w.Header().Set("X-Request-ID", id)
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func GetRequestID(ctx context.Context) string {
    if id, ok := ctx.Value(requestIDKey).(string); ok {
        return id
    }
    return "unknown"
}

func generateID() string {
    b := make([]byte, 8)
    rand.Read(b)
    return hex.EncodeToString(b)
}

Now your logger can include the request ID:

func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("request",
            "request_id", GetRequestID(r.Context()),
            "method", r.Method,
            "path", r.URL.Path,
            "duration", time.Since(start),
        )
    })
}

Chaining Middleware

Apply multiple middleware by wrapping them:

func main() {
    mux := http.NewServeMux()
    registerHealthRoutes(mux)
    registerBookmarkRoutes(mux)

    // Wrap: RequestID → Recovery → Logger → mux
    handler := RequestID(Recovery(Logger(mux)))

    slog.Info("listening", "addr", ":8080")
    log.Fatal(http.ListenAndServe(":8080", handler))
}

Order matters. The nesting reads inside-out, but executes outside-in. When a request arrives, it peels the layers from the outside:

Request → RequestID → Recovery → Logger → mux → Response
  1. RequestID runs first — generates an ID
  2. Recovery runs second — catches panics
  3. Logger runs third — logs the request (with the ID from step 1)
  4. mux handles the actual route

A Chain Helper

If nesting gets ugly, write a simple chain function:

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

Usage:

handler := Chain(mux, RequestID, Recovery, Logger)

Reads left to right: RequestID → Recovery → Logger → mux. The reverse loop ensures the first middleware in the list is the outermost wrapper.

The order is intentional. RequestID runs first so the ID is available to all inner middleware. Recovery wraps the handler so panics are caught. Logger wraps Recovery so it measures the full request duration, including panic recovery.

CORS Middleware

If your API is called from a browser on a different domain:

func CORS(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusOK)
            return
        }

        next.ServeHTTP(w, r)
    })
}

In production, replace "*" with your actual frontend domain. This is a minimal CORS setup — production APIs may also need Access-Control-Max-Age (to cache preflight responses) and Access-Control-Allow-Credentials: true (if the frontend sends cookies or auth headers).

Route-Specific Middleware

Not all middleware should apply to every route. Wrap individual handlers:

// Auth middleware for protected routes only
// (we won't build Auth in this course — this shows the pattern)
mux.Handle("POST /bookmarks", Auth(http.HandlerFunc(createBookmark)))
mux.Handle("DELETE /bookmarks/{id}", Auth(http.HandlerFunc(deleteBookmark)))

// Public routes — no auth
mux.HandleFunc("GET /bookmarks", listBookmarks)
mux.HandleFunc("GET /health", healthCheck)

Key Takeaways

  • Middleware is func(http.Handler) http.Handler. Take a handler, return a handler
  • next.ServeHTTP(w, r) passes control to the next handler
  • Always use Recovery middleware in production
  • RequestID + Logger gives you request tracing for free
  • Chain middleware with nesting or a Chain helper
  • Apply middleware globally (wrap the mux) or per-route (wrap individual handlers)
  • Order matters — outermost runs first

🚀 Ready to run?

Complete examples for this lesson. Copy and run locally.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

© 2026 ByteLearn.dev. Free courses for developers. · Privacy