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. Then the globals become quiet handcuffs.
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 — 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.
.Handler
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:
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()}, callss.routes()to get anhttp.Handler, and drives it withhttptest.NewServer(s.routes())orhttptest.NewRecorder()— no network, no globals, a fresh isolated store per test. That singleroutes()method is the entire wiring surface, so what your test exercises is exactly whatmainruns.
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:
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:
const userKey ctxKey = 0
.Handler
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 writecontext.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:
&http.Server
srv
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:
&http.Server
go
// Block until we get an interrupt or SIGTERM. NotifyContext cancels its
// context when one of those signals arrives.
signal
defer
<-ctx // wait here for the signal
// Give in-flight requests up to 10s to finish before forcing the exit.
context
defer
if srv; err != nil
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.ErrServerClosedcheck.ListenAndServealways returns a non-nil error, and after a cleanShutdownthat error ishttp.ErrServerClosed— the normal, expected exit. If you don't special-case it,log.Fatalwill scream about a "failure" every time you shut down cleanly. Second:signal.NotifyContext(Go 1.16+) replaces the older hand-rolledsignal.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; this is the server-side half that makes it work.
Recap
- Dependencies on a struct, handlers as methods. A
server/appstruct holds the store, logger, and config; handlers reach them throughs.instead of globals. Oneroutes()method assembles the mux and returns anhttp.Handler. - That
routes()method is the test seam. Tests build a freshserver, callroutes(), and drive it withhttptest— no globals, no network, perfect isolation, and the same handlermainruns. r.Context()carries cancellation and request-scoped values. Thread it intoQueryContext/NewRequestWithContextso a dropped client unwinds your call chain. Stash values withcontext.WithValue+r.WithContext, read with.Value.- Always use an unexported
ctxKeytype for context keys — never a bare string, which collides across packages. - Never ship bare
ListenAndServe. Build anhttp.ServerwithReadTimeout,WriteTimeout, andIdleTimeoutto 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, callsrv.Shutdown(ctx)with a deadline, and treathttp.ErrServerClosedas a clean exit.
Test yourself on the two ideas that bite hardest:
[
{
"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."
}
]
← Phase 5: A JSON REST API With No Framework · Guide overview · Phase 7: What the Frameworks Add →
Check your understanding
1. Why hold your dependencies on a struct with handlers as methods, instead of package-level globals?
2. Why must a context value key be an unexported type like `type ctxKey int` rather than a plain string?
3. After a clean graceful shutdown, what does srv.ListenAndServe() return, and how should you treat it?