# Web Services With Only net/http

> Build real Go web services using nothing but the standard library: the Handler/ServeMux/Server mental model, routing by hand, reading requests and writing JSON, middleware as plain wrappers, a full JSON REST API with no framework, project structure with context and graceful shutdown, and exactly what Gin/Echo/chi add on top. The foundation every Go framework is built on.


---

# Web Services With Only net/http

Here's a thing that surprises people coming to Go: you often don't need a web framework at all. The
standard library's `net/http` already gives you a production-grade HTTP server, a router, and everything
to read requests and write responses. [Gin](/guides/gin-from-zero), [Echo](/guides/echo-from-zero), and
[chi](/guides/chi-from-zero) are conveniences *over this* - and once you've built a real JSON API with
only the standard library, every one of them reads as "net/http with some boilerplate removed." This is
the **roots** guide: learn it and the frameworks stop being magic.

The mental model is three standard types. A **`Handler`** is anything with `ServeHTTP(w, r)` - your code
that handles one request (a plain function becomes one via `http.HandlerFunc`). A **`ServeMux`** is the
router: it maps URL patterns to handlers. And the **`Server`** ties an address to a handler and listens.
That's the whole architecture: *the mux routes a request to a handler, the handler writes the response.*
Middleware? Just a function that wraps a `Handler` and returns a new one. Hold those, and you can read any
Go web codebase - framework or not.

> 📝 This teaches the **standard library** - it assumes you know **Go** (functions, interfaces, structs,
> `error` - [Go From Zero](/guides/go-from-zero)) and basic **HTTP** (methods, status, headers -
> [HTTP, Explained](/guides/http-explained)). It's the Go parallel to
> [WSGI & ASGI Explained](/guides/wsgi-and-asgi-explained) (Python's foundation) and is best read before
> or alongside the framework guides so you can see what each one adds. Examples run as plain Go programs.

## How to read this

Short and foundational - read in order. It builds a bare server, then a full JSON API (a small
**messages** service), then maps it onto the frameworks. Uses modern Go (1.22+) routing. Phases carry
difficulty badges.

## The phases

1. **[The net/http Mental Model](01-the-mental-model.md)** 🟢 - `Handler`, `ServeMux`, `Server`, and how one request flows through them.
2. **[Handlers & Routing by Hand](02-handlers-and-routing.md)** 🟡 - `HandlerFunc`, registering routes, and the Go 1.22 method+path patterns.
3. **[Reading Requests, Writing JSON](03-requests-and-json.md)** 🟡 - params, query, body decoding, and writing JSON responses with the right status.
4. **[Middleware Is Just a Wrapper](04-middleware-is-a-wrapper.md)** 🟡 - `func(http.Handler) http.Handler`, chaining, and logging/auth examples.
5. **[A JSON REST API With No Framework](05-rest-api-no-framework.md)** 🔴 - full CRUD for the messages resource using only the standard library.
6. **[Structure, Context & Graceful Shutdown](06-structure-and-shutdown.md)** 🔴 - dependency wiring, `context`, timeouts, and shutting down cleanly.
7. **[What the Frameworks Add](07-what-frameworks-add.md)** 🟢 - mapping Gin/Echo/chi back onto this, and when you genuinely don't need them.

> The throughline: **mux routes to handler, handler writes response, middleware wraps handlers.** That's
> net/http, and that's the skeleton inside every Go web framework.


---

# The net/http Mental Model

A thing that catches people off guard when they come to Go from almost anywhere else: you often
don't reach for a web framework at all. The standard library ships a production-grade HTTP server, a
router, and everything you need to read a request and write a response - right there in `net/http`, no
`go get` required. This is the **roots** guide. Learn what's in here and [Gin](/guides/gin-from-zero),
Echo, and chi stop being magic - they read as "net/http with some boilerplate removed."

> 📝 This is the Go parallel to [WSGI & ASGI Explained](/guides/wsgi-and-asgi-explained) - Python's
> foundation under Flask and Django. Same idea, different ecosystem: a standard shape that the server and
> your code agree on. This guide assumes you know basic HTTP (methods, status codes, headers - see
> [HTTP, Explained](/guides/http-explained)) and Go itself ([Go From Zero](/guides/go-from-zero)).

## The whole architecture is three types

Before any code, get the picture in your head - the code will then just be names attached to ideas you
already hold. The entire `net/http` server model is **three standard types**, and they each do exactly
one job.

📝 **`http.Handler`** - an interface with a single method:

```go
type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}
```

Anything that has a `ServeHTTP(w, r)` method *is* a handler. This is **your request-handling code** - the
part that looks at a request and writes a response. `r` is the incoming request (method, path, headers,
body); `w` is where you write the reply.

📝 **`http.HandlerFunc`** - an adapter that lets a plain function `func(w, r)` count as a `Handler`,
so you don't have to declare a type with a method every time you want to handle a request. (More on the
trick behind this below - it's the one piece of cleverness in the whole design.)

📝 **`http.ServeMux`** - the **router** (mux = "multiplexer"). It maps URL patterns like `/ping` or
`/messages` to the handler that should serve them. The neat part: a `ServeMux` is *itself* a `Handler` -
its `ServeHTTP` looks at the request path and forwards to the right registered handler.

📝 **`http.Server`** - ties an **address** (like `:8080`) to a handler and **listens** for connections.
It owns the accept-loop and the HTTP plumbing; when a request arrives it calls the handler's `ServeHTTP`.

Here's the mental model to carry through the whole guide, the sentence everything else hangs off of:

💡 **The mux routes a request to a handler; the handler writes the response.** That's it. The server runs
the loop, the mux picks who handles each request, and the handler does the one interesting thing -
decides what to send back.

```mermaid
flowchart LR
  C[Client] -->|HTTP request| S[http.Server]
  S -->|ServeHTTP| M[http.ServeMux]
  M -->|matches path, forwards| H[your Handler]
  H -->|writes to w| R[HTTP response]
  R --> C
```

*What just happened:* the client sends a request; the **server** accepts it and calls the handler it was
given. That handler is usually the **mux**, which looks at the path and forwards to the specific handler
you registered for it. Your handler writes the response into `w`, and the server ships it back. Notice the
mux is just a handler that happens to delegate - that uniformity is why the whole thing composes so well.

## The smallest server that does something

Now the picture in code. This is a complete, runnable Go program - a server that answers `/ping` with
`pong`.

```go
package main

import (
    "fmt"
    "net/http"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "pong")
    })
    http.ListenAndServe(":8080", mux)
}
```

*What just happened:* line by line against the three types -

- `http.NewServeMux()` creates the **router**. Empty for now, with no routes.
- `mux.HandleFunc("/ping", ...)` registers a route: "when a request comes in for `/ping`, run this
  function." The function is a plain `func(w, r)` - `HandleFunc` wraps it as a `Handler` for you (that's
  the `HandlerFunc` adapter doing its job behind the scenes).
- Inside the handler, `fmt.Fprintln(w, "pong")` writes `pong` to `w` - the `ResponseWriter`. Writing to
  `w` *is* sending the response body. No `return` of a value; you write, the client receives.
- `http.ListenAndServe(":8080", mux)` is the **server**: bind to port 8080 and listen, using `mux` as the
  top-level handler. This call blocks - it runs the accept-loop forever (until something goes wrong).

⚠️ `http.ListenAndServe` returns an `error` and we're ignoring it here. That's fine for a first sketch,
but real code checks it - `log.Fatal(http.ListenAndServe(...))` is the usual one-liner. Phase 6 replaces
this convenience with an explicit `http.Server` so you can set timeouts and shut down cleanly. For now,
know that `ListenAndServe` is a shortcut that builds an `http.Server` for you under the hood. **The mux is
the handler it serves.**

Run it and hit it:

```bash
go run main.go
# in another terminal:
curl localhost:8080/ping
# pong
```

*What just happened:* `go run main.go` compiles and starts the program; it sits there blocked in
`ListenAndServe`, holding port 8080. `curl` opens a connection, sends `GET /ping`, the server routes it
through the mux to your function, your function writes `pong`, and curl prints it. You just ran a web
server with zero dependencies.

## Why a plain function is allowed to be a Handler

Look back at that example: `ServeMux` wants `Handler`s (things with a `ServeHTTP` method), but you handed
it a bare function. How does a function satisfy an *interface*? This is `http.HandlerFunc`, and it's worth
understanding because it's the move every Go web framework copies.

