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:

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.

← All articles