Skip to content
gohacks.dev
Web

Route Organization for Larger Go Web Applications Cleanly

Organize routes in a growing Go web app with a small Group helper on top of ServeMux, one routes method per feature, shared middleware and a route table test.

14 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

A web application starts with eight routes in main.go and nobody thinks about organization. Two years and 140 routes later, the registration lives in one file that nobody wants to touch, authentication is applied to some routes by wrapping them and to others by remembering, and finding out which middleware a given endpoint runs requires reading three files. The code itself is fine. The structure around it has silently stopped scaling.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article lays out a way to organize routes that works with the standard ServeMux, adds no dependency, and keeps every handler a plain http.HandlerFunc. It builds a small Group helper, uses it to compose versioned and protected parts of an API, prints the resulting route table, and shows how to test that table. It belongs to the web cluster that ends in a production HTTP server, builds on the Go 1.22 mux patterns and middleware, and its companion on wiring dependencies into handlers is handler dependency injection. Everything ran on Go 1.25.6 with the standard library only.

What goes wrong as routes accumulate

Four problems show up in roughly this order.

One giant registration function. main or a setupRoutes grows to hundreds of lines. Adding a feature means editing a file every other developer also edits, which produces merge conflicts and no sense of ownership. The fix is to give each feature the job of registering its own routes.

Repeated prefixes. Every route writes /api/v1/ by hand. Introducing /api/v2, renaming v1 or moving the whole API under a subdomain means touching every line. The fix is to say the prefix once, on a group.

Middleware by memory. Some routes are wrapped in requireAuth(...) and some are not, and the difference is whether the author remembered. The mistake that matters, an unprotected admin endpoint, is invisible in code review because the missing wrapper is a missing line. The fix is to attach middleware to the group, so a new route inherits it by default and has to opt out deliberately.

No overview. Nobody can list the routes, so nobody can check them. The fix is a route table that the router records as it registers.

None of these needs a framework. They need a layer between the application and ServeMux with three abilities: a prefix, a middleware list that nests, and a record of what was registered.

The router helper

router.goGo
package main

import (
	"net/http"
	"slices"
	"strings"
)

type Middleware func(http.Handler) http.Handler

// Router wraps a ServeMux with a path prefix and a list of middleware that apply to every route registered through it.
type Router struct {
	mux    *http.ServeMux
	prefix string
	mw     []Middleware
	table  *[]string
}

func NewRouter() *Router {
	return &Router{mux: http.NewServeMux(), table: new([]string)}
}

// Group returns a router with an extended prefix and extra middleware. The parent is not changed.
func (r *Router) Group(prefix string, mw ...Middleware) *Router {
	return &Router{
		mux:    r.mux,
		prefix: r.prefix + prefix,
		mw:     append(slices.Clone(r.mw), mw...),
		table:  r.table,
	}
}

// Handle registers a pattern such as "GET /users/{id}" under the group's prefix, wrapped in the group's middleware.
func (r *Router) Handle(pattern string, h http.Handler) {
	method, path, hasMethod := strings.Cut(pattern, " ")
	if !hasMethod {
		method, path = "", pattern
	}
	for i := len(r.mw) - 1; i >= 0; i-- { // the first middleware in the list ends up outermost
		h = r.mw[i](h)
	}
	full := r.prefix + path
	if method != "" {
		full = method + " " + full
	}
	*r.table = append(*r.table, full)
	r.mux.Handle(full, h)
}

func (r *Router) HandleFunc(pattern string, f http.HandlerFunc) { r.Handle(pattern, f) }

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.mux.ServeHTTP(w, req) }

// Table lists every registered pattern, sorted, for documentation and tests.
func (r *Router) Table() []string {
	out := slices.Clone(*r.table)
	slices.Sort(out)
	return out
}

That is the whole helper. Group copies the parent's middleware slice (slices.Clone) so that two sibling groups cannot overwrite each other's list through a shared backing array, which is the classic append aliasing bug. Handle applies the middleware from the last to the first so that the first one listed is the outermost, which matches how you read a stack: authentication first, then the rest. The shared table pointer is what lets any group record into one list.

Features register themselves

Each feature gets a method that takes a router and registers its routes relative to wherever it is mounted. The feature neither knows nor cares what prefix it lives under.

features.goGo
package main

import (
	"fmt"
	"net/http"
)