`http.HandlerFunc` is a named function type defined in the standard library, roughly:

```go
type HandlerFunc func(w http.ResponseWriter, r *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r) // calling ServeHTTP just calls the function
}
```

*What just happened:* `HandlerFunc` is a type *whose underlying type is a function*, and it has a
`ServeHTTP` method that calls the function it wraps. So when you convert a `func(w, r)` into a
`HandlerFunc`, it suddenly satisfies the `Handler` interface - `ServeHTTP` is defined, and all it does is
invoke your function. `mux.HandleFunc(...)` does that conversion for you; you could also write
`mux.Handle("/ping", http.HandlerFunc(myFunc))` and get the same result. The adapter exists so you can
write handlers as ordinary functions instead of declaring a struct with a method every single time.

💡 This is the small, elegant trick at the heart of `net/http`: by making "a handler" an interface with
one method, *anything* can be a handler - a function (via `HandlerFunc`), a struct, the mux itself, or a
wrapper around another handler (that last one is middleware, in Phase 4). One interface, endless
composition.

## Where the frameworks fit - and what we'll build

Here's the reveal that justifies the whole guide. When you eventually use Gin or Echo or chi, you're not
escaping these types - you're sitting on top of them.

💡 **Gin, Echo, and chi are conveniences over exactly `Handler`, `HandlerFunc`, and `ServeMux`.** Their
routers are `http.Handler`s; you can mount a chi router as the handler in an `http.Server`, or wrap a Gin
engine and serve it the same way you served `mux` above. They add nicer routing, parameter binding, and
helpers - but the request still enters through a server, gets routed, and lands on something that writes a
response. The skeleton is the one you just met.

To make all of this concrete instead of abstract, the rest of the guide builds one small thing the whole
way through: a **messages** service. The core data is deliberately tiny -

```go
type Message struct {
    ID   int
    Text string
}
```

*What just happened:* nothing yet - that's just the shape of the data our API will serve. Over the next
phases we'll route requests to it (Phase 2), read and write it as JSON (Phase 3), wrap it with middleware
(Phase 4), and grow it into a full CRUD REST API with no framework at all (Phase 5). Every step is the
same three types, doing their one job each.

## Recap

1. The entire `net/http` server model is **three types**: `http.Handler` (your code), `http.ServeMux`
   (the router), and `http.Server` (listens on an address) - plus the `HandlerFunc` adapter.
2. A **`Handler`** is anything with a `ServeHTTP(w, r)` method. You write to `w` to send the response; `r`
   is the incoming request.
3. The mental model, all the way down: **the mux routes a request to a handler; the handler writes the
   response.** The mux is itself a handler that delegates by path.
4. **`http.HandlerFunc`** lets a plain `func(w, r)` satisfy the `Handler` interface - its `ServeHTTP` just
   calls the function - so you write handlers as ordinary functions.
5. `http.ListenAndServe(":8080", mux)` is a convenience that builds an `http.Server` for you and serves
   the mux as its handler. Phase 6 swaps in an explicit `http.Server` for timeouts and graceful shutdown.
6. **Gin/Echo/chi are conveniences over these same types** - their routers are `http.Handler`s. We'll
   build a **messages** service (`Message{ID, Text}`) on the bare standard library across the guide.

## Quick check

Three questions on the ideas that have to stick before Phase 2:

```quiz
[
  {
    "q": "What makes something an http.Handler in Go?",
    "choices": [
      "It has a ServeHTTP(w http.ResponseWriter, r *http.Request) method",
      "It is registered with mux.HandleFunc",
      "It imports the net/http package",
      "It returns an (response, error) pair"
    ],
    "answer": 0,
    "explain": "http.Handler is an interface with exactly one method, ServeHTTP(w, r). Anything that defines that method satisfies the interface and can serve requests - a function (via HandlerFunc), a struct, even the ServeMux itself."
  },
  {
    "q": "In one sentence, what is the net/http mental model?",
    "choices": [
      "The mux routes a request to a handler; the handler writes the response",
      "The handler routes requests and the server writes the response",
      "The client builds the response and the server validates it",
      "The Server parses the body and the Handler manages the TCP socket"
    ],
    "answer": 0,
    "explain": "The ServeMux looks at the request path and forwards to the right handler; that handler writes the reply into w. The Server runs the accept-loop and calls the handler. Mux routes, handler writes - that's the whole architecture."
  },
  {
    "q": "Why can you pass a plain func(w, r) where a Handler is expected?",
    "choices": [
      "http.HandlerFunc is a function type whose ServeHTTP method just calls the function, so it satisfies the Handler interface",
      "Go automatically treats every function as an interface",
      "ListenAndServe converts all functions into goroutines",
      "ServeMux ignores the Handler interface entirely"
    ],
    "answer": 0,
    "explain": "http.HandlerFunc is a named type whose underlying type is func(w, r), and it defines ServeHTTP to call that function. Converting your function to a HandlerFunc gives it a ServeHTTP method, so it satisfies Handler. mux.HandleFunc does this conversion for you."
  }
]
```


---

# Handlers & Routing by Hand

**A router is a lookup table from patterns to handlers.** A request comes in carrying a method and a path - `GET /messages/42` - and the router's whole job is to find the one handler that claims that combination and hand the request to it. Nothing more mystical than that.

For years in Go this lookup table was deliberately dumb. The standard `http.ServeMux` matched on the *path* and nothing else - no methods, no path parameters. That's exactly why the ecosystem grew routers like chi, gorilla/mux, and the ones baked into Gin and Echo: people needed `GET` vs `POST` on the same path, and they needed `/messages/{id}` to pull `42` out for them.

> ⚠️ Then **Go 1.22 (early 2024) upgraded `http.ServeMux`** to understand **method + wildcard patterns** directly. The dumb table got smart. For routing alone - the thing most apps actually need - you rarely reach for a third-party router anymore. This phase teaches the new way as the default, then shows you the old way so legacy code stops looking foreign.

We'll keep building the **messages** service from Phase 1: each message is a `Message{id, text}`, and we want to list them, create one, and fetch one by id.

## Registering routes: `Handle` and `HandleFunc`

A `ServeMux` gives you two ways to register:

- `mux.Handle(pattern, handler)` - `handler` is anything satisfying the `http.Handler` interface (it has `ServeHTTP(w, r)`).
- `mux.HandleFunc(pattern, fn)` - `fn` is a plain `func(http.ResponseWriter, *http.Request)`, and the mux wraps it into a handler for you.

You'll use `HandleFunc` ninety percent of the time because writing a function is less ceremony than declaring a type with a method. Reach for `Handle` when you already *have* a value that implements `Handler` (a struct with dependencies attached, which we'll do in a later phase).

## The Go 1.22 way: method + path patterns

This is the part to internalize. A pattern can now start with an HTTP method and embed `{name}` wildcards in the path:

```go
package main

import (
	"fmt"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /messages", listMessages)
	mux.HandleFunc("POST /messages", createMessage)
	mux.HandleFunc("GET /messages/{id}", getMessage)

	http.ListenAndServe(":8080", mux)
}

func listMessages(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "here are all the messages")
}

func createMessage(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "created a new message")
}

func getMessage(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	fmt.Fprintf(w, "you asked for message %s\n", id)
}
```

*What just happened:* We registered three routes that all share the `/messages` path family, but the mux now tells them apart. `GET /messages` and `POST /messages` are two *different* entries - same path, different method, different handler. And `GET /messages/{id}` declares a wildcard segment named `id`. When a request hits `GET /messages/42`, the mux matches that third route and the handler reads the captured value with **`r.PathValue("id")`**, which returns the string `"42"`. No request body parsing, no manual string splitting on `/`. The method that doesn't match anything (say `DELETE /messages`) gets an automatic `405 Method Not Allowed` - the mux handles that for you now too.

> 💡 `r.PathValue` always returns a `string`. If you need an `int` for a database lookup, you convert it yourself with `strconv.Atoi` - and that conversion is also your validation: a non-numeric id fails the parse, and you return `400 Bad Request`. We'll wire that into the real handlers in [Reading Requests, Writing JSON](03-requests-and-json.md).

## The old way (pre-1.22): one path, `switch` on the method

Before Go 1.22, the mux only saw the path. To handle `GET` and `POST` on `/messages`, you registered **one** handler for the path and branched inside it on `r.Method`:

