go / web framework
I build a Go web app as a thin layer over net/http. A monolith
serving HTML with templates, Postgres via
DB, a small middleware stack, and background work via
job queues. This is the Go counterpart to my Ruby
Rack framework.
Why a thin layer, not a framework
A full framework carries opinions on everything from routing to migrations. Most are irrelevant to any given application, yet they arrive anyway: as dependencies to manage, abstractions to navigate, and conventions for an AI to misremember.
The standard library already has an HTTP server, routing, and cookies. What I add is a few hundred lines: a router, auth wrappers, a composition root, and a handful of middleware. It fits in one package, which makes it simple to read, quick to change, and easy to test. When something breaks, the stack trace points at my code.
The small surface also helps AI-assisted development. A model holds the whole framework in context rather than a decade of shifting framework internals.
Exact-match router
The router maps a method and an exact path to a handler. No regex, no wildcards, no path parameters:
type exactMux struct {
handlers map[string]map[string]http.Handler
}
func (m *exactMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
byMethod, ok := m.handlers[r.Method]
if !ok {
http.NotFound(w, r)
return
}
if handler, ok := byMethod[r.URL.Path]; ok {
handler.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
}
Identifiers travel in the query string (/events/show?id=42), so paths
stay literal. Registration validates each path and panics at boot on a
wildcard or a trailing slash, so a malformed route fails before the
server starts:
func validateExactRoutePath(path string) {
switch {
case !strings.HasPrefix(path, "/"):
panic(fmt.Sprintf("route path must start with '/': %q", path))
case strings.Contains(path, "{") || strings.Contains(path, "}"):
panic(fmt.Sprintf("route path must be literal (no wildcards): %q", path))
case path != "/" && strings.HasSuffix(path, "/"):
panic(fmt.Sprintf("route path must be exact: %q", path))
}
}
Auth at the registration site
The router wraps each route with its auth policy as it registers it, so the policy is visible at the call site and secure by default:
func (rb *router) GET(path string, h http.Handler) {
rb.handle("GET", path, rb.server.RequireLogin(h))
}
func (rb *router) POST(path string, h http.Handler) {
rb.handle("POST", path, rb.server.RequireLogin(rb.csrf.Protect(h)))
}
func (rb *router) AdminGET(path string, h http.Handler) {
rb.handle("GET", path, rb.server.Admin(h))
}
func (rb *router) PublicGET(path string, h http.Handler) {
rb.handle("GET", path, h)
}
GET and POST require login. POST also protects with CSRF.
AdminGET/AdminPOST add the admin role. PublicGET/PublicPOST opt
out for login pages, webhooks, and health checks. Choosing a public
route is a deliberate act, so forgetting the wrapper fails closed.
RequireLogin resolves the user, redirects to /login when absent,
and puts the user on the request context for handlers to read:
func (s *Server) RequireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := s.currentUser(r)
if err != nil {
webutil.WriteServerError(w, err)
return
}
if u == nil || !u.Active {
if r.Method == "GET" {
pushReturnTo(r.URL.Path, r.URL.RawQuery, r)
}
redirect(w, r, "/login")
return
}
ctx := context.WithValue(r.Context(), contextKeyUser{}, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Composition root
I build a shared dependency bundle once and hand the same value to every handler as a struct literal. Handlers that need more take extra fields:
std := webdeps.Standard{
DB: db,
RenderView: renderView,
RenderPage: renderPage,
WriteHTML: webutil.WriteHTML,
WriteError: webutil.WriteError,
Redirect: redirect,
FlashError: flashError,
FlashSuccess: flashSuccess,
CurrentUserEmail: currentUserEmail,
}
srv.events = &handleevents.Handler{Standard: std, DownloadCSV: downloadCSV}
srv.people = &handlepeople.Handler{Standard: std}
Reading this block tells me the whole dependency graph. There is one place to look when I want to know what a handler can touch. The fields are function values, not interfaces. Tests inject fakes by assigning a field. Production wires the real functions once.
Standard lives in a leaf package, www/webdeps, that the feature
packages import without pulling in www itself. That keeps the import
arrows pointing one direction and avoids a cycle:
type Standard struct {
DB *pgdb.DB
RenderView RenderViewFunc
RenderPage RenderPageFunc
WriteHTML WriteHTMLFunc
WriteError WriteErrorFunc
Redirect RedirectFunc
FlashError FlashErrorFunc
FlashSuccess FlashSuccessFunc
CurrentUserEmail CurrentUserEmailFunc
}
Handlers
Every feature gets its own package: www/handleevents,
www/handlepeople, and so on. The package exports a single Handler
that embeds Standard and adds whatever extra dependencies it needs:
package handleevents
import "eds/www/webdeps"
type Handler struct {
webdeps.Standard
DownloadCSV DownloadCSVFunc
}
The template engine takes map[string]any, so a map is what crosses
that boundary. Everything before the boundary stays typed. I scan
database rows into a struct, then format each field in Go before it
reaches the template:
type indexRow struct {
ID int64 `db:"id"`
Name string `db:"name"`
Score float64 `db:"score"`
LastMetOn pgtype.Date `db:"last_met_on"`
}
func (h *Handler) Index(w http.ResponseWriter, r *http.Request) {
if err := webutil.ValidateParams(r, "sort", "dir", "query"); err != nil {
h.WriteError(w, 400, err.Error())
return
}
dbRows, err := h.DB.Query(r.Context(), qFetchIndex, fuzzy(r))
if err != nil {
h.WriteError(w, 500, "query failed")
return
}
rows, err := pgx.CollectRows(dbRows, pgx.RowToStructByName[indexRow])
if err != nil {
h.WriteError(w, 500, "collect failed")
return
}
viewRows := make([]map[string]any, 0, len(rows))
for _, row := range rows {
viewRows = append(viewRows, map[string]any{
"id": row.ID,
"name": row.Name,
"score": fmt.Sprintf("%.1f", row.Score),
"url": fmt.Sprintf("/events/show?id=%d", row.ID),
"last_met_on": formatDate(row.LastMetOn),
})
}
html, err := h.RenderPage(r, "index", map[string]any{"rows": viewRows})
if err != nil {
h.WriteError(w, 500, err.Error())
return
}
h.WriteHTML(w, 200, html)
}
Every method follows the same four beats: validate params, fetch typed rows, map and format into view dictionaries, render. Params get validated at the top, so a bad request fails before it touches the database. Formatting happens in Go where the compiler checks it, which keeps templates logicless. When something breaks, the stack trace points at the handler, not at a template line I have to reverse-engineer.
Routes register the handler methods by name in one table, so the URL surface reads top to bottom:
r.GET("/events", http.HandlerFunc(s.events.Index))
r.GET("/events/show", http.HandlerFunc(s.events.Show))
r.POST("/events/create", http.HandlerFunc(s.events.Create))
Middleware stack
Cross-cutting concerns are http.Handler wrappers composed around the
router. The order matters, so I read it bottom to top as the request
travels inward:
h = s.session.Wrap(h)
h = middleware.CSP(h, s.cfg.AppEnv)
h = middleware.CanonicalHost(h, s.cfg.AppEnv, s.cfg.CanonicalHost)
h = middleware.HTTPS(h, s.cfg.AppEnv)
h = http.TimeoutHandler(h, s.cfg.RequestTimeout, "request timed out")
h = s.requestLogMiddleware(h)
h = middleware.Recover(h, s.cfg.AppEnv, capturePanic)
Recover and the request logger sit outermost so they see every
request, including one a redirect or timeout short-circuits. HTTPS
redirects http to https and sets HSTS in production:
func HTTPS(next http.Handler, appEnv string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if appEnv != "production" {
next.ServeHTTP(w, r)
return
}
if requestScheme(r) == "http" {
http.Redirect(w, r, "https://"+r.Host+r.URL.RequestURI(), 301)
return
}
w.Header().Set("Strict-Transport-Security", HSTSValue)
next.ServeHTTP(w, r)
})
}
Static assets are served before the CSRF and session layers, so an
asset request does not seed a csrf_token or a session write.
Open-redirect defense
RequireLogin and every handler redirect through one guard that rejects
anything that is not an internal path or a same-origin URL:
func redirect(w http.ResponseWriter, r *http.Request, location string) {
if !webutil.SameOriginOrInternalPath(r, location) {
w.WriteHeader(400)
return
}
http.Redirect(w, r, location, 303)
}
Open redirects come in a few shapes:
https://www.example.com.attacker.tld/x(prefix-match fool)//evil.com/x(protocol-relative)/\evil.com/x(some browsers normalize\to/)/@evil.com/x(parses as a userinfo authority)
InternalOnlyPath rejects all four by requiring a leading / and
excluding /, \, and @ as the second character. The same check
guards the return_to value stashed before a login redirect.
Content Security Policy
A CSP header tells the browser which origins may load scripts, styles, and frames. The middleware sets it only on HTML responses, starting from default-deny and naming specific hosts:
func cspHeaderValue(appEnv string) string {
return strings.Join([]string{
"base-uri 'none'",
"connect-src 'self' " + s3BucketURL(appEnv),
"font-src 'self' data:",
"frame-src 'self' https://app.sigmacomputing.com",
"object-src 'none'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
}, "; ")
}
Each external host is a deliberate addition. Avoid 'unsafe-eval' and a
wildcard like https: in script-src, which accept any origin and turn
the directive into documentation rather than enforcement. A
test asserts the header stays tight so a regression fails
before it ships.
Sessions and cookies
The session is a signed cookie decoded into a per-request map. Writes mark the state dirty, and the cookie is rewritten only when it changed:
func sessionSet(r *http.Request, key, value string) {
st := currentSessionState(r)
st.values[key] = value
st.dirty = true
}
The session cookie is HttpOnly, SameSite=Lax, and Secure in
production. For a sensitive value like a remember token, signing is not
enough, so I encrypt with AES-256-GCM:
func encryptRememberToken(plaintext string, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aead, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, 12)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
sealed := aead.Seal(nil, nonce, []byte(plaintext), nil)
return base64.RawURLEncoding.EncodeToString(append(nonce, sealed...)), nil
}
Route enumeration
Because routes register into a plain map, I can enumerate every mounted
route without serving a request. The auth wrappers and handler method
values only dereference their receivers at request time, so a Server
with nil dependencies is enough:
func MountedRoutes() []MountedRoute {
s := &Server{}
mux := newExactMux()
r := &router{server: s, csrf: noopCSRF{}, mux: mux}
mountRoutes(r, s)
// walk mux.handlers into a sorted []MountedRoute
}
Cross-referencing the result against the requests table finds dead
routes: paths mounted in code that no request has hit.
Tests
A test package builds a real server against a test database and serves
requests through the full middleware stack with httptest:
func ServerAsUser(t *testing.T) (*testutil.D, *Server) {
tu := testutil.NewDB(t)
user := tu.InsertUser()
return tu, NewServer(tu, SignedInAs(user.ID))
}
func (s *Server) Get(t testing.TB, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
resp := httptest.NewRecorder()
s.Handler().ServeHTTP(resp, req)
return resp
}
SignedInAs authenticates as a real fixture user row, so tests exercise
the same auth path as production. A dual-mode handler is driven into its
AJAX partial by setting a referer header, so both the full page and the
fragment stay covered:
func TestEventsShow(t *testing.T) {
tu, s := ServerAsUser(t)
id := tu.InsertEvent(EventArgs{Name: "Kickoff"})
resp := s.Get(t, fmt.Sprintf("/events/show?id=%d", id))
if resp.Code != 200 {
t.Fatalf("got %d", resp.Code)
}
if !strings.Contains(resp.Body.String(), "Kickoff") {
t.Fatal("missing event name")
}
}
HTTP methods
GET and POST only. HTML forms submit only those two verbs. Two methods simplify routing, middleware, and CSRF handling.