DEV Community

Cover image for Go HTTP middleware explained: what it is, how it works, and how to build your own
Fer Rios
Fer Rios

Posted on • Originally published at ferztyle.me

Go HTTP middleware explained: what it is, how it works, and how to build your own

This is part 2 of 2 in the series Go HTTP middleware from scratch. If you're new to Go types, → read part 1 first.


The same code keeps showing up everywhere

You're building an HTTP server. Every request needs a unique ID for tracing. Every request needs to check authentication. Every request needs its duration logged. The naive approach: copy that code into every handler.

// Without middleware — same boilerplate in every single handler
func getUserHandler(w http.ResponseWriter, r *http.Request) {
    // Auth check — copy/pasted from every other handler
    token := r.Header.Get("Authorization")
    if token == "" {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }

    // Request ID — copy/pasted from every other handler
    requestID := uuid.New().String()
    log.Printf("requestID=%s handling getUser", requestID)

    // actual handler logic finally starts here...
}
Enter fullscreen mode Exit fullscreen mode

Do this across 20 handlers, and you have a maintenance nightmare. Change the auth logic once, and you have to update 20 files.

Middleware is the Go way to solve this. Write the cross-cutting logic once, wrap it around your handlers, and never think about it again.


What is this post about

  • What middleware is and how Go's HTTP model makes it natural.

  • How the http.Handler interface works, the foundation everything builds on.

  • How to write your own middleware from scratch.

  • How to chain multiple middleware together.

  • How to pass data through the chain using context.

  • Tips for advanced usage: ordering, third-party routers, and common pitfalls.


The foundation: the http.Handler interface

As I mentioned in the first part of this serie, everything in Go's HTTP stack is built on one tiny interface:

// This is the entire http.Handler interface — just one method.
// Any type that has a ServeHTTP method satisfies it automatically.
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
Enter fullscreen mode Exit fullscreen mode

If you've read the previous posts in this series, you'll recognize this pattern. Go interfaces are just method signatures. Any Go type with ServeHTTP(ResponseWriter, *Request) is automatically an HTTP handler. No registration, no declaration. If you remember, http.HandlerFunc is a convenience type that lets you turn a plain function into an http.Handler:

// HandlerFunc is a function type that also implements http.Handler.
// This is how you write handlers without creating a struct.
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}
Enter fullscreen mode Exit fullscreen mode

This means these two ways of writing a handler are equivalent:

// Option A: using http.HandleFunc, to convert a plain function 
// to a HandlerFunc type
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hello")
})

// Option B: a struct implementing http.Handler
type HelloHandler struct{}

func (h HelloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hello")
}
Enter fullscreen mode Exit fullscreen mode

Most people use option A for simple handlers. The struct approach becomes useful when your handler needs to hold state (like a database client).


What is middleware?

Middleware is a function that:

  1. Accepts an http.Handler.

  2. Returns a new http.Handler.

  3. Does something before and/or after calling the original handler.

// This is the shape of every middleware in Go.
// It wraps a handler and returns a new one.
func MyMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Do something BEFORE the handler runs
        // (auth check, logging, adding context...)

        next.ServeHTTP(w, r) // call the actual handler

        // Do something AFTER the handler runs
        // (log the response, record timing...)
    })
}
Enter fullscreen mode Exit fullscreen mode

The next parameter is the handler this middleware wraps. Calling next.ServeHTTP(w, r) passes control forward, like handing a baton in a relay race.

The relay race explained

Here's the mental model that makes middleware click:

Request comes in
      │
      ▼
┌─────────────────┐
│   Middleware 1    ← does its work (e.g. logs start time)
│       │         
│       ▼         
│  Middleware 2     ← does its work (e.g. checks auth)
│       │         
│       ▼         
│    Handler        ← does the actual business logic
│       │         
│       ▼         
│  Middleware 2     ← does its work after (e.g. checks result)
│       │         
│       ▼         
│  Middleware 1     ← does its work after (e.g. logs duration)
└─────────────────┘
      │
      ▼
Response goes out
Enter fullscreen mode Exit fullscreen mode

