Struct-Tag Routing: reflect in Practice
Struct-Tag Routing: reflect in Practice
Traditional routing makes you register one r.GET() at a time. Gortex scans struct tags with reflect to discover the whole table. This post dissects how route_registration.go does it.
Prerequisite: A2 Struct Tags and reflect (
go-struct-tags-and-reflection).
Imperative vs declarative routing
Most Go frameworks are imperative: one route per line, r.GET("/users/:id", h.GetUser). The route table lives apart from the handler it points at, so the two drift — change a handler and it’s easy to forget the registration.
Gortex inverts that. Routes are struct tags on the handler struct, and the whole table is a single struct:
type HandlersManager struct {
Home *HomeHandler `url:"/"`
Users *UserHandler `url:"/users/:id"` // path param
Static *FileHandler `url:"/static/*"` // wildcard
API *APIGroup `url:"/api"` // nested group
Profile *ProfileHandler `url:"/profile" middleware:"auth"` // protected
Chat *ChatHandler `url:"/chat" hijack:"ws"` // WebSocket
}
app, _ := app.NewApp(app.WithHandlers(&HandlersManager{}))
reflect walks that struct at startup and turns the tags into real routes. The cost is reflection at boot; gortex’s answer is a production build (go build -tags production) that swaps in generated static registration, keeping reflection in development where the instant feedback pays for itself.
Turning tags into routes with reflect
The entry point is RegisterRoutes(app, manager), backed by the recursive registerRoutesRecursiveWithMiddleware:
managermust be a pointer to a struct, otherwise you gethandlers must be a pointer to struct.autoInitHandlersfills nil handler fields viareflect.New, so you don’t hand-initialise every&UserHandler{}.- It walks the fields, handling only those that are exported, pointer-to-struct, and carry a
urltag; nourl, skipped. fullPath := pathPrefix + urlTag, with nested groups threading the prefix down.
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
urlTag := field.Tag.Get("url")
if urlTag == "" {
continue // a field without url isn't a route
}
fullPath := pathPrefix + urlTag
// …then read the middleware / ratelimit / hijack tags, register or recurse…
}
(Simplified from core/app/route_registration.go.) Beyond url, gortex reads middleware, ratelimit, hijack and inject; each is covered below. The discovered routes land in gortex’s own segment-trie router — the matching internals are B3, “The Segment-Trie Router” (gortex-segment-trie-router).
Method names to verbs, kebab paths
The HTTP method isn’t in a tag; it’s the handler’s method name:
- A name that is a standard verb (
GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS) → registered for that verb at the field’surl. - Any other name is a “custom” method → camelCase becomes kebab-case, and it’s always registered as POST, nested under the field’s url.
func (h *UserHandler) GET(c types.Context) error { /* GET /users/:id */ }
func (h *UserHandler) DELETE(c types.Context) error { /* DELETE /users/:id */ }
func (h *UserHandler) Profile(c types.Context) error { /* POST /users/:id/profile */ }
Profile is POST, not GET — and naming it GetProfile is still POST. To pin a verb on a custom endpoint you currently have to use a standard method name; a method tag is marked “future version” in the source and isn’t implemented yet (verify against later releases).
Middleware inherited recursively
A group’s middleware flows down the struct tree: /admin with middleware:"auth" guards every child handler beneath it. One implementation detail is worth stealing — the parent chain isn’t appended to directly, it’s cloned first:
currentMiddleware := make([]MiddlewareFunc, len(parentMiddleware), len(parentMiddleware)+1)
copy(currentMiddleware, parentMiddleware)
// only then append this field's own middleware
The reason is slice backing-array sharing: append straight onto parentMiddleware and sibling fields under the same parent share one array, clobbering each other’s middleware. Cloning gives each field an independent chain. It’s the reflect from A2 meeting Go’s slice semantics in anger.
ratelimit:"100/min" is parsed at this layer too (parseRateLimit); the limiter store it creates is registered back with the app so Shutdown stops its background cleanup goroutine, instead of leaking one per tagged route.
Fail loud
The real risk in declarative routing is a misconfiguration that registers silently. Gortex chooses to crash at startup rather than find out in production:
middleware:"auth"with no matchingMiddlewareFuncregistered → an error, rather than registering a route you believe is protected. The source comment is blunt: a droppedauthwould “expose a route the developer believes is protected”.middleware:"rbac"→ RBAC isn’t implemented; it errors and tells you to register your own middleware underrbac.- An unknown middleware name → an error listing the known ones (
auth,requestid,recover,rbac). - An
inject:""tag → DI isn’t implemented; a nil field errors and asks you to set it manually, rather than leaving a nil pointer to panic later. - A malformed
ratelimit(not<number>/<sec|min|hour>) → an error.
All of these fire at NewApp / RegisterRoutes time, not on the first request. The convenience of declarative routing rests on errors you can’t ignore.
Takeaways
- Declarative routing collapses the whole table into one handler struct; reflect scans it once at startup, and a production build swaps in codegen to drop reflection.
- The HTTP method comes from the method name: standard verbs map to same-named methods, everything else is POST plus a kebab path (use a standard name to pin a verb).
- Middleware inherits down the group tree; clone the parent chain or siblings corrupt each other through a shared slice backing array.
- Misconfiguration fails at startup — unknown middleware, the unimplemented rbac/inject, a broken ratelimit — never silently.
- Reflection’s cost is paid once at boot; the request hot path never touches it — how that path reaches zero allocations is B4, “The Zero-Allocation Secret” (
gortex-zero-allocation-context).
Source: yshengliao/gortex.
Outline by Sheng, drafted with Claude · Go 1.24 (gortex go.mod) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.