ADR-0054 — @kavo/next resolves routes at request time, against an entity-key map
Status: accepted
Context
ADR-0016 already settled where a Next.js binding lives in the package graph: a packages/frameworks/* sibling of @kavo/nest, depending on @kavo/core only. What it left open is how such a binding generates routes at all, because @kavo/nest's whole route-generation story (ADR-0006, ADR-0012) assumes something the App Router does not have: a decoration-time moment. @Kavo(Entity) builds the entity's operation registry and calls Nest's @Get/@Post/… decorators once, when the class is defined, so Nest's own router scan can see the resulting methods (ADR-0012). Next.js's App Router has no decorator, no DI container, and no class for a decorator to attach to — a route file's exports (GET, POST, …) are plain functions, fixed once the module loads, with no equivalent "decoration time" at which per-entity route shapes could be computed and baked into distinct exported handlers.
A catch-all route (app/api/[...kavo]/route.ts) is the only shape the App Router offers for "one file serves many entities, many operations" — every request lands in the same handful of exported functions (GET, POST, PUT, PATCH, DELETE), and whatever entity and operation it targets has to be figured out from the request itself.
Decision
createKavoHandler(entities) takes a Record<string, DefaultKavoService> — one entry per entity, keyed by the path segment that addresses it — and returns one handler per HTTP method. Each handler resolves its target per request, not once at startup:
- The first path segment (from the App Router's own dynamic-segment
params) looks up the entity in the map. A miss is a404. - The entity's
engine.registry.all()— the same operation registrycreateCrudbuilt and@Kavowould read — is walked for the first enabled descriptor whose resolved route (resolveRoute, ADR-0007'smeta.routesconvention) matches the request's method and remaining path segments (matchRoute). No match is also a404, never a405— a route this entity never configured is indistinguishable, from the outside, from a route that does not exist.
This makes route "generation" a pure function run on every request instead of a one-time side effect at class-definition time. That is a real cost — registry.all() and route resolution run per request rather than once — but the App Router leaves no earlier moment to do it in, and the registry itself is cheap to walk (a handful of entries, resolved from data already in memory).
@kavo/next reads the exact same meta.routes convention @kavo/nest established (OperationMetadata.routes, ADR-0007's open metadata bag): both packages independently declare module "@kavo/core" to add the identical routes?: KavoRouteOptions field. An entity's custom-operation route configuration (meta.routes: { method, path, ... }) is one piece of config both bindings read identically — an app that configures a custom operation's route once gets the same dispatch whether it is served by @Kavo or by createKavoHandler.
There is no manual-method-wins equivalent (ADR-0012's escape hatch for a hand-written controller method to suppress a generated route): with no class to override, a caller who wants a genuinely custom implementation for one path wins the way Next.js already resolves two routes that could match one request — a sibling, more specific route file (e.g. app/api/books/[id]/publish/route.ts) is matched by Next.js's own router before the [...kavo] catch-all ever runs, removing that path from the catch-all's reach entirely. Nothing on the Kavo side needs to know this happened.
Consequences
- A route file's shape is fixed forever (
GET/POST/PUT/PATCH/DELETEexports); nothing aboutcreateKavoHandler's signature communicates what routes exist. A caller who wants an OpenAPIpathsdocument, or any other static enumeration of routes, has to walkengine.registry.all()themselves —resolveRoute/matchRouteare exported from@kavo/nextfor exactly that reuse (buildKavoSchemasonly emitscomponents.schemas, deliberately leavingpathsto the caller). - Because dispatch is a plain function over
Request/Response,createKavoHandler's output needs no App Router-specific type at all beyond the two-argument route-handler signature Next.js itself expects — the whole implementation is portable to any other host whose routing primitive is "one file, several HTTP-method exports, aRequestin, aResponseout" with no code changes to@kavo/nextitself. - The entity-key map is the registration mechanism this binding gets in exchange for having no decorator: an entity absent from the map served by no route in that mount, and a caller wanting the entity reachable at a different key writes a different map, not a different config option on
createCrud.
Amendment (issue #457): createKavoHandler also accepts the root KavoInstance directly
The explicit map above still works unchanged, but it re-lists every entity kavo.createCrud(Entity, ...) already registered — bookkeeping that grows with the app and is easy to forget one entry out of. createKavoHandler now also accepts the root KavoInstance itself (createKavoHandler(kavo)) and derives the map from KavoInstance.services() — a new method (packages/core/src/kavo.ts) that enumerates every DefaultKavoService that root's own createCrud has produced, in call order. createCrud stays the only way an entity enters the system; services() is read-only reflection over what it already built, not a second registration path (the same constraint this issue's acceptance criteria named).
The URL key for an auto-discovered entity is resolved.entityName with only its first character lowercased — Author → author, BookAuthor → bookAuthor — and nothing else. No pluralization: unlike casing, English plurals are irregular enough (Category → Categories, not Categorys) that guessing one would trade a small bookkeeping cost for a correctness one. A caller who wants authors rather than author still writes the explicit map — that form is unaffected and remains the escape hatch for any key that isn't entityKeyFor(entityName).
Both call forms dispatch identically once the map is built (still per-request, per the original Decision above); only how the map is obtained changed.