D-0008 — Declarative Router Groups — api/rest, api/events, api/reqreply¶
Status: Implemented — architectural foundation. Phases A/B/C fully SHIPPED (
api/rest,api/events,api/reqreply+ the deferred-item review, see "Phase C, sub-step 2" below — all 6 items confirmed correctly deferred, zero new work promoted; item 3 spun off a separate idea-stage doc,typed-router-groups.md) — seeapi/rest/router.go/api/events/router.go/api/reqreply/router.go(each now a thin wrapper around the sharedrouterpackage — see this doc's own "Addendum — consolidated onto the sharedrouterpackage" below),docs/features/router-groups.md.Router.Tags/WithRouterTags(deferred-item 4) was LATER implemented once a concrete driver (a pass retrofitting Router into 3 real, already-shipped example registration call sites) surfaced — see item 4 below for the shipped design (simpler than originally assessed) andapi/reqreply.Route.WithOpt(a small, separately-motivated general escape hatch for attaching anyRouteOpt, e.g.WithHandleCallback, to an already-declared Route value — found necessary during that same pass). PROMOTED fromdocs/roadmap/declarative-router-groups.md— this doc establishes a pattern all 3 messaging APIs (rest/events/reqreply) now follow (declarative, chi-inspired path/topic-prefix grouping + group-wide middleware), qualifying it as architectural foundation rather than a single-feature roadmap, mirroring D-0007's own identical promotion precedent.
Motivation¶
go-codex's existing workflow is declare → compose → register: declare a
Route/Channel (path/topic + codecs), compose it with Middleware/
BoundMiddleware (see d-0007-declarative-middleware-layering.md),
then register it with a Server/Client. This works well for ONE route/channel
at a time, but has no answer for a THIRD, group-level concern a real API always
has: "this whole family of routes lives under /api/v1/users, and ALL of them
need the same security middleware" — today a user must either repeat the full
path string and the same .Use(mw) call on every single route (copy-paste,
error-prone, no single source of truth for the prefix), or invent their own ad
hoc Go helper to stitch path strings together outside go-codex's own API.
Investigated what today's declarative middleware mechanism (shipped by
d-0007-declarative-middleware-layering.md, now fully rolled out
across all 3 patterns) already provides toward this, confirmed via code — see
"What exists today" below. Nothing exists today that solves prefix
composition or group-wide middleware attachment — this is net-new surface,
not a gap in an existing mechanism.
This is explicitly modeled on Traefik's own Router/Middleware separation (a
Router matches a path/rule and attaches Middlewares to everything it matches;
Middleware itself stays a separate, reusable concern) and, more directly
relevant prior art in Go, chi.Router.Route(pattern, fn)/.Mount(pattern,
subRouter)/.Group(fn) — though (confirmed via code) adapters/chi never
actually uses chi's own native grouping internally, so there is no adapter-level
shortcut to lean on either; this has to be a genuinely new api/*-level
construct, mirroring how Middleware/SecurityScheme/ErrorPattern are each
their own api/*-level concept with per-pattern siblings, not an
adapter-level one.
What exists today (confirmed via code, not assumed)¶
| Mechanism | What it solves | What it does NOT solve |
|---|---|---|
rest.NewRoute[Req,Resp](method, path, ...) / events.NewChannel[T](topic, ...) / reqreply.NewRoute[Req,Resp](topic, ...) |
Declares ONE route/channel's full path/topic string | No prefix composition — path/topic is always a complete string, set once, at construction |
rest.Path / events.Topic (reusable template+params) |
Reuses the SAME path/topic shape across 2+ routes with DIFFERENT Req/Resp types (e.g. GET+DELETE on one resource) | No prefix semantics — still a complete, standalone template, not a composable prefix+suffix pair |
Server/Client (reqreply) |
Holds a FLAT []routeEntry/equivalent list, mutex-guarded for concurrent Register calls |
No hierarchy, no grouping, no notion of "these N entries share a prefix" |
middleware.Middleware[In,Out] + .Use(mw) |
ONE reusable-class middleware value attachable to any number of routes/channels, individually, one .Use() call per route |
No "attach to every route under this prefix in one declaration" — still one call per route |
RouteMeta.Tags []string |
OpenAPI spec-only grouping metadata (cosmetic, for generated docs) | Not a routing/registration mechanism at all — purely descriptive |
adapters/chi |
Wires each route individually onto a flat gochi.Router via r.Method(...) |
Never uses chi's own native Mount/Route/Group — no adapter-level prior art to borrow |
Conclusion: the user's own framing is confirmed accurate — "before registering the routes/channels, paths/topics are split across many packages or the user invents their own method to assemble prefixes." A Router construct is a genuinely missing piece, not a refinement of something partially there.
Chi framework survey (reference prior art)¶
Read go-chi/chi/v5 (v5.3.1, the project's own vendored dependency) directly
from source — confirmed it's the strongest available prior art for this
exact problem, and that its design separates MORE distinct concepts than
this doc's first draft accounted for:
| chi mechanism | Shape | What it maps to in this design |
|---|---|---|
Router.Mount(pattern, h) / Router.Route(pattern, fn) Router |
NEW path segment + a FRESH middleware stack for everything under it | Router.Mount(sub Router) (already in this doc's draft; Router is an immutable value in go-codex's own design — see "Foundational reconsideration" below — not a pointer as chi's own Mux is) |
Router.Group(fn func(r Router)) Router |
SAME path (no new segment) — just a scoped COPY of the current middleware stack, for a SUBSET of routes sharing one path (chi's own doc comment: "useful for a group of handlers along the same routing path that use an additional set of middlewares") | Missing from the first draft — added below as Router.Group(fn func(sub Router) Router) (go-codex's own immutable-value design requires fn to explicitly return its built-up value, unlike chi's own mutation-style callback) |
Router.With(mws ...) Router |
Ad hoc, ONE-OFF extra middleware for a SINGLE next-declared route — no permanent group, no new Router value kept around |
Missing from the first draft — added below as Router.With(mws ...middleware.RouteMiddleware) Router |
Routes() []Route + chi.Walk(r Routes, walkFn WalkFunc) error |
Walk is a RECURSIVE VISITOR — it walks Routes()'s tree (which nests SubRoutes for every Mount), composing the full path AND the full middleware stack per leaf, calling walkFn(method, fullPath, handler, middlewares...) once per leaf. Routes() itself only returns ONE LEVEL of the tree (sub-mounts are opaque Route.SubRoutes entries) — Walk is what actually flattens/composes it. This is the EXACT mechanism chi's own docgen subpackage (OpenAPI/route-doc generation) builds on. |
Confirms this doc's Router.Routes() idea is sound, but shows the right INTERNAL shape is a visitor (Walk), with the flat, pre-materialized []RouterEntry as a convenience WRAPPER over it — not a parallel, independently-implemented mechanism. Added Router.Walk(fn WalkFunc) error below. |
Router.NotFound(h) / Router.MethodNotAllowed(h) |
Per-(sub)router fallback handler — a Mount-ed sub-chi-Router can have its OWN "nothing matched" behavior, distinct from the parent's |
Not in this doc's scope decisions at all — flagged as a new Phase 2+ open question below (no committed design) |
middleware/ subpackage (Logger, Recoverer, Throttle, BasicAuth, Compress, RequestID, ~20 more) |
Ready-made, OPERATIONAL func(http.Handler) http.Handler implementations |
Explicitly unrelated — this is a middleware CATALOG (concrete behaviors), not a routing/grouping STRUCTURE; go-codex's own middleware.Middleware[In,Out] mechanism is the equivalent catalog-of-behaviors concern, already shipped, orthogonal to this doc entirely. No action. |
Also confirmed: stdlib http.ServeMux — what adapters/nethttp actually
wraps (not adapters/chi) — has even less structure than chi here: no
Mount/Group/With/Walk equivalent of any kind, just flat
Handle(pattern, handler) registration (Go 1.22+ added method+wildcard
PATTERN syntax, but nothing resembling grouping). This reinforces the
existing scope decision that a Router MUST live at the api/* level,
uniformly across all 3 patterns — there is no adapter to delegate to for
EITHER of go-codex's 2 HTTP adapters, let alone the non-HTTP ones (MQTT/
MQTT5/ZeroMQ have no router concept of their own at all).
Scope decisions (confirmed via ask_user)¶
| In scope | Out of scope (this doc) |
|---|---|
rest.Router (path prefixes), events.Router (topic prefixes), reqreply.Router (topic prefixes) — all 3 patterns designed now, mirroring d-0007-declarative-middleware-layering.md's own all-3-patterns precedent |
api/mcp — tool names/resource URI templates don't have the same hierarchical-path shape; flagged as an open item in mcp-ports-declarative-middleware.md's "Open design decisions" instead (see that doc's update, not designed here) |
Nesting — a Router may Mount sub-Routers (prefixes compose: parent + child) |
Dynamic/runtime router mutation after Register() — mirrors the existing "no hot-adding routes to an already-Serve'd mux" limitation (docs/roadmap/dynamic-port-rebinding.md's own scope) |
STATIC prefixes only (e.g. "/api/v1", "tenants/eu") — no {var} placeholders in a Router's own prefix for v1 |
Variable prefixes (e.g. "/tenants/{tenantID}") merging into every grouped route's own Req/T — deferred; see "Out of scope (Phase 2+)" |
Reusable-class Middleware[In,Out]/Declaration[In,Out] attachment at the Router level (.Use(mw)-equivalent, group-wide) |
Bound-class (BoundMiddleware[Req,...]) attachment at the Router level — structurally impossible to express group-wide (a BoundMiddleware is tied to ONE concrete Req/T type; a Router groups routes of DIFFERENT Req/T types) — bound middlewares stay route-specific, unchanged |
A Router.Routes()-style inspection accessor — the "final, holistic view of the assembled path/topic tree" the user explicitly asked for |
Spec-rendering changes (OpenAPI/AsyncAPI tag auto-assignment from Router prefix) — noted as a nice-to-have, not committed |
Reference pattern being designed (one shape, 3 packages)¶
// api/rest
// Router is a fully IMMUTABLE VALUE type — matching Route[Req,Resp]/
// Channel[T]/Subscriber[T]/Publisher[T] exactly (every chained method
// returns a NEW value, never mutates in place). Confirmed via a later
// review round: this is the CORRECT tier for Router (an intermediate,
// composable declarative value), not the mutable-pointer tier Server/
// Client occupy (the TERMINAL, final-stage builders) — Router had
// initially, incorrectly, followed the wrong tier's convention. Because
// of this, Router needs NO mutex anywhere — concurrent reads/derivations
// of the SAME immutable value need zero synchronization, by construction
// (the SAME property every other api/* declarative type already has,
// with zero special-casing ever needed for them either).
type Router struct { /* prefix string; mws, pendingMws []middleware.RouteMiddleware; children []routerChild */ }
func NewRouter(prefix string, opts ...RouterOpt) Router
func (rt Router) Use(mws ...middleware.RouteMiddleware) Router
func (rt Router) Route(r routable) Router // attach a leaf Route[Req,Resp]/SSERoute[Req,Resp]
func (rt Router) Mount(sub Router) Router // attach a nested sub-Router (NEW prefix segment + fresh mw stack)
func (rt Router) Group(fn func(sub Router) Router) Router // SAME prefix, scoped mw subset (chi's Group — no new
// segment); fn MUST explicitly return its built-up
// value (no mutation to observe otherwise, since
// Router is immutable) — rt.Group(fn) still returns
// rt itself (the PARENT, updated), diverging
// deliberately from chi's own Group (which returns
// the CHILD), for consistency with every other method
// here, all of which chain off rt
func (rt Router) With(mws ...middleware.RouteMiddleware) Router // one-shot extra mw for the NEXT .Route() call
// only — see "With" below for how immutability alone
// (no special type) achieves this
func (rt Router) Register(b *Server) error // walk the tree, compose prefixes+mws, Register every leaf
func (rt Router) Routes() []RouterEntry // inspect the fully-assembled path tree BEFORE Register (flat)
func (rt Router) Walk(fn WalkFunc) error // recursive visitor; Routes() is a convenience wrapper over this
// api/events (topic prefixes instead of path prefixes — same shape)
type Router struct { /* prefix string; mws, pendingMws []middleware.RouteMiddleware; children []routerChild */ }
func NewRouter(prefix string, opts ...RouterOpt) Router
func (rt Router) Use(mws ...middleware.RouteMiddleware) Router
func (rt Router) Route(r routable) Router // attach a Subscriber[T]/Publisher[T]
func (rt Router) Mount(sub Router) Router
func (rt Router) Group(fn func(sub Router) Router) Router // returns rt, same rationale as REST's above
func (rt Router) With(mws ...middleware.RouteMiddleware) Router
func (rt Router) Register(client *Client) error
func (rt Router) Routes() []RouterEntry
func (rt Router) Walk(fn WalkFunc) error
// api/reqreply (topic prefixes, mirrors events' Router exactly, Server-scoped —
// uses the current, non-deprecated *Server name; Route.Register itself still
// says *Builder today, a deprecated alias, not perpetuated in this new code)
type Router struct { /* prefix string; mws, pendingMws []middleware.RouteMiddleware; children []routerChild */ }
func NewRouter(prefix string, opts ...RouterOpt) Router
func (rt Router) Use(mws ...middleware.RouteMiddleware) Router
func (rt Router) Route(r routable) Router // attach a Route[Req,Resp]
func (rt Router) Mount(sub Router) Router
func (rt Router) Group(fn func(sub Router) Router) Router // returns rt, same rationale as REST's above
func (rt Router) With(mws ...middleware.RouteMiddleware) Router
func (rt Router) Register(b *Server) error
func (rt Router) Routes() []RouterEntry
func (rt Router) Walk(fn WalkFunc) error
How a leaf Route[Req,Resp]/Channel/etc. attaches without a new generic method type-parameter¶
Go forbids a method from introducing new type parameters beyond its
receiver's own — Router.Route cannot be func (rt Router) Route[Req, Resp
any](r Route[Req, Resp]) Router. The resolution mirrors
d-0007's own resolution for this identical problem (the interface-plus-receiver-scoped-type-params approach already shipped there):
define a small, unexported interface that Route[Req,Resp]/SSERoute[Req,Resp]
(REST), Subscriber[T]/Publisher[T] (events), and reqreply.Route[Req,Resp]
each satisfy via methods ADDED to their EXISTING receiver (Req/Resp/T are
already in scope on the receiver — no NEW type parameter is introduced):
// routable is satisfied by every leaf type a Router can hold, regardless of
// its own Req/Resp/T type parameters — the SAME resolution
// d-0007's own "BoundMiddleware" attachment resolution already established
// for exactly this same "new type param on a method" problem.
//
// NOTE: this is NOT one single interface shared literally across all 3
// packages — each package (api/rest, api/events, api/reqreply) defines its
// OWN, separately-compiled, unexported `routable` interface, using ITS OWN
// concrete builder type for registerAny's parameter (*Server below is
// REST's/reqreply's own; api/events' own routable interface uses *Client
// instead — never `any`, since each is package-private and every
// implementer within that package already knows its own concrete type).
// The sketch below shows REST's own instantiation as the representative
// example; events'/reqreply's versions are structurally identical, just
// with their own pattern's types substituted throughout.
type routable interface {
// withRouterPrefix returns a NEW leaf value (same concrete type) with
// prefix prepended to its own path/topic string and mws PREPENDED to its
// own accumulated opts (router-level middleware runs BEFORE the route's
// own — see "Prefix + middleware composition semantics" below).
withRouterPrefix(prefix string, mws []middleware.RouteMiddleware) routable
// method reports this leaf's HTTP method, for RouterEntry.Method — needed
// because Walk/Routes() has NO OTHER way to populate that field from a
// type-erased routable value. SCOPED PER PACKAGE, same as this whole
// interface (see this block's own NOTE above): this method() accessor
// (and RouterEntry.Method below) exist ONLY on api/rest's own routable/
// RouterEntry — api/events' own routable interface declares role()
// INSTEAD (no method() at all, since events has no HTTP-method axis);
// api/reqreply's own routable interface declares NEITHER (confirmed in
// its own Design Decisions section: no second axis exists at all for
// reqreply). Each pattern's interface/struct carries ONLY the accessor/
// field that actually applies to it — never a dead, always-"" stand-in
// for an axis that pattern doesn't have.
method() string
// middlewareNames reports the leaf's OWN (pre-Router, directly-attached
// via .Use()) reusable-class middleware names, in attachment order —
// trivially derivable since middleware.Middleware/declaration types
// already carry a Name string field. Walk PREPENDS the Router's own
// contributed names (tracked by Router itself, not the leaf) to this
// leaf-reported list when populating RouterEntry.MiddlewareNames —
// needed for the exact same reason method()/role() are: Walk has no
// other way to inspect a type-erased routable value's own middleware.
middlewareNames() []string
// registerAny performs the SAME work as Register(b)/Handle(client) would
// (b is the pattern's own concrete builder type — *Server here for
// REST/reqreply's own routable; api/events' own routable interface uses
// *Client instead, per this block's own NOTE above), discarding any
// returned *RouteHandle/*ChannelHandle. A caller needing the typed
// handle back uses WithHandleCallback (see Decision #1 below) instead —
// Router itself never exposes one.
registerAny(b *Server) error
}
Confirmed via code that discarding the handle is the OVERWHELMINGLY common
case already — must(route.WithHandler(fn).Register(b), "...")-style calls
dominate every example across all 3 patterns (the handler is already embedded
via WithHandler/an opt BEFORE Register/Handle is ever called) — but it is
NOT universal (a small number of call sites DO keep the returned handle for a
later, separate need). This asymmetry is RESOLVED, not left open — see
Decision #1 in "Design decisions — resolved for api/rest" below
(WithHandleCallback, a free-function RouteOpt attached directly to the
leaf, independent of Router entirely).
Prefix + middleware composition semantics¶
- Path/topic joining:
prefix + leaf.path, with exactly ONE/(REST) or topic-separator (events/reqreply) inserted between them regardless of whether either side already has/lacks a trailing/leading separator — mirrorspath.Join-style normalization, not naive string concatenation. NestedMountcomposes prefixes transitively:rt.Mount(sub)wherert's own prefix is"/api/v1"andsub's is"/users"produces an effective prefix of"/api/v1/users"for everything undersub. - Middleware composition:
Router.Use(mw)'s mws are PREPENDED to each grouped leaf's ownoptsslice (declaration order = dispatch order, the SAME established ruled-0007-declarative-middleware-layering.md's "stacked" demonstration already relies on) — so a Router-level concern (e.g. a generic bearer-token presence check) runs BEFORE a route's own, route-specificBoundMiddleware-attached business logic. NestedMountaccumulates: a leaf underrt.Mount(sub)receivesrt's mws, thensub's mws, then its own — outer-to-inner, outermost-declared-first. - Declaration-time vs. registration-time:
Router.Route/Mountare PURELY ACCUMULATING (mirrors every otherapi/*builder call — infallible, captures the spec only). ALL actual path/topic validation, middleware name-uniqueness checking (D6(b)), and codec/security coverage checking happen atRouter.Register(b)time, by walking the tree and calling each leaf's own, UNCHANGEDRegister/Handlemethod on the prefix+mws-applied copy — zero new validation logic duplicated from each leaf's own, already-correctregisterHandle/Registerinternals. Groupvs.Mount— SAME prefix, scoped middleware subset, no new segment: mirrors chi's ownGroup(fn func(r Router)) Routerin BEHAVIOR, not in return value or callback shape (see below).rt.Group(fn)passesfna NEWRoutervalue that inheritsrt's CURRENT prefix UNCHANGED (no path segment is appended) but starts with a COPY ofrt's accumulated mws thatfncan append MORE to via ordinary chained calls —fnMUST explicitlyreturnits built-up value (e.g.return sub.Route(a).Route(b)), sinceRouteris immutable and there is no mutation forGroupto observe otherwise (a small, deliberate ergonomic difference from chi's own mutation-style callback, accepted in exchange forRouter's overall immutability — see "Foundational reconsideration" below). Routes built up insidefnget the extra, scoped mws; routes declared onrtdirectly (outsidefn) do not. This is the "auth only on POST/PUT/DELETE, GET stays open, same resource path" use caseMountcannot express (Mountalways implies a NEW path segment).rt.Group(fn)RETURNSrtITSELF (the PARENT, updated withfn's built-up child incorporated), NOT the childfnreceived — confirmed viaask_user, a DELIBERATE divergence from chi's ownGroup(which returns the childim) for consistency with every other method in THIS doc's own sketch (Use/Route/Mount/Withall chain off the receiver itself) —rt.Group(fn1).Mount(other)therefore chains ontort, not ontofn1's child. Internally,GroupandMountcreate the SAME child-node type (one unified tree representation, Design Decision #6 below) —Group's child simply contributes an empty prefix segment.Withvs.Use— one-shot, not permanent, achieved via ORDINARY IMMUTABLE VALUE FLOW, no special type needed.rt.With(mw)returns a NEWRoutervalue (SAME type as everything else) withpendingMwsset —rtITSELF (the original value) is NEVER touched, so a SUBSEQUENTrt.Route(otherLeaf)(called on the ORIGINALrt, not on.With(mw)'s returned value) never seesmwat all.Route(leaf)reads+incorporatespendingMws(if set) for THAT leaf specifically, then returns a NEWRoutervalue withpendingMwsRESET to nil — sort.With(mw).Route(leafA).Route(leafB)appliesmwtoleafAONLY (leafB's call happens on the valueRoute(leafA)returned, which already hadpendingMwscleared). Repeated.With()calls ACCUMULATE, never silently overwrite:rt.With(mw1).With(mw2)produces a value with BOTHmw1ANDmw2pending — a clean, deliberate choice immutability makes safe to make either way (no race to consider, unlike a hypothetical shared-mutable-state design), chosen specifically to eliminate the "second call silently discards the first" footgun entirely.
Foundational reconsideration: Router is a fully immutable VALUE type, not a mutable pointer¶
Found during a LATER review round (per explicit ask_user direction to
reconsider foundational/shipped design choices more broadly, not just the
ClientHandle finding) — re-examined Router's OWN most basic shape.
Confirmed via code: EVERY existing intermediate api/* declarative type
(Route[Req,Resp], SSERoute[Req,Resp], Channel[T], Subscriber[T],
Publisher[T], middleware.Middleware[In,Out], BoundMiddleware) is an
IMMUTABLE VALUE type — only Server/Client (the TERMINAL, final-stage
builders, used for the rest of the program's life) are mutable,
pointer-based, mutex-guarded accumulators. An EARLIER draft of this doc
made Router mutable/pointer-based — the FIRST intermediate type to break
this otherwise-universal 2-tier convention — which is DIRECTLY why that
earlier draft needed BOTH a mutex (to guard concurrent accumulation) AND a
separate pendingRoute type (to make With's one-shot behavior race-free
against that same shared mutable state).
Making Router a plain, immutable VALUE type (matching Route/Channel
exactly) makes BOTH of those mechanisms UNNECESSARY, not just simpler:
an immutable value needs ZERO synchronization for concurrent reads/
derivations, by construction — the SAME property every other api/*
declarative type already has, with zero special-casing ever needed for
them either. This was a DELIBERATE choice (confirmed via ask_user,
weighed against a "hybrid" alternative that would have kept Group's
chi-style mutation-only callback ergonomics at the cost of 2 distinct
types instead of 1) — full immutability was chosen for MAXIMUM
consistency with the rest of api/*, accepting Group's small
explicit-return requirement as the one trade-off.
Router.Routes()/Router.Walk() — the "final, holistic assembled view"¶
Directly addresses the user's explicit ask: "a point where the user, in their
workflow, assembles the final paths/topics for the API — having the final
overview of how the API assembles, BEFORE registering." Confirmed via chi's
own Routes() []Route + chi.Walk(r Routes, walkFn WalkFunc) error that the
right INTERNAL shape is a recursive VISITOR, with a flat, pre-materialized
slice as a convenience wrapper over it — NOT two independently-implemented
mechanisms (chi's own Routes() only returns one tree level, opaque
SubRoutes for nested Mounts; Walk is what actually recurses+composes).
This doc adopts the same split:
// WalkFunc is called once per LEAF (never per intermediate Router/Group),
// after full prefix + middleware composition — mirrors chi.WalkFunc's own
// shape (method/path/middlewares), adapted to also report the DECLARING
// Router's prefix depth for callers that want to reconstruct the tree.
// SCOPED PER PACKAGE, same as routable/Router/RouterEntry themselves — each
// pattern declares its OWN WalkFunc taking its OWN RouterEntry shape.
type WalkFunc func(entry RouterEntry) error
// RouterEntry is shown here in its api/rest shape (the representative
// example) — SCOPED PER PACKAGE, same as routable/Router: api/events' own
// RouterEntry has a Role string field INSTEAD of Method (no Method field
// at all, since events has no HTTP-method axis); api/reqreply's own
// RouterEntry has NEITHER field — just Path and MiddlewareNames (confirmed
// via its own Design Decisions section: no second axis exists for
// reqreply at all). Each pattern's struct carries ONLY the field that
// actually applies to it — never a dead, always-empty stand-in for an
// axis that pattern doesn't have.
type RouterEntry struct {
Method string // this leaf's HTTP method (api/rest's own field — see NOTE above
// for events'/reqreply's own, differently-shaped RouterEntry)
Path string // the FINAL, prefix-applied path/topic string
MiddlewareNames []string // every reusable-class middleware name that will
// apply to this leaf, in dispatch order —
// Router-contributed names first, then the
// leaf's own
}
// Walk is the one true primitive — recurses through every Mount/Group,
// composing prefixes and middleware exactly as Register would, calling fn
// once per leaf, in declaration order. A non-nil error from fn stops the
// walk immediately and is returned as-is (no wrapping) — mirrors
// chi.Walk's own short-circuit behavior.
func (rt Router) Walk(fn WalkFunc) error
// Routes is a convenience wrapper over Walk — collects every leaf into a
// flat, declaration-ordered slice. Implemented (not hand-duplicated) as:
//
// func (rt Router) Routes() []RouterEntry {
// var entries []RouterEntry
// _ = rt.Walk(func(e RouterEntry) error { entries = append(entries, e); return nil })
// return entries
// }
//
// Walkable and printable WITHOUT calling Register (no builder/server/client
// needed), so a caller can inspect/log/assert on the assembled tree in a
// test or at startup, before committing to registration.
func (rt Router) Routes() []RouterEntry
Structured errors (all implement slog.LogValuer)¶
// RouterPrefixError is returned by Router.Register ONLY when a leaf's
// final, prefix-applied path/topic fails the SAME validation its own
// Register would apply standalone (e.g. REST's path-template syntax
// check, duplicate-path-param-name check).
//
// Carries ONLY Prefix+ComposedPath+Err — all of which Router already
// knows/computes WITHOUT any extra routable interface accessor. An
// earlier draft also carried a LeafPath field (the leaf's own,
// as-declared pre-prefix path), populated via a dedicated
// `declaredPath()` accessor every leaf type across all 3 patterns would
// have needed to implement — re-examined and DROPPED (confirmed via
// `ask_user`): LeafPath was single-purpose (no other mechanism in this
// design needed it) and its ONLY safe implementation (retaining the
// original value, NOT deriving it by trimming Prefix off ComposedPath —
// the join logic NORMALIZES, trimming an existing separator before
// inserting its own, so a naive index-based trim could be off by one
// character) required a whole extra interface method per pattern for a
// field that only made one error message marginally more convenient.
// Dropping it does not compromise declarativeness or any other feature —
// ComposedPath still fully identifies what actually failed.
//
// RouterPrefixError wraps ONLY this one failure mode. Every OTHER error a
// leaf's own Register can return (DuplicateMiddlewareNameError, security
// coverage failures, DuplicateRouteError, etc.) propagates COMPLETELY
// UNWRAPPED from Router.Register, exactly as it would from a direct,
// Router-less call — RouterPrefixError is not a catch-all wrapper for
// every possible Register failure. When 2 leaves' FINAL, composed paths
// collide (a Router-introduced duplicate neither leaf would have had
// standalone), the resulting DuplicateRouteError's ordering (which leaf's
// error surfaces first) follows Walk's own declaration-order traversal —
// deterministic, not implementation-accidental.
type RouterPrefixError struct {
Prefix string // the Router's own (or accumulated-nested) prefix
ComposedPath string // the leaf's FINAL, prefix-applied path/topic that
// actually failed validation
Err error // the underlying error from the leaf's own Register
}
func (e RouterPrefixError) Error() string { /* ... */ }
func (e RouterPrefixError) Unwrap() error { return e.Err }
func (e RouterPrefixError) LogValue() slog.Value { /* ... */ }
No NEW middleware-attachment error type is needed — a Router-contributed
.Use(mw) middleware composes onto each leaf's own, pre-existing opts
slice using the EXACT SAME mechanism a direct .Use(mw) call on the leaf
itself would use, so D6(b)'s existing DuplicateMiddlewareNameError already
fires correctly (and correctly) if a Router-level middleware's name collides
with one the leaf ALSO declared directly — confirmed this is DESIRABLE
behavior (a genuine naming collision), not something to suppress. This
error, like every other non-prefix-related Register failure, propagates
UNWRAPPED (see RouterPrefixError's own doc comment above) — it is never
itself wrapped in RouterPrefixError.
Observer integration¶
No changes — mirrors d-0007-declarative-middleware-layering.md's own, already-confirmed
precedent exactly: Router.Register/RouterPrefixError are BUILDER-TIME
(spec-assembly) concerns, structurally identical in category to
InvalidPathError/DuplicateMiddlewareNameError/BoundMiddlewareReqMismatchError
— all of which are plain RETURNED errors, never routed through
stats.Observer (which only fires at REQUEST-DISPATCH time, confirmed via
grep of every stats.ReportErrors/RecordSecurityRejection call site —
all live in adapters/*, never in any api/* builder code). A Router simply
produces the SAME kind of routeBuilder/channelBuilder-populating opts a
direct .Use()/HandleMW() call already produces — once Register()
succeeds, the resulting handle is indistinguishable from one built without a
Router at all, so every existing Observer call site downstream (adapter
dispatch) is completely unaware a Router was ever involved.
Unit test plan¶
| Test | Verifies |
|---|---|
TestRouter_Route_ComposesPrefixCorrectly |
NewRouter("/api/v1").Route(route)'s registered path is "/api/v1" + route.path, correctly /-joined regardless of either side's leading/trailing separator |
TestRouter_Mount_ComposesNestedPrefixesTransitively |
parent+child prefixes compose correctly across 2+ levels of nesting |
TestRouter_Use_AppliesToEveryGroupedLeaf |
a Router-level .Use(mw) middleware dispatches for EVERY route/channel registered under it, confirmed via an actual Register+dispatch, not just spec inspection |
TestRouter_Use_DeclarationOrderIsDispatchOrder |
Router-contributed middleware runs BEFORE a leaf's own route-specific middleware (stacked, mirrors d-0007-declarative-middleware-layering.md's own stacked-demo precedent) |
TestRouter_Use_NestedMountAccumulatesOuterToInner |
parent Router's mws run before a nested sub-Router's own mws, which run before the leaf's own |
TestRouter_DuplicateMiddlewareName_ReturnsTypedError |
a Router-level mw name colliding with a leaf's own direct .Use() name correctly surfaces the EXISTING DuplicateMiddlewareNameError, unchanged |
TestRouter_InvalidComposedPath_ReturnsRouterPrefixError |
a leaf whose FINAL, prefix-composed path fails validation (not the leaf's own, pre-prefix path alone) surfaces RouterPrefixError wrapping the real cause, with both pre- and post-prefix paths populated |
TestRouter_Routes_ReflectsFinalAssembledTreeBeforeRegister |
Routes() returns the correct, fully-composed path/topic + middleware-name list WITHOUT ever calling Register |
TestRouter_Register_IndistinguishableFromDirectRegister |
a route registered via a Router produces a *RouteHandle/*ChannelHandle byte-for-byte equivalent (same dispatch behavior) to one registered directly, with no Router involved at all |
TestRouter_Group_SharesPrefixAddsScopedMiddlewareOnly |
rt.Group(fn)'s leaves get rt's own prefix UNCHANGED (no new path segment) but an ADDITIONAL, scoped mw only fn's own leaves receive — a sibling leaf declared on rt directly (outside fn) does NOT receive the Group's extra mw |
TestRouter_With_AppliesToNextRouteOnlyNotSiblings |
rt.With(mw).Route(leafA) applies mw to leafA only; a SUBSEQUENT rt.Route(leafB) (no .With(...) of its own) does NOT receive mw — confirms With never mutates rt's own permanent mws |
TestRouter_Walk_VisitsEveryLeafExactlyOnceInDeclarationOrder |
Walk recurses through nested Mount/Group correctly, visiting every leaf exactly once, in declaration order, with fully-composed path+middleware-names per leaf |
TestRouter_Walk_StopsOnFirstError |
a non-nil error returned by the WalkFunc stops the walk immediately and is returned as-is (no wrapping), mirroring chi.Walk's own short-circuit behavior |
TestRouter_Routes_EquivalentToWalkCollected |
Routes()'s output is byte-for-byte identical to manually collecting every Walk callback into a slice — confirms Routes() is a true wrapper, not a parallel implementation that could silently drift |
TestRouter_With_RepeatedCallsAccumulateNotOverwrite |
rt.With(mw1).With(mw2) applies BOTH mw1 AND mw2 to the next .Route() call — confirms repeated .With() calls accumulate, never silently discard a prior one |
TestRouter_Immutability_OriginalValueUnaffectedByChaining |
after rt2 := rt.Use(mw), rt itself remains unchanged (no mw) — confirms Router's chained methods never mutate the receiver, matching Route[Req,Resp]'s/Channel[T]'s own established immutable-value contract exactly (replaces an earlier, now-unnecessary mutex-specific concurrency test — immutable values need no dedicated concurrency test beyond Go's own memory-model guarantees for immutable data) |
TestClientHandle_WithRouter_MatchesRegisteredPath (REST + reqreply, 2 tests) |
route.ClientHandle(WithRouter(rt))'s composed path is byte-for-byte identical to what rt.Route(route) + rt.Register(b) actually registered — confirms the client/server topic-mismatch risk is genuinely closed, not just documented |
Files to create¶
| File | Responsibility |
|---|---|
api/rest/router.go |
Router (immutable value type), NewRouter, RouterOpt, Use/Route/Mount/Group/With/Register/Routes/Walk, routable interface, RouterPrefixError, ClientHandleOpt/WithRouter |
api/events/router.go |
Same shape, topic-prefix composition, Subscriber[T]/Publisher[T] as leaves — NO ClientHandleOpt/WithRouter (confirmed N/A) |
api/reqreply/router.go |
Same shape, topic-prefix composition, Route[Req,Resp] as the leaf, includes its own ClientHandleOpt/WithRouter |
api/rest/builder.go, api/rest/middleware.go |
Add withRouterPrefix/method/middlewareNames/registerAny methods to Route[Req,Resp]/SSERoute[Req,Resp] (no new type params — Req/Resp already on the receiver; no role — REST only, per-pattern scoping) |
api/events/builder.go |
Same, for Subscriber[T]/Publisher[T] — role not method |
api/reqreply/route.go |
Same, for Route[Req,Resp] — NEITHER method nor role (no second axis) |
docs/features/router-groups.md |
User-facing feature doc, once shipped |
examples/{rest,events,reqreply}-api/demo_router_groups.go |
A worked example per pattern — nested Routers, group-wide Security, Routes() inspection printed at startup |
Out of scope (Phase 2+)¶
- Variable prefixes (
"/tenants/{tenantID}") merging into every grouped leaf's ownReq/T— this is a genuinely harder problem (every leaf's codec-declared merge-field vocabulary would need to ALSO accept Router-contributed vars, andcodex.ValidateParams/BuildFromParamswould need to compose params from 2+ sources) — deferred until a concrete need surfaces, confirmed viaask_userthis round. api/mcpRouter — tool names/resource URIs don't share REST/events/ reqreply's hierarchical path shape; flagged as an open item inmcp-ports-declarative-middleware.mdinstead (this round's explicit direction), not designed here.- Bound-class middleware at the Router level — structurally impossible
(a
BoundMiddleware[Req,...]is tied to ONEReq/T; a Router groups leaves of DIFFERENT types) — remains a route-specific-only concern, unchanged. - Spec-rendering auto-tagging (OpenAPI/AsyncAPI
Tags/channel-grouping auto-populated from a Router's prefix) — a plausible, low-risk follow-on once the core mechanism ships, not committed in this phase. ports.Patternintegration — whetherports.RESTPattern/EventPattern/ReqReplyPatternshould gain aRouter-aware variant is unexplored; today'sportsmachinery always callsRegister(builder)directly on one handle at a time, with no grouping concept — flagged as a future question, not designed here.- Router-scoped fallback handler (mirrors chi's own per-(sub)router
NotFound(h)/MethodNotAllowed(h)) — "what happens when nothing under this Router's prefix matches" is a genuinely distinct idea from everything else in this doc (it's about UNMATCHED requests, not registered leaves) and REST already has its OWN, separateErrorPattern/ErrorChannelconvention for MATCHED-but-failed handler errors — whether/how a Router-scoped "nothing matched under this prefix" hook should exist, and how it would interact with (or replace) each adapter's own global not-found handling, is unexplored. Flagged here as a concrete idea worth a future dedicated look, not designed or committed in this phase.
Design decisions — resolved for api/rest (closed this round, via ask_user)¶
Per the user's explicit direction, api/rest is designed and closed FIRST,
since it is closest to chi's own domain (HTTP routing) — api/events/
api/reqreply deliberately stay deferred (see "Deferred to events/reqreply"
below) until REST's design is validated (and ideally implemented), not
designed in lockstep. All 6 of the original "Open design decisions" are
resolved below, for api/rest specifically.
- Typed-handle recovery — resolved:
WithHandleCallback, a generalRoute[Req,Resp]enhancement, NOT Router-specific surface at all. Re-readingrouteBuilder's existing type-erasure pattern (itsrequestFormats any/respFormats anyfields, resolved back to concrete types generically insideregisterHandlewhereReq/Respare known) revealed the clean resolution: add a FREE FUNCTION (Go allows free functions, never methods, to introduce new type parameters) —
// WithHandleCallback registers fn to run immediately after this route's
// *RouteHandle is successfully constructed, inside Register/RegisterHandle
// — regardless of whether Register was called directly OR via a Router's
// Register (which just calls the SAME unchanged registerHandle through
// the routable interface). Stored type-erased in routeBuilder exactly
// like requestFormats/respFormats, resolved back to the concrete
// *RouteHandle[Req,Resp] at the SAME point registerHandle already builds
// it. A route declared with this opt needs NO special Router-aware
// handling — composes for free with Router.Route(leaf) because
// registerAny(b) always delegates to the leaf's own, unchanged Register.
func WithHandleCallback[Req, Resp any](fn func(*RouteHandle[Req, Resp])) RouteOpt
This means Router needs ZERO special "give me the handle back"
mechanism — a caller who needs the handle back (SSE serving,
ports.Pattern wiring) attaches WithHandleCallback directly to the
Route[Req,Resp] value at declaration time, same whether it's later
registered directly OR grouped into a Router. The overwhelmingly common
case (discard the handle) needs zero opt at all, unchanged.
Found during a LATER review round (not this decision's original
scope, but directly related): Route.ClientHandle() has its OWN,
separate gap Router introduces — see "ClientHandle's new WithRouter opt — resolving
the Router/ClientHandle prefix-mismatch risk" below, added after
initial closure.
2. Naming — confirmed: Router/Use/Route/Mount/Group/With/
Routes/Walk, no change — chi's own vocabulary, directly reused
(not just "inspired by"), since api/rest is the pattern closest to
chi's own domain. A REST user already familiar with adapters/chi (or
chi itself, the most common Go HTTP router) recognizes every name
immediately.
3. Middleware ordering — confirmed: Router-first (outer-to-inner).
Router-contributed .Use(mw) middleware dispatches BEFORE a leaf's own
HandleMW/HandleBoundMW-attached middleware — matches Traefik's own
documented precedence and d-0007-declarative-middleware-layering.md's
existing "stacked" convention. Nested Mount/Group accumulate
outer-to-inner, outermost-declared-first, all the way down to the
leaf's own.
4. Multi-Router attachment — resolved: no restriction needed, falls out
of existing value semantics. Route[Req,Resp] is ALREADY an
immutable value type — every existing chained method (.Use,
.WithHandler, .HandleMW, etc.) returns a NEW value, never mutates in
place. Router.Route(leaf) composing a prefix+mws onto a COPY of
leaf (via withRouterPrefix) means the SAME leaf value can already
be passed to 2+ Routers with ZERO special-casing — this was never a
genuine restriction to design, just an untested assumption in the first
draft. No code or API surface needed to resolve this.
5. RouterOpt — resolved: YES, keep the variadic parameter from day 1,
empty-for-now, reserved for 3 already-identified future extensions.
NewRouter(prefix string, opts ...RouterOpt) Router ships with ZERO
concrete RouterOpt implementations in Phase 1 (matching every other
go-codex constructor's own established convention —
NewRoute/NewChannel/NewTopic/NewPath all take variadic opts even
when most callers pass none) — reserved for this doc's own "Out of
scope (Phase 2+)" ideas, each of which would need exactly this
extension point when/if picked up: WithRouterNotFound(handler) (the
fallback-handler idea), WithRouterTags(...) (OpenAPI auto-tagging),
WithRouterPathParam(...) (variable prefixes). Avoids a breaking
constructor-signature change the day any of those 3 ideas ships.
6. Group vs. Mount("", sub) — resolved: ONE unified internal tree
representation (option b). Group and Mount create the SAME
internal child-node type; Group's child simply contributes an empty
prefix segment, so Walk/Register have exactly ONE recursion
implementation, not two. The PUBLIC guarantee ("Group never adds a path
segment") is enforced by Group's own signature
(func(sub Router) Router, no prefix parameter to pass one even
accidentally), not by a second, parallel code path — this is the
SIMPLER option specifically BECAUSE it minimizes the amount of new
tree-walking logic Walk/Register need, at zero cost to the
documented API contract.
Design philosophy — why this stays "simple" for api/rest users¶
The explicit goal is: the codec-declaration step itself (rest.NewRoute[Req,
Resp](method, path, reqCodec, respCodec, opts...) + .WithHandler(fn))
does not change AT ALL. A user who has never heard of Router writes
EXACTLY the same code as today. The ONLY new step is an OPTIONAL one:
instead of calling .Register(b) on each fully-declared Route value N
times, a user who wants shared prefixes/middleware wraps a BATCH of
already-fully-declared Route values in rest.NewRouter(prefix).Use(mw).
Route(a).Route(b).Register(b) once. This is the SAME grouping/prefix/
middleware-stack semantics chi itself offers — just elevated to operate on
go-codex's typed, codec-declared Route[Req,Resp] LEAF VALUES instead of
chi's own raw, untyped http.Handler leaves. Nothing about declaring a
route's path, codecs, or handler changes; Router is purely an additive,
optional grouping/assembly layer on top of an unchanged declaration step —
directly satisfying the request to keep the "simple codec declaration
workflow" for users while gaining chi's own routing/middleware-grouping
ergonomics.
ClientHandle's new WithRouter opt — resolving the Router/ClientHandle prefix-mismatch risk¶
Found during a LATER review round (not part of the original 6 decisions,
added here after REST's initial closure) — a MAJOR gap, confirmed via
code: TODAY, with no Router at all, a Route[Req,Resp] value's path is
the ONE single source of truth — both .Register(b) (server) and
.ClientHandle() (client) read from the SAME value, so they can never
disagree. Route.ClientHandle() takes NO builder/server argument at all
— confirmed via code, a pure accessor based purely on the leaf's own
fields. Router's withRouterPrefix is a ONE-WAY transformation producing
a NEW, prefixed COPY fed ONLY into Router.Register's internal
registerAny call — the ORIGINAL, un-prefixed Route value remains
fully intact and independently usable. Since REST's whole model is "ONE
declared var route = ..., imported by BOTH a server package
(.Register) AND a client package (.ClientHandle())," a realistic
pattern — server-side code groups route under a Router prefix;
client-side code (a different file/package, unaware of the Router) calls
route.ClientHandle() directly on the SAME var — produces the client
calling the WRONG (pre-prefix) path while the server listens on the
prefixed one. Silent mismatch: no compile error, no runtime error until
a request actually goes to the wrong place.
Re-examined once more, considering whether to change ALREADY-SHIPPED
behavior instead of bolting on a new, separate mechanism (per explicit
ask_user direction) — 4 alternatives considered and rejected before
confirming the final design:
- Bake the Router into
NewRoute(...)itself — rejected: directly contradicts this feature's founding premise (declare routes independently across many packages, assemble under a Router later). - Mutate the original
Routevalue when.Route(leaf)runs — rejected:Route[Req,Resp]is a pervasive, immutable value type throughout the ENTIRE codebase; breaking this would be a massive, invasive change. Also structurally INCOMPATIBLE with Decision #4 (a leaf can belong to 2+ different Routers) — there is no single correct prefix to bake in if that's allowed. - Explicit reassignment (
route = route.Mount(rt)) — rejected: reintroduces a Go package-init-ORDER correctness hazard (a different package'sinit()could read the stale, un-composed value before the reassignment runs) — a pure, stateless function has no such hazard. - Narrowing Decision #4 (restrict a leaf to exactly one Router, to make alternative 2 viable) — rejected: a leaf mounted at 2+ prefixes (e.g. a stable path + a legacy-compat alias) is a legitimate, real pattern; restricting it ADDS validation/error-handling complexity, contradicting the simplicity goal.
Final resolution: fold the fix INTO .ClientHandle() itself via a
NEW, variadic, 100% NON-BREAKING parameter — not a separate top-level
free function living alongside the existing method. ClientHandle's
EXISTING zero-argument call sites (confirmed throughout every existing
example/test) keep compiling UNCHANGED, since variadic parameters are
backward compatible:
// ClientHandle, now variadic (was: zero parameters). Every existing
// call site (route.ClientHandle()) keeps compiling unchanged.
func (r Route[Req, Resp]) ClientHandle(opts ...ClientHandleOpt) *RouteHandle[Req, Resp]
// WithRouter tells ClientHandle to apply rt's CURRENT accumulated
// prefix+mws before building the handle — the EXACT SAME composition
// rt.Route(leaf) + rt.Register(b) would have produced for leaf's SERVER
// side — WITHOUT requiring the caller to separately track, reconstruct,
// or hand-type rt's own accumulated prefix anywhere. A client-side
// package calls `route.ClientHandle(rest.WithRouter(serverSideRouterVar))`
// (importing the SAME exported Router value the server-side package
// registers through) instead of a bare `route.ClientHandle()` — rt is the
// single, unambiguous source of truth for the prefix, eliminating the
// forgot-to-reapply-the-prefix-string risk entirely, chosen SPECIFICALLY
// over a raw-string-based alternative for this exact reason.
//
// Internally: ClientHandle checks for a WithRouter opt among its
// variadic args; if present, applies r.withRouterPrefix(rt's prefix,
// rt's mws) — the SAME transformation rt.Route(leaf) would apply —
// BEFORE proceeding with its existing (unchanged) construction logic.
// Does NOT require leaf to have actually been .Route()'d into rt — it is
// a pure composition convenience, not a validation that the pairing is
// registered (mirrors ClientHandle()'s own existing infallible,
// non-validating character). For a MULTI-LEVEL nested Mount/Group, pass
// the SPECIFIC (innermost) Router value leaf was (or will be) .Route()'d
// into — its own effective prefix already composes all ancestor Routers'
// contributions transitively, consistent with Register's own existing
// nested-composition behavior.
func WithRouter(rt Router) ClientHandleOpt
This qualifies as "changing already-shipped behavior" in the sense asked
— ClientHandle's signature changes — but it is the SAFEST form of that
change: strictly additive, zero migration cost for existing callers, and
replaces what would otherwise have been TWO mechanisms (a method + a
separate free function) with ONE, consistent with go-codex's own
established "variadic opts" idiom used everywhere else (NewRoute(...,
opts...), NewChannel(..., opts...)).
Confirmed N/A for api/events: events has no bare, zero-argument
accessor equivalent — Subscriber.Handle(client)/Publisher.Handle(client)
ALREADY take a builder/client argument, so this specific gap does not
apply there. Applies to api/reqreply too — see that pattern's own
section below for its own WithRouter.
Design decisions — resolved for api/events (closed this round, via ask_user)¶
Continuing one-pattern-at-a-time (per the user's explicit direction):
api/events is next, since its Channel[T]/Subscriber[T]/Publisher[T]
builder internals were re-read directly (not assumed) to ground each
adaptation — api/reqreply remains deferred (see "Deferred to
api/reqreply" below).
- Typed-handle recovery — resolved:
WithHandleCallback, adapted, fires once per role.channelBuilder.formats/subscribeFormats/publishFormatsis the SAME type-erasure precedent REST's resolution relies on, so the SAME free-function shape applies:
// WithHandleCallback registers fn to run immediately after a
// *ChannelHandle[T] is successfully constructed, inside
// Subscriber.Handle OR Publisher.Handle — regardless of whether Handle
// was called directly or via a Router's registerAny. Stored
// type-erased in channelBuilder exactly like
// formats/subscribeFormats/publishFormats. Fires ONCE PER .Handle()
// CALL — if the declaring Channel[T] forks into BOTH a Subscriber AND
// a Publisher (two independent .Handle() calls), fn runs TWICE, once
// per independently-constructed *ChannelHandle[T] — the SAME fn runs
// for BOTH roles; use the role-targeted siblings below to run DIFFERENT
// behavior per role instead.
func WithHandleCallback[T any](fn func(*ChannelHandle[T])) ChannelOpt
// WithSubscribeHandleCallback/WithPublishHandleCallback — resolved via
// ask_user this round, addressing the "can a caller target ONLY one
// role?" open question: fires ONLY in Subscriber.Handle /
// ONLY in Publisher.Handle respectively, stored in 2 NEW
// channelBuilder fields (subscribeHandleCallback any /
// publishHandleCallback any), mirroring channelBuilder's existing
// formats/subscribeFormats/publishFormats 3-way split precedent
// exactly. Composable with WithHandleCallback (a caller can mix a
// shared callback with a role-specific one if genuinely needed, though
// the common case picks exactly one of the 3).
func WithSubscribeHandleCallback[T any](fn func(*ChannelHandle[T])) ChannelOpt
func WithPublishHandleCallback[T any](fn func(*ChannelHandle[T])) ChannelOpt
Router/Use/Route/Mount/
Group/With/Routes/Walk vocabulary — these are generic grouping
verbs, not HTTP-specific (chi itself uses "Route" for generic handler
mounting, not just HTTP method dispatch), so no events-specific
renaming is needed.
3. Middleware ordering — confirmed Router-first, unchanged. Pure
declaration-order concept, protocol-agnostic — transfers directly from
api/rest's resolution with no new reasoning required.
4. Multi-Router attachment — resolved identically, confirmed via code.
Subscriber[T]/Publisher[T] are ALREADY immutable value types (every
chained method — .Use, .WithHandler, .SubscribeMW,
.SubscribeBoundMW/.PublishBoundMW — returns a NEW value, never
mutates in place) — the exact same property api/rest's Route[Req,
Resp] has. No restriction needed, same reasoning as REST's decision
#4.
5. RouterOpt — resolved: YES, reserved for 2 of REST's 3 ideas (NOT
3). WithRouterTags/WithRouterTopicParam-equivalents still apply
(AsyncAPI channel-grouping auto-tagging; variable topic prefixes) —
but WithRouterNotFound does NOT transfer: pub/sub has no "unmatched
request" concept the way REST has 404s (a subscribe-side either
receives messages matching its topic filter or it doesn't — there is
no synchronous "nothing matched" response to customize a handler for).
Confirmed via ask_user this round — dropped from events' reserved
list entirely, not carried forward as an open question either.
6. Group vs. Mount("", sub) — resolved identically, same unified
internal representation. Pure data-structure/implementation concern,
protocol-agnostic — transfers directly, no events-specific
reconsideration needed.
7. NEW for events — Group's MOTIVATING EXAMPLE is ROLE, not method
(resolved via ask_user). Events has no HTTP-method axis, but a
Channel[T] forks into independent Subscriber[T]
(.WithSubscribe()) and/or Publisher[T] (.WithPublish()) leaves
from ONE shared topic — structurally analogous to REST's GET/POST
forking from one shared path. Group's events-equivalent motivating
USE CASE is therefore ROLE-based: "apply extra, scoped middleware only
to the SUBSCRIBE-role leaves under this topic-prefix; PUBLISH-role
leaves stay unmodified" (or vice versa) — directly mirroring REST's
"auth only on POST/PUT/DELETE, GET stays open" framing, with role
substituting for method as the partition axis. Important
clarification: Group's own signature
(func (rt Router) Group(fn func(sub Router) Router) Router) takes NO
role/method parameter at all — Group has NO STRUCTURAL awareness of
"role" (or "method," for REST) whatsoever; it is a completely generic,
arbitrary-subset grouping primitive. "Role" (like REST's "method") is
purely a NAMING/USAGE CONVENTION a caller chooses to follow, by only
calling .Route() with Subscriber[T] values inside one particular
Group block — the SAME generic mechanism works equally well for ANY
OTHER arbitrary partition a caller invents (e.g., "only the
PII-tagged subset," unrelated to role entirely). api/events' own
RouterEntry has a Role string field ("subscribe"/"publish") IN
PLACE OF REST's Method field (per-pattern scoping — see the updated
routable/RouterEntry sketches above; events' own RouterEntry has
no Method field at all) so Routes()/Walk() can distinguish a
topic's two role-forked leaves in the assembled tree view — populated
via events' own routable interface's role() accessor, NOT via any
Group-specific mechanism.
Design philosophy — why this stays "simple" for api/events users¶
Identical promise to api/rest's: the codec-declaration step itself
(events.NewChannel[T](topic, codec, opts...) + .WithSubscribe(...)/
.WithPublish(...) + .WithHandler(fn)) does not change AT ALL. The
ONLY new step is optional: instead of calling .Handle(client) on each
fully-declared Subscriber[T]/Publisher[T] value N times, a user who
wants shared topic-prefixes/middleware wraps a BATCH of already-declared
values in events.NewRouter(prefix).Use(mw).Route(sub).Route(pub).
Register(client) once. Nothing about declaring a channel's topic, codec,
or handler changes; Router is purely an additive, optional grouping/
assembly layer on top of an unchanged declaration step.
UPDATE (superseded this round) — events.WithRouter IS needed, and now
ships. The original finding above ("No ClientHandle/WithRouter
equivalent needed for api/events") assumed Subscriber.Handle(client)/
Publisher.Handle(client) already taking a builder/client argument was
sufficient protection — but this missed a REAL gap, found during
examples/events-api's own Router stress-test (retrofitting a genuine
Mount, not just a Group, onto routes.SensorDataChannel): a bare
Publisher[T]/Subscriber[T] value passed DIRECTLY to
Client.Publish/Client.Subscribe (or .Handle(client) with NO
Router-awareness) still only ever rebuilds from the LEAF's OWN declared
topic — exactly REST's/reqreply's already-solved
ClientHandle/WithRouter problem, just reached through a different
call shape (Client.Publish(ctx, pub, msg) instead of
Client.Call(ctx, route, req)).
Resolution — events.HandleOpt/events.WithRouter, mirroring REST's/
reqreply's shape exactly:
// HandleOpt configures a Subscriber.Handle/Publisher.Handle call —
// currently only WithRouter.
type HandleOpt interface{ applyHandle(r routable) routable }
// WithRouter tells Subscriber.Handle/Publisher.Handle to apply rt's
// CURRENT accumulated prefix+middleware before building the handle.
func WithRouter(rt Router) HandleOpt
Subscriber.Handle/Publisher.Handle both gained a variadic
opts ...HandleOpt parameter (backward compatible — existing
zero-opt callers are unaffected). A caller who needs a Router-composed
topic for a STANDALONE Client.Publish/Client.Subscribe call now does:
handle, err := routes.SensorDataPub.Handle(nil, events.WithRouter(sensorRouter))
// handle.Topic == "sensor/data", matching whatever rt.Route(pub) +
// rt.Register(client) would have registered server-side.
err = client.Publish(ctx, handle, msg) // pre-built *ChannelHandle[T], not the bare Publisher
A second, equally-necessary fix: all 3 adapters'
(adapters/mqtt5, adapters/mqtt, adapters/zeromq) internal
recoverHandle helper — which Client.Publish/Client.Subscribe
delegate to via their attached Transport — ONLY ever accepted a bare
Subscriber[T]/Publisher[T] (calling .Handle(client) on it with ZERO
opts, discarding any pre-built handle's Router composition entirely).
Fixed to dual-mode accept EITHER a bare Subscriber[T]/Publisher[T]
(unchanged, calls .Handle(client) itself) OR an ALREADY-BUILT
*events.ChannelHandle[T] (used directly — the caller already composed
it via .Handle(client, events.WithRouter(rt))) — mirroring
adapters/nethttp's own recoverClientRouteHandleValue's identical
dual-mode acceptance for api/rest's Route/*RouteHandle pair.
Without this adapter-side fix, events.WithRouter alone would have been
inert: the pre-built handle's composed topic would have been silently
discarded and re-derived from the bare, un-composed declaration.
Confirmed via TestHandle_WithRouter_MatchesRegisteredTopic_Subscribe/
_Publish/_DoesNotMutateOriginal (api/events/router_test.go) plus
one TestAttach_ClientPublish_AcceptsPreBuiltHandle_WithRouter test per
adapter (adapters/mqtt5, adapters/mqtt, adapters/zeromq),
end-to-end verified via examples/events-api's own "sensor" Mount
(routes.SensorDataChannel, shared across mqtt5broker/mqttbroker/
zeromqbroker) and demo_security_subscribemw.go's 3
previously-bare-Publisher Publish call sites.
Design decisions — resolved for api/reqreply (closed this round, via ask_user)¶
The LAST of the 3 patterns — api/rest and api/events closed in prior
rounds this session. Route[Req,Resp]/routeBuilder internals were
re-read directly to ground each adaptation, confirming a structural
difference from BOTH prior patterns.
- Typed-handle recovery — resolved:
WithHandleCallback, adapted, SIMPLER than events' (fires once, no per-role duplication).routeBuilder.requestFormats/formatsis the SAME type-erasure precedent REST's/events' resolutions rely on:
// WithHandleCallback registers fn to run immediately after this
// route's *RouteHandle is successfully constructed, inside
// Register/RegisterHandle — regardless of whether Register was called
// directly or via a Router's registerAny. Stored type-erased in
// routeBuilder exactly like requestFormats/formats. Unlike events'
// sibling (which can fire TWICE, once per role), this fires EXACTLY
// ONCE — reqreply has no fork-into-multiple-leaves: a single
// Route[Req,Resp] IS already the complete leaf, with exactly one
// Register call.
func WithHandleCallback[Req, Resp any](fn func(*RouteHandle[Req, Resp])) RouteOpt
Route[Req,Resp] is confirmed an immutable value type (.WithHandler
returns a new value) — same property REST's/events' leaf types have.
No restriction needed.
5. RouterOpt — resolved: YES, reserved for 2 ideas (NOT 3), same
pattern as events. WithRouterTags/WithRouterTopicParam-
equivalents still apply — but WithRouterNotFound does NOT transfer,
confirmed via ask_user this round, same reasoning as events: reqreply
has no "unmatched topic" concept at the api/reqreply layer (that's an
adapter/transport-level question — e.g. a ZeroMQ REP socket receiving a
request for an unregistered topic — never modeled as an api-layer
builder concern). Found and cross-referenced (NOT conflated with) an
ADJACENT, already-adequate mechanism: DeadLetter — reqreply's
existing fallback for a request that WAS matched to a registered route
but failed for a reason not covered by ErrorPattern (same category as
ErrorPattern itself, "matched but failed") — a structurally DIFFERENT
concern from "nothing matched any topic at all," which remains
unaddressed at this layer for all 3 patterns alike.
6. Group vs. Mount("", sub) — resolved identically, same unified
internal representation. Pure data-structure/implementation concern,
transfers unchanged.
7. NEW for reqreply — Group has NO special structural axis (resolved
via ask_user). Confirmed via code: unlike REST (one path, many
Route values differing by HTTP METHOD) or events (one Channel[T]
forking into Subscriber[T]/Publisher[T] via ROLE), a single
reqreply.Route[Req,Resp] IS ALREADY the complete, final leaf — topic
+ both codecs + handler, all in ONE declaration. .Register(b) wires
the server side; .ClientHandle() gives the client side of the SAME
declaration — but see "ClientHandle's new WithRouter opt" below for a gap THIS
parameterless accessor creates once a Router is involved; it is NOT as
simply "Router-independent" as it may first appear. There is no second
axis for Group to specifically target. Group therefore keeps chi's
ORIGINAL, baseline semantics for reqreply: an arbitrary, user-chosen
subset of routes sharing a topic-prefix needing extra scoped
middleware — exactly as meaningful as chi's own Group is for plain
HTTP routes that happen to share a path for reasons OTHER than method
(chi's Group was never method-exclusive either; REST/events each
found their OWN natural, domain-specific partitioning criterion ON TOP
of this baseline, but the baseline itself never required one).
ClientHandle's new WithRouter opt — the SAME Router/ClientHandle prefix-mismatch risk as api/rest¶
Found during the SAME later review round that added this to api/rest's
own section (see that section's full problem writeup, not repeated here
verbatim) — reqreply's .ClientHandle() has the IDENTICAL shape (bare,
zero-builder-argument accessor) and therefore the IDENTICAL risk:
server-side code groups a Route[Req,Resp] under a Router prefix;
client-side code (importing the SAME declared var, exactly reqreply's
own intended usage pattern) calls .ClientHandle() directly and gets the
WRONG, pre-prefix topic. Resolved identically:
// WithRouter — reqreply's own instantiation of the SAME ClientHandleOpt
// api/rest's section defines; identical rationale, identical internal
// mechanism (ClientHandle applies rt's current prefix+mws via
// withRouterPrefix when a WithRouter opt is present).
func (r Route[Req, Resp]) ClientHandle(opts ...ClientHandleOpt) *RouteHandle[Req, Resp]
func WithRouter(rt Router) ClientHandleOpt
Design philosophy — why this stays "simple" for api/reqreply users¶
Identical promise to api/rest's and api/events's: the codec-declaration
step itself (reqreply.NewRoute[Req,Resp](topic, reqCodec, respCodec,
opts...) + .WithHandler(fn)) does not change AT ALL. The ONLY new
step is optional: instead of calling .Register(b) on each fully-declared
Route value N times, a user who wants shared topic-prefixes/middleware
wraps a BATCH of already-declared values in reqreply.NewRouter(prefix).
Use(mw).Route(a).Route(b).Register(b) once — exactly mirroring api/rest's
and api/events' own additive-only promise. Route.ClientHandle() itself
is never MUTATED by Router (the original, un-prefixed value is always
still there, untouched) — but a caller who NEEDS a prefix-aware client
handle uses the new ClientHandle(WithRouter(rt)) above, not a bare .ClientHandle()
accessor directly, once a Router is involved. Router only wires the
REGISTRATION side (.Register()/.Handle() — covering BOTH REST's
server-only and events' dual subscribe+publish shape uniformly); it never
replaces or silently redirects any leaf's own separate, pre-existing
accessor used OUTSIDE the registration step.
All 3 patterns now CLOSED — design phase complete¶
api/rest, api/events, and api/reqreply all have every decision
resolved as of this round. No pattern remains deferred. The next step for
this roadmap is implementation (Mode 3 of the plan-a-new-codex-feature
skill), triggered by a future explicit "implement it" request — not
assumed or started in this round.
Phase C, sub-step 2 — deferred-item review (post-implementation)¶
Per the user's explicit direction, this review happens AFTER all 3 patterns' implementations shipped (Phases A/B/C sub-step 1), as a clean, separate sub-step — not interleaved with reqreply's own implementation. Each of the 6 "Out of scope (Phase 2+)" bullets above gets an explicit, reasoned ship-now-vs-stay-deferred decision. A FIRST pass re-examined these against what Phases A/B/C actually shipped/learned; a SECOND, item-by-item discussion round (explicitly requested before closing to Phase D) went deeper still — considering concrete alternative designs, not just re-stating conclusions — and corrected one overstatement. Final decisions below:
- Variable prefixes (
{tenantID}-style Router prefixes) — STAYS DEFERRED, as a DELIBERATE simplicity-over-capability tradeoff, not just "no driver yet." Would requireNewRouterto accept a prefix TEMPLATE (not a static string) + param declarations (mirrorsPathParam/TopicParam). The hard part: a Router-level{tenantID}var must ALSO exist as a field on EVERY grouped leaf'sReq/T(via a merge field) forBuildPath/BuildTopicto populate it — but Router-level param registration and each leaf's owncodex.ValidateParams/merge-field registration are currently two fully separate, non-communicating construction paths. Making them compose would mean threading a NEW param source intocodex.ValidateDeclaredParams/BuildFromParams, which don't know about "Router" at all today — a change reaching intocodex/itself, not justapi/*. Explicitly decided: this would add real complexity to the CORE codec-declaration layer to support a feature with no concrete driver — directly against the "simple, declarative, consistent workflow" goal this whole effort exists for. api/mcpRouter — STAYS OUT OF SCOPE, but with a more precise follow-on: confirmedmcp.Tool[In,Out]has a flatName string— no hierarchical path, so a prefix-COMPOSING "Router" genuinely doesn't fit. The adjacent, smaller idea — a prefix-LESS middleware-grouping convenience (attach one middleware set to N tools in one declaration, no path composition at all) — is a structurally DIFFERENT, simpler mechanism than this doc's "Router," and now has a concrete design sketch recorded inmcp-ports-declarative-middleware.mdinstead of this doc (keeps this doc's scope to the 3 patterns sharing the hierarchical-path/topic shape).- Bound-class middleware at the Router level — PERMANENTLY OUT OF
SCOPE for today's Router, confirmed via actually considering (and
rejecting) alternatives, not just restating "structurally
impossible." The core conflict:
Routeris deliberately TYPE- ERASED/heterogeneous (one Router groups leaves of differentReq/Resp/Tpairs), butBoundMiddleware[Req,Resp,...]is tied to ONE concrete type pair by construction. Two alternatives were considered and rejected: (a) a reflection-based runtime type-check that applies a bound middleware only to matching leaves, silently skipping others — rejected because it would be the ONE place in this entire mechanism where a type mismatch becomes a silent skip instead of a loud, typed error, contradicting theHandleCallbackTypeError/BoundMiddlewareReqMismatchErrorprecedent everywhere else in this doc; (b) a strongly-typedRouter[Req,Resp]that only groups same- typed leaves — this is a fundamentally different, PARALLEL construct, not an incremental addition to today's Router, so it does NOT belong as a bullet in this doc. Flagged instead as its own, separate idea- stage roadmap doc:typed-router-groups.md. - Spec-rendering auto-tagging (
WithRouterTags) — IMPLEMENTED, in a LATER round, once a concrete driver (an example-integration pass) surfaced. Originally deferred here with a design sketch assessing the effort as MEDIUM (assuming a NEWroutableinterface method or aRoute.Register/Subscriber.Handle/Publisher.Handlebody change would be needed to avoidRouteMeta.applyRoute's whole-struct- overwrite gotcha). The ACTUAL fix shipped turned out simpler than that assessment: APPEND (never prepend) a dedicatedrouterTagsOpt/channelTagsOptto the END of the leaf's own opts list — by the time itsapplyRoute/applyChannelruns, the leaf's own (possibly-absent)RouteMeta/ChannelMetaopt has ALREADY resolvedrb.meta/cb.meta, so the appended opt reads the CURRENT.Tags, prepends the Router's own tags, and writes the merged result back — entirely contained inside each package'srouter.go+ thewithRouterPrefixsignature (which gained a 3rdtags []stringparameter), with ZERO changes toRegister/HandleBODIES in any package. Shipped:Router.Tags(tags ...string) Router(accumulating, mirrors.Use()),routable.tags() []string(mirrorsmiddlewareNames()),RouterEntry.Tags. Events targetsChannelMeta.Tags(channel-item level) only, notSubscribe.Tags/Publish.Tags.SSERoute(REST) gained the signature change (interface compliance) but its owntags()/merge-opt still work identically, sinceSSERouteshares the SAMERouteOpt/routeBuilderplumbing asRoute. Verified against 3 REAL example integrations (not just unit tests) — seeexamples/rest-api/chiserver/server.go,examples/reqreply-api/mqtt5server/server.go,examples/events-api/mqtt5broker/broker.go— each prints a real, merged Router+leaf tag list in its own live AsyncAPI/OpenAPI spec output. ports.Patternintegration — STAYS a future question, with a CORRECTED (less optimistic) framing and a found blocker. The prior pass's wording overstated this: "ports.RESTPattern/EventPattern/ReqReplyPattern's existingPluginXxxPatternmethods already work UNCHANGED for any Router-grouped leaf" is NOT accurate — verified viaports/handle.go's pattern-building functions (buildEventPatternHandlesand siblings):PatternALWAYS builds a brand-new leaf internally fromMethod/Path/Optsand registers THAT; there is no field or mechanism to pass in an EXISTING, already- Router-composed leaf, or aRouteritself, at all. The accurate statement:ports.PatternandRouterare two fully INDEPENDENT, non-composable mechanisms today — using one doesn't break the other, but a caller cannot get BOTH Router-grouping ANDports.Pattern's one-call convenience for the SAME route. A real blocker found this round:Router.withRouterPrefix(the mechanism that would need to compose a Pattern-built leaf with a Router's prefix+mws) is UNEXPORTED/package-private —portsis a different package and cannot call it directly. Implementing this would require Router to expose a NEW, EXPORTED composition hook (e.g.Router.Prefix() string+Router.Middlewares() []middleware.RouteMiddlewareaccessors, or a dedicatedRouter.Apply(leaf) (composed, path)method) in ALL 3 packages, BEFOREportscould consume it at all — genuine new public API surface, not internal wiring. Design direction: add aRouter *rest.Router/*events.Router/*reqreply.Routerfield to eachPatternstruct; when set, the pattern-building function composes the freshly-built leaf through the Router's (newly-exported) apply hook before registering. No concrete driver surfaced during implementation to justify building this now — stays deferred, with this corrected, more precise reasoning replacing the prior overstatement.- Router-scoped fallback handler (
WithRouterNotFound) — STAYS DEFERRED, with a materially STRONGER finding than "no driver yet." Investigated each pattern's REAL dispatch mechanism rather than restating the prior "adjacent DeadLetter/ErrorPattern" answer: REST — confirmed viaadapters/nethttp/adapters/chi: go-codex NEVER owns the mux; the caller always constructs their own*http.ServeMux/chi.Routerexternally and go-codex's adapters just BIND registered routes onto it. The underlying mux's OWN NotFound/MethodNotAllowed mechanism (chi.Router.NotFound(h), or a catch-all"/"pattern onhttp.ServeMux) is ALREADY fully available to every caller TODAY, with ZERO new go-codex code — wrapping it inapi/rest.Routerwould be pure, redundant duplication of a mechanism the caller already owns directly. Events — confirmed viaexamples/events-api/demo_wildcard_subscription.go: a wildcard-topicSubscriber(MQTT#/+) is ALREADY just an ordinary, Router-groupable leaf — "catch everything unmatched" already has a direct, existing answer, no gap. Reqreply — confirmed viaapi/reqreply/builder.go'sServer.Serve: dispatch is NOT a single, central mux lookup;Servespins up ONE GOROUTINE PER REGISTERED TOPIC, each independently bound to the transport — there is structurally NOWHERE for an "unmatched topic" concept to even reach in theapi/reqreplylayer; it's answered entirely below this layer, per transport. Net finding: this isn't "nobody asked for it yet" — it's "the 3 patterns' OWN existing architectures already make a Router-level version either redundant (REST/events) or structurally impossible (reqreply)," a materially different and more conclusive finding than the original review's wording.
Net outcome: all 6 items confirmed correctly deferred — zero new work
promoted into Phase C. Item 3 spun off a separate idea-stage roadmap
doc (typed-router-groups.md); item 2's adjacent idea is now sketched in
mcp-ports-declarative-middleware.md; items 4 and 5 now carry concrete
design sketches (including found gotchas/blockers) for a future
implementer to start from, instead of a bare "deferred." The
deferred-item review itself is now closed; Phase D (documentation
graduation) is the only remaining step.
Addendum — consolidated onto the shared router package (docs/design/d-0009-internalize-shared-mechanics.md's Phase 1)¶
The 3 independently-shipped implementations this doc designed (api/rest/api/events/
api/reqreply's own Router/Mount/Group/Walk/Routes/Register/RouterPrefixError
mechanics, ~1800 lines combined, confirmed near-identical by direct code comparison) were
consolidated into a new shared router package (later relocated to internal/router — see
docs/design/d-0009-internalize-shared-mechanics.md — the package name itself is unchanged, only its
import path). This was a PURE internal-implementation consolidation — zero behavior change for
any existing caller; every pre-consolidation router_test.go in all 3 packages passed UNCHANGED
against the new implementation, with 2 new regression tests added per package confirming the
MethodReporter boundary (see below).
What moved to router.Router[Target]/router.Routable[Target]:
- The full state machine: prefix/mws/tags accumulation, routerChild[Target], Mount/Group
tree structure, Walk/Routes traversal, Register/register.
- Routable[Target]'s 4 genuinely-uniform leaf methods: WithRouterPrefix/MiddlewareNames/
RouteTags/RegisterAny (promoted from this doc's original unexported withRouterPrefix/
middlewareNames/tags/registerAny).
- cloneMws/cloneChildren/cloneTags/middlewareNameOf — identical in all 3 packages.
What a new, previously-undesigned MethodReporter interface resolves — found during
implementation planning, not anticipated by this doc's original design: this doc's own
routable already noted REST's routeMethod()/events' role()/reqreply's NEITHER as a
structural difference (see the "per-package" callouts throughout this doc), but never gave it
its own name or resolved HOW a shared core should handle it. router.MethodReporter
(RouteMethod() string) is a SEPARATE, OPTIONAL interface router.Router[Target].Walk/Routes
type-asserts for, falling back to "" when a leaf doesn't implement it — mirrors this
codebase's existing optional-extension convention (stats.FileObserver/SQLObserver/etc.)
rather than forcing reqreply's Route to implement a method it has no meaningful answer for.
What a new PrefixErrorFunc hook resolves: each package's RouterPrefixError stays a
PER-PACKAGE type (matching MiddlewareMisattachedError's precedent) — but the shared
register() still needs to know WHETHER/HOW to wrap a leaf's registration failure into it.
PrefixErrorFunc func(prefix, composed string, err error) (wrapped error, ok bool), supplied
once at NewRouter time alongside JoinFunc, does exactly what each package's former inline
asInvalidPathError/asInvalidTopicError check did — pure relocation into a closure.
A real design conflict found and resolved during implementation planning: the original plan
was a pure type ALIAS (type Router = router.Router[*Server]) for each package's own Router —
simpler, 100% automatic method-set compatibility. This turned out to be INCOMPATIBLE with keeping
each package's own, unchanged RouterEntry field names (Method for rest, Role for events,
absent for reqreply): Go only lets the package defining a named type add methods to it (even a
generic instantiation), so a pure alias would force Walk/Routes to return the verbatim shared
router.RouterEntry with ONE shared field name — there would be no way for events.Router{}.
Routes()[i].Role to coexist with rest.Router{}.Routes()[i].Method. Resolved: each package's
Router is a thin WRAPPER (type Router struct { inner router.Router[*Server] }), not an alias —
every method except Walk/Routes is a trivial 1-3 line forward (re-wrapping the inner result
back into the outer type for fluent chaining); Walk/Routes map the shared router.RouterEntry
into each package's own, unchanged shape. Router.Prefix()/OwnMiddleware()/OwnTags() were
added to router.Router[Target] to expose a Router's own, single-level accumulated state — needed
by each package's WithRouter-style ClientHandleOpt/HandleOpt, which previously reached
directly into now-cross-package-inaccessible unexported fields.
See docs/design/d-0009-internalize-shared-mechanics.md's
Phase 1 for the full design history (confidence review, all 4 design decisions, rejected
alternatives) that produced this addendum.
See also¶
d-0007-declarative-middleware-layering.md— the declarative middleware mechanism this Router composes with (unchanged); itsBoundMiddleware/BoundSubscribeMiddleware/BoundPublishMiddlewareattachment resolution (unexported interface + receiver-scoped type parameters, since Go forbids new type parameters on a method) is the direct precedent this doc'sroutableinterface reuses.mcp-ports-declarative-middleware.md— now carries a concrete, prefix-lessToolGroupdesign sketch (spun out of this doc's Phase C deferred-item review, item 2) for whetherapi/mcpever wants an equivalent grouping mechanism.typed-router-groups.md— the strongly-typedRouter[Req,Resp]idea spun out of this doc's Phase C deferred-item review (item 3), as a PARALLEL construct to this doc's heterogeneousRouter, not a replacement for it.