```go
// Pre-1.22 style - you still see this everywhere in older code.
mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
	switch r.Method {
	case http.MethodGet:
		listMessages(w, r)
	case http.MethodPost:
		createMessage(w, r)
	default:
		http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
	}
})
```

*What just happened:* The path `/messages` mapped to a single function, and that function did the method dispatch by hand with a `switch`. Notice you also had to write the `405` yourself in the `default` case - the old mux wouldn't do it for you. And getting an id out of `/messages/42` was worse: you'd register `/messages/` (with a trailing slash to match the subtree), then chop the path apart with `strings.TrimPrefix(r.URL.Path, "/messages/")` and hope the format was what you expected.

> 📝 That boilerplate - method switches and manual path-slicing in every handler - is precisely the pain routers like **chi** and **gorilla/mux** were born to remove. When you read a codebase that pulls in chi *just for routing*, it was very likely written before 1.22 (or by someone who hasn't noticed the stdlib caught up). Recognizing this pattern tells you a lot about a project's age.

## Pattern features worth knowing

The new mux has a few more rules that matter once your routes grow:

**Trailing slash matches a subtree.** A pattern ending in `/` matches the path *and everything under it*:

```go
mux.HandleFunc("GET /static/", serveStaticFiles)
// matches /static/, /static/logo.png, /static/css/app.css, ...
```

*What just happened:* The trailing slash on `/static/` turns it into a subtree match - every path that starts with `/static/` lands here. Without the trailing slash, `GET /static` would match *only* the exact path `/static`. This is how you serve a whole directory tree from one handler.

**Trailing `{name...}` captures the rest of the path.** If you want the remaining segments as one value, end the pattern with `{name...}`:

```go
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
	rest := r.PathValue("path") // "docs/2026/report.pdf" for /files/docs/2026/report.pdf
	fmt.Fprintf(w, "serving %s\n", rest)
})
```

*What just happened:* The `...` makes `{path}` greedy - it swallows every segment after `/files/`, slashes and all, into a single `PathValue`. A plain `{path}` (no dots) only captures *one* segment and would not match a path with extra slashes in it.

**More specific wins, and real conflicts panic.** When two patterns could both match, the more specific one takes the request: `GET /messages/{id}` beats a broad `GET /messages/`, and a method-specific pattern beats a method-less one for the same path. But if two patterns are genuinely ambiguous - neither is more specific than the other - the mux **panics at registration time**, when you call `Handle`/`HandleFunc`, not at request time.

> 💡 That registration-time panic is a feature, not a footgun. You find out about a conflicting route the instant your server tries to start, with a clear message naming both patterns - never as a silent wrong-handler bug that ships to production and confuses you at 2am.

## So where do the frameworks fit?

Step back and look at what you just learned. Method matching, path parameters, subtree mounts, precedence rules, conflict detection - that's the entire routing feature set that Gin, Echo, and chi advertise. For *routing*, the standard library now covers it.

> 💡 The frameworks still add real things on top - grouped routes with shared prefixes, a slicker middleware chain, built-in JSON/validation helpers, and nicer context objects. We'll map each of those back onto net/http in [What the Frameworks Add](07-what-frameworks-add.md). But the routing core they wrap? You're already holding it. When someone says "chi has a great router," what they mean is "chi wraps `r.PathValue` and method patterns in a fluent API" - and now you can read straight through that to what's underneath.

## Recap

- A router is just **patterns → handlers**: it looks up the request's method+path and calls the one handler that claims it.
- Register with **`mux.HandleFunc(pattern, fn)`** (a plain function) or **`mux.Handle(pattern, handler)`** (anything implementing `http.Handler`).
- **Go 1.22** taught `ServeMux` method+wildcard patterns: `"GET /messages"`, `"POST /messages"`, `"GET /messages/{id}"` - and you read the wildcard with **`r.PathValue("id")`** (always a string).
- The **pre-1.22 way** registered one handler per path and did `switch r.Method` plus manual path-slicing by hand - the boilerplate that gave us chi and gorilla/mux.
- A trailing `/` matches a **subtree**, `{name...}` captures the **rest of the path**, more specific patterns win, and ambiguous patterns **panic at registration**.
- For routing alone you rarely need a third-party router now - the frameworks wrap exactly these features.

Quick gut check before moving on:

```quiz
[
  {
    "q": "In Go 1.22+, how do you read the value captured by the wildcard in the pattern \"GET /messages/{id}\"?",
    "choices": ["r.URL.Query().Get(\"id\")", "r.PathValue(\"id\")", "strings.TrimPrefix(r.URL.Path, \"/messages/\")", "r.FormValue(\"id\")"],
    "answer": 1,
    "explain": "r.PathValue(\"id\") returns the captured segment (as a string). Query() is for ?id=... query params, and the TrimPrefix approach is the old manual workaround."
  },
  {
    "q": "Before Go 1.22, how did you serve both GET and POST on the same /messages path?",
    "choices": ["Register two patterns: \"GET /messages\" and \"POST /messages\"", "Register one handler for \"/messages\" and switch on r.Method inside it", "It was impossible without a third-party router", "Use r.PathValue(\"method\")"],
    "answer": 1,
    "explain": "The old mux ignored the method, so you registered one handler for the path and branched on r.Method with a switch - writing the 405 yourself in the default case."
  },
  {
    "q": "What happens when you register two route patterns that are genuinely ambiguous (neither more specific)?",
    "choices": ["The first one registered always wins", "The last one registered wins", "The mux panics at registration time", "Requests return 500 at runtime"],
    "answer": 2,
    "explain": "The Go 1.22 mux panics the moment you register a conflicting pattern, naming both - so you catch the bug at startup, not as a silent wrong-handler in production."
  }
]
```


---

# Reading Requests, Writing JSON

**A handler reads from one thing and writes to another.** It reads from `*http.Request` - the incoming request, with its path, query string, headers, and body. It writes to `http.ResponseWriter` - the outgoing response, where you set headers, a status code, and the body. That's the entire conversation.

Notice what's *not* in that sentence: no framework, no "context object," no magic binding layer. `*http.Request` and `http.ResponseWriter` are plain standard-library types. JSON isn't special either - it's `encoding/json` applied to the body on the way in and to the writer on the way out. Once you see a handler as "read from `r`, write to `w`, with `encoding/json` doing the translation on each side," every Go web handler you'll ever read becomes legible.

> 💡 You already met `r.PathValue` in [Phase 2](02-handlers-and-routing.md). This phase fills in the *other* three sources of input (query, headers, body) and the full story of writing a response. The running example stays the **messages** service: a `Message` is just `{id, text}`.

## Reading from the request

Let's gather every kind of input a handler typically needs. Imagine a route registered as `GET /messages/{id}` - we want the path value, a query flag, and an auth header.

```go
type Message struct {
	ID   string `json:"id"`
	Text string `json:"text"`
}

func getMessage(w http.ResponseWriter, r *http.Request) {
	// 1. Path wildcard - from the {id} in the route pattern.
	id := r.PathValue("id")

	// 2. Query string - /messages/42?verbose=true
	verbose := r.URL.Query().Get("verbose")

	// 3. Header - case-insensitive lookup.
	auth := r.Header.Get("Authorization")

	fmt.Printf("id=%q verbose=%q auth=%q\n", id, verbose, auth)
}
```

*What just happened:* Three different inputs, three different accessors, all from the standard library. `r.PathValue("id")` reads the `{id}` segment the mux captured. `r.URL.Query()` parses the query string into a map-like value and `.Get("verbose")` returns the first value (or `""` if absent - it never panics on a missing key). `r.Header.Get("Authorization")` looks up a header *case-insensitively*, so `authorization` or `AUTHORIZATION` resolve the same. Every one of these returns an empty string when the thing isn't there, so you check for `""` rather than guarding against a nil.

### Decoding a JSON body

For a `POST` or `PUT`, the interesting data lives in the request body - a stream of bytes you decode with `encoding/json`. The idiom is to declare a struct for the shape you expect and decode into it.

```go
type CreateMessage struct {
	Text string `json:"text"`
}

func createMessage(w http.ResponseWriter, r *http.Request) {
	var in CreateMessage
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
		return
	}
	fmt.Printf("got text: %q\n", in.Text)
}
```

*What just happened:* `json.NewDecoder(r.Body)` wraps the body stream, and `.Decode(&in)` reads it and fills the struct - matching JSON keys to fields via the `json:"text"` tags. The part people skip and then regret: **decoding can fail** (malformed JSON, a number where a string was expected, an empty body), so you check `err` and, when it's non-nil, return `400 Bad Request` and `return` immediately. Forgetting that `return` is a classic bug - without it the handler keeps running on garbage data.

> ⚠️ By default the decoder *silently ignores* JSON keys that don't match any struct field. A client typo like `{"txt": "hi"}` decodes happily into a `Message` with an empty `Text` and no error. If you'd rather reject unknown fields, opt in:
> ```go
> dec := json.NewDecoder(r.Body)
> dec.DisallowUnknownFields()
> if err := dec.Decode(&in); err != nil { /* 400 */ }
> ```
> Now an unexpected key is an error you can catch instead of a confusing empty value later.

## Writing JSON back

Writing a response has three moving parts, and - this is the one thing to burn into memory - **they happen in a fixed order**: set headers, then write the status code, then write the body. Get the order wrong and Go quietly ignores half of what you asked for.

Because you'll do this on every endpoint, write it once as a helper:

```go
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}
```

*What just happened:* One function captures the whole ritual. `w.Header().Set(...)` declares the content type so the client parses the body as JSON. `w.WriteHeader(status)` sends the status line (e.g. `201 Created`). `json.NewEncoder(w).Encode(v)` serializes `v` straight to the response stream - no intermediate `[]byte`, no `Marshal` then `Write`. From here on, returning JSON is a one-liner: `writeJSON(w, http.StatusOK, msg)`.

### ⚠️ The #1 net/http gotcha: order matters

This trips up nearly everyone once. The rules, stated plainly:

- `w.Header().Set(...)` must come **before** `w.WriteHeader(...)`.
- `w.WriteHeader(...)` must come **before** you write any body.
- **The first call to `w.Write` (which `Encode` does for you) implicitly sends `200 OK` if you haven't called `WriteHeader` yet.**

That last rule is the trap. Look at this *wrong* version:

```go
func brokenHandler(w http.ResponseWriter, r *http.Request) {
	json.NewEncoder(w).Encode(map[string]string{"error": "nope"}) // sends 200 NOW
	w.WriteHeader(http.StatusBadRequest)                          // too late - ignored
}
```

*What just happened:* The `Encode` call writes to the body, and because no status was set yet, Go automatically commits `200 OK` and flushes the headers. The *next* line tries to set `400`, but the status line already went out the door - so the client receives `200`, your `400` is silently dropped, and Go logs a `http: superfluous response.WriteHeader call` warning to stderr. Nothing crashes; you just get the wrong status and a log line that's easy to miss. The fix is always the same order: **header, status, body** - exactly what `writeJSON` enforces.

> 💡 Mnemonic: *headers and status are an envelope, the body is the letter.* You can't change the address after the envelope is sealed and mailed.

## Status codes, and the no-body case

The status constants live in `net/http` - use the named ones (`http.StatusCreated`) over magic numbers (`201`); they read better and the compiler catches typos. A quick map for the messages service:

- `200 OK` - `http.StatusOK`, a successful read.
- `201 Created` - `http.StatusCreated`, you just made a resource.
- `400 Bad Request` - `http.StatusBadRequest`, the client sent something invalid.
- `404 Not Found` - `http.StatusNotFound`, no such message.
- `204 No Content` - `http.StatusNoContent`, success with *nothing to return* (e.g. a delete).

That last one is special: **204 means there is no body.** You send the status and stop.

```go
func deleteMessage(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	delete(store, id) // pretend store is our in-memory map
	w.WriteHeader(http.StatusNoContent) // no Encode, no Write - that's the point
}
```

*What just happened:* For a `204` you call `WriteHeader` and then write *nothing*. No `Content-Type`, no encoder. Writing a body after a `204` contradicts the status (and earns you another superfluous-WriteHeader-style complaint), so resist the urge to be "helpful" with a `{"ok": true}`. Silence is the correct response.

## Validation by hand

Here's a truth that surprises people from framework backgrounds: **net/http has no built-in validation.** Decoding fills the struct; it does not check that `Text` is non-empty, or under some length, or anything else. That's your job, in plain Go, right after the decode.

Let's put the whole pipeline together - decode, check, respond - for creating a message:

```go
func createMessage(w http.ResponseWriter, r *http.Request) {
	var in CreateMessage
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}

	// Validation is just normal Go - no magic.
	if strings.TrimSpace(in.Text) == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"})
		return
	}

	msg := Message{ID: newID(), Text: in.Text}
	store[msg.ID] = msg

	writeJSON(w, http.StatusCreated, msg)
}
```

*What just happened:* The handler reads top to bottom as the request's life story. **Decode** into `in`, bailing with `400` if the JSON is broken. **Validate** with an ordinary `if` - `strings.TrimSpace(in.Text) == ""` rejects empty or whitespace-only text, again with `400` and an immediate `return`. Only once the input is trustworthy do we build the `Message`, store it, and reply `201 Created` with the new resource as JSON. Notice there's no validation library and no annotations - just `if` statements you can read and reason about. That directness *is* the net/http philosophy: nothing happens that you didn't write.

> 📝 Each guard ends in `return`. Skipping it means the handler keeps going and may write a *second* response - and now you've written headers twice, which produces (you guessed it) the superfluous-WriteHeader warning. "Check, respond, return" is the rhythm.

## Recap

- A handler **reads from `*http.Request`, writes to `http.ResponseWriter`** - both plain stdlib, with `encoding/json` doing the translation on each side.
- Inputs come from four places: path (`r.PathValue("id")`), query (`r.URL.Query().Get("q")`), headers (`r.Header.Get(...)`, case-insensitive), and the body (`json.NewDecoder(r.Body).Decode(&in)`). A failed decode means `400`; add `DisallowUnknownFields()` to reject typos.
- Writing JSON has a **fixed order**: `Header().Set` → `WriteHeader(status)` → `Encode(body)`. Wrap it in a `writeJSON` helper so you never get it wrong.
- The first body write implicitly sends `200 OK`, so a `WriteHeader` *after* writing is ignored and logs `superfluous response.WriteHeader call`. Order is everything.
- Use named status constants; `204 No Content` carries **no body**. There's **no built-in validation** - check fields with ordinary `if` statements and return `400`, always followed by `return`.

## Quick check

```quiz
[
  {
    "q": "In writeJSON, what is the correct order of the three calls?",
    "choices": [
      "WriteHeader, then Header().Set, then Encode",
      "Header().Set, then WriteHeader, then Encode",
      "Encode, then Header().Set, then WriteHeader",
      "Order doesn't matter as long as all three run"
    ],
    "answer": 1,
    "explain": "Headers must be set before the status, and the status before the body. The first body write implicitly commits the status, so anything set afterward is ignored."
  },
  {
    "q": "What does Go do when you call w.Write (or Encode) without having called w.WriteHeader first?",
    "choices": [
      "Returns an error you must handle",
      "Panics with a missing-status error",
      "Implicitly sends 200 OK before writing the body",
      "Buffers the body until you set a status"
    ],
    "answer": 2,
    "explain": "The first write implicitly commits 200 OK. That's why a later WriteHeader is ignored and logs a 'superfluous response.WriteHeader call' warning."
  },
  {
    "q": "A client POSTs {\"text\": \"   \"} (only spaces). How does net/http reject it as invalid?",
    "choices": [
      "json.Decode returns an error for blank fields",
      "It doesn't - you validate by hand with an if and return 400",
      "A required:true struct tag enforces it automatically",
      "The mux rejects it before the handler runs"
    ],
    "answer": 1,
    "explain": "net/http has no built-in validation. Decoding succeeds with an empty Text; you check it yourself (e.g. strings.TrimSpace == \"\") and return 400."
  }
]
```


---

# Middleware Is Just a Wrapper

The secret that takes the word "middleware" from intimidating to boring: in net/http,
**middleware is a handler that wraps another handler.** That's the whole thing. There is no special
middleware type, no registration system, no framework magic. It's a function that takes an
`http.Handler`, holds onto it in a closure, and hands you back a *new* `http.Handler` that does
something extra before or after calling the one you gave it.

The mental model, in one line: **middleware is `func(next http.Handler) http.Handler`** - a function
that takes the "next" handler and returns a wrapped version of it. The wrapper runs your code, then
calls `next.ServeHTTP(w, r)` to let the real work happen, then optionally runs more code on the way
back out. It's an onion: each layer wraps the one inside it, the request travels inward through every
layer, and the response travels back outward through them in reverse.

> 📝 You already know everything you need for this. A `Handler` is anything with `ServeHTTP(w, r)`
> (Phase 1), and a closure is a function that remembers a variable from where it was created (Go From
> Zero). Middleware is just those two ideas shaken together. If "closure over `next`" feels fuzzy,
> that's the only new thing here - everything else you've seen.

## The shape of every middleware

Let's build the canonical example: logging. We want to print the method, path, and how long each
request took. Watch the shape carefully, because *every* middleware you ever write looks like this.

```go
func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}
```

*What just happened:* `Logging` takes one handler (`next`) and returns a brand-new one built with
`http.HandlerFunc`. Inside that new handler, we record the start time, call `next.ServeHTTP(w, r)` to
run whatever was wrapped (the mux, another middleware, your actual route handler - we don't care
which), and *then*, after it returns, we log how long it took. The returned function is a closure: it
"remembers" `next` and `start` even though `Logging` has long since returned. Code before
`next.ServeHTTP` runs on the way *in*; code after it runs on the way *out*. That before/after split is
the entire vocabulary of middleware - auth checks go before, timing and cleanup go after.

## Applying it: wrap the mux

A handler that wraps a handler is useless until you actually wrap something. Your router (the
`ServeMux` from Phase 2) is an `http.Handler` - so you wrap *it*, and hand the wrapped result to the
server instead of the bare mux.

```go
mux := http.NewServeMux()
mux.HandleFunc("GET /messages", listMessages)

var h http.Handler = mux   // the mux is a Handler
h = Logging(h)             // now h is "logging, then the mux"

http.ListenAndServe(":8080", h)
```

*What just happened:* We declared `h` as an `http.Handler` and started it as the mux. Then
`h = Logging(h)` replaced it with the logging wrapper, which still has the mux tucked inside it as
`next`. When a request arrives, the server calls `h.ServeHTTP` - that's the logging layer, which logs
and then calls the mux, which routes to `listMessages`. We pass `h`, not `mux`, to `ListenAndServe`.
The mux didn't change at all; we just put a coat on it.

## Chaining: wrappers around wrappers

One middleware is wrapping; several is *nesting*. Because each middleware takes a handler and returns
a handler, the output of one is valid input to the next. You stack them by nesting the calls:

```go
var h http.Handler = mux
h = Logging(Auth(mux))   // Auth wraps the mux; Logging wraps Auth

http.ListenAndServe(":8080", h)
```

*What just happened:* Read it inside-out. `Auth(mux)` produces a handler that does auth and then calls
the mux. `Logging(...)` wraps *that*, producing a handler that logs and then calls the auth layer. So a
request flows: **Logging → Auth → mux → your handler**, and the response unwinds back the same way in
reverse. The **outermost wrapper runs first** on the way in. That ordering matters: putting `Logging`
outermost means it times the *whole* request including auth; swapping them would exclude auth from the
timing. Nesting reads backwards, which is exactly why people reach for a helper.

That nesting gets ugly fast with four or five middlewares. A tiny helper flattens it into a readable
list:

```go
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

// usage:
h := Chain(mux, Logging, Auth)   // same as Logging(Auth(mux))
```

*What just happened:* `Chain` takes the base handler plus a variadic list of middlewares. It applies
them **back to front** (the loop counts down from the last index) so that the *first* one you list ends
up as the outermost wrapper - matching how you'd read it: "Logging, then Auth, then the mux." Now
adding a middleware is appending a name to the list, not re-nesting parentheses. This is the same
helper, give or take, that every Go middleware library ships under names like `Use` or `With`.

## Auth middleware: when *not* to call next

Logging always calls `next` - it never blocks a request, it just observes. Auth is the interesting
case, because its whole job is to *sometimes refuse*. The rule is simple: if the request fails the
check, write an error response and **`return` without calling `next`.** That short-circuits the onion -
the inner layers never run.

```go
func Auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "missing Authorization header", http.StatusUnauthorized)
            return // stop here - do NOT call next
        }
        next.ServeHTTP(w, r) // authorized: let the request continue
    })
}
```

*What just happened:* We read the `Authorization` header. If it's empty, we write a `401 Unauthorized`
and `return` immediately - the wrapped handler never runs, so the protected route is never reached.
That bare `return` is load-bearing: forget it, and after writing the 401 you'd *also* call `next`,
running the real handler and writing a second response on top of the error. Only when the header is
present do we fall through to `next.ServeHTTP(w, r)`.

> ⚠️ Once you've written to `w` (status or body), you can't un-write it. After `http.Error` the
> response is committed, so the `return` isn't optional politeness - it prevents a corrupt
> double-response. The pattern "write the error, then `return`" is one you'll repeat constantly.

Real auth doesn't just check that a token *exists* - it validates it and figures out *who* the user is.
You'll want to pass that identity down to the handlers inside. You can't add a field to `*http.Request`,
but you *can* attach values to its `context`. Here's the shape (Phase 6 goes deep on context):

```go
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
```

*What just happened:* `context.WithValue` produces a new context carrying `user` under a key, and
`r.WithContext(ctx)` makes a copy of the request using that context. We pass the copy down, so any
inner handler can call `r.Context().Value(userKey)` to retrieve the user the middleware authenticated.
This is how middleware talks to the handlers it wraps - not by mutating the request, but by enriching
its context on the way in. We'll do this properly, with a typed key and a getter, in
[Phase 6](06-structure-and-shutdown.md).

> 💡 Look back at that `func(http.Handler) http.Handler` signature. It is *exactly* what
> [chi](/guides/chi-from-zero) uses - chi middleware is plain net/http middleware, no translation
> needed, which is why chi feels like "net/http with a nicer router." [Gin](/guides/gin-from-zero) and
> Echo wrap the same before/after idea around their *own* context type (`c.Next()` is their version of
> `next.ServeHTTP`), but it's the identical onion. Learn it once here and every framework's middleware
> chapter is review.

## Recap

- **Middleware is `func(next http.Handler) http.Handler`** - a function that takes a handler and
  returns a new handler wrapping it. No special type, just a closure over `next`.
- Code **before** `next.ServeHTTP` runs on the way in; code **after** it runs on the way out. The
  request travels inward through the layers and the response unwinds back outward.
- **Apply** middleware by wrapping your mux (`h = Logging(mux)`) and passing the wrapper, not the bare
  mux, to the server. **Chain** by nesting (`Logging(Auth(mux))`) or with a small `Chain` helper; the
  outermost wrapper runs first.
- An **auth** middleware that rejects a request must write its error and **`return` without calling
  `next`** - otherwise the protected handler runs anyway and you write two responses.
- Pass data (like the authenticated user) to inner handlers via `context.WithValue` +
  `r.WithContext`, not by mutating the request - expanded in Phase 6.

## Quick check

Three quick ones to make sure the wrapper model stuck.

```quiz
[
  {
    "q": "What is the type signature of a net/http middleware?",
    "choices": [
      "func(w http.ResponseWriter, r *http.Request)",
      "func(next http.Handler) http.Handler",
      "type Middleware interface { Use() }",
      "func(mux *http.ServeMux) error"
    ],
    "answer": 1,
    "explain": "Middleware takes the next http.Handler and returns a new http.Handler that wraps it. It's a plain function over a closure - no special type involved."
  },
  {
    "q": "In an auth middleware, what must you do when the request is unauthorized?",
    "choices": [
      "Call next.ServeHTTP anyway so the handler can decide",
      "Write the error response and return WITHOUT calling next",
      "Panic so the server recovers and sends a 500",
      "Delete the Authorization header and retry"
    ],
    "answer": 1,
    "explain": "Write the 401 and return immediately. If you don't return, you'll call next after writing the error, running the protected handler and writing a second, corrupt response."
  },
  {
    "q": "Given Logging(Auth(mux)), which layer sees the request first?",
    "choices": [
      "mux, because it's innermost",
      "Auth, because authentication always goes first",
      "Logging, because the outermost wrapper runs first on the way in",
      "They run in parallel"
    ],
    "answer": 2,
    "explain": "Read it inside-out: Logging wraps Auth wraps mux. The outermost wrapper (Logging) runs first on the way in, then Auth, then the mux - and the response unwinds in reverse."
  }
]
```


---

# 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](02-handlers-and-routing.md), [reading requests and writing JSON](03-requests-and-json.md), and [middleware as a plain wrapper](04-middleware-is-a-wrapper.md). 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:

```go
type Message struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
}

type Store struct {
	mu     sync.Mutex
	data   map[int]Message
	nextID int
}

func NewStore() *Store {
	return &Store{data: make(map[int]Message), nextID: 1}
}
```

*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 writes` and kills the whole process. A plain `map` is not safe for concurrent writes. The `sync.Mutex` makes 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:

```go
func (s *Store) List() []Message {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Message, 0, len(s.data))
	for _, m := range s.data {
		out = append(out, m)
	}
	return out
}

