Skip to content

ADR 0016: The NestJS server target

Status: accepted

ADR 0012 fixes what every server target generates around the developer’s controller and lists, under “Target obligations”, what each target’s own ADR must state. @seampact/codegen-nestjs is the first server target built, for the end-to-end slice (A47): it generates, for each api of the app config, the boundary around the developer’s @Controller() classes, validating with its validators link: a Standard Schema provider (codegen-zod, codegen-yup) or codegen-class-validator (ADR 0009 “The provider seam”, “Validators” below).

NestJS is a layer over an HTTP platform, Express by default, and three of its behaviours shape the placement. A route has a fixed status that NestJS re-applies when it writes the reply, so an interceptor cannot choose the status. Exceptions go to one filter each, chosen by scope. And everything that happens before routing (body parsing, an unknown route) is outside every route-scoped mechanism. This ADR states where each step of ADR 0012’s check order runs in NestJS, how each obligation is met, and what the target refuses by name or leaves for later. The Fastify and Express targets reuse the same request checks through @seampact/codegen-core and cite this ADR for the shared obligations instead of restating them.

  • Platform under NestJS: Express only in the slice, or Express and Fastify together. Choice: Express, NestJS’s default; Fastify under NestJS comes right after the slice, since its raw-body and reply handling differ and would double the riskiest glue.
  • Reply path: the boundary writes status, headers and body itself, or it hands a value to NestJS and turns off Express’s ETag with app.set('etag', false). Choice: the boundary writes the reply; turning ETags off app-wide would change the app’s own routes (A36), and NestJS would still re-apply the route status.
  • Raw body for signed routes: a boot check that refuses to start without rawBody: true, a generated startup helper, or a per-request check with documented setups. Choice: the per-request check, fail closed, and an opt-in generated verify function; NestJS exposes no public way to read the parser setup at boot, and the app keeps its own main.ts (A36).
  • Handler refusals: a typed returned error, a typed thrown exception, or both. Choice: both (ADR 0012 “Errors the handler answers”), the returned error in the slice and the thrown exception right after it.
  • Placement of steps 6 to 8: in a seampact interceptor, at the end of the seampact guard, or in a seampact pipe on the operation’s input param. Choice: the pipe (amendment 2026-10-21 below); NestJS runs a param’s own pipes after every app pipe, so validation is NestJS’s pipe role and no app pipe changes what the contract validates, at the cost of one awaited pipe chain per request, measured and accepted in that amendment.
  • Placement of step 10 and the write: a seampact interceptor around the handler, or a wrapper of the controller method. Choice: the wrapper by default (amendment 2026-10-20 below), since NestJS runs any route with an interceptor through a per-request path of its own that costs about half a baseline request and, on 12.1.x, leaks native memory; and, opt-in, a global seampact reply interceptor (target option reply: interceptor, amendment 2026-10-21) for an app whose global interceptors shape replies.
  • Exceptions on contract routes: a seampact filter that catches every exception there, or one that catches only seampact’s own while the app’s filters handle the rest. Choice: the narrow filter, since NestJS gives each exception to exactly one filter and a catch-all would hide the app’s own; the contract is then enforced on the outgoing response, whoever wrote it.

The developer writes the controller and implements the generated interface of each resource (ADR 0007 handlers), as ADR 0012 “Your controller, the generated utilities around it” shows: @TasksNest.updateTask() update(@TasksNest.updateTask.input() input: Tasks.UpdateTaskInput), with import * as TasksNest from '.../tasks/nestjs' and import type * as Tasks from '.../tasks/types'. Codegen emits, per api, under the target entry’s out:

  • A module middleware, bound to the routes of the generated route table, that opens the request context (AsyncLocalStorage, ADR 0012 “Open tenancy”) and runs the step 1 checks that belong to the contract (the per-route body limit and the media type, below) and installs the outgoing response check (below). It writes its refusals itself in the api’s error format, since an exception thrown by a middleware reaches only global filters.
  • Per operation, a composite method decorator built with applyDecorators: the verb and the full contract path with Version(VERSION_NEUTRAL), SetMetadata with the operation id and its access kind (the route metadata a native guard reads through Reflector, ADR 0012 “Native security layers”), UseGuards with the seampact guard, UseFilters with the seampact filter, the response mark of the reply path (below), and, applied first, the handler wrapper (below). It registers no interceptor. It carries no HttpCode: the reply path writes every status.
  • The guard runs steps 2 to 5: authenticate (over the raw body for a signed identity), principal requirements, scopes outermost first, requirements in declared order. It records the checked request and the grant on the request context. Guards run after middleware and before interceptors and pipes, and the module moves the seampact guard after every guard the app sets on the route (amendment 2026-10-20 below).
  • The guard and the handler wrapper wrap every hook call (ADR 0012 “Failures inside hooks”): a NestJS denial (ForbiddenException, UnauthorizedException) thrown inside a decide hook answers the requirement’s first declared denial code, and any other exception thrown or rejected by a hook is a 500 with onUnexpectedError called; both leave as generated contract exceptions, so the seampact filter answers them and no app filter sees a hook’s error.
  • The handler wrapper replaces the controller method in place, keeping its name, its arity and the metadata decorators written below the composite set on it. On a request routed to its operation it answers 500 unless the input pipe checked the input, then runs the idempotency claim or replay and calls the method. In the default reply mode it then runs step 10: undeclared status, projection of restricted output fields, stripping of undeclared fields from the IR shape, derived headers, the derived ETag and 304, and the write of the reply; it returns no value, so NestJS calls the method directly and adds no interceptor path. In the interceptor mode it returns the method’s result, and the reply interceptor runs step 10 and the write. Called anywhere else (a unit test, another handler calling it), it is the method itself.
  • Per operation, a param decorator made with createParamDecorator whose value is the output of the seampact input pipe it carries: steps 6 to 8, input validation in one pass with the validators link’s input validator over path, query, headers and body as recorded by the guard, restricted input fields and sort keys, and principal bindings. The pipe ignores the value NestJS extracts and any app pipe returned before it; it never uses the app’s global ValidationPipe (ADR 0012 “The check order”).
  • In the interceptor mode only, the reply interceptor, registered by the api’s module as an APP_INTERCEPTOR: on the api’s contract routes it runs step 10 on the value NestJS’s interceptors inside it pass on, and writes the reply; elsewhere it passes the call through. The module moves it to the front of the app’s global interceptors in its configure, as it moves its guard last.
  • The filter of the api’s error format, the dynamic module registering the hooks, and the boot check (below).

