Structuring & Testing
You've built the whole articles API in one file. That's the right way to start - one file you can read top to bottom beats a maze of folders you have to keep jumping between. But around the time you add a second resource, or your fourth handler reaches for the same store, the single-file version starts to creak. This phase is about the move that happens next, done in the way that won't bite you later.
The mental model to hold onto: handlers stay thin, and their dependencies are explicit. A handler's job is to read the request, call something that does the real work, and write a response. The "something" - your store, a logger, a config - should be handed to the handler on purpose, not reached for through a package-level global. The idiom Go reaches for here is a struct that holds the dependencies, with methods that are your handlers. Wire it once in main, and every handler gets what it needs through the receiver - no globals, no magic.
Handlers as methods on a struct
Here's the shape. A Handler struct holds whatever the handlers need - for the articles API, that's the store. The handlers become methods on it.
// handlers/handlers.go
package handlers
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"yourmodule/store"
)
type Handler struct
func New(s *store.ArticleStore) *Handler
func (w http.ResponseWriter, r *http.Request)
What just happened: GetArticle is a method on *Handler, but its signature is still exactly func(http.ResponseWriter, *http.Request) - a plain http.HandlerFunc. The receiver h is how the handler reaches the store. There's no package-level var store anywhere; the dependency arrived through h.Store, set when we built the Handler. Dependency injection, minus the ceremony.
💡 Why a method instead of a free function that takes the store as an argument? Because http.HandlerFunc is fixed at (w, r) - you can't add a store parameter and still satisfy the interface. Hanging the handler off a struct gives it access to dependencies without changing its signature.
Now wire it in main:
// main.go
package main
import (
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"yourmodule/handlers"
"yourmodule/store"
)
func newRouter(s *store.ArticleStore) http.Handler
func main()
What just happened: main does the wiring and nothing else - create the store, build the router, start the server. We pulled the router construction into its own newRouter(s) function that returns an http.Handler. That looks small, but it's the single most important move in this phase: because newRouter builds the entire application and hands you back an http.Handler, your tests can call it too and exercise the real thing.
📝 Notice newRouter returns http.Handler, not *chi.Mux. Callers (including main and your tests) only need the http.Handler behavior, so that's all you promise them. A chi router is an http.Handler, so this costs nothing.
A layout that scales
You don't need a folder for everything on day one. But once you split, a conventional Go web layout looks like this:
articles-api/
go.mod
main.go ← build the router, wire dependencies, start the server
handlers/
handlers.go ← the Handler struct + its method handlers
store/
store.go ← ArticleStore: Get/List/Create/Update/Delete
models/
article.go ← the Article struct (shared shape)
The dependency arrows all point one way: main imports handlers and store; handlers imports store and models; store imports models; models imports nothing. Keep it acyclic and Go stays happy (it refuses to compile import cycles anyway, which is a feature).
⚠️ Don't over-split early. A services/, repository/, dto/, interfaces/ tower of folders for a CRUD app with one resource is cosplay, not architecture. Start with the four above, and only add a layer when a real second use forces it - the layout should follow the code, not lead it.
Request-scoped values through context, done right
Some data isn't part of the URL or the body - it belongs to this request and needs to travel from a middleware down to a handler. The authenticated user. A request ID for tracing. The standard library's answer is context.Context, which rides along on every *http.Request.
A middleware computes the value and stashes it; the handler reads it back out. Here's a request-ID example:
// middleware/requestid.go
package mw
import (
"context"
"net/http"
"github.com/google/uuid"
)
type ctxKey int
const requestIDKey ctxKey = 0
func RequestID(next http.Handler) http.Handler
// RequestIDFrom pulls the request ID back out, in a handler.
func RequestIDFrom(ctx context.Context) (string, bool)
What just happened: context.WithValue returns a new context carrying the value, and r.WithContext(ctx) returns a new request wrapping it - contexts and requests are immutable, so you always build a new one and pass it forward via next.ServeHTTP. Downstream handlers call r.Context().Value(requestIDKey) (wrapped here in the tidy RequestIDFrom helper) to read it back.
⚠️ The trap that bites everyone: never use a bare string as the context key. If you write context.WithValue(ctx, "user", u) and some library you import also writes context.WithValue(ctx, "user", somethingElse), the keys collide and silently clobber each other - no compile error, just a baffling runtime bug. The fix is an unexported custom key type: type ctxKey int lives only in your package, so no other package can ever produce a value equal to your requestIDKey. That makes collisions impossible by construction.
Here's the handler side reading the value:
func (w http.ResponseWriter, r *http.Request)
What just happened: the handler never knew or cared how the request ID got there - it just asks the context. That's the payoff of the pattern: middleware and handler are decoupled, talking only through a typed, collision-proof key.
💡 Context carries more than your values. r.Context() also propagates cancellation and deadlines. If the client hangs up, or you wrap a route in middleware.Timeout(2 * time.Second), the context's Done() channel fires - and any database driver or http.Client call that respects context will abort instead of running forever. Passing r.Context() down into your store and outbound calls is what makes that work: free request-scoped cancellation, as long as you thread the context through.
Testing the whole router with httptest
Now collect on the promise from newRouter. Because it returns an http.Handler, and because the standard library ships net/http/httptest, you can test the entire stack - routing, middleware, and handler - without ever opening a real socket.
// handlers/handlers_test.go
package handlers_test
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestGetArticle(t *testing.T)
What just happened: httptest.NewRequest builds a *http.Request in memory, httptest.NewRecorder is a fake http.ResponseWriter that captures what the handler writes, and r.ServeHTTP(rec, req) runs the request through the real router exactly as a live server would. Afterward, rec.Code, rec.Body, and rec.Header() hold everything the handler produced. No port, no network, no flakiness.
⚠️ Here's the part people get wrong, and it's specific to routed frameworks like chi: route through r.ServeHTTP, not by calling the handler directly. It's tempting to write h.GetArticle(rec, req) and skip the router. Don't - chi.URLParam(r, "id") reads the id from chi's route context, which only gets attached when the request passes through the router that matched the pattern /{id}. Call the handler directly and that context is missing: chi.URLParam returns an empty string and your test exercises a code path that never happens in production. Test the router, not the bare function.
Testing a POST is the same shape, with a body and a header:
import "strings"
func TestCreateArticle(t *testing.T)
What just happened: strings.NewReader(body) gives the request a JSON body to read, and req.Header.Set("Content-Type", "application/json") mirrors what a real client sends - if your handler checks the content type, the test now satisfies it. The router runs the full POST path: middleware, JSON decode, store write, 201 Created. Including rec.Body.String() in the failure message means a broken test tells you why instead of just what.
💡 Step back and notice what you didn't have to learn. There's no chi-specific test harness, no special TestClient, no framework mock. Because your handlers are plain net/http handlers and your router is a plain http.Handler, your tests are plain net/http too - httptest is the standard library testing the standard library. That's the dividend chi pays for staying compatible: the skills transfer in both directions.
Recap
- Thin handlers, explicit dependencies: a
Handlerstruct holds the store; its methods are yourhttp.HandlerFuncs, getting dependencies through the receiver instead of globals. - Wire once in
main, and pull router construction into anewRouter(s) http.Handlerfunction so tests can build the real application too. - A small package layout -
main.go,handlers/,store/,models/- scales fine; don't add layers until a real second use demands them. - Context carries request-scoped values set in middleware and read in handlers - always with an unexported custom key type (
type ctxKey int), never a bare string, to make collisions impossible. r.Context()also carries cancellation and deadlines, so threading it down into stores and outbound calls gives you free timeout propagation.- Test through the real router with
httptest:r.ServeHTTP(rec, req)exercises routing + middleware + handler, and is the only waychi.URLParamresolves - calling the handler directly leaves chi's route context empty.
Quick check
[
{
"q": "Why hang handlers off a Handler struct as methods instead of using package-level globals for the store?",
"choices": ["chi requires handlers to be methods", "It injects dependencies through the receiver without changing the http.HandlerFunc signature", "Methods run faster than functions in Go", "Globals are not allowed in Go programs"],
"answer": 1,
"explain": "http.HandlerFunc is fixed at (w, r), so you can't add a store parameter. A method gets the dependency via its receiver while keeping the required signature."
},
{
"q": "What's the danger of using a bare string as a context.WithValue key?",
"choices": ["Strings are too slow as map keys", "Another package using the same string key silently collides and clobbers your value", "context.WithValue rejects string keys at compile time", "Strings can't be read back with Value()"],
"answer": 1,
"explain": "Two packages using the same string key collide with no compile error. An unexported custom key type (type ctxKey int) makes collisions impossible by construction."
},
{
"q": "Why test through r.ServeHTTP rather than calling h.GetArticle(rec, req) directly?",
"choices": ["Calling the handler directly panics", "Only r.ServeHTTP can use httptest.NewRecorder", "Routing through the real router attaches chi's route context, so chi.URLParam resolves the {id}", "Direct calls skip JSON encoding"],
"answer": 2,
"explain": "chi.URLParam reads from the route context that only gets attached when the request passes through the matching router. Call the handler directly and URLParam returns an empty string."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. Why hang handlers off a Handler struct as methods instead of using package-level globals for the store?
2. What's the danger of using a bare string as a context.WithValue key?
3. Why test through r.ServeHTTP rather than calling h.GetArticle(rec, req) directly?