func (s *Store) Get(id int) (Message, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	m, ok := s.data[id]
	return m, ok
}

func (s *Store) Create(text string) Message {
	s.mu.Lock()
	defer s.mu.Unlock()
	m := Message{ID: s.nextID, Text: text}
	s.data[m.ID] = m
	s.nextID++
	return m
}

func (s *Store) Update(id int, text string) (Message, bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.data[id]; !ok {
		return Message{}, false
	}
	m := Message{ID: id, Text: text}
	s.data[id] = m
	return m, true
}

func (s *Store) Delete(id int) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.data[id]; !ok {
		return false
	}
	delete(s.data, id)
	return true
}
```

*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](03-requests-and-json.md):

```go
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}
```

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`:

```go
func parseID(r *http.Request) (int, error) {
	return strconv.Atoi(r.PathValue("id"))
}
```

*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

```go
func (s *Store) handleList(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, s.List())
}
```

*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

```go
func (s *Store) handleGet(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	m, ok := s.Get(id)
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "message not found"})
		return
	}
	writeJSON(w, http.StatusOK, m)
}
```

*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

```go
type createInput struct {
	Text string `json:"text"`
}

func (s *Store) handleCreate(w http.ResponseWriter, r *http.Request) {
	var in createInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}
	if strings.TrimSpace(in.Text) == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"})
		return
	}
	m := s.Create(in.Text)
	writeJSON(w, http.StatusCreated, m)
}
```

