Core Framework

Routing

Define route scopes, controller actions, REST resources, named routes, and route metadata.

By Guillermo Alvarez - Published - Updated

Routing responsibilities

golazy.dev/lazyroutes owns route definition and route metadata:

  • The route DSL used by init/routes.go.
  • Controller action registration.
  • REST resource conventions.
  • Nested path, name, and namespace scopes.
  • Route table collection.
  • Request context route metadata.

Dispatch responsibilities live in golazy.dev/lazydispatch: public-file fallback, middleware ordering, and dispatch-level response behavior. Application startup wires both through golazy.dev/lazyapp.

Application draw function

Application routes are registered in init/routes.go:

func Draw(router *lazyroutes.Scope) {
    router.Get("/", home.New, (*home.HomeController).Index)
    router.Resources(posts.New)
}

Draw receives the framework-created scope. It does not create a mux, install public files, or return a handler.

Scope

lazyroutes.Scope is the route DSL object. It embeds *http.ServeMux, so it is still an http.Handler, but application code should use the scope methods instead of calling Handle directly.

lazyapp.New creates the root scope and calls Draw.

The root scope stores the route table:

routes := app.Router.Routes

Child scopes share the same mux and append to the root route table.

Controller routes

Use HTTP verb methods to bind a path to a controller constructor and an action:

router.Get("/", home.New, (*home.HomeController).Index)
router.Post("/posts", posts.New, (*posts.PostsController).Create)
router.Patch("/posts/{post_id}", posts.New, (*posts.PostsController).Update)
router.Delete("/posts/{post_id}", posts.New, (*posts.PostsController).Delete)

Available verb methods are:

router.Get(path, controller, action)
router.Post(path, controller, action)
router.Put(path, controller, action)
router.Patch(path, controller, action)
router.Delete(path, controller, action)

The controller argument is the constructor:

func New(ctx context.Context) (*PostsController, error)

The action argument is a method expression:

(*PostsController).Show

Actions keep one signature:

func (c *PostsController) Show(w http.ResponseWriter, r *http.Request) error

Go 1.26 does not support generic methods, so the scope methods accept controller any and action any internally. The router validates the constructor and action signatures when routes are drawn.

Plain handler routes

Use HandleFunc for non-controller routes:

router.HandleFunc("GET", "/health", func(w http.ResponseWriter, r *http.Request) error {
    w.WriteHeader(http.StatusNoContent)
    return nil
})

Plain handler routes still receive route metadata in request context and still enter the same route table.

Path syntax

GoLazy route paths use the standard library http.ServeMux pattern syntax:

/posts
/posts/{post_id}
/posts/{post_id}/comments/{comment_id}
/files/{path...}
/

The root path / is registered internally as the exact root pattern /{$}. This avoids the standard-library subtree behavior where / would match every path. Route metadata still stores the user-facing path /.

Named path segments use braces:

{post_id}
{comment_id}

Catch-all segments use ...:

{path...}

The standard library still performs final route matching and method handling. GoLazy records metadata around those patterns.

REST resources

Use Resources for conventional REST routes:

router.Resources(posts.New)

For PostsController, GoLazy derives the plural resource posts, the singular resource post, and the member parameter post_id.

Only controller actions that exist are registered:

GET    /posts                 Index
GET    /posts/new             New
POST   /posts                 Create
GET    /posts/{post_id}       Show
GET    /posts/{post_id}/edit  Edit
PATCH  /posts/{post_id}       Update
PUT    /posts/{post_id}       Update
DELETE /posts/{post_id}       Delete

The default route names are:

posts       GET    /posts
new_post    GET    /posts/new
posts       POST   /posts
post        GET    /posts/{post_id}
edit_post   GET    /posts/{post_id}/edit
post        PATCH  /posts/{post_id}
post        PUT    /posts/{post_id}
post        DELETE /posts/{post_id}

Multiple HTTP methods may share a route name. Later URL helpers will use the route name plus required params to generate paths.

Resource configuration

Configure a resource with a callback:

router.Resources(posts.New, func(posts *lazyroutes.Resource) {
    posts.Path("articles")
    posts.Singular("article")
    posts.Plural("articles")
    posts.Param("slug")
})

Path changes the URL prefix:

/articles

Singular changes singular naming and the default member parameter.

