Middleware
π‘ Since last time: You taught yourself path parameters with r.PathValue(βidβ) and strconv.Itoa β and added a GET /users/{id} endpoint. Great instincts β thatβs exactly how you fetch a single resource in Go 1.22+.
π― Your mission: Production APIs need cross-cutting behavior β request logging, auth checks, timing, rate limiting β applied to every endpoint without repeating code. Middleware is how Go solves this. By the end youβll wrap your existing handlers with a logging layer that tracks every request.
The problem middleware solves
Without middleware, youβd copy logging code into every single handler. With 3 handlers thatβs tedious. With 30 itβs unmaintainable. Middleware lets you write the behavior once and apply it to all handlers. β Go Middleware Patterns
The http.Handler interface
This is the key abstraction. The interface has one method: ServeHTTP(http.ResponseWriter, *http.Request). Both http.NewServeMux() and your middleware-wrapped handler satisfy this interface. Thatβs why theyβre interchangeable. β http.Handler docs
The wrapping pattern
A middleware is a function that takes an http.Handler, wraps it, and returns a new http.Handler. The returned handler does something before calling the original (next.ServeHTTP(w, r)) and/or after it returns. http.HandlerFunc(func(w, r) { ... }) converts a plain function into an http.Handler. β Go by Example: Middleware
The onion model
Middleware stacks like layers of an onion: the outermost middleware runs first on the way in, and last on the way out. Request flows inward through each layer, hits the handler, then flows back outward. This is perfect for things like timing (start timer before, log duration after) or setting response headers.
// Without middleware β every handler repeats logging code:
func getUsers(w, r) { <span class="dim">log(...)</span>; /* actual work */ }
func createUser(w, r) { <span class="dim">log(...)</span>; /* actual work */ }
func getUserById(w, r) { <span class="dim">log(...)</span>; /* actual work */ }
// With middleware β logging lives in one place:
<span class="hl">loggingMiddleware</span>(mux)
β
βΌ
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w, r) {
<span class="dim">// BEFORE: runs for every request</span>
log.Printf("%s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r) <span class="dim">// β pass control to the next handler</span>
<span class="dim">// AFTER: runs after the handler finishes</span>
})
}| Before (lesson 0003) | After (this lesson) |
|---|---|
| http.ListenAndServe(":8080", mux) | http.ListenAndServe(":8080", loggingMiddleware(mux)) |
| Pass the mux directly | Wrap the mux with middleware first |
| No request logging | Every request logged with method, path, and duration |
Follow each step in order. Click Done β after completing it.
mkdir -p ~/dev/learn/go-lang/lessons/0004-middleware
cd ~/dev/learn/go-lang/lessons/0004-middleware
go mod init middlewareHereβs a standalone logging middleware function. Read it line by line β focus on the signature and flow:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("β %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
log.Printf("β %s %s (%v)", r.Method, r.URL.Path, time.Since(start))
})
}Key things to notice:
- The function takes an
http.Handlerand returns anhttp.Handlerβ thatβs the wrapping contract http.HandlerFunc(β¦)converts our anonymous function into a Handler β itβs just an adapternext.ServeHTTP(w, r)is the handoff β everything before it runs inbound, everything after runs outboundtime.Since(start)gives the duration β this is how you time requests
Create main.go in the lesson directory. This is your struct-based server from lesson 0003, upgraded with path-parameter routing and logging middleware:
package main
import (
"encoding/json"
"log"
"net/http"
"strconv"
"time"
)
// --- Middleware ---
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
log.Printf("β %s %s", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
log.Printf("β %s %s (%v)", r.Method, r.URL.Path, time.Since(start))
})
}
// --- Data ---
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
}
var users = []User{
{ID: 1, Name: "Alice", Email: "alice@example.com", CreatedAt: time.Now()},
{ID: 2, Name: "Bob", Email: "bob@example.com", CreatedAt: time.Now()},
}
// --- Handlers ---
func getUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}
func getUserById(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
for _, user := range users {
if strconv.Itoa(user.ID) == id {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
return
}
}
http.Error(w, "user not found", http.StatusNotFound)
}
func createUser(w http.ResponseWriter, r *http.Request) {
var newUser User
err := json.NewDecoder(r.Body).Decode(&newUser)
if err != nil {
http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
defer r.Body.Close()
newUser.ID = len(users) + 1
newUser.CreatedAt = time.Now()
users = append(users, newUser)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(newUser)
}
// --- Main ---
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
getUsers(w, r)
case http.MethodPost:
createUser(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("GET /users/{id}", getUserById)
// Wrap the mux with logging middleware
handler := loggingMiddleware(mux)
log.Println("Server starting on http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}What changed from lesson 0003?
- Added:
loggingMiddlewarefunction that wraps any handler - Added:
GET /users/{id}route withr.PathValue(you figured this out yourself!) - Changed:
ListenAndServenow receiveshandler(wrapped mux) instead of rawmux handler := loggingMiddleware(mux)β this one line applies logging to every route
Start the server and watch the terminal β middleware logs will appear as you hit endpoints:
go run main.goIn a second terminal, make requests and watch the first terminal for log output:
# Make several requests and observe the logs in the server terminal
curl http://localhost:8080/users
curl http://localhost:8080/users/1
curl -X POST -H "Content-Type: application/json" \
-d '{"name":"Charlie","email":"charlie@example.com"}' \
http://localhost:8080/users
curl http://localhost:8080/users/999You should see log lines like β GET /users, β GET /users (45Β΅s) in the server terminal β each request gets two log entries, one inbound and one outbound with timing.
Press Ctrl+C to stop the server when youβre done.
Check your understanding
What does next.ServeHTTP(w, r) do in a middleware function?
If you chain three middleware functions β A, B, C β wrapping a handler H, what's the execution order?
Why do we wrap the mux with middleware rather than wrapping individual handler functions?
What would happen if you put next.ServeHTTP(w, r) before the first log.Printf in the middleware?