# Struct-Tag Routing: reflect in Practice

> How gortex builds its route table from struct tags with reflect: method names pick the verb, middleware inherits down groups, and bad config fails at startup.

Source: https://sheng.page/en/posts/gortex-struct-tag-routing/ · Published: 2026-02-08 · Author: Sheng

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: [Struct Tags and reflect](/en/posts/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:

```go
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`:

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.

```go
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](/en/posts/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'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.

```go
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:

```go
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 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 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.


## Notes

- 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 in [The Zero-Allocation Secret: Context Pool and smartParams](/en/posts/gortex-zero-allocation-context/).

Source: [yshengliao/gortex](https://github.com/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.