Core Framework
Routing
Define route scopes, controller actions, REST resources, named routes, and route metadata.
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.
Resources can also map one or more model types to their create, update, and delete routes:
router.Resources(cars.New, func(cars *lazyroutes.Resource) {
cars.Model(Car{})
})
lazyforms uses that mapping to target form_for and delete_button_for
without hard-coding 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.