ADR 0017: The Fastify server target
Status: accepted
Context
Section titled “Context”ADR 0016 fixes, for the first Node server target, the obligations every Node server target meets with the shared request checks of @seampact/codegen-core/node-server: the check order of ADR 0012, a reply written by the boundary, exceptions handled narrowly, the outgoing response check and its responses modes, the raw-body check, the body limit, query decoding and the bounded values. @seampact/codegen-fastify generates, per api of the app config, a Fastify plugin that places those checks around a developer handler map. This ADR states only what Fastify changes: the placement in Fastify’s route lifecycle, the reply path, the plugin’s error handler, the scoped JSON parser, the boot check and the refusals. What it does not restate holds as ADR 0016 says.
Three Fastify behaviours shape the placement. Each route runs its own lifecycle of hooks (onRequest, preParsing, preValidation, preHandler, onSend, onResponse), and Fastify parses the body after routing, so the body limit and a parse error belong to the route. An encapsulated plugin owns its error handler and its content-type parsers, which never reach the app’s own routes. And reply.send runs the onSend hooks of every enclosing context, where plugins such as @fastify/compress and @fastify/etag rewrite the payload, the status or the headers.
Options
Section titled “Options”- Plugin scope: an encapsulated plugin, or one wrapped with
fastify-pluginthat shares its hooks, parser and error handler with the app. Choice: encapsulated; its parser, error handler and hooks act on contract routes only, and the app’s own routes are unchanged (A36). - Reply path:
reply.hijack()with the shared writer onreply.raw, orreply.sendwith the buffer the boundary renders. Choice:reply.hijack();reply.sendwould run the app’sonSendhooks on contract replies, where@fastify/etagadds an ETag and a 304 the contract does not declare. The spike below shows that the app’sonResponsehooks, request logging included, still run after a hijack, so thereply.sendfallback is not needed. - Step 10: an
onSendhook, as ADR 0012’s table first said, or the shared outgoing response check onreply.raw. Choice:reply.raw; anonSendhook never runs for a hijacked reply and sees a stream as one object, while the check on the raw response sees every reply, chunk by chunk, with the one implementation every Node target shares. - Validation without a linked validator: the Ajv fallback of ADR 0012 “Target obligations”, or a refusal. Choice: the Ajv fallback. With no
validatorslink, Fastify generates Ajv, and a link always wins. Ajv runs withmultipleOfPrecision: 12andownProperties: true, and its schemas are compiled at module load.
Decisions
Section titled “Decisions”Placement
Section titled “Placement”The developer writes handler objects implementing the generated interfaces of the api (ADR 0007 handlers) and registers the plugin with them:
import * as TasksFastify from "./__generated__/fastify/tasks/fastify.js";
await app.register(TasksFastify.plugin, { handlers: { TasksEndpoints: tasksHandlers }, hooks });handlers holds one object per generated interface, keyed by the interface’s name as ADR 0007 derives it, since a target never re-cases a name on its own; the interface’s methods are called on that object, so a class instance keeps its this. The plugin is encapsulated and registers, for each operation of the generated route table, one route with four route-level hooks and a handler:
onRequest: opens the request context, installs the outgoing response check onreply.raw(below), and runs step 1’s media-type check before the body is read. The context opened here withboundaryContext.run(context, done)reaches every later hook and the handler, a body read in several packets included (spike).preParsing: decodes a compressed body (below).- The route’s
bodyLimitand the plugin’s JSON parser (below) do step 1’s bounded read. preValidation: steps 2 to 5, authenticate over the raw body for a signed identity, principal requirements, scopes and requirements.preHandler: steps 6 to 8, the input validated in one pass with the zod link’sinputvalidator, restricted input fields and sort keys, principal bindings.- The route handler calls the developer’s method (step 9) and writes the reply (step 10, below).
Hook calls are wrapped as ADR 0016 states. Fastify’s own denial is an error whose statusCode is 401 or 403 (@fastify/sensible’s httpErrors, @fastify/jwt): thrown inside a decide hook it answers the requirement’s first declared denial code, and any other failure of a hook is a 500 with onUnexpectedError called (ADR 0012 “Failures inside hooks”).
Route paths use Fastify’s syntax with positional parameter names (/tasks/:p1) and a literal : doubled in a static segment (/tasks::batch); find-my-way reads a parameter up to the next /, so generate refuses by name (path.segment.mixed.unsupported) a segment that mixes a placeholder with literal text, such as {id}:archive. The boundary never reads request.params. It matches the raw path and the request’s own method, not the route’s, against the contract templates with the shared matchContractRoute and decodes the path with decodeRequestParams. So a request that only Fastify’s relaxed routing accepts answers the path’s notFound 404 in the api’s format: a path that only ignoreTrailingSlash, caseSensitive: false or ignoreDuplicateSlashes matches. A HEAD request, which Fastify runs on the GET route (exposeHeadRoutes), runs that GET operation and goes out without its body (ADR 0016 “Errors raised before routing”); the boot check refuses an instance created with exposeHeadRoutes: false by name, since HEAD would then never reach the operation. The plugin also registers every method on a wildcard under each of the api’s base paths, which find-my-way matches after the contract routes: a path under a base path that no route serves exactly answers the api’s 404 there, and a contract path whose param the router refused (above maxParamLength) the operation’s step 6 refusal. The wildcard of a base path is registered once per root instance, by the first plugin that claims it; a path another api of the app also claims goes to the app’s not-found handler (ADR 0016).
The route table lists the operations most specific first, static segments before parameters (/tasks/search before /tasks/{id}), and the plugin registers them in that order. Fastify’s router prefers a static segment whatever the order; matchContractRoute iterates the same table. Fastify decodes a percent-encoded static segment (/tasks/%73earch reaches /tasks/search) while the boundary matches the raw path, so when the two name different operations the boundary answers the path’s notFound 404 before any check, and no handler runs under another operation’s access and input.
The plugin is generated into the target’s module of the api, fastify.ts, re-exported by the api’s index.ts (ADR 0007 “Amendment 2026-10-19: generated modules”). Its fixed exports are plugin, collectFrameworkRoutes, renderContractError, renderPreRoutingError, isContractRoute, MAX_CHECKED_ERROR_BODY_BYTES and MAX_RENDERED_ISSUES; no export is named by an operation, so none takes the reserved-name suffix. Next to it, the generated tree holds the shared routes.ts, handlers.ts and hooks.ts modules and the shared request checks under node-server/, copied with the generated header (ADR 0009 “What generated code depends on at run time”); this target adds one placement file that imports them.
The reply path
Section titled “The reply path”Every reply of a contract route the boundary writes, a 2xx among several, the derived 304, an idempotent replay and every contract error, goes through reply.hijack() and then the shared writeContractReply on reply.raw. Before writing, the boundary copies the headers set on the reply so far (reply.getHeaders()), so a header an app sets in an onRequest or preHandler hook, such as @fastify/cors’s, reaches the client. Headers and payload changes made in onSend hooks do not apply to these replies: @fastify/compress does not compress them, and @fastify/etag adds neither an ETag nor a 304.
Exceptions on contract routes
Section titled “Exceptions on contract routes”The plugin’s setErrorHandler answers, in the api’s format, only the generated contract exceptions, the refusals of the plugin’s own parser and of the route’s body read (a malformed body, and every FST_ERR_CTP_* error with a 4xx status raised on the route, FST_ERR_CTP_BODY_TOO_LARGE, FST_ERR_CTP_INVALID_MEDIA_TYPE and FST_ERR_CTP_INVALID_CONTENT_LENGTH among them), and an error whose statusCode is 401, 403 or 429, each mapped to the role of its status as ADR 0016 maps NestJS’s classes. It reads the status only, so the generated code imports neither @fastify/rate-limit nor @fastify/jwt. Any other error is rethrown, which hands it to the app’s error handler (the parent context’s), untouched. A refusal whose status the operation does not declare is a 500. An app error handler renders a contract error with renderContractError(reply, error), which writes it through the reply path above.
An app’s root-level hooks run before the route’s onRequest. An error thrown there reaches the plugin’s error handler without a request context; the handler finds the operation from the route’s config and answers as above (spike). Before it rethrows an error outside that set, it installs the outgoing response check on reply.raw when it is absent, with the operation from the route’s config, so the app error handler’s reply is checked as on any contract request. A reply an app root hook writes itself is not checked, since the check is not installed yet; such a hook throws instead, or skips contract routes with isContractRoute(request).
Under NODE_ENV=development the error handler’s reply to an exception the plugin does not map goes out as Fastify wrote it, whatever the responses mode, after a log line naming the exception’s message and where it was thrown and the usual onUnexpectedError call (ADR 0016 “Amendment 2026-10-28”), and the violation check is skipped for it.
The outgoing response check
Section titled “The outgoing response check”onRequest installs the shared installOutgoingCheck on reply.raw, so it sees every reply on a contract route: the boundary’s hijacked writes, reply.send from the app’s error handler, and a stream. Its rules, the responses modes, bodyValidation and the completion of the idempotency record are ADR 0016’s “The outgoing response check”.
A held body may arrive encoded: when the app’s error handler sends an error body, @fastify/compress encodes it in a root onSend hook, before the raw write the check sees. The shared check therefore decodes a held body whose Content-Encoding is gzip, deflate or br with node:zlib, its decoded size bounded by MAX_CHECKED_ERROR_BODY_BYTES, checks it, and sends the encoded bytes as written; any other content coding is a violation. The rule belongs to the shared check, so it holds for every Node target.
Errors raised before the plugin
Section titled “Errors raised before the plugin”Fastify parses after routing, so a malformed body and a body over the route’s limit reach the plugin’s error handler. Three cases stay outside the plugin:
- a request no route serves: an unknown path, and a method a contract path does not serve,
OPTIONSincluded, which answers that path’snotFound404 (ADR 0016); it reaches the app’s not-found handler; FST_ERR_BAD_URL(400), a path parameter that does not decode;FST_ERR_MAX_PARAM_LENGTH(414), a path parameter longer than the router’smaxParamLength(100 by default).
Fastify’s router writes the last two straight to the response in its own format, past every error and not-found handler, unless the app sets the server option frameworkErrors. The plugin exports renderPreRoutingError(request, reply, error?) for both places: the app’s setNotFoundHandler calls it without an error, and the app’s frameworkErrors function with the error. On a request whose path matches a contract route’s template it writes, in the api’s format, the notFound 404 without an error, or the matched operation’s schemaInvalid 400 for either router error, since step 6 reports an undecodable or over-long path parameter the same way; it returns true. On any other request it returns false and the app answers. Without it, Fastify’s default replies answer with their own status and format; the README’s Fastify setup shows both calls.
Raw body and body limit
Section titled “Raw body and body limit”The plugin adds its own parser for the JSON media types its routes declare, parseAs: 'buffer', inside its encapsulated context. It decodes with the boundary’s JSON decode, keeps the raw bytes on the request for signed routes only, and records the parsed byte count. Fastify refuses to add a parser for a media type that already has one in scope, and an app may have registered its own root application/json parser, so the plugin first removes the inherited parser of each declared media type (hasContentTypeParser, then removeContentTypeParser). The removal stays inside the plugin’s context: the app’s routes keep the app’s parser (spike). The per-request raw-body check of ADR 0016 stays: a signed route without raw bytes answers 500, fail closed, with a log naming the operation.
Each route carries its own limit as Fastify’s route bodyLimit, which refuses on Content-Length and bounds the read; it counts both the decoded and the encoded length when a preParsing hook decodes a compressed body and sets receivedEncodedLength. No app-wide parser limit applies to contract routes, so this target exports no LARGEST_BODY_LIMIT.
A compressed request body is inflated, as the Express and NestJS targets do with body-parser’s inflate, so one request gets one answer on every Node target. The route’s preParsing hook decodes a body whose Content-Encoding is gzip, deflate or br with node:zlib and sets receivedEncodedLength, so bodyLimit bounds both lengths; any other content coding answers the 415 in the api’s format. An app root preParsing hook that decodes bodies itself skips contract routes with isContractRoute(request), since the body would be decoded twice.
Query and bounded values
Section titled “Query and bounded values”The query is decoded from the raw query string of request.raw.url by the IR’s wire form, with form decoding (+ is a space), never from request.query. The issues bound, the throttle bucket key and the idempotency key are ADR 0016’s.
Options and registration
Section titled “Options and registration”The target options are ADR 0016’s: responses (strict, the default, or warn) and bodyValidation (false, the default, or true). The plugin options are handlers, hooks as one value ({ authenticate, deciders, scopeResolvers, idempotencyStore, onUnexpectedError }, onUnexpectedError optional), responses and bodyValidation for the api, and operations, the per-operation override keyed by the operation’s identifier: { operations: { createTask: { responses: "warn" } } }. An operation’s override wins over the plugin option, which wins over the target option. Each api is one plugin, registered once; two apis are two registrations, each with its own boot check.
The boot check
Section titled “The boot check”The plugin runs it when Fastify loads it, so app.ready() and app.listen() reject and the app does not start; it lists every problem at once with the operation id, the name and the fix:
- the hook problems of ADR 0016’s list (a missing authenticate hook, decider, scope resolver or idempotency store; two deciders claiming one name);
- a contract operation without a bound handler:
handlerslacks the interface’s object or the object lacks the method; - a key of
operationsthat names no operation of the api; - a registration
prefix(the plugin’sfastify.prefix, readable at load, spike) other than none or the api’s base path that every other base path of the api sits under (/v1for base paths/v1and/v1/hooks); under that prefix each route is registered below it, so it serves the path the contract declares, a route at the prefix itself without its trailing-slash form; - a router
maxParamLength(fastify.initialConfig) below the longest percent-encoded form of a declared path parameter (36 for auuid, whose unreserved ASCII characters a client sends as is; else twelve characters per code point of itsmaxLength, or three per character when its pattern is anchored at both ends, has no top-level alternation and matches only ASCII, since a JSON Schema pattern matches anywhere in the value), since Fastify measures the encoded length and would refuse a valid request with a 414; a path parameter that is neither auuidnor bounded by amaxLengthis a warning naming it, not a failure; - a Fastify major other than 5 (
fastify.version).
The framework-route check of ADR 0012 runs at onReady, over the routes that collectFrameworkRoutes(app) recorded: the app calls it right after Fastify() creates the root instance, before any route, since Fastify lists a route only as it is added, and the plugin refuses the start by name when no collector is installed and the check is on. collectFrameworkRoutes(app) itself throws, naming the fix, when the app already holds a route.
What the generated code depends on
Section titled “What the generated code depends on”At run time: fastify 5 as a peer dependency, the zod link’s runtime and, for types only, @seampact/hooks (ADR 0009). It imports no @fastify/* plugin and not fastify-plugin.
Refused by name or waiting
Section titled “Refused by name or waiting”generate refuses by name, with the case’s code and its fix, everything ADR 0016 “Refused by name or waiting” lists for the slice apart from its NestJS platform and class-validator items, and a path segment that mixes a placeholder with literal text (“Placement”). The validators link is required in the slice: its absence is the shared link.missing refusal, and the Ajv fallback of ADR 0012 “Target obligations” waits for a consumer without a validator library.
Amendment 2026-10-27: the runtime package
Section titled “Amendment 2026-10-27: the runtime package”ADR 0025 moves the shared request checks out of the generated tree: they are those of @seampact/server-runtime, imported, no longer the copies of @seampact/codegen-core/node-server under node-server/, and the placement file is the package’s /fastify export, no longer fastify-boundary.ts. Each api’s generated tree keeps fastify.ts, routes.ts, handlers.ts, hooks.ts, index.ts, its own validators when it writes them and, with nativeDocs, its docs module.
What the generated code depends on adds @seampact/server-runtime as a production dependency: its root, /node and /fastify exports, at the generator’s version (ADR 0025, G1). seampact generate refuses an app that does not install it (runtime.missing) or installs one of another major.minor or a lower patch (runtime.version-mismatch).
The boot check runs one step first, before any other: the version the api’s route table carries is compared with the installed runtime’s, and a table written by another major.minor or a later patch throws runtime.generated-for-other-version when fastify.ts is imported. Each api keeps its own logger in the shared runtime, keyed by its route table.
Run on Fastify 5.12.7, @fastify/compress 9.2.2 and Node 24.21, and not kept. An encapsulated plugin registered routes with the onRequest, preValidation and preHandler hooks above, a reduced model of the shared outgoing check installed from onRequest on reply.raw, and an error handler that answered contract errors and rethrew the rest to the app’s error handler.
| Case | Result |
|---|---|
boundary reply through reply.hijack() and reply.raw |
200 as written; the app’s onResponse hook ran with the right status; a header set by an app onRequest hook arrived (copied from reply.getHeaders()); an app onSend header did not; not compressed with Accept-Encoding: gzip |
| contract error thrown by the handler | the plugin’s error handler answered 404 in the api’s format |
| app error handler, declared 404 in the format | passed |
| app error handler, unlisted code; undeclared 418 | 500; under warn, 418 sent as written with a logged violation |
app error handler, 4 KB declared 404 with @fastify/compress |
arrived at the check gzip-encoded, so a check that does not decode makes it a 500 (the decoding rule above) |
| app error handler, a stream of three chunks 150 ms apart | 200, three chunks at 150, 300 and 450 ms; with an undeclared 206, a 500 and no chunk after it |
malformed JSON; a body over the route’s bodyLimit |
reached the plugin’s error handler: 400 and 413 in the api’s format |
| signed route | raw bytes kept as received ({"a": 1}, the space included) |
| app routes | kept the app’s JSON parser and its error format |
app root application/json parser, then the plugin adding its own |
boot failed with FST_ERR_CTP_ALREADY_PRESENT; with hasContentTypeParser and removeContentTypeParser first, it booted, the contract route used the plugin’s parser and the app’s route the app’s |
path parameter of 101 characters; /tasks/%E0%A4%A |
414 FST_ERR_MAX_PARAM_LENGTH and 400 FST_ERR_BAD_URL in Fastify’s format, no error or not-found handler run, unless frameworkErrors is set |
HEAD /tasks/1; OPTIONS /tasks/1 |
HEAD ran the GET route; OPTIONS reached the not-found handler |
app root onRequest hook throwing a 401 |
reached the plugin’s error handler before any context, answered in the api’s format |
app root onRequest hook replying a 401 itself |
went out unchecked |
plugin registered with prefix: '/v1' |
fastify.prefix read /v1 inside the plugin |
context opened in onRequest with run(context, done), body sent in two packets |
read in preValidation and in the handler |
| non-contract route; unknown path | untouched; the app’s not-found handler |
Consequences
Section titled “Consequences”- App input rules (ADR 0028): Fastify: step 8b runs in the shared
runInputSteps(handler-map.ts), withinputRulesin the plugin options; a linked Ajv takes an app-compiled validate function. - An app keeps its Fastify instance, its root hooks, its error handler and its not-found handler; on contract routes the boundary’s checks and reply win, and its own routes are unchanged.
- Boundary replies skip every
onSendhook:@fastify/compressnever sees them and they carry no@fastify/etagvalidator; a header an app must add to them is set before the handler, inonRequestorpreHandler. - When the app registers
@fastify/compressbefore the plugin (itscompressreply decorator), the boundary compresses its own replies: a body of 1024 bytes or more, the plugin’s default threshold, in the coding the client prefers among br, gzip and deflate, withVary: Accept-Encodingand the ETag made weak, and a 304 with the ETag andVaryits 200 would have. The plugin’s own options do not reach these replies,global: falseincluded: the plugin keeps them private, so only its decorator is read. - App error handlers keep working on contract routes; under
strict, a reply of theirs outside the contract becomes a 500, and underwarna logged violation. - A reply an app root hook writes on a contract route is not checked; the README’s setup says to throw, or to skip contract routes with
isContractRoute. - An app that does not route Fastify’s router errors through
frameworkErrorsanswers an undecodable or over-long path parameter in Fastify’s format; the boot check keepsmaxParamLengthabove every declared bound. - A registration prefix other than the api’s base path is refused at boot instead of answering 500 per request as NestJS’s global prefix does.
Traceability
Section titled “Traceability”- FRs: FR-5 (the check order), FR-14 (a case refused by name), FR-35 (the shared suite this boundary will pass); amendments A36, A39, A40, A41, A42, A47.
- ADRs: 0007 (names, generated modules), 0009 (links, run-time dependencies), 0012 (placement and target obligations), 0013 (raw body), 0016 (the shared obligations).
- Proof in the development phase: the review of this ADR, the spike above, and the slice’s end-to-end gate on the Fastify consumer.