The two per-operation decorators are named by the operation (ADR 0007 operation role): the composite is the operation’s name, createTask(), and the param decorator is its input property, createTask.input(). They live in this target’s module of the api, apart from the types link’s module, so a declared schema of the same words (CreateTask in the reference contract) or the CreateTaskInput type never shares their scope (ADR 0007 “Amendment 2026-10-19: generated modules”). Inside this target’s output, its fixed exports (below) are shadowed names of ADR 0007 section 5 for the operation decorators, so an operation named responses gets responses_().

Route order is the app’s: NestJS on Express registers a controller’s routes in the order its methods are declared and matches the first, so a parameter route declared before a static sibling of the same method (GET /tasks/:taskId before GET /tasks/search) shadows it, and the member operation runs its checks on the action’s requests. The composite cannot reorder them; the developer declares the static route’s method first.

Steps 1 to 8 and 10 run the request checks of @seampact/codegen-core/node-server (framework-neutral, plus the Express platform glue, which Fastify and Adonis trees receive unused), copied into the generated tree with the generated header (ADR 0009 “What generated code depends on at run time”); this target writes only their NestJS placement.

The target reads both validator styles of ADR 0009: standard-schema (zod, yup) and class-validator, the one target that accepts the latter. In both styles every export of the descriptor is a Standard Schema v1 value, in class-validator an adapter over a decorated DTO class, so the route table and the guard read both alike and step 6 stays one call.

  • With class-validator, the provider generates the decorated DTOs and the adapter validates with strict options: unknown keys refused (whitelist with forbidNonWhitelisted), no implicit conversion (transform with enableImplicitConversion: false, a value coerced only where the contract’s wire rules decode it), and every issue reported.
  • These options apply inside the seampact input pipe’s step 6, not in a UsePipes pipe of the app: the request is validated once, its refusal is the contract’s schemaInvalid error with the same { in, pointer, rule } issues as the Standard Schema path, and the app’s own ValidationPipe, which runs before it, never changes the contract input.
  • Any other validator style is refused by the link check (link.style.unsupported), naming the first-party packages of both accepted styles.

Every contract route’s reply is written by the boundary with res.statusCode, res.setHeader and res.end, never res.json or res.send: one path for a 2xx among several, the derived 304, an idempotent replay (written without calling the handler) and every error. Express computes its weak ETag and its 304 inside res.send, so a reply written with res.end gets neither, and the app’s own routes keep Express’s settings. ADR 0012’s step 10 obligation for nestjs is met by this one rule.

NestJS still writes its own reply after the handler returns, unless the route takes the raw response. The composite therefore applies NestJS’s public Res() parameter decorator at the first index after both the method’s declared parameters (method.length) and its highest decorated parameter, since length stops at a parameter with a default value or a rest parameter: NestJS then treats the response as handled and never writes it, and the handler receives the response as an extra trailing argument its interface does not declare. In the default reply mode a result an app’s interceptor maps on a contract route is discarded, since the wrapper already wrote the contract’s projection; in the interceptor mode the mapped value is the one step 10 checks and writes. An interceptor that answers without calling next.handle() (@nestjs/cache-manager’s CacheInterceptor on a hit) skips the input pipe and the handler, so the boot check refuses a cache interceptor that can answer a contract route (below), and a value that reaches the reply interceptor without the input checked answers 500 with a log; in the default reply mode the reply interceptor is placed only where an app interceptor can answer (below, “Amendment 2026-10-27”). The target exports isContractRoute(context), true when the handler carries an operation’s composite metadata, for such an interceptor to call next.handle() at once on contract routes.

The spike that backs this, run on NestJS 12.1.3, @nestjs/platform-express 12.1.3, Express 5.2.1 and Node 24.21, and not kept, compared three ways for an interceptor that writes the reply:

Way Wire result After the reply
write, then emit a value right status, headers and body NestJS replies again on the ended response; on the 304, Express’s res.send finds the request fresh and throws ERR_HTTP_HEADERS_SENT into the filter
write, then complete without a value right status, headers and body every reply raises an EmptyError into the filter
mark the response with Res(), write, then emit right status, headers and body nothing: no second reply, no error

With the marked way, a GET answered 200 with the boundary’s strong ETag and no Express ETag, a matching If-None-Match answered the boundary’s 304, a POST whose NestJS default is 201 answered the handler’s 202, a replay answered 201 with the replay header without calling the handler, and a guard refusal answered 401 through the filter. On the same app, a route of the app’s own kept Express’s weak ETag and its 304.

NestJS hands each exception to exactly one filter, the first whose @Catch() types match, looking at the route’s filters, then the controller’s, then the global ones (the last registered first). A route-scoped filter that caught everything would therefore hide the app’s own filters on every contract route. The seampact filter on the composite catches only the generated contract exceptions and the framework refusals it maps; every other exception follows NestJS’s own path through the app’s method, controller and global filters, untouched. Non-contract routes are never touched.

The framework refusals mapped at step 1: UnauthorizedException, ForbiddenException, and a 429 HttpException such as @nestjs/throttler’s ThrottlerException, each to the role of its status. NestJS picks a filter by its @Catch() types and a picked filter cannot decline, so the seampact filter never lists HttpException itself, which would take an exception of a status the operation does not declare from the app’s filters: it lists the generated contract exceptions, the two classes above, a generated class whose static [Symbol.hasInstance] matches an HttpException of status 429, so the generated code does not import the throttler, and one matching an HttpException whose status the request’s operation declares (amendment 2026-10-27). Since the filter is bound on the method, NestJS picks it before the app’s controller and global filters for every status the operation declares: an app’s global @Catch(NotFoundException) or HttpException filter no longer sees those exceptions on contract routes, while an app @UseFilters written above the composite still comes first. These mappings apply only to refusals raised outside seampact’s hooks (an app guard, the throttler); a refusal whose status the operation does not declare is a 500, as for a hook (ADR 0012 “Failures inside hooks”). An app filter renders a contract error with the exported renderContractError(host, error), which writes it in the api’s format through the boundary’s reply path.