*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

```go
func (s *Store) handleUpdate(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	var in createInput
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
		return
	}
	if strings.TrimSpace(in.Text) == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "text is required"})
		return
	}
	m, ok := s.Update(id, in.Text)
	if !ok {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "message not found"})
		return
	}
	writeJSON(w, http.StatusOK, m)
}
```

*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

```go
func (s *Store) handleDelete(w http.ResponseWriter, r *http.Request) {
	id, err := parseID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
		return
	}
	if !s.Delete(id) {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "message not found"})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}
```

*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](04-middleware-is-a-wrapper.md):

```go
func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
	})
}

func main() {
	store := NewStore()
	mux := http.NewServeMux()

	mux.HandleFunc("GET /messages", store.handleList)
	mux.HandleFunc("POST /messages", store.handleCreate)
	mux.HandleFunc("GET /messages/{id}", store.handleGet)
	mux.HandleFunc("PUT /messages/{id}", store.handleUpdate)
	mux.HandleFunc("DELETE /messages/{id}", store.handleDelete)

	log.Println("listening on :8080")
	log.Fatal(http.ListenAndServe(":8080", Logging(mux)))
}
```

*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:

```bash
# Create two messages
$ curl -s -X POST localhost:8080/messages -d '{"text":"hello"}'
{"id":1,"text":"hello"}
$ curl -s -X POST localhost:8080/messages -d '{"text":"world"}'
{"id":2,"text":"world"}

# List them
$ curl -s localhost:8080/messages
[{"id":1,"text":"hello"},{"id":2,"text":"world"}]

# Get one
$ curl -s localhost:8080/messages/1
{"id":1,"text":"hello"}

# Update it
$ curl -s -X PUT localhost:8080/messages/1 -d '{"text":"hi there"}'
{"id":1,"text":"hi there"}

# Delete it (note -i to see the status - 204 has no body)
$ curl -s -i -X DELETE localhost:8080/messages/1 | head -1
HTTP/1.1 204 No Content

# A missing message is a clean 404
$ curl -s localhost:8080/messages/999
{"error":"message not found"}
```

