Routing & middleware
Nucleus has two routing surfaces. pkg/nucleus is the module-facing
layer and is the recommended entry point for application code.
pkg/router is the lower-level implementation and is only needed
when integrating third-party HTTP handlers or constructing an application
directly with pkg/app.
Defining routes (module layer — pkg/nucleus)
Inside a Module[C].Routes callback, the nucleus.Router interface is
the only surface you should use. It does not expose any pkg/router
types, so modules do not take a hard dependency on the router
implementation.
var articlesModule = nucleus.Module[struct{}]{
Name: "articles",
Prefix: "/api/articles",
Routes: func(r nucleus.Router, _ struct{}) {
r.Get("/", listArticles)
r.Post("/", createArticle)
r.Get("/{id}", showArticle)
r.Put("/{id}", updateArticle)
r.Delete("/{id}", deleteArticle)
},
}
nucleus.Router supports three coexisting styles:
- Flat declarative —
r.Get("/path", handler)for simple or audit-sensitive modules. - REST resource —
r.Resource("/path", controller, nucleus.Methods(...))for CRUD modules. Only the requested verbs are registered; reflection is not used. - Nested groups —
r.Group("/prefix", func(g nucleus.Router) { ... })for areas with nested URL hierarchy. Middleware added inside the callback is scoped to the group.
Routes: func(r nucleus.Router, _ struct{}) {
r.Group("/admin", func(g nucleus.Router) {
g.Get("/stats", adminStats)
g.Get("/users", listUsers)
})
},
Middleware type: func(http.Handler) http.Handler — standard
net/http middleware. No framework-specific wrapper type is needed.
Per-route middleware (Router.With)
Router.With(mw ...Middleware) Router returns a new Router whose
middleware applies only to routes registered on it. Routes registered
directly on the parent r are not affected — this is the per-route
counterpart to the module-level Module.Middleware field, which wraps
every route in the module.
// Guard a single route without affecting sibling routes.
// enforcer is *authz.Enforcer captured from OnStart.
Routes: func(r nucleus.Router, _ struct{}) {
r.Get("/products", listProducts) // no auth
r.With(enforcer.RequireRole("admin")).Get("/billing", billing) // admin only
},
With composes additively: chained or nested calls layer middleware
outer-to-inner. Any func(http.Handler) http.Handler value works directly
— Enforcer.RequireRole, router.CSRFMiddleware, or a hand-written guard
— with no adapter needed.
Lower-level routing (pkg/router)
pkg/router is used directly only in two cases:
- You are assembling an app with
pkg/app(notpkg/nucleus). - You need to mount an arbitrary
http.Handlerviaa.Router.Mount(prefix, handler).
In the pkg/app context, a.Router is a *router.Router and handlers
receive *router.Context:
// pkg/app-level wiring (not module code)
a.Router.Get("/api/articles", listArticles)
a.Router.Post("/api/articles", createArticle)
a.Router.Mux.Route("/admin/api", func(sub *router.Mux) {
sub.Use(adminAuthMiddleware)
sub.Get("/stats", adminStats)
})
router.Handler is func(*router.Context) error; errors bubble up to
the recovery / logging middleware.
Router.Mount(prefix, handler) mounts an arbitrary http.Handler —
useful for embedding third-party handlers or a second app.
The Context type
Handlers receive a *router.Context (or, in fluent mode, a
*nucleus.Context that wraps it). The context exposes:
Request/ResponseWriter- path parameters via
c.Param("id") - query string helpers (
c.Query,c.QueryInt, …) - body binding (
c.BindJSON,c.BindXML,c.BindForm) - response helpers (
c.JSON,c.XML,c.String,c.Status)
Body binding behaviour.
c.BindJSON decodes the request body as JSON then runs validate struct tags,
returning a *DomainError on failure.
c.BindForm decodes application/x-www-form-urlencoded or multipart/form-data
into a struct pointer, performs typed conversion, then runs validate tags —
the same discipline as BindJSON. Field resolution order: a form:"name" tag
wins, then json:"name", then the case-insensitive field name; form:"-" skips
a field. Supported types: string, bool (HTML checkbox value "on" binds as
true), signed and unsigned integers, floats, time.Time (RFC 3339,
2006-01-02T15:04, or 2006-01-02), and pointers to those. Embedded exported
structs are flattened. Present-but-empty values leave the field at its zero
value; unknown keys are ignored.
c.BindXML decodes XML only — it does not run validate tags.
- the request-scoped
context.Context - the resolved request scope (site, tenant) when multi-site is on
Built-in middleware
The default middleware chain (full-stack mode) installs:
| Middleware | Purpose |
|---|---|
| Recovery | Recovers from panics, logs with stack trace. |
| Request ID | Generates / propagates an X-Request-ID. |
| Structured logging | Emits one slog line per request with timing. |
| OpenTelemetry | Wraps the handler in an OTel span (when enabled). |
| CORS | Configured from cors.* keys. |
| CSRF | Opt-in, not auto-mounted. Use router.CSRFMiddleware(opts) / router.WithCSRF per module or at router construction. No config-key-driven CSRF is available yet — mount it explicitly in the modules that need it. |
| Rate limiting | Configured from rate_limit_* keys. |
| Request scope | Resolves multi-site / multi-tenant context. |
The auto-mounted middlewares are opt-out at the config level; none of them rely on hidden state. CSRF is opt-in — it is not mounted automatically and must be added explicitly where needed (see Auth & sessions for the module-scoped pattern). The order of auto-mounted middleware is fixed and documented — handlers can rely on the request having a logger, a request ID and a span by the time they run.
Custom middleware
func auditMiddleware(next router.Handler) router.Handler {
return router.Handler(func(c *router.Context) error {
start := time.Now()
err := next(c)
slog.InfoContext(c.Request.Context(),
"audit",
"method", c.Request.Method,
"path", c.Request.URL.Path,
"took", time.Since(start),
)
return err
})
}
r.Use(auditMiddleware)
router.Handler is a thin wrapper over http.Handler that returns an
error. Errors bubble up to the recovery / logging middleware where they
are translated into a JSON or HTML response according to the request
Accept header.
Mounting an OpenAPI document
The runtime ships an explicit OpenAPI mount:
import "github.com/jcsvwinston/nucleus/pkg/openapi"
// MountOpenAPI takes an openapi.DocumentProvider — a func() *openapi.Document.
a.MountOpenAPI("/api/openapi.json", func() *openapi.Document { return myDoc })
There is no auto-generation of the document from handler reflection — that path was deliberately not taken. The contract you ship is the one you wrote.