Middleware


Progress
0/4

πŸ’‘ 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 directlyWrap the mux with middleware first
No request loggingEvery request logged with method, path, and duration

Follow each step in order. Click Done βœ“ after completing it.

1Set up the lesson directoryCurrent
mkdir -p ~/dev/learn/go-lang/lessons/0004-middleware
cd ~/dev/learn/go-lang/lessons/0004-middleware
go mod init middleware
2Study the middleware pattern (read, don't type yet)Locked

Here’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.Handler and returns an http.Handler β€” that’s the wrapping contract
  • http.HandlerFunc(…) converts our anonymous function into a Handler β€” it’s just an adapter
  • next.ServeHTTP(w, r) is the handoff β€” everything before it runs inbound, everything after runs outbound
  • time.Since(start) gives the duration β€” this is how you time requests
3Write the full server with middlewareLocked

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: loggingMiddleware function that wraps any handler
  • Added: GET /users/{id} route with r.PathValue (you figured this out yourself!)
  • Changed: ListenAndServe now receives handler (wrapped mux) instead of raw mux
  • handler := loggingMiddleware(mux) β€” this one line applies logging to every route
4Run the server and watch the logsLocked

Start the server and watch the terminal β€” middleware logs will appear as you hit endpoints:

go run main.go

In 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/999

You 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?