Week 11: Middleware, Routers & API Design Patterns

Week 10's API works, but every handler would need to repeat the same logging, auth checks, and panic recovery. This week adds the layer that turns a working API into one other teams could actually depend on — middleware, a real router, and consistent conventions for errors and validation.

Module 11 of 16 Week 11 of 16 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write and chain middleware for logging, authentication, and panic recovery
  • Use a lightweight router (chi) for path params, route groups, and middleware composition
  • Design a consistent error response shape used across every endpoint
  • Validate a request body before it reaches business logic

1. Middleware Chains

Middleware in Go is just a function that wraps an http.Handler and returns another one — code that runs before (and optionally after) the real handler, composable by nesting.

a logging middleware
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r) // call the wrapped handler
        log.Printf("%s %s — %v", r.Method, r.URL.Path, time.Since(start))
    })
}
a recovery middleware — catches a panic instead of crashing the server
func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("panic recovered: %v", err)
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Without recoverMiddleware, a single unhandled panic in one request handler would crash the entire server process, taking down every other in-flight request with it — defer + recover, from Week 2, wrapping every request is what stops one bad request from becoming an outage.

2. A Lightweight Router (chi)

terminal
go get github.com/go-chi/chi/v5
route groups & middleware composition
r := chi.NewRouter()
r.Use(recoverMiddleware, loggingMiddleware) // applied to every route below

r.Route("/tasks", func(r chi.Router) {
    r.Get("/", listTasksHandler)
    r.Post("/", createTaskHandler)

    r.Route("/{id}", func(r chi.Router) {
        r.Get("/", getTaskHandler)
        r.Put("/", updateTaskHandler)
        r.Delete("/", deleteTaskHandler)
    })
})

http.ListenAndServe(":8080", r)

chi.Router satisfies http.Handler — it's a genuine standard-library-compatible drop-in, not a parallel framework, which is exactly why it's a common choice in idiomatic Go services: route groups and per-group middleware without giving up anything from Week 10's net/http foundation.

3. Consistent Error Responses

Every endpoint returning errors in a different shape is a real cost to anyone building against the API. A small shared error type, used everywhere, fixes that.

a shared error response shape
type ErrorResponse struct {
    Error   string `json:"error"`
    Details string `json:"details,omitempty"`
}

func writeError(w http.ResponseWriter, status int, message string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(ErrorResponse{Error: message})
}

// usage inside any handler
if err != nil {
    writeError(w, http.StatusNotFound, "task not found")
    return
}

Every failure mode — validation, not-found, an internal error the recovery middleware caught — now returns the exact same JSON shape, which is what lets a client write one error-handling code path instead of one per endpoint.

4. Request Validation

validating before touching business logic
type CreateTaskRequest struct {
    Title string `json:"title"`
}

func (r CreateTaskRequest) Validate() error {
    if strings.TrimSpace(r.Title) == "" {
        return errors.New("title is required")
    }
    if len(r.Title) > 200 {
        return errors.New("title must be under 200 characters")
    }
    return nil
}

func createTaskHandler(w http.ResponseWriter, r *http.Request) {
    var req CreateTaskRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeError(w, http.StatusBadRequest, "invalid JSON body")
        return
    }
    if err := req.Validate(); err != nil {
        writeError(w, http.StatusBadRequest, err.Error())
        return
    }
    // only now does the handler touch real business logic
}

A Validate() error method, called immediately after decoding and before anything else, keeps validation rules in one place per request type rather than scattered through handler logic — for anything beyond a few simple checks, a dedicated validation library is worth adopting, but the pattern stays the same.

5. Hands-on Exercise

Hands-on

Harden last week's API with middleware and consistent errors

Bring chi, a middleware chain, and consistent error handling to the Week 10 task API.

Requirements:

  1. Migrate the Week 10 API to chi, using r.Route to group the /tasks endpoints.
  2. A logging middleware and a recovery middleware, applied to every route with r.Use.
  3. A shared writeError helper (and ErrorResponse type) used for every error path across every handler — no handler writing its own ad hoc error JSON.
  4. A Validate() error method on your create/update request types, called before any business logic runs, rejecting an empty or oversized title with 400.
  5. Deliberately trigger a panic in one handler (e.g. a forced nil-pointer dereference behind a debug-only code path) and confirm the recovery middleware catches it — the server should log the panic and respond with a clean 500, not crash.
Hint

If the recovery middleware doesn't seem to catch a panic you're triggering, double-check it's the outermost middleware in your chain — defer/recover in a middleware only catches a panic that happens inside handlers it actually wraps, so a middleware registered after (inside) the one that panics won't see it.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does a single unhandled panic in one handler crash the entire server, without a recovery middleware in place?

Each incoming request in net/http is served on its own goroutine, but an unrecovered panic in Go unwinds and terminates the whole process by default, not just the single goroutine it occurred on — so one bad request handler, without protection, can bring down every other in-flight request along with it.

Q2

Why is chi.Router described as a "genuine standard-library-compatible drop-in" rather than a separate framework?

Because chi.Router itself satisfies the standard http.Handler interface, it can be passed directly to http.ListenAndServe and composed with anything else written against the standard library — there's no parallel ecosystem or incompatible request/response types to learn, just routing and middleware convenience layered on top of the same primitives from Week 10.

Q3

What real benefit does a shared ErrorResponse shape provide to a client consuming the API, beyond tidiness on the server side?

A client can write exactly one piece of code to parse and handle any error from any endpoint, because every failure — validation, not-found, an internal error — comes back in the identical JSON shape. Without that consistency, the client would need endpoint-specific error-parsing logic, or risk silently mishandling an error shape it didn't expect.

Q4

Why does Validate() run immediately after decoding the request body, before any business logic executes?

It ensures a request with genuinely invalid input is rejected with a clear 400 before any downstream logic — database calls, business rules, side effects — has a chance to run against bad data. Validating late (or not at all) risks either a confusing failure deeper in the stack, or worse, letting invalid data through to actually be processed or stored.