Each middleware wraps the next layer. Code before next.ServeHTTP runs on the way in. Code after runs on the way out. This is sometimes called the "onion model."


Building three real middleware functions

Let's build three practical middleware functions and chain them together. You can find the repo here.

But before writing any code, here's how the project is laid out. You will get a clearer understanding of how a Golang project should be structured if you read this post first about Go Packages. Each middleware gets its own file inside a shared middleware package, and cmd/server/main.go wires everything together.

go-middleware-examples/
├── go.mod
├── cmd/
│   └── server/
│       └── main.go            ← entry point — registers routes and chains middleware
└── internal/
    └── middleware/
        ├── requestid.go       ← RequestID middleware + context key + getter
        ├── logger.go          ← Logger middleware + responseWriter wrapper
        ├── auth.go            ← Auth middleware
        └── chain.go           ← Chain helper for readable middleware composition
Enter fullscreen mode Exit fullscreen mode

A few decisions worth noting:

  • All middleware lives under internal/middleware/, it's private to this module. If you later wanted to publish this middleware as a reusable library for other projects, it would move to pkg/middleware/ instead.

  • Each middleware gets its own file even though they all declare package middleware. This keeps each one focused and easy to find, requestid.go only ever deals with request IDs, auth.go only ever deals with authentication.

  • The context key (RequestIDKey) and its getter (GetRequestID) live in the same file as the middleware that sets it (requestid.go). Anything that reads the request ID imports the middleware package and calls middleware.GetRequestID(ctx), never reaching into the context directly. You can know more about Go contexts here.

  • cmd/server/main.go stays thin, it only registers handlers and chains middleware together. No middleware logic lives there.

From the root directory (go-middleware-examples), run the following commands to create your Go project, and install the uuid package:

go mod init github.com/<your-username>/go-middleware-examples
go get github.com/google/uuid
Enter fullscreen mode Exit fullscreen mode

You can name your project as you want. I usually name my projects according to my GitHub app repo name.

Here's the go.mod for reference:

module github.com/FerRiosCosta/go-middleware-examples

go 1.26.3

require github.com/google/uuid v1.6.0 // indirect
Enter fullscreen mode Exit fullscreen mode

Now let's build each file.

Request ID middleware

Please consider the first comment line, which specifies where the file should be created. This middleware attaches a unique ID to every request. Useful for tracing a single request through your logs.

// internal/middleware/requestid.go
package middleware

import (
    "context"
    "net/http"

    "github.com/google/uuid"
)

// key is an unexported type for context keys in this package.
// Using a custom type prevents collisions with keys from other packages.
// (Two packages both using the string "requestID" would overwrite each other.)
type key string

// RequestIDKey is the context key for the request ID.
// Exported so handlers in other packages can retrieve it.
const RequestIDKey key = "requestID"

// RequestID generates a unique ID for every incoming request,
// stores it in the context, and adds it to the response headers.
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Generate a new UUID for this request.
        id := uuid.New().String()

        // Store the ID in a new context derived from the request's context.
        // r.Context() returns the existing context, we build on top of it,
        // not replace it, so any previously set values are preserved.
        ctx := context.WithValue(r.Context(), RequestIDKey, id)

        // Add the ID to the response header.
        // This lets clients (and load balancers) correlate their logs with yours.
        w.Header().Set("X-Request-ID", id)

        // This is the line from the previous explanation:
        // create a copy of the request with the new context attached,
        // then pass it to the next handler in the chain.
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// GetRequestID is a helper to retrieve the request ID from a context.
// Handlers call this instead of reaching into the context directly.
func GetRequestID(ctx context.Context) string {
    id, _ := ctx.Value(RequestIDKey).(string)
    return id
}
Enter fullscreen mode Exit fullscreen mode

Logger middleware

Logs every request: method, path, duration, and status code. But, before looking at the full code, let's first try to understand how this logger middleware works under the hood.

What is http.ResponseWriter?

Before looking at the code, it helps to understand the type we're about to wrap.