type App struct{ name string }

func (a *App) userRoutes(r *Router) {
	r.HandleFunc("GET /users", a.say("list users"))
	r.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, req *http.Request) {
		fmt.Fprintf(w, "user %s", req.PathValue("id"))
	})
	r.HandleFunc("POST /users", a.say("create user"))
}

func (a *App) postRoutes(r *Router) {
	r.HandleFunc("GET /posts", a.say("list posts"))
	r.HandleFunc("GET /posts/{slug}", a.say("show post"))
}

func (a *App) adminRoutes(r *Router) {
	r.HandleFunc("GET /stats", a.say("admin stats"))
	r.HandleFunc("DELETE /users/{id}", a.say("delete user"))
}

func (a *App) say(text string) http.HandlerFunc {
	return func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, text) }
}

// trace records which middleware ran, in order, so the demo can show the nesting.
func trace(name string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Add("X-Trace", name)
			next.ServeHTTP(w, r)
		})
	}
}

func requireUser(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-User") == "" {
			http.Error(w, "sign in first", http.StatusUnauthorized)
			return
		}
		w.Header().Add("X-Trace", "user")
		next.ServeHTTP(w, r)
	})
}

func requireRole(role string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.Header.Get("X-Role") != role {
				http.Error(w, "forbidden", http.StatusForbidden)
				return
			}
			w.Header().Add("X-Trace", "role:"+role)
			next.ServeHTTP(w, r)
		})
	}
}

The feature methods are the only places that know a handler's relative path. Moving users from /api/v1/users to /api/v2/users, or mounting the same code on a second prefix for a mobile client, changes nothing in this file.

Composing the URL space

The shape of the whole application, prefixes and middleware, is written once, in one function.

routes.goGo
package main

// buildRouter is the only place that knows the URL space: prefixes, versions and which group gets which middleware.
func buildRouter(app *App) *Router {
	root := NewRouter()
	root.HandleFunc("GET /healthz", app.say("ok")) // public, no middleware

	api := root.Group("/api", trace("api"))
	v1 := api.Group("/v1", requireUser)
	app.userRoutes(v1)
	app.postRoutes(v1)

	v2 := api.Group("/v2", requireUser, trace("v2"))
	app.userRoutes(v2) // the same feature, mounted again

	admin := v1.Group("/admin", requireRole("admin"))
	app.adminRoutes(admin)
	return root
}
main.goGo
package main

import (
	"fmt"
	"net/http/httptest"
	"strings"
)

func main() {
	root := buildRouter(&App{name: "demo"})

	fmt.Println("route table:")
	for _, p := range root.Table() {
		fmt.Println("  " + p)
	}

	try := func(method, path string, headers ...string) {
		req := httptest.NewRequest(method, path, nil)
		for i := 0; i+1 < len(headers); i += 2 {
			req.Header.Set(headers[i], headers[i+1])
		}
		rec := httptest.NewRecorder()
		root.ServeHTTP(rec, req)
		fmt.Printf("%-6s %-26s -> %d %-14q trace=%s\n", method, path, rec.Code,
			strings.TrimSpace(rec.Body.String()), strings.Join(rec.Header().Values("X-Trace"), ">"))
	}
	fmt.Println("requests:")
	try("GET", "/healthz")
	try("GET", "/api/v1/users/7")
	try("GET", "/api/v1/users/7", "X-User", "ann")
	try("GET", "/api/v2/users/7", "X-User", "ann")
	try("GET", "/api/v1/admin/stats", "X-User", "ann")
	try("GET", "/api/v1/admin/stats", "X-User", "ann", "X-Role", "admin")
	try("DELETE", "/api/v1/posts", "X-User", "ann")
	try("GET", "/api/v3/users", "X-User", "ann")
}
Text
route table:
  DELETE /api/v1/admin/users/{id}
  GET /api/v1/admin/stats
  GET /api/v1/posts
  GET /api/v1/posts/{slug}
  GET /api/v1/users
  GET /api/v1/users/{id}
  GET /api/v2/users
  GET /api/v2/users/{id}
  GET /healthz
  POST /api/v1/users
  POST /api/v2/users