*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 plain 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 `if` checks, 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](07-what-frameworks-add.md) 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 `Store` must be **mutex-guarded**: the server runs each request in its own goroutine, and concurrent writes to a bare map panic with `concurrent map writes`. `Lock()` + `defer Unlock()` in every method.
- Handlers read with `r.PathValue("id")` → `strconv.Atoi`, decode bodies with `json.NewDecoder`, validate by hand (no built-in validation), and respond with `writeJSON` - using the store's `bool` return to choose `200` vs `404`.
- Status codes map to intent: `200` read, `201` create, `204` delete (no body), `400` bad input, `404` missing. Each guard ends in `return`.
- 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](07-what-frameworks-add.md).

## Quick check

```quiz
[
  {
    "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."
  }
]
```


---

# Structure, Context & Graceful Shutdown

You've got a working messages API now. It handles requests, decodes JSON, runs through middleware. And if you wrote it the way most tutorials do, it leans on package-level globals - a `var store *Store` sitting at the top of the file that every handler reaches into. That works right up until you want to write a test, run two configurations side by side, or reason about what a handler actually depends on.

The mental model for this phase: **your dependencies live on a struct, your handlers are methods on that struct, and a single `routes()` method assembles the mux.** No package globals, no `init()` magic. The struct *is* your application - you build one, hand it everything it needs, and ask it for an `http.Handler`. That one move makes the whole service testable and explicit. Then we'll make requests carry cancellation through `context`, harden the server against slow clients with timeouts, and teach it to stop without dropping the requests already in flight.

> 📝 This phase assumes you've built the messages service from [Phase 5: A JSON REST API With No Framework](05-rest-api-no-framework.md) - same handlers, same `Store`. We're not adding features; we're changing how it's *wired and run* so it survives contact with production.

## Dependencies on a struct, handlers as methods

Here's the shape. Define a `server` struct that holds everything your handlers need - the store, a logger, maybe config. Then write each handler as a *method* on `*server`, so it reaches its dependencies through `s.` instead of a global. Finally, one `routes()` method builds the mux and returns it as an `http.Handler`.

```go
type server struct {
    store  *Store
    logger *log.Logger
}

func (s *server) routes() http.Handler {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /messages", s.handleList)
    mux.HandleFunc("POST /messages", s.handleCreate)
    mux.HandleFunc("GET /messages/{id}", s.handleGet)
    return Logging(mux) // wrap the whole mux in middleware
}

func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
    msgs := s.store.All() // dependency reached through the struct, not a global
    writeJSON(w, http.StatusOK, msgs)
}
```

*What just happened:* `handleList` is a method, so it has `s` - and through `s` it has the store. No global lookup. To wire the whole thing in `main`, you construct the struct once and ask it for its handler:

```go
func main() {
    s := &server{
        store:  NewStore(),
        logger: log.New(os.Stdout, "", log.LstdFlags),
    }
    http.ListenAndServe(":8080", s.routes()) // we'll improve this line below
}
```

*What just happened:* every dependency is created in one place and handed to the `server`. A handler can't secretly depend on something - if it needs it, it's a field on the struct, visible in one definition.

> 💡 This is the seam that makes testing trivial. A test builds `s := &server{store: NewStore()}`, calls `s.routes()` to get an `http.Handler`, and drives it with `httptest.NewServer(s.routes())` or `httptest.NewRecorder()` - no network, no globals, a fresh isolated store per test. That single `routes()` method is the entire wiring surface, so what your test exercises is exactly what `main` runs.

## context done right

Every request carries a `context.Context`, reachable via `r.Context()`. It's two things at once: a **cancellation signal** (the client hung up, or a deadline passed) and a **carrier for request-scoped values**. Both matter in real services.

The cancellation half is the important half. When a client disconnects, `r.Context()` is cancelled - and if you thread that context into your database and outbound HTTP calls, *they* get cancelled too, so you stop doing work nobody's waiting for:

```go
func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    // Pass the request context down. If the client disconnects,
    // the query is cancelled instead of running to completion for nobody.
    msg, err := s.store.db.QueryContext(r.Context(), "SELECT ... WHERE id = ?", id)
    if err != nil {
        // a cancelled request surfaces here as context.Canceled
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    writeJSON(w, http.StatusOK, msg)
}
```

*What just happened:* the `Context`-suffixed methods (`QueryContext`, `ExecContext`, `http.NewRequestWithContext`) take a context and abort if it's cancelled. Threading `r.Context()` through means a dropped client connection unwinds your whole call chain instead of leaving a query grinding away.

