Groups, With, and Middleware
This chapter explains two closely related concepts:
- Scope composition: how
Group(...)andWith(...)build derived routers. - Middleware composition: how middleware is attached, validated, ordered, and applied.
jetwarp intentionally makes these rules explicit and deterministic. If you understand this page, you’ll be able to read and review routing setup code with confidence.
Assumption: Route registration happens during initialization (before serving requests). Unless a specific adapter documents otherwise, do not register routes concurrently with
ServeHTTP.
Groups and With
Jetwarp supports hierarchical routing setup. You can build a base router, then derive scopes from it:
Use(...)attaches middleware to the current scope (and everything derived from it).Group(prefix, ...)creates a derived router that adds a path prefix and adds middleware.With(...)creates a derived router that adds middleware only (no prefix change).
Why two primitives?
Many services need both:
- a structural prefix (e.g.
/api/v1,/internal,/admin) →Group - a temporary policy (auth, rate limits, feature flags) without changing the URL shape →
With
Keeping these separate makes setup code clearer and makes it much harder to accidentally “leak” middleware into unrelated routes.
Group(prefix, …)
Group(prefix, mws...) returns a new router scope where:
- every subsequently registered route is automatically prefixed with
prefix - middleware passed to
Groupis applied to routes registered on that group (and anything derived from it)
A group also inherits everything from its parent scope.
Conceptually:
- Parent middleware first
- Group middleware next
- Route-level middleware last
- Then the handler
With(…)
With(mws...) returns a derived router scope that:
- does not change the path prefix
- does not mutate the receiver (the original router remains unchanged)
- applies the given middleware only to routes registered via the returned router (and anything derived from it)
This is useful for “temporary scoping”:
- public routes on a group
- protected routes next to them on
group.With(Auth())
How nested scopes compose
Scopes compose left to right as you build them:
- prefixes are joined (no double slashes)
- middleware stacks in order (parent → child → route)
Example:
- root router has global middleware
A - group
/apihas middlewareB - group
/v1has middlewareC - a
With(D)scope addsD - a specific route adds per-route middleware
E
The execution order is always:
Use (global) → Group → With → per-route → handler
When the request returns back up the chain, middleware “unwinds” in reverse order (per-route exits first, global exits last).
A “non-trivial” example (with commentary)
This example shows a common layout:
- global middleware: request id + access log
/api/v1group: versioning- public routes on the group
- private routes on
With(AuthBearer(...)) - one route with extra per-route middleware (e.g. rate limiting)
package main
import (
"log"
"net/http"
"time"
"codeberg.org/iaconlabs/jetwarp/adapter"
"codeberg.org/iaconlabs/jetwarp/adapter/chi"
)
func main() {
r := chi.New()
// 1) Global middleware (Use): applies everywhere.
r.Use(
adapter.HTTPNamed("request_id", RequestID()),
adapter.HTTPNamed("access_log", AccessLog()),
)
// 2) API group (prefix + scoped middleware).
//
// Everything registered under v1 is reachable under /api/v1/...
v1 := r.Group("/api",
adapter.HTTPNamed("timeout_3s", Timeout(3*time.Second)),
).Group("/v1")
// 3) Public route (inherits global + group middleware).
v1.HandleFunc(http.MethodGet, "/healthz", healthz)
// 4) Public endpoint that uses a path param.
v1.HandleFunc(http.MethodGet, "/users/{id}", getUser)
// 5) A protected “temporary” scope (same prefix, extra middleware only).
private := v1.With(adapter.HTTPNamed("auth", AuthBearer("dev-token")))
// 6) Protected routes (inherit global + group + With middleware).
private.HandleFunc(http.MethodPost, "/users", createUser)
// 7) Per-route middleware: applies only to this route, most specific.
private.HandleFunc(http.MethodDelete, "/users/{id}", deleteUser,
adapter.HTTPNamed("rate_limit", RateLimit(10)),
)
// ---- do not skip this ----
// Registration does not panic. Any failures accumulate here.
if err := r.Err(); err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", r))
}
// The middleware/handlers below are intentionally omitted for brevity.
// See the Getting Started examples for full implementations.
func RequestID() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return next } }
func AccessLog() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return next } }
func Timeout(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return next }
}
func AuthBearer(token string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return next } }
func RateLimit(rps int) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return next } }
func healthz(w http.ResponseWriter, _ *http.Request) {}
func getUser(w http.ResponseWriter, r *http.Request) { _ = r.PathValue("id") }
func createUser(w http.ResponseWriter, r *http.Request) {}
func deleteUser(w http.ResponseWriter, r *http.Request) { _ = r.PathValue("id") }
What middleware runs for DELETE /api/v1/users/123?
The request will see middleware in this precise order:
request_id(globalUse)access_log(globalUse)timeout_3s(group/api)auth(theWith(...)scope)rate_limit(per-route)deleteUserhandler
And it unwinds in reverse when returning.
What about GET /api/v1/healthz?
That route is registered on v1 directly, not on private, so it does not see the auth middleware.
This is the core value of With: you can keep related routes close together without accidentally mutating a shared scope.
Middleware
Jetwarp middleware is intentionally split into two categories:
- Portable net/http middleware: works everywhere.
- Native / driver middleware: framework-specific, only available when the adapter/driver supports it.
Portable middleware
Portable middleware wraps the standard net/http shape:
func(http.Handler) http.Handler
You typically register it via helpers like:
adapter.HTTP(...)adapter.HTTPNamed("name", ...)
Portable middleware is always accepted and is the recommended default for portability.
Native middleware (not supported by built-in adapters yet)
Some frameworks expose middleware types that are not func(http.Handler) http.Handler (for example, gin.HandlerFunc).
Jetwarp’s public middleware interface (adapter.MW) is designed to leave room for adapters that can apply those middleware
kinds safely and deterministically. However, the built-in adapters shipped with jetwarp currently support portable
middleware only. If you pass a non-portable middleware value, jetwarp records jetwarp.ErrNativeMWUnsupported (and the
middleware is not applied).
If you strongly need framework-native middleware, use the adapter’s Engine() escape hatch and register it on the
underlying router — but be aware this steps outside the portability contract and may bypass registry/OpenAPI tooling.
How middleware is recorded and applied
When you call Use, Group, With, or Handle(..., mws...), jetwarp:
- validates the middleware slice (e.g., detects nil middleware)
- splits documentation-only middleware (
DocCarrier) from runtime middleware (so docs metadata never breaks routing) - splits runtime middleware into portable vs native categories
- records any errors into:
- the router’s accumulated
Err()list - and the registry snapshot (scope-level or route-level errors)
- the router’s accumulated
- builds a deterministic wrapper chain for each route at registration time
The important consequence is:
- middleware behavior is stable and inspectable
- errors do not panic and do not stop later registrations
- OpenAPI tooling reads from the registry, not from reflection or runtime introspection
Execution ordering (the invariant)
Jetwarp enforces this middleware ordering contract:
Use → Group → With → per-route → handler
Two details matter in practice:
- Derived scopes inherit parent middleware, then add their own.
- With does not mutate the receiver, so it cannot “leak” middleware into sibling routes.
This ordering is pinned by conformance tests. If you change a driver, adapter, or middleware implementation, treat this ordering as a compatibility contract.
Gotchas and best practices
Prefer portable middleware by default
If you want portability, start with portable net/http middleware.
Native middleware is sometimes tempting (because it’s what the framework ecosystem provides), but it reduces portability and often makes it harder to share code between adapters.
A good compromise is:
- keep your core middleware portable (auth, logging, timeouts, request id, tracing)
- use framework-native features only via well-documented escape hatches
Always check Err() once setup is done
Jetwarp does not panic on bad registration. It accumulates errors and keeps going.
This is intentional: it makes router setup robust and testable, and it allows you to collect multiple configuration issues in a single run.
But it means you must fail fast before serving traffic:
if err := r.Err(); err != nil {
log.Fatal(err)
}
Avoid registering routes directly on the underlying engine
Most adapters expose an engine escape hatch for debugging or narrow integrations.
Registering routes directly on the engine usually bypasses:
- the registry snapshot
- deterministic middleware composition
- OpenAPI generation / docs tooling
Don’t rely on “interleaving” portable and native middleware
Today, built-in adapters reject native middleware (jetwarp.ErrNativeMWUnsupported), so you’ll only see the portable ordering.
If you are using (or writing) an adapter that does support native middleware, the rule is:
- ordering is preserved within portable and within native categories
- but insertion-order interleaving across the two categories is not part of the contract
If ordering matters, keep middleware portable, or keep native middleware isolated and well-tested.
Where to go next
- If you’re building a service: keep middleware portable and use
Group/Withto model your API structure. - If you’re building tooling: use the registry snapshot; don’t scrape the router engine.
- If you’re writing drivers: treat capability checks and ordering as contracts, and add suite tests for any new guarantee.