Checkpoint: Building Services
Reviewed & published by Brayan K
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.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What This Checkpoint Reviews
- select, channels, and deterministic output
- sync: WaitGroup, Mutex, Once
- context for cancellation and deadlines
- Worker pools, pipelines, HTTP clients
- REST routing + middleware + JSON
- File I/O, errors.Is/As, generics, embedding
📚 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.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
)
type Task struct {
ID int `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
var tasks = map[int]Task{
1: {ID: 1, Text: "learn Go", Done: true},
2: {ID: 2, Text: "build a service", Done: false},
}
// 🎯 BUILD CHALLENGE — assemble a tiny JSON service:
//
// 1) writeJSON(w, status, v): set Content-Type, WriteHeader(status), encode v.
// 2) logging middleware: wrap a handler, capture the status via a wrapped
// ResponseWriter, and print "log: METHOD PATH -> STATUS".
// 3) routes(): a ServeMux with GET /tasks/{id} that parses the id, returns
// 404 {"error":"not found"} for an unknown id, else 200 with the Task.
// Wrap the whole mux in logging and return it.
// 4) In main, drive it with httptest for /tasks/1 and /tasks/99.
//
// ✅ Target output (see the full solution below to check yourself):
// log: GET /tasks/1 -> 200
// response /tasks/1: 200 {"id":1,"text":"learn Go","done":true}
// log: GET /tasks/99 -> 404
// response /tasks/99: 404 {"error":"not found"}
func main() {
// (these discards just let the scaffold compile before you write the
// real code — delete them as you fill in writeJSON, logging, routes)
_ = tasks
_ = json.NewEncoder
_ = strconv.Atoi
_ = httptest.NewRequest
_ = http.MethodGet
fmt.Println("start building!")
}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.
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
)
type Task struct {
ID int `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
var tasks = map[int]Task{
1: {ID: 1, Text: "learn Go", Done: true},
2: {ID: 2, Text: "build a service", Done: false},
}
// writeJSON centralises the response boilerplate.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// statusWriter wraps ResponseWriter so middleware can read the status code.
type statusWriter struct {
http.ResponseWriter
status int
}
func (s *statusWriter) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// logging is middleware: wrap a handler, record method/path/status.
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(sw, r)
fmt.Printf("log: %s %s -> %d\n", r.Method, r.URL.Path, sw.status)
})
}
func routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad id"})
return
}
t, ok := tasks[id]
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
writeJSON(w, http.StatusOK, t)
})
return logging(mux) // wrap the whole mux in middleware
}
func main() {
handler := routes()
// Drive the handler with httptest — one existing task, one missing.
for _, path := range []string{"/tasks/1", "/tasks/99"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
fmt.Printf("response %s: %d %s", path, rec.Code, rec.Body.String())
}
}📝 Checkpoint Quiz
Answer each before revealing it.
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.
🎉 Checkpoint Complete!
- ✅ You combined routing, middleware, and JSON into one small service
- ✅ You captured response status by wrapping ResponseWriter
- ✅ You tested handlers in-memory with httptest for deterministic output
- ✅ You revisited concurrency, context, errors, generics, and embedding
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.
Continue this course
- Previous: Embedding Files & Build Tags
- Next: Building Web Services — Create HTTP servers and REST APIs using Go's standard library net/http
Frequently asked questions
What does this checkpoint cover?
Everything in the services track: select and channel multiplexing, the sync package (WaitGroup, Mutex, Once), the context package, worker pools and pipelines, HTTP clients, building a REST API with the 1.22 router, HTTP middleware, file I/O, custom errors with errors.Is/As, generic constraints, and embedding files with build tags. The build challenge ties the HTTP pieces together into one small service.
Why test handlers with httptest instead of a running server?
httptest.NewRequest and httptest.NewRecorder let you call a handler directly and inspect the status, body, and headers with no port bound and no network — so tests are fast, isolated, and deterministic. httptest.NewServer is available too when you specifically want a real client round trip. The checkpoint uses the recorder approach so the output is identical every run.
How do middleware and the router fit together?
The router (http.ServeMux) is itself an http.Handler, so you can wrap the entire mux in middleware — logging(mux) — to apply cross-cutting behaviour to every route, or wrap individual handlers for per-route concerns. In the challenge, the logging middleware wraps the whole mux, captures each response's status via a wrapped ResponseWriter, and prints one log line per request.
I'm comfortable here — what comes next?
The next lesson, Building Web Services, builds on this foundation with a fuller application: combining routing, middleware, JSON, context-aware handlers, and concurrency into a complete service. Make sure the build challenge compiles and prints the target output before moving on.
My output order looks different from the expected — is that a bug?
For this checkpoint the output is fully deterministic because requests are driven one at a time through a recorder. If you adapt the examples to use goroutines, remember the lessons on determinism: collect results and sort them, or read channels in a fixed order, rather than relying on goroutine scheduling.