http.ResponseWriter is an interface, every handler you write receives one as its first argument. It's how your handler sends a response back to the client: the status code, the headers, and the body.

// http.ResponseWriter is an interface with three methods:
type ResponseWriter interface {
    Header() http.Header         // get/set response headers
    Write([]byte) (int, error)   // write the response body
    WriteHeader(statusCode int)  // write the status code (200, 404, 500...)
}
Enter fullscreen mode Exit fullscreen mode

A typical handler uses it like this:

func myHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)        // write the status code
    w.Write([]byte("hello!"))            // write the body
}
Enter fullscreen mode Exit fullscreen mode

The problem: you can't read the status code back

Here's the catch. http.ResponseWriter lets you write a status code, but it has no method to read it back afterward. There's no w.StatusCode() you can call. Once you write it, it's gone, sent straight to the client over the network.

This is a problem for our Logger middleware. Remember the relay race model: Logger calls next.ServeHTTP(w, r), waits for the handler to finish, and only then logs the result. But by the time the handler finishes, it has already called w.WriteHeader(404) (or whatever status it chose), and Logger has no way to find out what that status was. The information is gone.

The solution: wrap it in our own struct

To solve this, we create our own type that:

  1. Embeds the original http.ResponseWriter, so it still behaves exactly the same way for the handler

  2. Adds a field to remember the status code

  3. Intercepts the one method we care about (WriteHeader) to save the value before passing it through.

// responseWriter wraps http.ResponseWriter to capture the status code.
// http.ResponseWriter doesn't expose the status code after it's written,
// so we intercept WriteHeader to save it.
type responseWriter struct {
    http.ResponseWriter            // embed the original, we get all its methods for free
    statusCode         int         // the status code the handler wrote
}
Enter fullscreen mode Exit fullscreen mode

The embedded http.ResponseWriter is the key part. In Go, embedding a type inside a struct means the outer struct automatically gets all of the embedded type's methods, for free, with no extra code. So our responseWriter already has Header(), Write(), and WriteHeader() just by embedding http.ResponseWriter. As far as the handler is concerned, our responseWriter looks and behaves exactly like a normal http.ResponseWriter, because it satisfies the same interface.

The only thing left to do is override WriteHeader, the one method we actually need to intercept:

// WriteHeader intercepts the status code before passing it through.
func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code               // save it for logging
    rw.ResponseWriter.WriteHeader(code) // pass it through to the real writer
}
Enter fullscreen mode Exit fullscreen mode

This is the trick: when our responseWriter defines its own WriteHeader method, that one overrides the embedded version. So when a handler calls w.WriteHeader(404), it actually calls our version, which saves 404 into rw.statusCode, then calls the original ResponseWriter.WriteHeader(404) so the client still receives the real response. The handler never notices anything different happened.

Now Logger can read wrapped.statusCode after the handler finishes, something that was impossible with a plain http.ResponseWriter.

Here's the full middleware:

// internal/middleware/logger.go
package middleware

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

// responseWriter wraps http.ResponseWriter to capture the status code.
// http.ResponseWriter doesn't expose the status code after it's written,
// so we intercept WriteHeader to save it.
type responseWriter struct {
    http.ResponseWriter            // embed the original — we get all its methods for free
    statusCode         int         // the status code the handler wrote
}

// WriteHeader intercepts the status code before passing it through.
func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code               // save it for logging
    rw.ResponseWriter.WriteHeader(code) // pass it through to the real writer
}

// Logger logs each request's method, path, status code, duration,
// and request ID (if set by the RequestID middleware).
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Record when the request arrived.
        start := time.Now()

        // Wrap the ResponseWriter so we can capture the status code.
        wrapped := &responseWriter{
            ResponseWriter: w,
            statusCode:     http.StatusOK, // default to 200 if WriteHeader is never called
        }

        // Call the next handler — this is where the actual work happens.
        next.ServeHTTP(wrapped, r)

        // By the time we reach here, the handler has finished.
        // We can now log everything we know about this request.
        slog.Info("request",
            "method",     r.Method,
            "path",       r.URL.Path,
            "status",     wrapped.statusCode,
            "duration",   time.Since(start).String(),
            "requestID",  GetRequestID(r.Context()), // set by RequestID middleware
        )
    })
}
Enter fullscreen mode Exit fullscreen mode