The other half is stashing request-scoped values - say, a request ID or an authenticated user that middleware computed and a handler later reads. You attach with `context.WithValue` and a *new* request, then read with `.Value`:

```go
type ctxKey int // unexported key type - the whole point

const userKey ctxKey = 0

func (s *server) withUser(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user := lookupUser(r) // however you authenticate
        ctx := context.WithValue(r.Context(), userKey, user)
        next.ServeHTTP(w, r.WithContext(ctx)) // pass the enriched request down
    })
}

func (s *server) handleList(w http.ResponseWriter, r *http.Request) {
    user, _ := r.Context().Value(userKey).(*User) // read it back, type-assert
    s.logger.Printf("listing for %v", user)
    // ...
}
```

*What just happened:* middleware put a value on the context and called the next handler with `r.WithContext(ctx)` (contexts are immutable - you derive a new one and a new request). The handler reads it back with `.Value`. Note the type assertion: `Value` returns `any`, so you assert to the type you stored.

> ⚠️ Use an **unexported key type** (`type ctxKey int`), never a bare string. If you write `context.WithValue(ctx, "user", ...)`, any other package - including a library you imported - can use the string `"user"` too and silently clobber your value, or read yours. An unexported type defined in *your* package is impossible for anyone else to name, so collisions can't happen. This is the single most common context bug. Also: context values are for request-scoped data that crosses middleware boundaries, not a backdoor for passing your store around - that's what the struct is for.

## http.Server with timeouts

Look at that `main` again: `http.ListenAndServe(":8080", s.routes())`. Convenient, but it builds an `http.Server` with **no timeouts** under the hood. That's a real liability in production. A `Server` with no `ReadTimeout` will let a client open a connection, send one byte of a request header, and then... wait. Forever. Hold open enough of those and you exhaust the server's connections and memory without ever sending a complete request. That's the classic **Slowloris** attack, and bare `ListenAndServe` is wide open to it.

The fix is to construct the `http.Server` yourself and set the timeouts:

```go
srv := &http.Server{
    Addr:         ":8080",
    Handler:      s.routes(),
    ReadTimeout:  5 * time.Second,   // max time to read the full request (incl. body)
    WriteTimeout: 10 * time.Second,  // max time to write the response
    IdleTimeout:  120 * time.Second, // max time a keep-alive connection sits idle
}
srv.ListenAndServe()
```

*What just happened:* `ReadTimeout` caps how long a slow client can dribble in a request - kill the Slowloris. `WriteTimeout` caps a slow or stuck response. `IdleTimeout` reaps keep-alive connections that aren't doing anything. These are the three you almost always want; pick numbers that fit your real request shapes (big uploads need a longer read window). The point is the same: an unbounded server is a resource-exhaustion bug waiting to happen, and the fix is four extra lines.

## Graceful shutdown

When your service gets a shutdown signal - a `Ctrl+C` locally, or `SIGTERM` from your container orchestrator on a deploy - the naive behavior is to die instantly. Any request being served mid-flight just gets dropped: a half-written response, a transaction that never committed, a confused user. **Graceful shutdown** means: stop accepting *new* connections, but let the requests already in flight finish, then exit.

`http.Server.Shutdown` does exactly that. The pattern is to run the server in a goroutine, wait for a signal, then call `Shutdown` with a deadline:

```go
srv := &http.Server{
    Addr:         ":8080",
    Handler:      s.routes(),
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
}

go func() {
    // ListenAndServe blocks until the server stops. When Shutdown is called,
    // it returns http.ErrServerClosed - which is the *expected* exit, not a crash.
    if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatal(err)
    }
}()

// Block until we get an interrupt or SIGTERM. NotifyContext cancels its
// context when one of those signals arrives.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done() // wait here for the signal

// Give in-flight requests up to 10s to finish before forcing the exit.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
    log.Printf("graceful shutdown failed: %v", err)
}
```

*What just happened:* the server runs in its own goroutine so `main` is free to wait. `signal.NotifyContext` gives you a context that's cancelled the moment `Ctrl+C` (`os.Interrupt`) or `SIGTERM` lands, so `<-ctx.Done()` is "block until someone asks us to stop." Then `srv.Shutdown(shutdownCtx)` stops the listener accepting new connections and waits for active requests to drain - bounded by the 10-second `shutdownCtx` so a stuck request can't hang the process forever.

> ⚠️ Two things people trip on. First: the `err != http.ErrServerClosed` check. `ListenAndServe` *always* returns a non-nil error, and after a clean `Shutdown` that error is `http.ErrServerClosed` - the normal, expected exit. If you don't special-case it, `log.Fatal` will scream about a "failure" every time you shut down cleanly. Second: `signal.NotifyContext` (Go 1.16+) replaces the older hand-rolled `signal.Notify` + channel dance - fewer moving parts, harder to get wrong.

This is the difference between "deploys cause a blip of 502s" and "deploys are invisible to users." When you wire this service up for real - behind a process manager, in a container, fronted by a load balancer - graceful shutdown is what lets the orchestrator rotate instances without dropping traffic. The deployment side of that story (health checks, rolling restarts, signal handling in containers) is covered in [Ship Your Side Project](/guides/ship-your-side-project); this is the server-side half that makes it work.

## Recap

- **Dependencies on a struct, handlers as methods.** A `server`/`app` struct holds the store, logger, and config; handlers reach them through `s.` instead of globals. One `routes()` method assembles the mux and returns an `http.Handler`.
- **That `routes()` method is the test seam.** Tests build a fresh `server`, call `routes()`, and drive it with `httptest` - no globals, no network, perfect isolation, and the same handler `main` runs.
- **`r.Context()` carries cancellation and request-scoped values.** Thread it into `QueryContext`/`NewRequestWithContext` so a dropped client unwinds your call chain. Stash values with `context.WithValue` + `r.WithContext`, read with `.Value`.
- **Always use an unexported `ctxKey` type for context keys** - never a bare string, which collides across packages.
- **Never ship bare `ListenAndServe`.** Build an `http.Server` with `ReadTimeout`, `WriteTimeout`, and `IdleTimeout` to close the door on slow-client (Slowloris) resource exhaustion.
- **Graceful shutdown drains in-flight requests.** Run the server in a goroutine, wait on `signal.NotifyContext`, call `srv.Shutdown(ctx)` with a deadline, and treat `http.ErrServerClosed` as a clean exit.

Test yourself on the two ideas that bite hardest:

```quiz
[
  {
    "q": "Why hold your dependencies on a struct with handlers as methods, instead of package-level globals?",
    "choices": ["It makes handlers run faster", "Handlers can reach deps through the struct, making the service explicit and testable with a fresh isolated server per test", "Go forbids global variables in web servers", "It is required for context to work"],
    "answer": 1,
    "explain": "Methods on a struct reach dependencies through s. instead of globals. A test builds its own server and calls routes(), getting full isolation and exercising exactly what main runs - globals make that impossible."
  },
  {
    "q": "Why must a context value key be an unexported type like `type ctxKey int` rather than a plain string?",
    "choices": ["Strings are slower as map keys", "context.WithValue rejects string keys at compile time", "A bare string key can collide with keys set by other packages, silently overwriting or leaking your value; an unexported type can't be named elsewhere", "Unexported types use less memory"],
    "answer": 2,
    "explain": "Any package can use the same string, so string keys risk silent collisions. An unexported type defined in your package can't be named by anyone else, making collisions impossible."
  },
  {
    "q": "After a clean graceful shutdown, what does srv.ListenAndServe() return, and how should you treat it?",
    "choices": ["nil - treat it as success", "http.ErrServerClosed - the expected exit; special-case it so you don't log a false failure", "A panic you must recover from", "context.Canceled - retry the server"],
    "answer": 1,
    "explain": "ListenAndServe always returns a non-nil error; after Shutdown it's http.ErrServerClosed, the normal exit. Check for it explicitly or log.Fatal will report a 'failure' on every clean shutdown."
  }
]
```


---

# What the Frameworks Add

Notice what you just did. You built a real JSON REST API - routing, request decoding, JSON responses with the right status codes, middleware, a sensible project structure, a `context` that cancels, and a server that shuts down without dropping in-flight requests. And you did all of it with the standard library and nothing else. No imports you had to learn from a framework's docs. Just `Handler`, `ServeMux`, and `Server`.

