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:
- Performance. The rewrite cut page response times across the application.
- Type-checking. The compiler catches at build time what Ruby left for runtime.
- Object-orientation. Most of my
lib/was plain objects with aninitializeand acall. Go has structs, methods, and small interfaces that types satisfy implicitly. My Ruby already followed that style. - Error handling. Go returns errors as values, and the caller checks each one. A Ruby exception travels up the stack unseen.
- Tooling.
goimports,go vet, and the race detector ship with the language. - Security. The standard library covers HTTP without a gem, which leaves fewer dependencies to patch.
- Dependencies. Go pins module versions and verifies them against a checksum database. RubyGems serves mutable gems from a single registry.
- AI agents. Programming agents get compiler feedback as well as test failures.
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:
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 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.