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.

Correction (2 November 2026, 18:45 Taipei): removed the claim that a production build swaps in generated registration and keeps reflection off the hot path, since handler calls still go through reflect.Value.Call; noted that group middleware does not reach hijack:"ws" children (both suggested by Codex (gpt-6-astra), checked by Claude). Reworded the inject behaviour: nil pointer-to-struct dependencies inside handlers are filled with empty values first (suggested by Codex (gpt-6-astra), decided by a vote of Codex (gpt-6-astra), Grok and Claude Fable). Restored the missing “that” in the source-comment quote (suggested by Claude, checked by Claude).

Prerequisite: Struct Tags and reflect

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, and every call into a registered handler also goes through reflect.Value.Call, so the request hot path uses reflection too. Gortex once had a production build that swapped in generated static registration; those files were removed in commit 3c23cb2 on 26 July 2025, and no build tag turns reflection off today.

Turning tags into routes with reflect

The entry point is RegisterRoutes(app, manager), backed by the recursive registerRoutesRecursiveWithMiddleware:

  1. manager must be a pointer to a struct, otherwise you get handlers must be a pointer to struct.
  2. autoInitHandlers fills nil handler fields via reflect.New, so you don’t hand-initialise every &UserHandler{}.
  3. It walks the fields, handling only those that are exported, pointer-to-struct, and carry a url tag; no url, skipped.
  4. 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 in The Segment-Trie Router: Matching Without Regex.

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’s url.
  • 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 plain HTTP child handler beneath it. WebSocket children (hijack:"ws") are the exception: they are registered without the group’s middleware chain, so one under /admin skips auth entirely and needs its own protection. 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 the struct-tags post 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 matching MiddlewareFunc registered → an error, rather than registering a route you believe is protected. The source comment is blunt: a dropped auth would “expose a route that the developer believes is protected”.
  • middleware:"rbac" → RBAC isn’t implemented; it errors and tells you to register your own middleware under rbac.
  • An unknown middleware name → an error listing the known ones (auth, requestid, recover, rbac).
  • An inject:"" tag → DI isn’t implemented; a pointer field that is still nil when checked raises an error and asks you to set it manually. But autoInitHandlers runs first and fills nil pointer-to-struct fields inside handlers, inject-tagged ones included, with empty values, so those dependencies never error and arrive zero-valued; interface-typed fields are not checked at all. Set and validate dependencies yourself.
  • 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.

Notes

  • Declarative routing collapses the whole table into one handler struct; reflect scans it once at startup, and handler calls still go through reflect.Value.Call; there is no production codegen today.
  • 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 (WebSocket children excepted); 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.
  • The tag-scanning reflection is paid at boot, but handler calls still use reflection; how the rest of the request path reaches zero allocations is in The Zero-Allocation Secret: Context Pool and smartParams.

Source: yshengliao/gortex.


Ideas and technical judgement by Sheng; drafted with Claude · Go 1.24 (gortex go.mod) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.