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.