10. The Frontend API Client Is Generated by Hey API
Status
Accepted
Date
2026-09-21
Context
One OpenAPI document, backend/src/Api/wwwroot/openapi-v1.json - itself produced by ASP.NET’s GenerateOpenApiDocuments, not by NSwag - fed two generated clients, both produced by NSwag from the same MSBuild target on every backend build: a C# one for the integration tests, and a TypeScript one for the SPA.
The TypeScript half had three properties worth naming:
-
It was one class of 7,505 lines with a method per operation, each building its own request. Every call site reached it through a single instance, and a bundler could not drop the operations a route never called.
-
Its methods took positional parameters.
getVolunteerOpportunitiestook thirteen, so a call for page one of ten readgetVolunteerOpportunities(1, 10, undefined, undefined, undefined, undefined, …)- which is whylib/volunteerOpportunities.tsexists as a named-options wrapper over exactly that call. -
Its transport fell back to
windowwhen nohttpobject was injected. This codebase always injects one, so it never mattered in practice, but it is the kind of thing that decides whether a client can run somewhere without a DOM.
The C# half has none of these problems and no alternative worth the switch.
Decision
Generate the frontend’s client with @hey-api/openapi-ts (frontend/openapi-ts.config.ts) into frontend/src/client/generated/. NSwag keeps generating the OpenAPI document itself and the integration tests' C# client, unchanged.
Three choices inside that are load-bearing:
-
@hey-api/transformerswithdates: true. The previous generator parsed everydate-timeinto aDate, and the entire app is written against that -lib/format.ts, the calendar, every time-slot comparison. Keeping the transformer makes this a client swap rather than a date-handling rewrite. -
throwOnError: true, and the body unwrapped at one seam. The generated SDK resolves to{ data, request, response };src/client/index.tsreturns the body. Nothing in the app reads theRequestorResponseback - everything that inspects one already happens insideapi-instance.ts’s `fetch. A failed call still rejects with the parsedProblemDetails, which is the shapelib/apiError.tsand everycatchin the app already read. -
The
@tanstack/react-queryplugin is not enabled. Its generatedqueryOptions()call the SDK directly, bypassinghooks/useApiClient.ts- the seam every component test mocks, and the only place the current bearer token is installed. The hand-written keys inlib/queryKeys.tscost a few lines and keep both (see ADR-9).
Rationale
-
Options objects instead of positional parameters:
getVolunteerOpportunities({ query: { pageNumber: 1, pageSize: 10 } })says what it means without counting `undefined`s. -
A per-operation function instead of a class means the generated surface is 427 lines rather than 7,505, with the per-operation request building that took the space replaced by one shared client.
-
No
windowin the transport. The client needs afetchand nothing else, which is what keeps a non-browser runtime a porting job rather than a rewrite (see ADR-12). -
The
ProblemDetailscontract is preserved exactly, so the error handling this app has accumulated over many issues keeps working without being re-derived.
Considered Alternatives
Stay on NSwag for both clients
-
Pros
-
No migration: 151 call sites and 41 argument assertions stay as they are
-
One generator, one build step, already wired
-
-
Cons
-
Keeps positional parameters, and with them the wrapper functions written to hide them
-
Keeps a
windowreference in the transport for no reason
-
Orval
-
Pros
-
Generates ready-made React Query hooks, and optional MSW mocks
-
-
Cons
-
Generated hooks decide the call shape for every component. This codebase’s test harness mocks one seam (
useApiClient), and per-endpoint generated hooks would replace that with one mock per endpoint
-
openapi-typescript plus a hand-written fetch wrapper
-
Pros
-
Smallest possible generated output: types only
-
-
Cons
-
The request building - path interpolation, query serialisation, multipart bodies - becomes hand-written code that has to stay correct against 101 operations
-
Consequences
-
Generation is two steps rather than one:
scripts/normalize-openapi.jsnarrows the["integer", "string"]unions ASP.NET emits for all 112 numeric properties before the generator reads the document. Those unions describeJsonNumberHandling.AllowReadingFromString- what the server will accept, not what it sends - and NSwag had quietly narrowed them for years. Generating them faithfully would have meant a cast at every numeric read, which is a claim about the wire format that nothing checks. The script fails if it finds nothing to narrow, so the day the backend stops emitting them is a deliberate decision rather than a discovery. -
The frontend client is no longer regenerated by the backend build. It is generated by
pnpm client:generateand committed, andpnpm check:client-currentregenerates into a scratch directory and fails CI when the committed output differs. That check exists because this is the artefact that can now go stale. -
.claude/hooks/protect-generated-clients.shblocks edits tofrontend/src/client/generated/*as it did for the previous client. -
The generated folder is in
eslint.config.js’s `ignores. The previous client was protected only by an/* eslint-disable */header its generator happened to emit; an explicit ignore does not depend on a template staying the same.pnpm checkstill type-checks it. -
src/client/index.tsreferences the whole SDK namespace to unwrap it, so a bundler cannot drop unused operations. That trade was taken deliberately: it keeps 347 test mocks and every call site free of.data, and the operations are one-line wrappers over a shared client, so the bytes at stake are small..size-limit.cjsbudgets the client chunk, so if that ever stops being true it shows up as a failing check rather than a slow drift.