0019 — The handler layer: shared, generated, or hand-written
How a piece of handler code varies between services decides whether it is shared in common/, generated from the entity spec, or written by hand.
Decided. internal/handlers/ is not one thing. It holds three kinds of code, and each
has a different answer to "why is this not shared?". The kind is decided by how the code
varies between services:
| It varies by | Answer | Where it lives |
|---|---|---|
| Value — a name, a prefix, a path | Share the code, inject the value | common/httpapi |
Type — the request struct, the record, *db.Queries | Generate it from the entity spec | 0011 |
| Judgement — which authz action, which error is a 409, what the rule is | Write it | internal/handlers/ |
0018 said "routers are composed, not shared" and left it there. That was too coarse: it justified keeping the router assembly per-service and was read as justifying the whole package, including code that varies only by a string.
What this reclassifies
Invariant by the test above, and therefore moving to common/ — common/httpapi except
Swagger UI, which keeps its own package because it carries embedded assets:
CommandRequest[T]— the POST body contract,{data, why}. Varies by the type ofdataonly, which a generic already handles.ListPageInput— the page-mode query contract from 0014. Its response half (response.Page,Offset,DefaultPageSize) is already incommon/response; splitting one contract across two homes was an oversight.- The root endpoint —
ServiceInfoandRegisterRoot. Varies by service name, version, and two paths. apiConfig—huma.DefaultConfigplus clearingCreateHooksplus installing the error envelope. Varies by title and version.OpenAPISpecand the schema namer — identical logic in both services; the namer varies by module prefix and schema prefix, whichservices/template/README.mdalready lists as a manual copy step.- Swagger UI mounting —
common/swaggeruiplushttpapi.RegisterDocs, gated bySWAGGER_ON=true. One mount path (/api-docs) for every service; off by default.
Deleted rather than moved: template's RegisterOpenAPI and its sync.Once spec cache. See
below — it never served anything.
Staying per-service:
huma.Registercalls and the input/output structs. The struct tags are the OpenAPI document; each operation needs its own type. Generated, not shared.- Handler bodies. The sequence is invariant — authorize, execute, map error, envelope — but every step is typed differently per operation. See below.
mapError's domain cases and theHandlerstruct's dependencies.
Why the handler body is generated rather than shared
The sequence really is the same everywhere, which is what makes sharing it tempting:
if err := h.checker.Check(ctx, authz.ActionCreateAccount); err != nil { return nil, mapError(ctx, err) }
fid, _ := reqmeta.ActorFID(ctx)
created, err := command.Execute(ctx, h.pool, fid, input.IdempotencyKey, input.Body, h.createAccountFn)
if err != nil { return nil, mapError(ctx, err) }
return &AccountOutput{Body: response.NewData(ctx, http.StatusCreated, created)}, nilA shared function would have to be generic over the request type, the record type, and the queries type, and take the authz action, the domain function, and the error mapper as parameters. That is six things to pass to replace five lines, and the call site ends up longer than the body. The invariant here is a shape over varying types, and generation is the tool for that — a generated body is checked by the compiler at every call site, where a six-parameter helper is checked once and misused everywhere.
This is not a claim that the sequence may drift. It is generated from one template, so it cannot.
Alternatives
- One shared
handlerspackage with a sharedNewRouter. Rejected. Accounting runs Cedar authorization and a dev-auth shim that template does not, and each service owns a different set of operations and a different readiness check. A shared router grows a flag per difference, and the flags are the drift, relocated. Sharing the pieces a router installs, and leaving the assembly to the service, keeps the differences visible where they are decided. - Share the handler body behind generics now, generate later. Rejected as sequencing:
it is the six-parameter helper above, and it would be deleted by the generator that
supersedes it.
command.Executestill needs its own type parameter first. - Leave all of it per-service until the generator exists (status quo). Rejected on evidence — see below. The generator is not close, and the divergence is already real.
- Share only the types (
CommandRequest,ListPageInput) and nothing else. This is most of the value for the least movement, and is a reasonable smaller step. Rejected only becauseapiConfigand the schema namer fail the same test for the same reason, and splitting the move leaves the README's manual copy step in place.
Why
The handler layer diverged while the packages underneath it were being shared, and nothing flagged it. Probing both routers directly, rather than reading the source:
| Path | Template | Accounting |
|---|---|---|
/ | 200 | 404 |
/openapi.json, /openapi.yaml | 200 | 200 |
/docs (Huma's stock page) | 404 | 200 |
/api-docs (vendored Swagger UI) | 200 | 404 |
- The command request contract disagrees. Template accepts
{"data": {...}, "why": …}viaCommandRequest[T]. Accounting inlines the domain fields withwhyas a sibling:{"chart_code": …, "code": …, "why": …}. Two services, two shapes, one platform — a client cannot learn the command convention once. Accounting also pays for it twice: because embedding makes Huma nest the fields, its request struct restates all six domain fields thatCreateAccountBodyalready declares. - Accounting serves no root endpoint, and the two services disagree about API docs.
Accounting serves Huma's stock docs page at
/docs; template disables it and serves vendored Swagger UI at/api-docs. Nobody chose either. openapi.gois a copy whose only differences are two string constants, and the template README documents editing them as a step when creating a service.- The copy contains a bug that nothing noticed. Template's
RegisterOpenAPIrenders the schema-prefixed document into async.Oncecache and registers it on chi — and then Huma registers/openapi.jsonon the same routing tree and silently overwrites it. The served document is Huma's, with no prefixes; the cached one is unreachable. Confirmed by probe: template's/openapi.yamldoes not containTemplateDomain1Record. The file's own comments contradict each other about who serves the spec, which is how it survived.
0018 moved the platform mechanics and left this layer looking finished. It is not: the duplication that remains is the kind that varies by a string, which is the cheapest kind to share and the easiest to let rot.
What it rests on
- Huma derives the OpenAPI document by reflecting over handler input and output types. This is why per-operation structs cannot be shared or made generic away. If the API framework changed to a schema-first model, the "varies by type" row would shrink and more of this layer would become shareable.
- The generator does not exist yet. Everything in the "generate it" row is hand-written today and stays hand-written. This ADR decides where it will come from, not that it has arrived — and until it does, the handler body is copied by hand and nothing checks it.
- The route table above was measured, not read. An earlier draft of this ADR asserted
from the source that accounting served no spec endpoints; it serves them, because
huma.DefaultConfigsetsOpenAPIPath. The dead-RegisterOpenAPIfinding came from the same probe. Claims about what a router serves should be probed. - Two services.
ListPageInputand the root endpoint currently have one caller each, so they fail 0018's "second caller exists" test on a literal reading. They move anyway, because their siblings are already shared and the alternative is a contract split across two homes. Where 0018's rule and a split contract disagree, the contract wins.
Consequences
- Accounting gains a root endpoint and, when
SWAGGER_ON=true, Swagger UI at/api-docs, and loses Huma's stock/docspage. Template loses dead code. These are behavior changes, not moves, and are in scope here because adopting the shared code is what causes them. - The command request shape has to be reconciled, and one service's API breaks.
Template's
{data, why}is the documented convention, so accounting changes. There is no compatibility path that keeps both; there are no external consumers yet, which is what makes now the cheap moment. common/httpapigrows from middleware to "the HTTP layer a service does not write", including request and response contracts. If it grows past that — into anything domain- aware — split it rather than letting the name stretch.- The three-way test is only useful if it is applied when the tempting thing is to copy. Nothing enforces it; a reviewer noticing "this varies by a string" is the whole mechanism.
0018 — Shared platform code in common/
Service-invariant platform code lives in common/, domain models stay unshared, and spec-shaped code stays generated.
0020 — Frontend layering: where a file goes in a Next.js app
Every Next.js app in frontend/ uses one layered structure — routes, component tiers, hooks, services, logic, util, const, type — with the client/server split carried by the folders.