conceptadev / conceptadev/rockets
RFC: one schema-driven validation/serialization engine on Nest 12 Standard Schema — collapse the four validation paths
- Dominant language
- TypeScript
- Stars
- 1
- Forks
- 2
- Avg merge
- 2d 10h
- Merged PRs (30d)
- 23
Description
> [!IMPORTANT]
> **Status — implemented by #105, with one deliberate deviation from this proposal.**
>
> What shipped is **one schema contract, not one execution path**. The text below sketches a single *globally registered* `StandardSchemaValidationPipe`; the implementation is **route-scoped**: upstream reads controller-level `request.validation` and wires Nest's native pipe per route, and core *rejects* a global `StandardSchemaValidationPipe` at boot (a global one would double-validate every body and hide a route that lost its pipe — the `requireSchemaPipe` audit is what catches that instead).
>
> `operationResource` deliberately **keeps its own inline output validation** rather than Nest's serializer: the serializer lets `null`/`undefined` through and maps arrays per item, which contradicts the pinned 500-on-mismatch policy. So there are three executors reading one contract — the native pipe for requests, upstream's CRUD serializer for generated responses, inline validation for operation outputs — and that split is intentional.
>
> Everything else below landed as proposed: the schema is the single source of truth for validation, serialization and OpenAPI; `ZodBodyValidationInterceptor`, `compileDtoClass`'s class-transformer metadata, `@Allow()` stamps and the `class-validator` / `class-transformer` dependencies are gone. The section that follows is the original RFC, kept for the record.
---
## Summary
Proposal to make the schema the **single source of truth** for request validation, response serialization, and OpenAPI — executed by Nest 12's native Standard Schema pipe and serializer — and to delete the home-grown validation/serialization layers plus the `class-validator` / `class-transformer` dependency from `rockets-core`.
This is issue #74's stated direction ("make Nest's native Standard Schema components the single runtime validation/serialization boundary") taken to completion. It is **not** a proposal to adopt #75's public DTO factories; see "What this is not".
Open for discussion before any code. Nothing here is started.
## The problem, as verified on `main` (2026-08-25)
Request-body validation currently runs through **four separate implementations** of the same `~standard` contract:
| Route kind | Who validates | Where |
|---|---|---|
| Generated CRUD (`zodResource` / `defineResource`) | `ZodBodyValidationInterceptor` — a global `APP_INTERCEPTOR` reading the DTO's carried schema and validating raw `req.body` | `packages/rockets-core/src/infrastructure/interceptors/zod-body-validation.interceptor.ts`, registered in `rockets-core.module-definition.ts` |
| `operationResource` | inline `applyInputDto` / `applyOutputDto` / `validateAndWhitelistDto` inside the generated controller | `packages/rockets-core/src/infrastructure/resource/operation-resource/build-operation-controller.ts` |
| Hand-written controllers (what `examples/sample-server` actually does) | Nest's native `StandardSchemaValidationPipe` + explicit `@Body({ schema })` | `examples/sample-server/src/main.ts`, `auth.controller.ts` |
| `@concepta/rockets-core/standard-schema` (#75) | `StandardSchemaDtoValidationPipe` via `StandardSchemaModule` | **zero consumers** in `packages/` or `examples/` |
On top of that, upstream `@concepta/nestjs-crud@8.0.0-alpha.8` stamps a **class-validator `ValidationPipe` per body param** (`crud-init-validation.decorator.js`) that validates nothing useful for a zod DTO (the compiled class carries only `@Allow()` stamps), and its response path (`crud-serialize.interceptor.js`) runs on `class-transformer`. Rockets' `compileDtoClass` therefore has to emit `@Exclude`/`@Expose`/`@Transform` metadata for response projection and `@Allow()` stamps (#83) so bodies survive a foreign `whitelist: true` pipe — both sample apps register one globally.
Net effect: two libraries the project is otherwise moving away from stay in the critical path, held together by compatibility shims, and the same validation contract is implemented four times. The #83 field report (schema DTO silently emptied to `{}` with a `201`) is the kind of defect this shape produces.
## Proposal
One engine, driven by the schema:
1. **Request** — every generated route (CRUD and `operationResource`) is emitted with native route metadata (`Body({ schema })`, `Query({ schema })`, params) and **one** globally registered `StandardSchemaValidationPipe` validates. Hand-written routes already work this way.
2. **Response** — every generated route carries `SerializeOptions({ schema: responseSchema })` and Nest's `StandardSchemaSerializerInterceptor` serializes. The response schema comes from the zod layer's existing projection machinery (`packages/rockets-core/src/zod/zod-projections.ts` → `projectSchema`), extended for computed fields and nested relations. Invalid handler output fails closed (500), matching current semantics.
3. **OpenAPI** — generated from the same schemas (zod v4 `toJSONSchema` / Standard JSON Schema), so docs cannot drift from validation.
4. **DTO classes** become a thin carrier (`static schema`) with no `class-transformer` or `class-validator` metadata.
Authoring code (`zodResource`, `operationResource`, hand-written `@Body({ schema })`) does not change.
### Before / after (plumbing only)
```ts
// BEFORE — rockets-core.module-definition.ts
{ provide: APP_INTERCEPTOR, useClass: ZodBodyValidationInterceptor },
// BEFORE — build-operation-controller.ts
const input = await applyInputDto(operation.inputDto, rawInput);
return applyOutputDto(operation.output, result, label);
// BEFORE — zod-dto.ts (compileDtoClass)
const cls = allowStandardSchemaKeys(nameGeneratedDto(createZodDto(schema), name), Object.keys(schema.shape));
Exclude()(cls);
for (const [key, field] of Object.entries(schema.shape)) { Expose()(proto, key); Transform(...)(proto, key); }
// BEFORE — examples/sample-server/src/main.ts
app.useGlobalPipes(new StandardSchemaValidationPipe(), new ValidationPipe({ transform: true, whitelist: true }));
```
```ts
// AFTER — rockets-core.module-definition.ts
{ provide: APP_PIPE, useClass: StandardSchemaValidationPipe },
{ provide: APP_INTERCEPTOR, useClass: StandardSchemaSerializerInterceptor },
// AFTER — CRUD generator and build-operation-controller.ts: the route is born with its schemas
Body({ schema: inputSchema })(proto, methodName, bodyIndex);
SerializeOptions({ schema: responseSchema })(proto, methodName, descriptor);
request: { validation: false } // switches off upstream's class-validator pipe — supported today
// AFTER — zod-dto.ts
class Dto { static readonly schema = schema; }
// AFTER — main.ts
app.useGlobalPipes(new StandardSchemaValidationPipe());
```
### What gets deleted
- `ZodBodyValidationInterceptor`
- `applyInputDto` / `applyOutputDto` / `validateAndWhitelistDto` in `build-operation-controller.ts`
- `allowStandardSchemaKeys` and every `@Allow()` stamp (no whitelist pipe left to survive)
- `class-transformer` decorators in `compileDtoClass`
- `class-validator` and `class-transformer` from `rockets-core`'s dependencies
- the unused public surface of `@concepta/rockets-core/standard-schema` (see #75 note below); `nestjs-zod` likely afterwards (only used for the class carrier + OpenAPI factory, both replaceable)
Kept: `isStandardSchema` / `getCarriedStandardSchema` (load-bearing for `whitelistedFromDto` and the transition), and the `@standard-schema/spec` types #75 introduced.
## Why this is the best option for us
- **Correctness by construction.** One validator means one behavior; docs generated from the validating schema cannot lie. The #83 class of bug (validated, then emptied by a second pipe) becomes impossible because there is no second pipe.
- **Less code, fewer dependencies.** Four validation implementations → one (Nest's). Two legacy libraries out of core. The compatibility shims exist only to reconcile the two worlds; with one world they vanish.
- **Aligned with upstream.** `@concepta/nestjs-*@8.0.0-alpha.9` already replaced class-validator/class-transformer with zod schemas built on Nest 12 Standard Schema (verified in #102). Staying on the old shape means diverging from the engine we consume.
- **Vendor neutrality for free.** Everything speaks `~standard`; zod stays the authoring layer. #74's goal is achieved as a byproduct, without maintaining a separate public API for it.
- **Pre-1.0 timing.** The package is not published yet. Removing shims and dependencies now is free; after 1.0 every one of them is a compatibility promise.
## Staged plan (each stage its own PR, each independently shippable)
0. **Trim `@concepta/rockets-core/standard-schema` to its load-bearing helpers** — move `isStandardSchema`/`getCarriedStandardSchema` back to `common/utils/standard-schema.util.ts` (their original home), delete the unused factories/module/pipes/decorators/brands and the two package subpaths, regenerate the API report. Zero internal consumers of the removed symbols (verified).
1. **Upstream `alpha.9` upgrade (#102)** — re-scoped from "deferred, no gain" to "prerequisite": it removes class-validator from the CRUD engine and migrates the identity packages' DTOs to zod schemas (~32 files on our side; analysis in #102).
2. **Request unification** — generated routes emit native schema metadata; single global pipe; delete the interceptor and the inline validators. `request: { validation: false }` already lets us bypass upstream's per-param pipe, so most of this stage is possible before stage 1 lands.
3. **Response unification** — schema-driven projection through the native serializer; delete class-transformer usage. **This is the real engineering:** computed fields (`compute` → response-schema transform), nested relation projections (compose child response schemas), Date/ISO conversion, fail-closed field stripping (zod strips unknown keys by default). Also needs upstream's `CrudSerialize` interceptor disabled or bypassed — to verify whether `response.serialization: false` suffices on alpha.8 or whether this waits for stage 1.
4. **OpenAPI from schemas only** — drop `nestjs-zod` if nothing else needs it; regenerate the pinned contracts from #99.
## Risks and honest caveats
- **Nest 12 is alpha.** The native Standard Schema pipe/serializer/swagger `standardSchema` APIs may change; this proposal builds the whole system on them. Mitigation: upstream Concepta is making the same bet, so churn hits both and gets fixed once.
- **Stage 3 is not small.** Computed fields and nested projections are where the current class-transformer path does real work; the response schema must carry that faithfully. This stage should get its own design review before code.
- **`rockets-server-auth`** has 6 hand-written controllers on class-validator DTOs; they migrate in stage 1 alongside the upstream identity DTOs.
- **Sequencing:** do this **after** the currently open PRs (#92–#100) merge. #94 and #100 edit `build-operation-controller.ts` heavily, #96 touches the planner, #99 pins OpenAPI contracts that this change will (correctly) drift. Starting now means rebasing four reviewed PRs across a foundation change.
## What this is not
- Not adoption of #75's `createStandardSchemaDto` / `StandardSchemaModule` public API. That API has zero consumers and is not needed for the native path; stage 0 removes it. The valuable parts of #75 — the `@standard-schema/spec` types and the carrier-recognition helper — stay.
- Not a rewrite of the authoring layer. `zodResource`, `operationResource`, `f.*`, hooks, ACL: unchanged.
## Related
- #74 / #75 — original Standard Schema direction; this RFC is its "step 2".
- #83 — the whitelist trap this design makes structurally impossible.
- #102 — upstream alpha.9 evaluation; becomes stage 1 here.
- #99 — pinned OpenAPI contracts (will need regeneration in stage 4).
Contributor guide
Research direction
The proposal is already implemented by #105, whose route-scoped design and deliberate operationResource exception supersede the original global-pipe plan. For context, compare the implementation with the named areas: build-operation-controller.ts, zod-projections.ts, rockets-core.module-definition.ts, and the sample server; completion is represented by the shipped #105 behavior and its deviation from this RFC.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- api, backend, backend-api-design
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 15/100