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.
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))
})
}
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)
go get github.com/go-chi/chi/v5
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.
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
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
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:
- Migrate the Week 10 API to
chi, usingr.Routeto group the/tasksendpoints. - A logging middleware and a recovery middleware, applied to every route with
r.Use. - A shared
writeErrorhelper (andErrorResponsetype) used for every error path across every handler — no handler writing its own ad hoc error JSON. - A
Validate() errormethod on your create/update request types, called before any business logic runs, rejecting an empty or oversized title with400. - 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.
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?
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?
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?
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?
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.