Checkpoint: Building Services

This checkpoint consolidates the services track — concurrency primitives, HTTP clients and servers, middleware, file I/O, custom errors, generics, and embedding — into one build challenge: a tiny JSON API with logging middleware, tested entirely with httptest .

Learn Checkpoint: Building Services in our free Go course — an interactive lesson with runnable examples, a practice exercise and a quick reference.

Part of the free Go course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What This Checkpoint Reviews

📚 What You've Learned

Wait on many channels; default for non-blocking; time.After timeouts; done channels; fan-in.

WaitGroup to join, Mutex / RWMutex for shared state, Once , atomics, the race detector.

Cancellation and deadlines that propagate parent → child; ctx as the first argument.

Jobs → workers → results with a WaitGroup ; bounded concurrency; pipelines.

Requests, headers, timeouts, closing the body, decoding JSON responses.

Method + wildcard routing, PathValue , JSON in/out, status codes.

func(http.Handler) http.Handler ; chaining; wrapping ResponseWriter ; recovery.

os whole-file ops, bufio line/buffered I/O, io.Copy .

Sentinels, custom types, wrapping with %w , inspecting the chain.

Type parameters, constraints and type sets, generic types, inference.

//go:embed bundles assets; //go:build selects files per platform.

Collect-and-sort or fixed channel reads instead of relying on scheduling.

🛠️ Build Challenge: A Tiny JSON Service

Assemble a minimal task service: a GET /tasks/{id} route returning JSON, a writeJSON helper, and a logging middleware that wraps the whole mux and reports the status code — all exercised with httptest . Start from this scaffold and fill in the four pieces described in the comments.

A complete, compiling solution. Notice how the middleware wraps ResponseWriter to capture the status, and how the whole mux is wrapped in logging before being returned.

⏱ Timed Quiz

The plain ResponseWriter doesn't expose the status code after the fact. Embedding it in a struct and overriding WriteHeader lets the middleware record the code (defaulting to 200) so it can log it.

r.PathValue("id") returns it as a string ; convert with strconv.Atoi and handle the error (return 400 on a bad id).

The first write locks in the status (defaulting to 200). Setting headers and the status code before the first Encode / Write is the only way to send a non-200 code with a body.

Requests are driven one at a time through httptest.NewRecorder — no goroutines race to produce output. With concurrency you'd collect-and-sort or read channels in a fixed order instead.

context — pass a context.WithTimeout down through the request ( r.Context() gives you the request's context) and have handlers and clients respect ctx.Done() .

Because http.ServeMux is an http.Handler , wrap the entire mux: logging(mux) . Every route then runs through the middleware — exactly what routes() returns.

If any recap card felt shaky, revisit that lesson before continuing — the next lesson assumes all of it.

Practice quiz

Why does the logging middleware wrap http.ResponseWriter in a custom statusWriter type?

  • To make responses compress automatically
  • To buffer the entire response body
  • Because the plain ResponseWriter does not expose the status code after it is written
  • Because ServeMux requires it

Answer: Because the plain ResponseWriter does not expose the status code after it is written. ResponseWriter has no getter for the status; wrapping it and overriding WriteHeader lets middleware record the code to log.

In the route GET /tasks/{id}, how do you read the id segment and what type is it?

  • r.PathValue("id"), a string you convert with strconv.Atoi
  • r.URL.Query().Get("id"), an int
  • r.FormValue("id"), already an int
  • mux.Param("id"), a rune

Answer: r.PathValue("id"), a string you convert with strconv.Atoi. The 1.22 router exposes wildcards via r.PathValue, which returns a string; convert it with strconv.Atoi.

Why must w.WriteHeader(status) be called before writing the JSON body?

  • The body must be empty for non-200 codes
  • WriteHeader flushes the connection
  • Encoding fails otherwise
  • The first write locks in the status, so a non-200 code must be set before any Write/Encode

Answer: The first write locks in the status, so a non-200 code must be set before any Write/Encode. The first write commits the status (defaulting to 200); set headers and status before encoding to send a non-200 code.

What makes a Go middleware function? What signature does logging have?

  • func(w http.ResponseWriter, r *http.Request)
  • func(next http.Handler) http.Handler
  • func(mux *http.ServeMux) error
  • func() http.HandlerFunc

Answer: func(next http.Handler) http.Handler. Middleware takes an http.Handler and returns a wrapping http.Handler, enabling composition around any handler or mux.

How can a single middleware be applied to every route at once?

  • Wrap the whole mux, since http.ServeMux is itself an http.Handler: logging(mux)
  • Register it with mux.Use()
  • Call it inside each handler manually
  • Add it to http.DefaultServeMux only

Answer: Wrap the whole mux, since http.ServeMux is itself an http.Handler: logging(mux). Because ServeMux implements http.Handler, wrapping logging(mux) routes every request through the middleware.

Why are the handlers tested with httptest.NewRequest and httptest.NewRecorder instead of a live server?

  • Because handlers cannot run on a real server
  • Because httptest is required to compile handlers
  • To call handlers directly and inspect status/body/headers with no port or network, making tests fast and deterministic
  • To avoid writing assertions

Answer: To call handlers directly and inspect status/body/headers with no port or network, making tests fast and deterministic. The recorder approach drives a handler in-memory, giving fast, isolated, repeatable results with identical output each run.

When the task id is unknown, what does the service return?

  • 200 with an empty body
  • 404 with {"error":"not found"}
  • 500 with a stack trace
  • 400 with {"error":"bad id"}

Answer: 404 with {"error":"not found"}. An unknown id misses the map lookup, so the handler writes 404 with a not-found JSON error.

If each request needed to honor a timeout, which package would you reach for?

  • sync
  • time only
  • net/http/httptest
  • context

Answer: context. context.WithTimeout propagates a deadline through r.Context(), and handlers/clients respect ctx.Done().

What does writeJSON(w, status, v) centralize?

  • Routing logic
  • Setting Content-Type, calling WriteHeader(status), and encoding v as JSON
  • Reading the request body
  • Logging every request

Answer: Setting Content-Type, calling WriteHeader(status), and encoding v as JSON. writeJSON gathers the response boilerplate so every handler sets the header, status, and JSON body consistently.

Why is this checkpoint's output fully deterministic?

  • Because JSON is always sorted
  • Because the map is sorted on each access
  • Because requests are driven one at a time through a recorder, with no goroutines racing to produce output
  • Because httptest disables concurrency in Go

Answer: Because requests are driven one at a time through a recorder, with no goroutines racing to produce output. Sequential requests through NewRecorder avoid concurrent output; with goroutines you'd collect-and-sort or read channels in a fixed order.