Flow
Decisions

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 byAnswerWhere it lives
Value — a name, a prefix, a pathShare the code, inject the valuecommon/httpapi
Type — the request struct, the record, *db.QueriesGenerate it from the entity spec0011
Judgement — which authz action, which error is a 409, what the rule isWrite itinternal/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 of data only, which a generic already handles.
  • ListPageInput — the page-mode query contract from 0014. Its response half (response.Page, Offset, DefaultPageSize) is already in common/response; splitting one contract across two homes was an oversight.
  • The root endpoint — ServiceInfo and RegisterRoot. Varies by service name, version, and two paths.
  • apiConfig — huma.DefaultConfig plus clearing CreateHooks plus installing the error envelope. Varies by title and version.
  • OpenAPISpec and the schema namer — identical logic in both services; the namer varies by module prefix and schema prefix, which services/template/README.md already lists as a manual copy step.
  • Swagger UI mounting — common/swaggerui plus httpapi.RegisterDocs, gated by SWAGGER_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.Register calls 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 the Handler struct'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)}, nil

A 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 handlers package with a shared NewRouter. 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.Execute still 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 because apiConfig and 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:

PathTemplateAccounting
/200404
/openapi.json, /openapi.yaml200200
/docs (Huma's stock page)404200
/api-docs (vendored Swagger UI)200404
  • The command request contract disagrees. Template accepts {"data": {...}, "why": …} via CommandRequest[T]. Accounting inlines the domain fields with why as 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 that CreateAccountBody already 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.go is 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 RegisterOpenAPI renders the schema-prefixed document into a sync.Once cache and registers it on chi — and then Huma registers /openapi.json on 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.yaml does not contain TemplateDomain1Record. 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.DefaultConfig sets OpenAPIPath. The dead-RegisterOpenAPI finding came from the same probe. Claims about what a router serves should be probed.
  • Two services. ListPageInput and 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 /docs page. 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/httpapi grows 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.

On this page