go / web framework

I build a Go web app as a thin layer over net/http: a monolith that serves HTML with templates, reads Postgres, and runs job queues.

Why a thin layer

The standard library has an HTTP server, routing, and cookies. I add a few hundred lines: a router, auth wrappers, a composition root, and middleware. A stack trace points at my code, and the whole package fits in a model's context window.

Where this came from

I wrote Ruby from 2006 to 2026. The last codebase I worked on went from about 200,000 lines of Ruby to Go. In Ruby I kept things small: a Rack app instead of Rails, plain SQL instead of an ORM, thin HTTP clients instead of vendor SDKs, a test framework of about 250 lines, and a restricted subset of Haml for templates. Each dependency is an interface to learn and a surface to patch.

I moved to Go for the reasons I wrote minimal Ruby:

Exact-match router

The router maps a method and an exact path to a handler. No wildcards or 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)
}

An identifier goes in the query string (/events/show?id=42). The router panics at boot on a wildcard or a trailing slash:

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 at registration:

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)
}

PublicGET and PublicPOST opt out for login pages, webhooks, and health checks. The default requires login.

RequireLogin puts the user on the request context:

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 one dependency bundle and hand it to every handler. A handler that needs more takes 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}

This block is the complete dependency graph. The fields are function values, so a test injects a fake by assigning a field.

Standard lives in a leaf package, www/webdeps, so a feature package imports it without an import cycle:

type Standard struct {
	DB           *pgdb.DB
	RenderView   RenderViewFunc
	RenderPage   RenderPageFunc
	WriteHTML    WriteHTMLFunc
	WriteError   WriteErrorFunc
	Redirect     RedirectFunc
	FlashError   FlashErrorFunc
	FlashSuccess FlashSuccessFunc
	CurrentUserEmail CurrentUserEmailFunc
}

Handlers

Each feature is a package: www/handleevents, www/handlepeople. It exports one Handler that embeds Standard:

package handleevents

import "app/www/webdeps"

type Handler struct {
	webdeps.Standard
	DownloadCSV DownloadCSVFunc
}

The template engine takes map[string]any. Everything before that boundary stays typed. I scan rows into a struct, then format each field in Go:

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 has the same steps: validate params, fetch typed rows, format into view maps, render. A bad request fails before the query, and the compiler checks the formatting.

One table registers the routes:

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

I wrap the router in http.Handler layers. A request enters at the bottom of this list:

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 a request that a redirect or timeout ends early. HTTPS redirects 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)
	})
}

The server serves static assets before the CSRF and session layers, so an asset request does not write a session.

Timeout chain

A hung request holds a connection and a database session. Each layer sets a lower timeout than the layer outside it, so the inner layer fails with its own error before the outer one cuts the request off. RequestTimeout is the outer limit. The Postgres statement_timeout and every outbound HTTP client timeout sit below it.

My Ruby framework used the same order: Rack::Timeout at 25 seconds, statement_timeout at 24. The 25-second limit sent the worker a SIGTERM instead of raising in the thread, because Thread#raise can leave locks in a bad state.

Open-redirect defense

Every redirect goes through one guard that accepts 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)
}

The shapes it rejects:

InternalOnlyPath requires a leading / and rejects /, \, and @ as the second character. The same check guards return_to before a login redirect.

Content Security Policy

The middleware sets the CSP header on HTML responses. It starts from default-deny and names each host:

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'",
	}, "; ")
}

I do not use 'unsafe-eval' or a wildcard like https: in script-src. A test asserts the header value.

Before I tighten a policy, I ship it for one release as Content-Security-Policy-Report-Only. The browser reports violations and blocks nothing. I fix the sources it reports, then enforce.

Sessions and cookies

The session is a signed cookie decoded into a per-request map. The server rewrites the cookie only when a write marked it dirty:

func sessionSet(r *http.Request, key, value string) {
	st := currentSessionState(r)
	st.values[key] = value
	st.dirty = true
}

The cookie is HttpOnly, SameSite=Lax, and Secure in production. I encrypt a remember token 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

Routes register into a plain map, so I can list every route without serving a request. The wrappers touch their receivers only 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
}

A comparison with the requests table finds dead routes: paths no request has hit.

Tests

A test builds a real server on a test database and serves requests through the full middleware stack:

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 signs in as a fixture user row, so a test uses the production auth path:

func TestEventsShow(t *testing.T) {
	is := is.New(t)
	tu, s := ServerAsUser(t)
	id := tu.InsertEvent(EventArgs{Name: "Kickoff"})

	resp := s.Get(t, fmt.Sprintf("/events/show?id=%d", id))

	is.Eq(resp.Code, 200)
	is.True(strings.Contains(resp.Body.String(), "Kickoff"))
}

The assertions are is.

HTTP methods

GET and POST only, because HTML forms submit only those two.

← All articles