requests:
GET    /healthz                   -> 200 "ok"           trace=
GET    /api/v1/users/7            -> 401 "sign in first" trace=api
GET    /api/v1/users/7            -> 200 "user 7"       trace=api>user
GET    /api/v2/users/7            -> 200 "user 7"       trace=api>user>v2
GET    /api/v1/admin/stats        -> 403 "forbidden"    trace=api>user
GET    /api/v1/admin/stats        -> 200 "admin stats"  trace=api>user>role:admin
DELETE /api/v1/posts              -> 405 "Method Not Allowed" trace=
GET    /api/v3/users              -> 404 "404 page not found" trace=

Read the output top to bottom. The table lists the health check, the three user routes twice (v1 and v2), the two post routes once, and the two admin routes under /api/v1/admin. Nothing in the feature code had to change to appear under v2.

The requests show the nesting. An anonymous request to a v1 route gets a 401 from requireUser, and an authenticated one passes through the api trace first and then user: outer groups run before inner ones, which is the guarantee that makes group-level authentication safe. The v2 request shows the second group's own middleware (v2) running after user, because it was listed after it. The admin request with a user but no role is stopped with 403, and the same request with the role passes through three layers: api, user (inherited from v1), then role:admin. The admin group never had to restate authentication.

The last two requests show what the standard mux does for you: a DELETE on a path that only exists for GET is answered with a 405, and an unknown version is a 404, both from the mux itself.

Testing the table

Because the helper records patterns, a test can state structural rules that would otherwise live in someone's head. The test below builds the real router and checks two of them: no /api/ route answers an anonymous request with a success, and the table matches what was reviewed.

routes_test.goGo
package main

import (
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"
	"testing"
)

func TestEveryAPIRouteRejectsAnonymousRequests(t *testing.T) {
	root := buildRouter(&App{})
	for _, pattern := range root.Table() {
		method, path, _ := strings.Cut(pattern, " ")
		if !strings.HasPrefix(path, "/api/") {
			continue
		}
		path = strings.NewReplacer("{id}", "1", "{slug}", "x").Replace(path)
		rec := httptest.NewRecorder()
		root.ServeHTTP(rec, httptest.NewRequest(method, path, nil))
		if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden {
			t.Errorf("%s answered %d to an anonymous request", pattern, rec.Code)
		}
	}
}

func TestRouteTableIsReviewed(t *testing.T) {
	want := []string{
		"DELETE /api/v1/admin/users/{id}",
		"GET /api/v1/admin/stats",
		"GET /api/v1/posts",
		"GET /api/v1/posts/{slug}",
		"GET /api/v1/users",
		"GET /api/v1/users/{id}",
		"GET /api/v2/users",
		"GET /api/v2/users/{id}",
		"GET /healthz",
		"POST /api/v1/users",
		"POST /api/v2/users",
	}
	if got := buildRouter(&App{}).Table(); !slices.Equal(got, want) {
		t.Errorf("route table changed:\n got %v\nwant %v", got, want)
	}
}
Text
=== RUN   TestEveryAPIRouteRejectsAnonymousRequests
--- PASS: TestEveryAPIRouteRejectsAnonymousRequests (0.00s)
=== RUN   TestRouteTableIsReviewed
--- PASS: TestRouteTableIsReviewed (0.00s)
PASS
ok  	example.test/lab	1.270s

The first test is the safety net the group design earns. It walks the recorded table, fills in the wildcards, and sends an anonymous request to every /api/ route. A new route added directly to root under an /api/... path, outside any protected group, fails here with its own name in the message. The second is a snapshot: any route that appears or disappears must be acknowledged by editing the list, which turns an accidental deletion in a refactor into a visible diff. For a long table, store it in a golden file as described in golden files.

Versioning, and the routes that opt out

Mounting the same feature under two prefixes is the cheap version of API versioning, and it works for a short transition: both versions share code, and you diverge by giving v2 a handler of its own for the one route that changed, registered after the shared ones. It stops being cheap when the versions drift. At that point copy the feature into a usersv2 package and let v1 freeze; shared code that branches on the version is harder to read than two copies. Mark the old version as going away with a Sunset header from a group-level middleware, as in API deprecation, and the strategy trade-offs are in API versioning.

Some routes should not inherit the application's middleware: a webhook receiver cannot present a session cookie, a health check should not be rate limited, a static file mount does not want JSON error handling. The answer is not a skip list inside the middleware. It is a sibling group with its own, smaller stack, registered next to the protected one: hooks := root.Group("/hooks", verifySignature). The group tree then documents which routes are exempt from what, and a reviewer can read the exemptions in one place.

