A JSON REST API With No Framework
This is the phase where the pieces click together. You've met the mux and Go 1.22 routing, reading requests and writing JSON, and middleware as a plain wrapper. Now we build a complete CRUD API — a real messages service you can curl — using only the standard library.
Here's the mental model to anchor on, because it cuts through all the ceremony: a REST resource is five plain handlers over one collection. List, get-one, create, update, delete — that's the whole CRUD vocabulary. Each handler is an ordinary func(w http.ResponseWriter, r *http.Request). The Go 1.22 mux maps a method-plus-path pattern to each one. That's it. When you reach for Gin or Echo later, what they hand you is these same five handlers with some boilerplate shaved off. Today you write them by hand, and afterward no framework's "REST controller" will ever look like magic again.
💡 We're not introducing new net/http concepts here — we're composing the ones you already have. If a line surprises you, it's almost certainly explained in Phase 2, 3, or 4. This phase is the payoff for reading those.
The store: shared state needs a guard
Before the handlers, we need somewhere to keep messages. For a learning API, an in-memory map is perfect — no database to set up. A Message is just an ID and some text:
What just happened: Store bundles three things: the data map keyed by ID, a nextID counter for handing out fresh IDs, and — the part you cannot skip — a sync.Mutex. We hold the mutex in every method that touches data or nextID.
⚠️ This is the single most important line in the phase. The Go HTTP server runs every request in its own goroutine, so two clients can hit your handlers at the same time. If both write to the map concurrently, Go doesn't quietly corrupt it — it panics outright with
fatal error: concurrent map writesand kills the whole process. A plainmapis not safe for concurrent writes. Thesync.Mutexmakes each operation atomic: one goroutine at a time. Forget it and your API works perfectly in testing, then dies the first time two real users overlap.
Now the store methods, each one locking before it touches shared state:
What just happened: Every method follows the same rhythm — Lock(), defer Unlock(), then do the work. The defer guarantees the mutex is released even if the function returns early (as Update and Delete do when the ID is missing), so you can never accidentally leave the store locked. Notice List returns a freshly built slice and Get/Update/Delete return a bool saying whether the message existed — that boolean is what lets the handlers decide between 200 and 404. The store knows nothing about HTTP; it's plain Go. That separation is deliberate and it's exactly what Phase 6 builds on.
The five handlers
Now the HTTP layer. Each handler reads from r, calls a store method, and writes a response with the writeJSON helper from Phase 3:
We'll hang the handlers off the store so they have something to read and write. A tiny parseID helper turns the {id} path wildcard into an int:
What just happened: r.PathValue("id") pulls the {id} segment the mux captured (Phase 2), and strconv.Atoi parses it to an int. It returns an error for garbage like /messages/abc, which the handlers translate into a 400. One helper, reused by three handlers.
List — GET /messages → 200
What just happened: The simplest handler in the API. Ask the store for everything, write it as JSON with 200 OK. Because List returns a non-nil empty slice when there are no messages, the client gets [], not null — a small kindness that keeps JSON parsers on the other end happy.
Get one — GET /messages/{id} → 200 or 404
What just happened: Two guards, then the happy path. A bad ID is the client's fault → 400. A well-formed ID that doesn't exist → 404, driven entirely by the ok boolean the store returned. Only when both checks pass do we send the message with 200. Each guard ends in return — the "check, respond, return" rhythm from Phase 3 — so we never fall through and write a second response.
Create — POST /messages → 201
What just happened: The full intake pipeline from Phase 3, now wired to the store. Decode the body into createInput, bailing with 400 on broken JSON. Validate by hand — strings.TrimSpace(in.Text) == "" rejects empty or whitespace-only text, because net/http has no built-in validation; that if is your validation layer. Then s.Create assigns the next ID and stores the message, and we reply 201 Created with the new resource (including its server-assigned id) so the client learns what to address it by.
Update — PUT /messages/{id} → 200 or 404
What just happened: Update is get-one and create fused together — it parses the ID and decodes a body and validates and checks existence. Four guards, each with its own status and return. The store's Update returns false when the ID is missing, so a PUT to a non-existent message is a clean 404 rather than a silent create. On success it's 200 with the updated message.
Delete — DELETE /messages/{id} → 204
What just happened: Delete the message, or 404 if it wasn't there. The success case is the 204 No Content special case from Phase 3: call WriteHeader(http.StatusNoContent) and write nothing — no writeJSON, no body. A 204 means "done, and there's nothing to tell you," so resist the urge to return {"ok": true}; a body after 204 contradicts the status.
Wiring it up
Five handlers, one mux, mapped by the Go 1.22 method+path patterns, wrapped in the Logging middleware from Phase 4:
.Handler
What just happened: This is the whole API in one screen. Each mux.HandleFunc line reads like a routing table: a method, a path pattern, and the handler that serves it. The mux dispatches GET /messages/{id} and PUT /messages/{id} to different handlers even though the paths look identical, because in Go 1.22 the method is part of the pattern — that's the feature that makes hand-rolled CRUD pleasant. We pass Logging(mux) (not bare mux) to ListenAndServe, so every request flows through the middleware first and gets logged. The store is created once and shared across all handlers via the method receiver — and because it's mutex-guarded, that sharing is safe under the concurrent goroutines the server spawns.
Driving it with curl
Start the server (go run .), then exercise every endpoint:
# Create two messages
# List them
# Get one
# Update it
# Delete it (note -i to see the status — 204 has no body)
|
# A missing message is a clean 404
What just happened: Every status code path you wrote, exercised from the outside. Create returns 201 with the server-assigned ID; list returns the array; update mutates in place; delete sends a bodiless 204 (the -i flag prints the status line so you can see it); and a request for a non-existent ID returns the 404 JSON your handleGet produces. This is a working REST API — and there isn't a framework import anywhere in the file.
So... do you need a framework?
Now the honest comparison, because you've earned it by building the thing.
💡 For basic CRUD over one resource, this is roughly the same amount of code a framework would have you write. The five handlers, the validation, the status codes — Gin or Echo don't make those disappear; they're inherent to the job. So where does a framework actually pay for itself? Three places: validation (declarative tag-based binding instead of hand-written
ifchecks, which matter once you have ten fields per request), many routes (route groups, shared prefixes, and per-group middleware get unwieldy by hand at thirty endpoints), and ecosystem (off-the-shelf middleware for auth, CORS, rate limiting, request IDs that you'd otherwise write yourself). For a handful of endpoints, the stdlib is genuinely enough — and now you can tell when you've crossed the line. Phase 7 maps each of these conveniences back onto exactly the net/http code you just wrote.
Recap
- A REST resource is five plain handlers over one collection: list, get-one, create, update, delete — the same five a framework gives you, written by hand.
- The in-memory
Storemust be mutex-guarded: the server runs each request in its own goroutine, and concurrent writes to a bare map panic withconcurrent map writes.Lock()+defer Unlock()in every method. - Handlers read with
r.PathValue("id")→strconv.Atoi, decode bodies withjson.NewDecoder, validate by hand (no built-in validation), and respond withwriteJSON— using the store'sboolreturn to choose200vs404. - Status codes map to intent:
200read,201create,204delete (no body),400bad input,404missing. Each guard ends inreturn. - The Go 1.22 mux routes by method+path (
"GET /messages/{id}"vs"PUT /messages/{id}"), and you wrap the whole mux in Logging middleware when starting. - For basic CRUD this is about as much code as a framework; frameworks earn their keep with heavy validation, many routes, and middleware ecosystems — see Phase 7.
Quick check
[
{
"q": "Why does the in-memory Store need a sync.Mutex?",
"choices": [
"To make JSON encoding thread-safe",
"Because the HTTP server handles each request in its own goroutine, and concurrent writes to a plain map panic",
"Maps are slow without a lock around them",
"The Go 1.22 mux requires handlers to be synchronized"
],
"answer": 1,
"explain": "Go's HTTP server runs every request in a separate goroutine. A plain map isn't safe for concurrent writes — two overlapping requests trigger a 'fatal error: concurrent map writes' panic. The mutex serializes access."
},
{
"q": "How does the mux send GET /messages/{id} and PUT /messages/{id} to different handlers despite identical paths?",
"choices": [
"It inspects the request body to decide",
"In Go 1.22 the HTTP method is part of the route pattern, so each method+path pair maps to its own handler",
"You register one handler and switch on r.Method inside it",
"It can't — you need a third-party router for that"
],
"answer": 1,
"explain": "Go 1.22 added method-prefixed patterns. 'GET /messages/{id}' and 'PUT /messages/{id}' are distinct patterns, so the mux dispatches each to a different handler — no manual r.Method switch needed."
},
{
"q": "What does handleDelete write on a successful delete?",
"choices": [
"200 OK with {\"ok\": true}",
"201 Created with the deleted message",
"204 No Content with no body at all",
"404 Not Found"
],
"answer": 2,
"explain": "A successful delete returns 204 No Content: call w.WriteHeader(http.StatusNoContent) and write nothing. A 204 means there's no body, so returning JSON would contradict the status."
}
]
← Phase 4: Middleware Is Just a Wrapper · Guide overview · Phase 6: Structure, Context & Graceful Shutdown →
Check your understanding
1. Why does the in-memory Store need a sync.Mutex?
2. How does the mux send GET /messages/{id} and PUT /messages/{id} to different handlers despite identical paths?
3. What does handleDelete write on a successful delete?