That's the whole point of this guide. The frameworks people reach for - [Gin](/guides/gin-from-zero), [Echo](/guides/echo-from-zero), [chi](/guides/chi-from-zero) - are not a different world. They're conveniences layered over the exact skeleton you now have in your hands. So this last phase is the payoff: we point your new X-ray vision at the frameworks and watch the "magic" turn into machinery you can already name.

## Mapping the magic to the mechanism

💡 Here's the thing worth reading slowly: every "feature" a Go web framework advertises is a convenience over something in this guide. Once you've seen the bare version, the framework version stops being a spell.

```mermaid
flowchart LR
  RT["Gin/Echo router"] --> MUX["ServeMux + extras"]
  CR["chi router"] --> MUX2["IS net/http-compatible"]
  CTX["gin.Context / echo.Context"] --> WR["w + r, bundled with helpers"]
  MW["Framework middleware"] --> WRAP["func(http.Handler) http.Handler"]
  BIND["ShouldBindJSON / Bind+Validate"] --> HAND["your decode + validate + writeJSON"]
```

Reading that left to right, in plain words:

- **The router.** A framework router is `http.ServeMux` with extras bolted on: route groups, richer path patterns, and (before Go 1.22 made it unnecessary) parameter parsing. [chi](/guides/chi-from-zero)'s router is the friendliest case - it *is* net/http-compatible, so a chi route is a plain `http.Handler` and you keep using `w`/`r` directly. [Gin](/guides/gin-from-zero) and [Echo](/guides/echo-from-zero) ship their own fast radix-tree routers for speed, but each one still implements `http.Handler` underneath. Same contract you learned in [Phase 1](01-the-mental-model.md).
- **The context object.** Gin's `*gin.Context` and Echo's `echo.Context` bundle the `w` and `r` you've been passing around, plus a pile of helpers: `c.JSON(...)` instead of hand-writing headers and `json.NewEncoder`, `c.Param("id")` instead of `r.PathValue("id")`, and request binding. chi adds *no* context object - that's deliberate, and it's why chi feels like "net/http with a better router."
- **Middleware.** chi uses the *exact* `func(http.Handler) http.Handler` signature you wrote in [Phase 4](04-middleware-is-a-wrapper.md) - your logging and auth wrappers would drop straight into a chi app. Gin and Echo use their own context-based middleware signature, but it's the identical idea: a function that runs before and after the next handler and can short-circuit the chain.
- **Binding, validation, render helpers, and error handling.** This is where frameworks genuinely earn their keep. In your bare API you hand-rolled `json.NewDecoder(r.Body).Decode(...)`, checked fields yourself, and wrote a `writeJSON` helper. Gin gives you `c.ShouldBindJSON(&v)` plus struct-tag validation (`binding:"required"`); Echo gives you `c.Bind(&v)` paired with `c.Validate(v)`; both let you register one central error handler instead of repeating error-writing in every handler. You built the manual version, so you know exactly what these are saving you.

That covers the Go web world. The frameworks differ in ergonomics and feature sets - but underneath, every one of them is an `http.Handler` that a `Server` invokes.

## Do I even need a framework?

📝 Let me be direct, because a roots guide that pretends you must reach for a framework would be lying to you: a lot of the time, plain net/http is genuinely enough now.

The reason is [Phase 2](02-handlers-and-routing.md). Since **Go 1.22**, the standard `http.ServeMux` does method-and-path routing - `mux.HandleFunc("GET /messages/{id}", ...)` with `r.PathValue("id")` - which is the single biggest thing frameworks used to be *required* for. For a small service with a handful of routes, you can ship the bare stdlib version and not feel like you're missing anything.

So when do you reach for a framework? When you want the batteries:

- **You want binding and validation, a big middleware ecosystem, and you're writing a lot of routes** - reach for **[Gin](/guides/gin-from-zero)** or **[Echo](/guides/echo-from-zero)**. The `ShouldBindJSON` + validator-tags story alone saves real boilerplate once you have dozens of endpoints.
- **You love the stdlib but want nicer routing ergonomics without leaving it** - reach for **[chi](/guides/chi-from-zero)**. It's net/http-pure: your `func(http.Handler) http.Handler` middleware and your `w`/`r` handlers work unchanged.

That's the plain decision. Not "always use a framework," and not "frameworks are bloat" - just match the tool to the size of the job.

## Where to go from here

You're now in the rare, comfortable position of being able to **pick a framework with your eyes open** instead of cargo-culting a tutorial:

- **[Gin](/guides/gin-from-zero)** - the batteries-included popular default, biggest ecosystem, terse `c.JSON` style.
- **[Echo](/guides/echo-from-zero)** - similar feature set with a slightly cleaner error-returning handler style (`func(c) error`).
- **[chi](/guides/chi-from-zero)** - stdlib-pure, no context object, the natural next step if you liked this guide.

And whichever you choose, a real service needs a database. The standard next move is to add persistence: see **[GORM From Zero](/guides/gorm-from-zero)** to swap your in-memory messages slice for a real store.

## The magic was always three types

Here's the line to carry out of this whole guide: the "magic" inside every Go web framework was always just **`Handler`, `ServeMux`, and `Server`** - a thing that handles one request, a thing that routes to it, and a thing that listens. Everything else is convenience stacked on top. Binding is your decode step with a nicer name. The context object is `w` and `r` in a bag. Middleware is a function wrapping a handler.

You didn't learn one framework. You learned the foundation under *all* of them. Open the source of any Go web codebase now - Gin's, Echo's, chi's, or some service at your job - and you'll find the same skeleton you built by hand, with the boilerplate filed off. You can read all of it.

## Recap

1. **Frameworks are conveniences over net/http, not a separate world.** The router is `ServeMux` plus extras, the context object bundles `w`/`r` with helpers, and middleware is the wrap-the-next-handler idea you already wrote.
2. **chi is the net/http-pure option:** its router is `http.Handler`-compatible, it adds no context object, and it uses the exact `func(http.Handler) http.Handler` middleware signature from Phase 4.
3. **Gin and Echo earn their keep on batteries:** `ShouldBindJSON` / `Bind`+`Validate` with struct-tag validation and central error handlers replace the decode/validate/`writeJSON` you hand-rolled.
4. **Since Go 1.22, plain net/http is genuinely enough for small services** - the stdlib mux does method+path routing. Reach for a framework when you want binding/validation, a big middleware ecosystem, and lots of routes.
5. **The skeleton is always `Handler`, `ServeMux`, `Server`** - learn it once and you can read any Go web codebase, framework or not. Add a database next with [GORM From Zero](/guides/gorm-from-zero).

## Quick check

One last check - the mappings that turn frameworks from magic into mechanism:

```quiz
[
  {
    "q": "Mechanically, what is a Gin or Echo router relative to net/http?",
    "choices": [
      "http.ServeMux with extras (route groups, richer patterns) - and it still implements http.Handler",
      "A replacement for the http.Server that listens on its own",
      "A browser-side routing library",
      "A database query router with no relation to net/http"
    ],
    "answer": 0,
    "explain": "Gin and Echo ship their own fast radix routers for speed, but each still implements http.Handler - it's ServeMux's job with extras. chi goes further and its router is directly net/http-compatible."
  },
  {
    "q": "What is a framework's context object, like *gin.Context or echo.Context?",
    "choices": [
      "The w and r you already pass around, bundled together with helpers like c.JSON and c.Param",
      "A second http.Server running concurrently",
      "Go's context.Context renamed",
      "A database connection pool"
    ],
    "answer": 0,
    "explain": "gin.Context and echo.Context bundle the ResponseWriter and *Request with conveniences (c.JSON, c.Param, binding). chi deliberately adds no context object - you use w and r directly, which is why it feels like plain net/http."
  },
  {
    "q": "Given Go 1.22, when is reaching for Gin or Echo most justified over plain net/http?",
    "choices": [
      "When you want binding/validation, a large middleware ecosystem, and you're writing lots of routes",
      "Whenever you need any routing at all, since the stdlib mux can't route by method",
      "Only when you cannot use the standard library for licensing reasons",
      "Never - frameworks no longer add anything over net/http"
    ],
    "answer": 0,
    "explain": "Since Go 1.22 the stdlib ServeMux does method+path routing, so small services often need nothing more. Frameworks earn their keep on batteries - binding/validation, big middleware ecosystems, and many routes - or, for stdlib-pure ergonomics, chi."
  }
]
```