Conventions that keep the URL space readable

A router helper does not decide what the URLs look like, and the URL space is what your users and your future colleagues read first. Four conventions have held up for me. Use plural nouns for collections (/users, /users/{id}) and put actions that are not CRUD in a sub-path or as a verb under the resource (POST /orders/{id}/cancel), so the table reads like a list of things and what you can do to them. Keep nesting to two levels at most: /users/{id}/posts is fine, /users/{id}/posts/{pid}/comments/{cid}/likes is a sign that comments deserve their own top-level resource with a filter. Choose one style for multi-word paths (/reset-password, not a mix of dashes, underscores and camel case), because URLs are case sensitive and inconsistent ones cause 404s that look like bugs in the client. And decide on trailing slashes once: the standard mux treats /users and /users/ as different patterns, and a pattern ending in / matches the whole subtree, so register collections without it.

What the table cannot show is intent, which is why a short comment above each feature's registration method, saying what the feature owns and what it deliberately does not, is more useful than a comment on any single route. The REST-specific rules are covered in REST API design rules.

When the helper is not enough

The helper has no notion of route names, so you cannot ask it for a URL. It treats middleware as a flat list per group, with no way to remove one inherited from a parent; if you need that, restructure the groups instead, because "all of /api except these three routes" is a design smell. And it records patterns as strings, which is fine for tests and documentation but is not an OpenAPI document. If you need the spec to stay in sync with the routes, generate one from the other, as covered in OpenAPI for Go services.

Treat the file as a living piece of code. The first version above is thirty lines. The version in a real project grows a few options (a Use method for adding middleware after creation, a Mount for sub-handlers, a debug endpoint that prints the table), and each addition should be tested the way the original was. What matters is the property it enforces: the structure of the URL space and its guards live in one readable place, features register themselves relative to wherever they are mounted, and a missing guard shows up in a test.

Layout on disk

A package layout that fits this structure puts each feature in its own file, or in its own package for larger applications: users.go with the userRoutes method and its handlers, posts.go likewise, routes.go with the function that builds the router, and main.go that only wires dependencies and starts the server. Resist the urge to organize by layer (handlers/, services/, models/) in a way that spreads one feature across five directories. When a change to "comments" touches a handler file, a service file, a model file and a route file in different packages, the structure is working against you. Organize by feature first, and by layer inside the feature if the feature is big.

For the bigger split, a feature becomes a package that exports one function, Mount(r *Router, deps Deps), and the application's routes.go calls it for each feature. That keeps the feature's handlers unexported, which stops other features from calling them directly. If you expose a sub-application as an http.Handler of its own, such as an embedded admin interface, mount it with http.StripPrefix("/admin", handler) and register it under a pattern ending in /.

Dependencies reach the handlers through the feature

The App type in the demo carries a name and nothing else. In a real service, App (or one struct per feature) holds the dependencies the handlers use: the database handle, a logger, the mailer. A method such as func (a *App) userRoutes(r *Router) then registers handlers that are themselves methods on a, so each handler reaches its dependencies through the receiver, with no globals and no context.Value lookups. This is the shape that handler dependency injection develops in full, and it composes with the groups here without any extra machinery: main builds the dependencies, creates the App, calls buildRouter, and hands the result to http.Server.

The benefit shows up in tests. A test constructs an App with fakes, calls the same buildRouter, and drives the whole route table with httptest, which is the route-level test above applied to a real feature.

What this does not do

It does not generate URLs from route names, so building a link to a user page is a fmt.Sprintf or a small helper beside the routes; most JSON APIs never need it, and server rendered sites usually write the helper next to the template functions. It does not do regex constraints on parameters, which the standard mux also lacks; validate in the handler. And it does not add a third-party router's performance characteristics, which for most applications do not matter, as the router comparison measured.

If you find yourself wanting these things all at once, that is the point at which chi is worth adding. Its Route and Group functions work the same way as the helper above, and moving over takes an hour because your handlers are already plain functions. Until then, thirty lines you understand are cheaper than a dependency. The mux's own documentation of patterns and precedence is at pkg.go.dev/net/http, and the Go team's article on routing enhancements explains the design that makes this approach possible.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.