Auth middleware

Checks for a valid Bearer token before allowing the request through.

This one looks more complicated than RequestID and Logger at first glance, it's a function that returns a function that returns a function. Let's slow down and unpack why, before looking at the full code.

Why Auth needs an extra layer

RequestID and Logger both have this shape:

func RequestID(next http.Handler) http.Handler {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

One input (next), one output (http.Handler). That's the standard middleware shape we covered earlier.

But Auth needs something extra: a configurable token. Every other middleware works the same way no matter what, but Auth needs to know which token counts as valid, and that's different for every app (and usually comes from an environment variable, not a hardcoded value).

So Auth can't just be a middleware, it needs to be a function that produces a middleware, once you tell it which token to check:

// Auth is NOT middleware itself.
// It's a function that BUILDS a middleware, once you give it a token.
func Auth(validToken string) func(http.Handler) http.Handler {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Breaking down the three layers

Here's the same function with each layer labeled:

//        ┌─── Layer 1: takes your config (the token)
//        │
func Auth(validToken string) func(http.Handler) http.Handler {
    //                        └─── Layer 2: this is what gets returned —
    //                             a real middleware function, with the
    //                             standard func(http.Handler) http.Handler shape

    return func(next http.Handler) http.Handler {
        //     └─── Layer 2 starts here. This is now a normal middleware,
        //          identical in shape to RequestID and Logger.

        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            //   └─── Layer 3: the actual handler logic that runs per-request.
            //        This is the same as the inner function in every middleware
            //        you've already seen.

            // ... auth check logic goes here ...
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Three layers, three different jobs:

Layer Runs when Job
1 — Auth(validToken) Once, at startup Receives your config (the token)
2 — func(next http.Handler) http.Handler Once, when you build your middleware chain Receives the next handler to wrap
3 — func(w, r) On every single request Does the actual work — checks the header

Why this pattern, and how you use it

The payoff is that calling Auth looks almost identical to using RequestID or Logger, you just add one extra set of parentheses to pass in the token:

// RequestID and Logger take no config, pass them directly
middleware.RequestID
middleware.Logger

// Auth takes config, call it first to get back a real middleware
middleware.Auth("my-secret-token")   // ← this call returns a middleware function
Enter fullscreen mode Exit fullscreen mode

That's it. Auth("my-secret-token") runs Layer 1 immediately and hands you back Layer 2, a normal func(http.Handler) http.Handler, ready to be used exactly like any other middleware in your Chain:

Chain(mux,
    middleware.RequestID,                // sets the request ID first
    middleware.Logger,                   // then logs — sees the ID
    middleware.Auth("my-secret-token"),  // called with () — returns a middleware
)
Enter fullscreen mode Exit fullscreen mode

If this still feels unfamiliar, it helps to compare it to something simpler, a function that builds a greeting function:

// Same shape, much simpler example.
// MakeGreeter takes a name and returns a NEW function
// that already "remembers" that name.
func MakeGreeter(name string) func() string {
    return func() string {
        return "Hello, " + name + "!"
    }
}

sayHiToAlice := MakeGreeter("Alice") // returns a function
fmt.Println(sayHiToAlice())          // "Hello, Alice!"
Enter fullscreen mode Exit fullscreen mode

Auth does the exact same thing, it just returns a middleware function instead of a greeting function. Once you see MakeGreeter, Auth(validToken) stops looking complicated; it's the same "function that builds and returns another function" pattern, just doing more useful work inside.

Now here's the full code with that context in mind:

// internal/middleware/auth.go
package middleware

import (
    "net/http"
    "strings"
)

// Auth checks for a valid Bearer token in the Authorization header.
// If the token is missing or invalid, it returns 401 and stops the chain —
// next.ServeHTTP is never called, so the handler never runs.
func Auth(validToken string) func(http.Handler) http.Handler {
    // Return a middleware function — this pattern lets us configure
    // middleware with parameters (the token) while keeping the standard shape.
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Read the Authorization header.
            authHeader := r.Header.Get("Authorization")

            // Check the format: "Bearer <token>"
            parts := strings.SplitN(authHeader, " ", 2)
            if len(parts) != 2 || parts[0] != "Bearer" {
                // Respond with 401 and stop. next.ServeHTTP is never called.
                http.Error(w, "missing or malformed token", http.StatusUnauthorized)
                return
            }

            // Validate the token.
            // In a real app you'd verify a JWT or look up the token in a database.
            if parts[1] != validToken {
                http.Error(w, "invalid token", http.StatusUnauthorized)
                return
            }

            // Token is valid — pass the request to the next handler.
            next.ServeHTTP(w, r)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Chaining middleware together

Now let's wire everything up. But first, create your own secret token and paste it into your main.go file, you can create it from here. Remember to also include that token in your request. You can see how to do it in the section about testing the server.

The standard Go way to chain middleware is to nest the calls:

// cmd/server/main.go
package main

import (
    "fmt"
    "net/http"

    "github.com/yourname/myapp/internal/middleware"
)

// userHandler is the actual business logic.
// It doesn't know about auth, logging, or request IDs —
// all of that is handled by middleware before it runs.
func userHandler(w http.ResponseWriter, r *http.Request) {
    // Retrieve the request ID that was set by RequestID middleware.
    id := middleware.GetRequestID(r.Context())
    fmt.Fprintf(w, "hello! your request ID is %s\n", id)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/user", userHandler)

    // Chain middleware by wrapping from the outside in.
    // The outermost middleware runs first on the way IN,
    // and last on the way OUT.
    //
    // RequestID must wrap Logger — not the other way around.
    // Logger only ever sees the *r it directly receives. If RequestID
    // runs AFTER Logger, Logger is holding the original request,
    // not the enriched one RequestID produces — so the ID would be empty.
    //
    // Execution order for a request:
    //   RequestID → Logger → Auth → userHandler
    //               (then back out in reverse)
    handler := middleware.RequestID(
        middleware.Logger(
            middleware.Auth("your-secret-token")(
                mux,
            ),
        ),
    )

    http.ListenAndServe(":8080", handler)
}
Enter fullscreen mode Exit fullscreen mode

Testing the server

# Start the server
go run ./cmd/server

# Valid request — should return 200 with a request ID
curl -H "Authorization: Bearer your-secret-token" http://localhost:8080/user

# Missing token — should return 401
curl http://localhost:8080/user

# Invalid token — should return 401
curl -H "Authorization: Bearer wrong-token" http://localhost:8080/user
Enter fullscreen mode Exit fullscreen mode

Server logs:

2026/06/21 20:46:13 INFO request method=GET path=/user status=200 duration=7.208µs requestID=e667de2b-310a-4038-9891-2d948613e93b
2026/06/21 20:46:15 INFO request method=GET path=/user status=200 duration=90.667µs requestID=23a61730-4dd2-4b01-8dc5-986fbcb46436
Enter fullscreen mode Exit fullscreen mode

Every request gets logged. The request ID ties your server log to the X-Request-ID header in the response, if a client reports a bug, they can give you their request ID and you can find exactly that request in your logs.


Best practices

Order matters, RequestID before Logger, Logger before Auth

The outermost middleware runs first on the way in and last on the way out. Two rules drive the order here:

  • RequestID must be outermost (or at least before Logger), Logger can only read context values from the exact *http.Request it was handed. context.WithValue and r.WithContext don't mutate the original request, they return a new one. If RequestID runs after Logger, Logger is stuck holding the original, un-enriched request, GetRequestID will always come back empty. If you are new to Go contexts, you can check this post about it; it explains everything you need to know about Go contexts.

  • Logger should wrap Auth, so that failed (401) requests still get logged, not just successful ones.

// Good order
Chain(mux,
    middleware.RequestID, // outermost: enriches the request first
    middleware.Logger,    // sees the enriched request — requestID shows up in logs
    middleware.Auth(...), // innermost: blocks invalid requests, but they're still logged
)
Enter fullscreen mode Exit fullscreen mode
// Bad order — this is the bug you'll hit if you swap these two
Chain(mux,
    middleware.Logger,    // ← runs first, but hasn't seen RequestID's context yet
    middleware.RequestID, // ← enriches the request, but only for handlers AFTER this point
    middleware.Auth(...),
)
// Logger's slog.Info call reads r.Context() from its OWN copy of r —
// which was captured before RequestID ever ran. requestID="" every time.
Enter fullscreen mode Exit fullscreen mode

The general rule: any middleware that reads a context value must be positioned after (further in than) the middleware that sets it.

Never call next.ServeHTTP more than once

Calling it twice sends a double response, which causes a panic in Go's HTTP stack. Always return after an early exit:

// Good — returns after writing the error
if !valid {
    http.Error(w, "unauthorized", http.StatusUnauthorized)
    return // ← critical: stops execution here
}
next.ServeHTTP(w, r)

// Bad — falls through and calls next anyway
if !valid {
    http.Error(w, "unauthorized", http.StatusUnauthorized)
    // missing return — next.ServeHTTP still runs!
}
next.ServeHTTP(w, r)
Enter fullscreen mode Exit fullscreen mode

Use unexported context keys

Always define context keys as a custom unexported type, not a plain string. This prevents key collisions between packages:

// Bad — any package using the string "userID" will collide
ctx = context.WithValue(ctx, "userID", id)

// Good — only this package can access this key
type key string
const userIDKey key = "userID"
ctx = context.WithValue(ctx, userIDKey, id)
Enter fullscreen mode Exit fullscreen mode

Tips for advanced users

Tip 1: Use a third-party router for cleaner middleware

The standard library's http.ServeMux applies middleware to all routes at once. If you need per-route middleware (auth on some routes, not others), a router like chi or gorilla/mux makes this clean:

r := chi.NewRouter()

r.Use(middleware.Logger)    // applies to all routes
r.Use(middleware.RequestID) // applies to all routes

r.Group(func(r chi.Router) {
    r.Use(middleware.Auth("secret")) // applies only to routes in this group
    r.Get("/admin", adminHandler)
    r.Get("/users", usersHandler)
})

r.Get("/health", healthHandler) // no auth required
Enter fullscreen mode Exit fullscreen mode

Tip 2: Capture response body size

Extend responseWriter to also track bytes written — useful for monitoring:

type responseWriter struct {
    http.ResponseWriter
    statusCode int
    bytes      int
}

func (rw *responseWriter) Write(b []byte) (int, error) {
    n, err := rw.ResponseWriter.Write(b)
    rw.bytes += n // track how many bytes were written
    return n, err
}
Enter fullscreen mode Exit fullscreen mode

Tip 3: Recover from panics

A panic in any handler crashes the goroutine handling that request. A recovery middleware catches it and returns a 500 instead of crashing:

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, "requestID", GetRequestID(r.Context()))
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}
Enter fullscreen mode Exit fullscreen mode

Add this as the outermost middleware so it catches panics from every layer:

Chain(mux,
    middleware.Recovery,  // outermost — catches panics from every layer below
    middleware.RequestID, // sets the request ID next
    middleware.Logger,    // sees the enriched request — requestID shows up in logs
    middleware.Auth("secret"),
)
Enter fullscreen mode Exit fullscreen mode

Summary

Middleware is just a function that wraps a handler and returns a new one. Everything else follows from that.

Three things to take away:

  • next.ServeHTTP(w, r.WithContext(ctx)), this is the line that passes the request forward with an enriched context. Understanding it unlocks the whole pattern.

  • Order matters, outermost middleware runs first on the way in, last on the way out. Anything that sets a context value (like RequestID) must come before anything that reads it (like Logger), or the value will be empty.

  • Always return after early exits, calling next.ServeHTTP after writing an error response causes a double response panic.

The three middleware functions in this post, RequestID, Logger, and Auth, are production-ready. Add Recovery and you have a solid foundation for any Go HTTP server.

Top comments (0)