Plural changes plural naming and also sets the path to the plural name.

Param changes the member parameter directly:

/articles/{slug}

Custom resource routes

Collection routes live under the resource path:

router.Resources(posts.New, func(posts *lazyroutes.Resource) {
    posts.Get("search", (*posts.PostsController).Search)
    posts.Post("import", (*posts.PostsController).Import)
})

Those register:

GET  /posts/search
POST /posts/import

Member routes live under the member path:

router.Resources(posts.New, func(posts *lazyroutes.Resource) {
    posts.MemberGet("preview", (*posts.PostsController).Preview)
    posts.MemberPatch("publish", (*posts.PostsController).Publish)
})

Those register:

GET   /posts/{post_id}/preview
PATCH /posts/{post_id}/publish

Custom route names are built from the custom path and resource name:

search_posts
preview_post

Nested scopes

Scopes compose path, route-name, and namespace metadata.

Namespace prefixes all three:

router.Namespace("admin", func(admin *lazyroutes.Scope) {
    admin.Resources(posts.New)
})

That produces paths and names such as:

admin_posts  GET /admin/posts
admin_post   GET /admin/posts/{post_id}

Route metadata also records:

Namespace: "admin"

Path prefixes only the URL path:

account := router.Path("accounts/{account_id}")
account.Get("/posts/{post_id}", posts.New, (*posts.PostsController).Show)

That produces:

/accounts/{account_id}/posts/{post_id}

As prefixes only the route name:

router.Path("accounts/{account_id}").As("account").Get(
    "/posts/{post_id}",
    posts.New,
    (*posts.PostsController).Show,
)

That produces:

account_posts

Path, As, and Namespace return child scopes, so they can be chained or used with callbacks.

Route table

Every registered route appends a lazyroutes.Route to the root scope:

type Route struct {
    Method      string
    Path        string
    Name        string
    Controller  string
    Action      string
    Namespace   string
    NamedParams map[string]bool
}

Example route metadata:

{
  "method": "GET",
  "path": "/posts/{post_id}/comments/{comment_id}",
  "name": "post_comment",
  "controller": "comments",
  "action": "Show",
  "params": {
    "post_id": true,
    "comment_id": true
  }
}

NamedParams records which params are required by the path. It is intentionally separate from runtime param values so URL helpers can later know what values must be supplied.

Inspect the table from the application root:

lazy routes

The command runs the normal application command with the lazydev,printroutes build tags. lazyapp.New initializes the app, calls Draw, writes one route as JSONL per line, and exits before the server starts. The CLI decodes that JSONL and prints a table:

Name   Method  Path              Controller#Action  Params
root   GET     /                 home#Index
posts  GET     /posts            posts#Index
post   GET     /posts/{post_id}  posts#Show         post_id

Route context

The matched route is attached to the request context before the action runs:

route, params, ok := lazyroutes.RouteFromRequest(r)
if !ok {
    return fmt.Errorf("route metadata missing")
}

route is the Route table entry. params contains runtime values:

postID := params["post_id"]
commentID := params["comment_id"]

Controllers can still use the standard library directly:

postID := r.PathValue("post_id")

Route context is for framework and view integration; PathValue remains the direct request API.

Dispatcher handoff

When lazyapp.New wires the application, it installs the route scope into the dispatcher as router middleware.

The scope can answer whether any registered route owns a path:

router.HandlesPath("/posts/hello")

The dispatcher uses that to decide whether to invoke the router or continue to the next middleware, usually the asset fallback.

This keeps application routes ahead of public assets without installing a root catch-all route in the router.

Testing routes

For full application behavior, test through appinit.App() so dispatch, assets, context, views, and routes are exercised together:

func application() http.Handler {
    return appinit.App()
}

Inspect the app's router when a test needs route metadata:

app := appinit.App()

if len(app.Router.Routes) == 0 {
    t.Fatal("no routes registered")
}

How to use without lazyapp

Most applications should let lazyapp.New create the route scope. Use lazyroutes.New directly only when testing the router package or building a custom application assembly.

Construct a route scope manually:

ctx := context.Background()
router := lazyroutes.New(ctx)
Draw(router)

The result is an http.Handler:

router.ServeHTTP(w, r)

Manual assembly also means you are responsible for dispatcher wiring, public files, renderer setup, and application context setup.