Since an app filter may answer anything, the contract is enforced on the outgoing response of each contract route, whoever wrote it. The module middleware installs the check on that request’s response object only, around writeHead, write and end; Node calls writeHead itself before the first byte of any reply, so res.send, res.json and a piped stream all pass through it. Replies the boundary writes itself skip the contract checks; they still complete the idempotency record below. Every reply on a contract route keeps the headers the app’s middleware set (CORS, security headers, a throttler’s Retry-After): the boundary replaces only the body headers (Content-Type, Content-Length, Content-Encoding, ETag) and the names its reply writes, and merges Vary. Headers passed as writeHead’s argument are checked as those set with setHeader.

Express’s weak ETag and its 304 come with every res.send and res.json, an app filter’s included, and step 10 excludes both from contract routes (ADR 0012 “Target obligations”). The check therefore removes, before the head goes out, an ETag header the boundary did not set, whatever the status, and treats a 304 the boundary did not write as a violation.

  • A declared success status passes at once, chunk by chunk, a stream included. Under strict, a 2xx the app writes outside the handler result is checked for its status only, and for its body only under bodyValidation, since holding and validating every success body costs each reply.
  • An undeclared status is a violation as soon as the status is known; the writer’s chunks are not kept.
  • A declared error status is held until the end, up to MAX_CHECKED_ERROR_BODY_BYTES = 65536, and its body must be in the api’s error format with a code the operation lists; a longer body is a violation. A body in gzip, deflate or br (compression middleware) is decoded first, the cap applying to the decoded bytes; any other content encoding is a violation.

A violation follows the responses mode. Under strict, the default, the reply is replaced by a 500 in the api’s format and the optional onUnexpectedError(exceptionOrReply, context) hook is called with the exception behind the reply when the boundary knows it, else the violating reply ({ status, headers, body, violation }), for logging and error tracking; without the hook the violation is logged; a failure inside the hook is logged and the 500 still goes out. Under warn, the reply goes out as written and the violation is logged once with the operation id. The mode is a target option, overridden per controller or per method by a generated decorator (@TasksNest.responses('warn') for the tasks api); the seampact guard reads it through Reflector and records it on the request, so a reply written before that guard runs (an app global guard’s refusal) follows the target option. The target option bodyValidation, off by default and meant for development and tests, validates the JSON success bodies the app writes itself with the validators link’s output validators, and a body that fails is a violation; the body the boundary writes from a handler result is never validated, as on Node.

An idempotent operation’s record is completed with the reply that went out, after the check: the boundary’s own reply, a contract error from the seampact filter, or a declared error an app filter wrote; it is abandoned after a 500. Under warn, a violating reply that goes out is stored and replayed as written, since the client already received it; a 5xx abandons the record as a 500 does. The check thereby sees the stored error the handler wrapper cannot (ADR 0012 “Target obligations”).

A second spike, on the same versions and not kept, installed such a check from a module middleware. A controller-scoped app filter’s declared 404 in the api’s format passed; its undeclared 418 became a 500 with the hook called, and passed with a logged violation on a controller marked warn. A global filter registered with useGlobalFilters answering a declared 409 as plain JSON became a 500; its 409 in the api’s format passed with a listed code and became a 500 with an unlisted one. A StreamableFile of three chunks 150 ms apart reached the client as three chunks at the same pace, and the same stream with an undeclared 206 became a 500. A non-contract route was untouched. The check has three limits: a writer that bypasses the response object (writing on res.socket) is not seen; a refusal raised before routing comes before the middleware and goes through the exported global filter below; and res.headersSent reads false while an error body is held. A 2xx written by anyone but the boundary (an app filter) is checked for its status, and for its body only under bodyValidation, but neither projected nor stripped, so restricted output fields are not removed from it, and bodyValidation does not check field rights.

These reach only the global filters: a body the platform’s JSON parser cannot parse (400), a body above the app-wide parser limit (413), a path no route serves (404). The target exports, per api, a global filter instance and a helper for them (preRoutingFilter, renderPreRoutingError(exception, host)). They act only when the boundary’s request context is absent, since the module middleware opens it on every contract route before any handler-side exception can be thrown; then, on a request that matches a contract route’s path template, they render the refusal in that api’s format. On any other request, the filter hands the exception to NestJS’s default BaseExceptionFilter and the helper returns that it did nothing. NestJS tries the last registered global filter first and gives the exception to that one alone, so the exported filter matches no exception raised on a contract route, which the app’s filters keep (amendment 2026-10-28), and suits for the others an app without a global filter of its own; an app with one, or with two apis, calls each api’s helper first in its own global filter. Without either, NestJS’s default JSON body answers these refusals on contract routes, with the right status and the wrong format; the README’s NestJS setup shows both forms. For two apis, one @Catch() filter extending BaseExceptionFilter, built with app.getHttpAdapter(), tries each api’s renderPreRoutingError(exception, host) in turn and calls super.catch(exception, host) when none acts. A method the path does not serve (PUT on a task) is a 404 NestJS raises before routing, so the filter answers it with that path’s notFound code: the contract has no 405. On every Node target, a method a contract path does not serve (OPTIONS included) on a path whose operations declare no 404 answers a bare 404 with no body; nothing failed, so onUnexpectedError is not called. A request whose raw path is under one of the api’s base paths (its surfaces’ resolved prefixes, case-sensitive, after the mount) and that no contract route serves exactly (a trailing slash, //, a percent-encoded static segment, an unknown sub-path) answers the api’s notFound code (404) in its format on every Node target, never a domain 404 code of one operation, and bare when no operation carries the notFound role; a path outside the base paths (/V1/... on /v1) is the app’s. Two apis of one app may share a base path (the default /v{version} prefix gives every version-1 api /v1): each api records its base paths in a per-app registry when it is registered, and a path under a base path that another api of the app also claims is left to the app (Express next(), Fastify reply.callNotFound(), NestJS and AdonisJS their default 404), so no api answers another’s routes. The Express router reads the contract routers mounted on the app from the app’s router stack at request time, so the mount order does not matter. HEAD on a path with a GET operation runs that operation fully (access, validation, no idempotency) and writes its status and headers without a body (RFC 9110 section 9.3.2), on every Node target. The filter delegates to BaseExceptionFilter with the HTTP adapter the api’s module records when it starts, so the app’s own routes keep NestJS’s statuses and its error log; an unclaimed exception on a contract route is also kept for onUnexpectedError when the reply NestJS writes for it violates the contract. A pre-routing 500 (a parser limit below a route’s limit) calls onUnexpectedError too.

parseOptions accepts responses (strict, the default, or warn), bodyValidation (false, the default, or true) and reply (handler, the default, or interceptor), and refuses any other key by name. reply is per api: two apis of one app may differ, each reply interceptor acting on its own api’s routes; no other Node target takes it. Every Node server target also takes frameworkEtag (strip, the default, or keep) and catchAll (true, the default, or false, which leaves unserved paths under the base path to the app), and Fastify takes compression (off, or a thresholdBytes threshold). AdonisJS also takes rules (ADR 0009), which shapes its own VineJS validators only.

A signed identity’s authenticate hook needs the raw bytes (ADR 0013 “Where the verification happens”). There is no boot check on the raw body. The guard checks, on each request to a signed route, that the raw bytes are present as a Buffer on req.rawBody; when they are not, it answers 500 without calling the hook, fail closed, and logs the operation id, the route and both fixes below. The template signature header is parsed before the hook, and a malformed or missing header answers 401 without calling it (ADR 0013 “Test vectors” S10 and S11).

Three setups are documented, each keeping the app’s own startup code:

  1. NestFactory.create(AppModule, { rawBody: true }), with app.useBodyParser('json', { limit }): NestJS keeps the raw bytes of every request.
  2. NestFactory.create(AppModule) with rawBody off, and app.useBodyParser('json', { limit, verify: TasksNest.keepRawBody }): the generated opt-in verify function keeps the raw bytes only on the api’s signed routes, and records the parsed byte count on every contract route for the body limit. Zero body bytes is no body on every Node target: the boundary clears the {} that body-parser sets for an empty or absent body, so a required body answers 400 required, as on Fastify. NestJS 12 replaces any verify option of useBodyParser with its own when rawBody is on, so this setup needs rawBody off. An app that serves two apis with signed routes calls each api’s function from one verify.
  3. bodyParser: false and the app’s own parser, which sets req.rawBody to the bytes as received on signed routes and re-serializes nothing before.

Setups 1 and 2 pass strict: false to useBodyParser('json', ...), as ADR 0018 does on its route parsers: a JSON scalar body then parses, and a value of the wrong type is a step 6 issue (ADR 0012 step 1), not the parser’s malformed-body 400.

  • The app-wide JSON parser does the bounded read; its limit must be at least the largest route limit, which the target exports per api as a generated constant (TasksNest.LARGEST_BODY_LIMIT for the tasks api) for the setups above. The module middleware then checks the route’s own limit and answers 413. It counts the parsed bytes, the raw body or the recorded byte count, whenever either is present, since the parser inflates a compressed body by default and Content-Length then gives the compressed size; Content-Length alone serves only for an early refusal, and as the body’s size for a request without Content-Encoding or with identity. A request whose body size none of these gives (a chunked body, or a non-identity Content-Encoding, under the app’s own parser) answers 500 in the slice, fail closed, with a log naming the setups; accepting it within the parser limit is a follow-up. A parser 413 whose limit is below the route’s limit is a setup error, not the contract’s refusal: the exported pre-routing filter and helper answer it with 500 and a log naming the generated constant.
  • Media type: a contract route with a JSON body refuses another Content-Type with 415 in the module middleware, since the JSON parser only skips it. The declared media type is matched exactly, on every Node target: a +json suffix (application/merge-patch+json) on an application/json route is another media type and answers 415.
  • Query: decoded from the raw query string of req.originalUrl by the IR’s wire form (ADR 0002 section 7), never from req.query. Path, query and header strings are decoded before the validator; a value that does not decode stays the raw string, so the validator reports it with every other issue in step 6’s one pass, and the validator never coerces.
  • The issues list rendered into error params is bounded by MAX_RENDERED_ISSUES = 100. Each issue is { in, pointer, rule } (ADR 0006 section 6), pointer inside the part in names, and the binding’s params template renders the list. rule is the contract keyword the value breaks, never the validator’s issue code: required, type, enum, format, pattern, minLength, maxLength, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, minItems, maxItems, oneOf for a union, and unknownKeys for each unknown member, at that member’s pointer. A check the validator reports as custom carries its rule name in the issue’s params.rule, which codegen-zod writes on each custom check (code-point lengths, formats checked by a function); a regex failure the validator reports is a format, except on a pattern refinement, which codegen-zod checks with a custom check named pattern.
  • A paged list’s limit the client does not send reaches the handler as the operation’s defaultLimit. Cache-Control is the operation’s value for the caller kind, or no-store when noStore is set.
  • The throttle bucket key and the idempotency key are fixed-size SHA-256 digests. The idempotency key digests the api id, the operation id, the identity id, a digest of the validated principal (none for an anonymous caller) and the client’s key, never the credentials (ADR 0012 “Target obligations”).

The generated handler interface takes the operation’s input from the types link and returns its output or its errors (ADR 0007 roles): a success { status, body } or a declared error { status, code, params }, checked by the compiler. A code the operation does not list is a type error, and at run time a 500. A handler or a service throws a declared error as the exported ContractException(code, params), typed with the api’s codes and their params, through the same filter (amendment 2026-10-27); the generated exception per code of ADR 0012 (TasksLockedError) still comes after the slice and goes through the same filter.

Each api gets one dynamic module (SeampactModule.forRoot({ authenticate, deciders, scopeResolvers, idempotencyStore, onUnexpectedError }, { logger }), or SeampactModule.forRootAsync({ imports, inject, useFactory }, { logger }) for hooks built from the app’s providers, in the shape of NestJS’s ConfigurableModuleBuilder); onUnexpectedError and the options are optional. logger takes the boundary’s warnings and unexpected failures, as the other Node targets’ registration option does; the console is the default. Hooks are NestJS providers registered under explicit injection tokens, the api’s exported SEAMPACT_HOOKS holding the app’s hooks, so a test replaces them with overrideProvider: an authenticate hook per identity, or one serving several; a decider by a list of right and rule names, or all; a scope resolver per scope kind, or one serving several. Two deciders claiming one name is a boot error, and no default hook exists (ADR 0012 “Registration and per-right overrides”). Names that ADR 0007 has no role for are fixed words, without the api’s segments: PascalCase for classes (SeampactModule), camelCase for decorators, functions and values (responses, preRoutingFilter, keepRawBody, renderPreRoutingError) and upper snake case for constants (LARGEST_BODY_LIMIT); the per-operation decorators follow “Placement”. Each api’s copy is its own module, so two apis in one app never collide once imported as namespaces (ADR 0007 “Amendment 2026-10-19: generated modules”). The api-neutral exports behave the same in every api’s copy: renderContractError and isContractRoute read the api from the boundary’s request context or the route metadata, and MAX_CHECKED_ERROR_BODY_BYTES and MAX_RENDERED_ISSUES are fixed values. Each api’s boot check covers its own operations, and refuses a second registration of the api in one app by name (here a second forRoot of one api), since replies written outside a request context report to one registration’s hooks; the Fastify and AdonisJS targets refuse it too.

The module runs it in onApplicationBootstrap, before the app accepts a request, through NestJS’s public DiscoveryService and Reflector; it lists every problem at once with the operation id, the name and the fix, and throws, so the app does not start:

  • an identity used by a bound operation without an authenticate hook, a right or rule without a decider, a scope without a resolver, an idempotent operation without an idempotency store;
  • two deciders claiming one name;
  • a contract operation without a bound handler: no controller method carries its composite;
  • a route of the Express adapter that no operation’s decorated controller method registered and that is not allow-listed in frameworkRoutes (ADR 0012), unless artefacts.bootCheck.frameworkRoutes is false;
  • on a contract method, a NestJS decorator that writes after the boundary’s reply (Redirect, Render, Sse) or a developer Res, Response or Next parameter, the passthrough cookie idiom included, all read through Reflector: NestJS would write again after the reply and raise ERR_HTTP_HEADERS_SENT;
  • a platform other than Express (HttpAdapterHost);
  • a contract method without its operation’s input param, read through NestJS’s route-argument metadata: no pipe would run steps 6 to 8;
  • a contract method whose guard list does not end with the seampact guard (an empty list included), once, naming the guards after it;
  • an app interceptor in effect on a contract route (global, controller or route) whose trackBy function, @nestjs/cache-manager’s API read without importing it, returns a key for a request of the route’s method and path, or throws: a cache hit would answer without steps 6 to 8 and the idempotency claim for this caller (a request-scoped interceptor has no instance at boot and is not probed, so the boot warns naming it);
  • forRoot options other than logger, or a logger without warn and error functions;
  • on a contract method, @HttpCode or @Header, which shape a reply NestJS never writes there;
  • a path prefix on a contract route: a controller path (@Controller('tasks')), a RouterModule path of the controller’s module, or a global prefix (setGlobalPrefix) whose exclude option does not match the route, read from ApplicationConfig with NestJS’s own exclusion test.

A controller in a lazily loaded module is not checked at boot: NestJS runs no boot hook for it.

It reads the app’s global interceptors from NestJS’s ApplicationConfig, a provider of every module, which lists those of useGlobalInterceptors as well as APP_INTERCEPTOR providers. The cache probe builds its execution context with NestJS’s ExecutionContextHost, exported by @nestjs/core/internal only; a test of the target fails by name if a NestJS release moves it. It also warns, without stopping the boot: in the default reply mode, once per api naming the app’s global interceptors and once per contract route naming its controller and route interceptors, since a value they map is ignored; in the interceptor mode, once naming each app global interceptor still before the reply interceptor after the module moved it first (one another module’s configure puts in front later), with the fix.

One check of ADR 0012’s list moves to the request, since NestJS 12 exposes no public read of it at boot (Express keeps the parser limit inside the parser): a parser limit below a route’s limit is answered as above. A global prefix, refused at boot above, is also caught by the module middleware when the request path is a contract path under a leading prefix (one set after the boot), and answers 500 with a log naming setGlobalPrefix and its exclude option. The middleware reads the request target raw from req.originalUrl, never through WHATWG URL (which turns \ into / and resolves dot segments), so it checks the path Express routes. Express 5 also routes a path case-insensitively and with a trailing slash; the contract serves neither as sent, so such a path under the api’s base path answers the api’s notFound code (404) without calling the handler, and one outside it (/V1/...) gets NestJS’s own 404 through the global filters.

At run time: @nestjs/common, @nestjs/core (its internal entry for ExecutionContextHost, with rxjs as their peer, which the reply interceptor imports), the runtime of the validators link (zod, yup, or class-validator with class-transformer) and, for types only, @seampact/hooks (ADR 0009). It uses explicit injection tokens with @Inject, never emitDecoratorMetadata, so any transpiler that supports NestJS’s legacy decorators runs it. It never imports @seampact/signed-webhooks: the app’s authenticate hook calls it.

generate refuses by name, with the case’s code and its fix, every input the slice does not serve (A47); each comes with the bringer named:

  • Waiting for the target after the slice: the NestJS Fastify platform (the boot check refuses a non-Express adapter); the thrown exception per error code; nativeDocs and frameworkDocs; hooks: vendored (A40); accessHooks: false and access: native (A42); per-right overrides, record loaders, decideMany and decider params checks; form, multipart, download and redirect bodies (A39); api.wire.urls: absolute; handler groupings other than ADR 0007’s handlers default.
  • Waiting for the boot check of the installed hooks range.
  • Every IR form absent from the reference contract’s golden IRs is refused until a consumer needs it.
  • On every Node target, a static segment a client sends percent-encoded, one holding a space, a control or non-ASCII character, ", <, >, ^, `, \, {, }, |, [ or ] (path.segment.encoded.unsupported): the shared boundary matches the raw path, so no request would reach the route, until a consumer needs one.

Some forms of the reference contract are validated or passed on by the boundary but enforced by the handler (ADR 0012 “Errors the handler answers”). generate names each with a warning, not a refusal, carrying the operation, the IR member and what the handler does, and the CLI prints it on stderr: concurrency (concurrency.handler: the handler compares the version and answers the stateConflict code), owner: caller (owner.handler: the handler scopes its reads and writes to the principal), the deadline header and budgetMs (deadline.handler), sort keys (paging.sortKeys.handler: the boundary validates the key, the handler orders by it) and an identity’s request-side headers (identity.headers.handler: validated as declared headers only). An identity header written on a response (a versionToken header, or one declared in: response) is no warning: no handler can write it, so generate refuses it by name (identity.response.header.unsupported). filteredBy carries no warning: grammar section 10 makes the list filter the application’s own by design, reaching only the handler context and the documents.

Amendment 2026-10-20: the boundary without an interceptor

Section titled “Amendment 2026-10-20: the boundary without an interceptor”

Amendment 2026-10-21 below moves steps 6 to 8 to a seampact pipe and adds the opt-in reply interceptor; the statements it names here are replaced.

The seampact interceptor is gone: steps 6 to 8 run at the end of the seampact guard and step 10 in the handler wrapper. On any route with an interceptor, NestJS 12 runs the handler through a per-request path of its own (two AsyncResource.bind calls, an rxjs defer chain, a client-disconnect watcher), the same for a pass-through interceptor as for ours. Measured on the bench’s nestjs-zod pair against its hand-written baseline, same run, that path cost about 0.6 of a baseline request: health went from about 0.55 to 0.8 of the baseline’s throughput once it was gone. On NestJS 12.1.x each bound resource’s native memory also outlives the GC: off-heap memory grew past 150 MB over 40,000 requests, against about 40 MB without the path. NestJS 12.2.0 cures the growth but not the CPU cost.

NestJS’s stages on a contract route, in request order, and the steps of ADR 0012 “The check order” each holds:

NestJS stage Before After
app-wide parser, app middleware step 1 parser refusals (pre-routing filter) same
seampact module middleware step 1 contract part (body limit, media type), request context, outgoing check same
global guards (useGlobalGuards, APP_GUARD), then controller guards the app’s same
route guards, in the order their decorators apply (bottom-up) seampact guard steps 2 to 5 at its place in the list: an app method guard written above the composite runs after it every app method guard, wherever written, then the seampact guard last: steps 2 to 5, then 6 to 8
interceptors, pre (global, controller, route) global and controller ones, then the route’s in decorator order: one written below the composite before the seampact interceptor (steps 6 to 8, idempotency claim or replay), one written above it after the app’s only; none on a route of an app without its own
pipes (global, controller, route, param) the app’s; the input param reads the validated input same
handler the developer’s method the wrapper: replay or claim, the method, step 10 and the write
interceptors, post (route, controller, global) a route interceptor written above the composite mapped the handler’s result, and step 10 checked and wrote its value; then the seampact interceptor’s step 10 and write; then the others, which saw undefined every app interceptor sees undefined; the reply is already written, and a value one maps is ignored
exception filters (route, controller, global) the seampact filter for contract errors and mapped refusals, else the app’s; outgoing check on every reply same

NestJS appends a method’s guards in the order their decorators apply, bottom-up, so an app guard written above the composite would come after the seampact guard. The module’s configure, which NestJS calls before it reads any route’s guards, moves the seampact guard to the end of the guard list of each controller method that carries one of the api’s operations: the composite’s wrapper, or the function a decorator written above it put in its place with the metadata copied (a tracing @Span()). There it also warns once per contract route that carries route or controller interceptors of the app, naming the route and the interceptors and saying a value they map is ignored.

What an app observes:

  • Its guards: global and controller guards run first, as before; a method guard runs before the seampact guard whichever side of the composite it is written, where before one written above ran after it. A passport 401 or 403 is mapped to the operation’s role by the seampact filter (“Exceptions on contract routes”).
  • Its interceptors: their pre-phase runs after steps 2 to 8, so a request refused at step 6, 7 or 8 reaches none of them, as a 401 or 403 from a guard already did, and their catchError no longer sees those refusals; an exception from the handler or step 10 still passes through their catchError. Their post-phase sees undefined once the reply is written, so a response-transforming interceptor (ClassSerializerInterceptor, a map) changes nothing on the wire: as before for a global or controller interceptor, while a route interceptor written above the composite used to map the value step 10 checked and wrote; the boot warns once per route that carries a route or controller interceptor. A result mapping belongs in the handler, so the reply written is the one checked against the contract. An interceptor that answers without next.handle() answers 500 with a log (amendment 2026-10-27; isContractRoute unchanged).
  • Its pipes: unchanged; they run after steps 2 to 8, and a global ValidationPipe skips the input param (a custom param decorator) unless validateCustomDecorators is set, and then sees the validated input.
  • An idempotent replay: the app’s pipes and route interceptors run before the stored reply is replayed, since the replay is made in the wrapper; before, the seampact interceptor replayed in its pre-phase, ahead of the pipes and of a route interceptor written below the composite.
  • Its filters: unchanged; the seampact filter catches what it caught before, thrown from the guard or the wrapper, and every reply passes the outgoing check.

What the boundary guarantees: every step that needs the principal (3 to 8) runs after every app guard of the route and after step 2; steps 6 to 8 finish before NestJS resolves the input param; the reply is written once, by the boundary, from the handler’s result after step 10, and the outgoing check sees every reply on a contract route; no app interceptor or pipe can refuse after an idempotency claim, since the claim is made in the wrapper, just before the method.

  • Shape the result in the handler: in the default reply mode no interceptor mapping reaches the wire.
  • Log and measure in a middleware on the response’s finish event: it sees every reply, refusals before routing included, at no interceptor-path cost.
  • Type the input param with the generated input type (@TasksNest.createTask.input() input: Tasks.CreateTaskInput, a type-only import), so a global ValidationPipe with validateCustomDecorators finds no class to validate.
  • A cache interceptor returns undefined from trackBy when isContractRoute(context) is true, or the boot refuses it.
  • Choose reply: interceptor when the app’s global interceptors must shape replies (ClassSerializerInterceptor, a response envelope): their result must still match the contract, they run inside the reply interceptor whether registered with app.useGlobalInterceptors(...) or as APP_INTERCEPTOR providers, and every route of the app, its own included, pays NestJS’s interceptor path.

Amendment 2026-10-21: validation in a pipe and the reply interceptor

Section titled “Amendment 2026-10-21: validation in a pipe and the reply interceptor”

Steps 6 to 8 move from the seampact guard to a seampact pipe on the operation’s input param, in every mode, and the target option reply: interceptor lets a seampact interceptor run step 10 and write the reply. NestJS’s roles on a contract route are then: the guard checks access, the pipe validates, the wrapper or the reply interceptor replies. It replaces these statements of amendment 2026-10-20: steps 6 to 8 at the end of the guard and before NestJS resolves the input param; app interceptors whose pre-phase runs after step 8 and whose catchError no longer sees a step 6 to 8 refusal; app pipes that run after steps 2 to 8.

NestJS’s stages on a contract route, in request order (on NestJS 12.2.0, nestjs/nest#18171 still open):

NestJS stage handler mode (default) interceptor mode
route guards the app’s method guards, then the seampact guard last: steps 2 to 5 same
interceptors, pre the app’s, before steps 6 to 8 the reply interceptor, then the app’s
pipes the app’s; on the input param the seampact pipe last: steps 6 to 8 same
handler wrapper: replay or claim, the method, step 10, the write wrapper: replay (written) or claim, the method, its result returned
interceptors, post the app’s see undefined the app’s map the result; the reply interceptor runs step 10 on it and writes; an app global interceptor another module’s configure puts before it sees undefined (warned at boot)
exception filters as amendment 2026-10-20 same

What an app observes:

  • Its pipes: they run before steps 6 to 8 on every param and see undefined on the input param, their result ignored; a global ValidationPipe skips the input param (a custom param), and with validateCustomDecorators returns it untouched when the param’s design type is Object or absent.
  • Its interceptors: their pre-phase sees the request before validation, and a refusal of step 6, 7 or 8 passes through their catchError. One that rethrows another exception hands the reply to the app’s filters, and the outgoing check holds the contract: a 500 under strict, a logged violation under warn.
  • In the interceptor mode, the reply interceptor is an APP_INTERCEPTOR of the api’s module. NestJS orders APP_INTERCEPTOR providers by module scanning, the app module’s first, so the module’s configure moves the reply interceptor to the front of ApplicationConfig’s global list, in place since the class has no setter; NestJS reads that list when it registers the routes, after every configure. It then sits outside every other global interceptor, APP_INTERCEPTOR or useGlobalInterceptors, every controller and route interceptor and every request-scoped global one, and their mappings are the value step 10 checks. The boot names a global another module’s configure puts in front of it afterwards. On a replay the wrapper writes the stored reply and the app’s interceptors see undefined; an app interceptor that fails after the claim is answered as a handler exception, the record completed with that reply or abandoned on a 500. The response stays marked with Res(), so NestJS writes nothing after the boundary.
  • The boot: the global-interceptor warnings, the cache interceptor refusal, the guard-last check and the input-param check of “The boot check”.

The cost of the pipe, measured on the bench’s nestjs-zod pair on NestJS 12.2.0 in the handler mode (throughput over the hand-written baseline, four runs each): health, a route with no work, from about 0.785 to about 0.74, the awaited pipe chain NestJS gives any param with a pipe (nestjs/nest#18147); listTasks unchanged at about 0.62. It is accepted: it shows only on a no-op route, and validation in a pipe is NestJS’s idiom. The interceptor mode puts every route on NestJS’s interceptor path: health about 0.61 without an app interceptor, 0.70 with one global app interceptor (0.79 in the handler mode).

What the boundary guarantees: steps 3 to 8 run after every app guard and after step 2; steps 6 to 8 complete before the handler, the handler receives only their output, and neither the method nor step 10 runs without them (fail closed, a 500); the reply is written once, by the boundary, after step 10, from the handler’s result (handler mode) or from the value the app’s interceptors inside the reply interceptor pass on (interceptor mode); no app pipe can refuse after an idempotency claim, since every param’s pipes resolve before the wrapper claims; the outgoing check sees every reply on a contract route.

Amendment 2026-10-27: NestJS idioms on the boundary

Section titled “Amendment 2026-10-27: NestJS idioms on the boundary”
  • Hooks through DI: forRootAsync({ imports, inject, useFactory }) registers the factory under the exported SEAMPACT_HOOKS, and the module resolves the hooks from that token, so overrideProvider(SEAMPACT_HOOKS) replaces them in a test before the boot check reads them.
  • HTTP exceptions: on a contract route, an HttpException thrown by a handler, a service, a pipe or an app guard takes the code of its status’s role first, as the PHP targets map abort() and HttpException: 401 unauthenticated, 403 forbidden, 429 rateLimited, 503 serviceUnavailable; else the operation’s one code of that status (NotFoundException the notFound code, a ThrottlerException the one 429 code of an operation with no rateLimited code); else a 500. Two codes of one status answer 500 reported to onUnexpectedError, since the exception cannot say which code it means. A code whose params have a required member, the role’s code included, answers only when the boundary can fill them: on a 429, a rateLimited code whose required params are only retryAfterMs takes it from the Retry-After (delta-seconds or an HTTP-date) that the throttler set on the reply (Retry-After-<name> for a named @nestjs/throttler throttler) or that the exception carries; any other such code answers 500 reported to onUnexpectedError, whose message names ContractException(code, params); the app’s filters never write on a contract route. The Express, Fastify and Hono boundaries map a framework 401, 403 or 429 by the same rule. A status the operation does not declare stays with the app’s filters, as before. ContractException(code, params) is the typed way to answer any declared code; a code the operation does not declare answers its 500, reported to onUnexpectedError. Each api has its own runtime copy, so a ContractException of one api thrown on a route of another matches neither api’s filter and gets NestJS’s default 500.
  • An interceptor that skips the handler, in the default reply mode: the module’s configure places the reply interceptor outside the app’s interceptors of each route that has one, in front of the global list when the app has a global interceptor, else first on the controller or the method that binds one. Its post-phase sees undefined once the wrapper wrote the reply; a value it receives with no reply written is checked and written as in the interceptor mode, and one that skipped the input pipe answers 500 with a log. Routes without an app interceptor stay off NestJS’s interceptor path. A global interceptor another module’s configure adds after this one is not covered.
  • Path prefixes and reply decorators are refused at boot by name (see “The boot check”).

ADR 0025 moves the 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 /nestjs export, no longer nestjs-boundary.ts. Each api’s generated tree keeps nestjs.ts, routes.ts, handlers.ts, hooks.ts, index.ts and, with nativeDocs, its docs module. A NestJS app on CommonJS loads the package’s CommonJS build.

Two apis of one app share the runtime, so what tells them apart is generated per api. nestjs.ts declares the api’s ContractException subclass and hands it to the boundary: each api’s filter answers the runtime’s own errors and its own api’s ContractException, never another api’s, which matches neither api’s filter and answers a 500, as with the per-api copies before. The exported name stays SeampactModule, while the class carries the api’s type name (TasksSeampactModule, TasksServiceSeampactModule), as NestJS logs it.

What the generated code depends on adds @seampact/server-runtime as a production dependency: its root, /node and /nestjs 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 nestjs.ts is imported. Each api keeps its own logger in the shared runtime, keyed by its route table.

Amendment 2026-10-28: configureApp, unbound operations and the debug path

Section titled “Amendment 2026-10-28: configureApp, unbound operations and the debug path”
  • configureApp(app), exported per api, does what main.ts and an e2e setup of an app serving this api alone need: the JSON parser at the largest route limit (the shared largestRouteBodyLimit), non-strict, keeping the raw body of signed routes, and the pre-routing filter. An app serving two apis keeps the explicit calls.
  • SeampactModule.forRoot(hooks, { unboundOperations }) takes error (the default, the boot check refuses an operation no controller method binds) or warn, which logs the unbound operations once and lets a test module hold some controllers; any other value is a boot problem. A request to an unbound operation’s route answers the api’s notFound 404 in its format, not a 500.
  • Debug path: under NODE_ENV=development, an exception no seampact filter claims, whose reply NestJS’s filters wrote and which breaks the contract, goes out as written, whatever the responses mode. The boundary logs once the exception’s message and where it was thrown, and calls onUnexpectedError as before. Outside it the api’s 500 replaces the reply as before. NODE_ENV is read at each request. A reply that an app error handler wrote for a non-contract exception that breaks the contract is passed the same way, so the check does not report it as a violation in debug.

Amendment 2026-11-01: runtime options, input parts, scoped parsers and the Swagger defaults

Section titled “Amendment 2026-11-01: runtime options, input parts, scoped parsers and the Swagger defaults”
  • forRoot and forRootAsync take responses and bodyValidation beside logger and unboundOperations; each defaults to the generated target option, a value of the wrong kind is a boot problem naming the option, and a responses() decorator on a controller or a method still wins over the module’s mode.
  • Without logger, the boundary writes through NestJS’s Logger with the context Seampact:<api>, so app.useLogger(...) and its levels apply; an explicit logger still wins. The other Node targets keep the console.
  • Per operation, path(), query(), headers() and body() param decorators exist for the parts its input declares. Each reads its part of the input the input() pipe checks: steps 6 to 8 run once per request, shared by every input param of the method, and a method with only part params passes the boot check’s input-param rule.
  • configureApp(app) installs one non-strict JSON parser per distinct route limit, each reading only the bodies of the api’s routes of that limit in their declared media type and keeping the raw body of signed routes, then NestJS’s default JSON parser for every other route (strict, NestJS’s limit, the raw body under rawBody: true). It no longer raises the app’s limit, accepts scalars or keeps raw bodies on the app’s own routes. configureApp(app, { appJsonParser: { limit, strict } }) sets that parser’s options; another JSON parser is refused by name, before configureApp when the call finds it, after it at boot, since the first would read contract bodies strictly at its own limit without their raw body and the second would never run. A second api’s configureApp on the same app adds its own contract parsers, and the app routes’ parser, installed once by the first call, skips every configured api’s contract routes. Setups 1 to 3 of “Raw body” stay valid for an app that writes its parsers itself, without configureApp. Fail closed: the module middleware answers 500 with a log naming configureApp when a contract route’s announced body reaches it unread.
  • createContractDocument writes openapi: '3.1.0' when the config has no version or DocumentBuilder’s default 3.0.0, and autoTagControllers: false; another version or autoTagControllers: true throws, naming the value. Under the default scope of the framework-route check, Swagger UI outside the api’s base paths needs no frameworkRoutes entry; an api served at the root still lists them.
  • App input rules (ADR 0028): NestJS: step 8b runs in the generated wrapper before the handler (nestjs.ts), with inputRules in SeampactModuleOptions and the rule classes registered as providers by forRoot.
  • An app keeps its main.ts, its global pipes, guards, interceptors and filters; on contract routes the boundary’s checks and reply win, and its own routes are unchanged, Express ETags included. A cache interceptor must skip contract routes with isContractRoute(context), or the app does not start. Any app interceptor on a contract route brings back NestJS’s per-request interceptor path there: its CPU cost, and on NestJS 12.1.x its native-memory growth, which 12.2.0 cures; the interceptor mode puts every route of the app on that path.
  • Every reply on a contract route is written by generated code, so NestJS features that shape the reply (a global serializer interceptor) do nothing there, and the boot check refuses those that would write it again (@Redirect, @Render, @Sse, a developer @Res()) or that would be ignored (@HttpCode, @Header).
  • App filters keep working on contract routes; under strict, a reply of theirs outside the contract becomes a 500, and under warn a logged violation.
  • Refusals raised before routing are in the api’s format only when the app registers the exported global filter or calls its helper; the README’s NestJS setup shows both.
  • A raw-body or parser-limit setup error answers 500 on the routes it affects, with a log naming the fix, instead of refusing to start.
  • The Fastify and Express targets reuse the shared request checks and cite this ADR for the obligations; only their placement is new.
  • FRs: FR-5 (the check order), FR-14 (a case refused by name), FR-35 (the shared suite this boundary will pass); amendments A30, A36, A39, A40, A41, A42, A47.
  • ADRs: 0007 (names), 0009 (links, run-time dependencies), 0012 (placement and target obligations), 0013 (raw body and signature header).
  • Proof in the development phase: the review of this ADR, the spike above, and the slice’s end-to-end gate on the NestJS consumer.