ADR-0055 — schema replaces dto as the source of truth for DTO shape, validation, and OpenAPI
Status: accepted
Context
createCrud's config carries a dto key (packages/core/src/config/entity-config.ts) mapping DTO slots (create/update/patch/query/item/list) to DTO classes. dto is deliberately shape-only — "DTOs in v6 are shapes for typing, serialization, and Swagger docs — there is no validation subsystem attached to them" (dto.ts). Input validation is left entirely to the caller: @kavo/nest offers an opt-in class-validator-decorated DTO class plus a ValidationPipe (issue #283, load-class-validator.ts), and issue #454 separately proposed deriving a Zod schema from a registered DTO to close the gap the other way.
In practice this produces two parallel field declarations per entity: a DTO class for shape/serialization/OpenAPI, and a hand-written validation schema (Zod, or class-validator decorators bolted onto the same class) mirroring the same fields. There is no single place that owns "what does a User create body look like."
Decision
A single per-slot schema config key becomes the source of truth for DTO shape, input validation, and OpenAPI component generation. It replaces dto outright — no coexistence period, per the issue's instruction that this repo is in heavy development and does not need a write-deprecation path.
Shape
Per-slot, split by input/output, mirroring the existing DTO-slot convention exactly rather than inventing a second axis:
schema: {
input: {
create: CreateUserSchema,
update: UpdateUserSchema,
patch: PatchUserSchema,
query: UserQuerySchema,
},
output: {
item: UserItemSchema,
list: UserListSchema,
},
}This was chosen over two broader alternatives ({ input, output } each as a single schema covering all slots; a single whole-entity schema) because per-slot control is what the current dto map already gives callers — create and update commonly diverge (e.g. update omitting a field create requires), and collapsing them into one schema would regress that.
The structural contract, not a Zod dependency
@kavo/core has zero runtime dependencies (ADR-0005), enforced by dependency-cruiser's core-imports-nothing rule — it cannot import the zod package. schema is therefore typed against a minimal structural contract core owns:
interface SchemaIssue {
readonly path: readonly PropertyKey[];
readonly message: string;
}
interface SchemaParseResult<Output> {
readonly success: boolean;
readonly data?: Output;
readonly error?: { readonly issues: readonly SchemaIssue[] };
}
interface KavoSchema<Output> {
safeParse(input: unknown): SchemaParseResult<Output>;
}A ZodType<T> satisfies this shape natively — .safeParse already returns exactly this discriminated result, and ZodError.issues already carries path/message — so callers hand Kavo a real Zod schema and get full Zod ergonomics, while core never imports Zod's types or takes it as a dependency. Any other validation library whose result shape can be adapted to SchemaParseResult works the same way; nothing in core assumes Zod specifically.
Output type inference mirrors z.infer without depending on it:
type SchemaOutput<S> = S extends KavoSchema<infer Output> ? Output : never;Validation timing
Input schemas (schema.input.*) run at the engine's deserialization stage (KavoEngine.execute's pipeline) — the same stage that today applies dto-derived shape narrowing to an incoming body, and before the query/ handler stages ever see the payload. A safeParse failure raises SchemaValidationException (KAVO_SCHEMA_INVALID, 400) with one errors[] entry per issue, reusing the existing QueryIssueDto { field, code?, detail } shape (errors/problem-details.ts) rather than inventing a second field-level issue shape — that shape is already general-purpose in practice (issue #437's class-validator bridge reuses it for framework-level body validation today). SchemaIssue.path joins with . into QueryIssueDto.field the same way class-validator's nested children already do in kavo-validation-exception-factory.ts.
Output schemas (schema.output.*) run at response mapping/serialization, replacing dto.item/dto.list's shape role — they are not re-validated on the way out (nothing about a value Kavo itself produced needs rejecting); they only narrow/shape the response the way a dto class's field set did.
OpenAPI
registerKavoSchemas (@kavo/nest) generates OpenAPI component schemas from schema instead of dto. When no schema is configured for a given entity/slot, generation falls back to the Entity's own ORM metadata (the same metadata dto's absence already falls back to today) — schema is optional, not a precondition for docs to exist.
Consequences
- The DTO class-literal type-inference chain must be re-derived.
dto's slots today carry literalDtoClasstypes, which is what drivesKavoService's typed surface (DtoInputOf/DtoOutputOf/DtoQueryOf, the per-operationOperationDtoMapnarrowing from issue #131). AKavoSchema<Output>'s type is recovered viaSchemaOutput<S>(conditional-type inference) rather than a constructor signature. This ADR settles the contract shape; porting every consumer of the old DTO-class-typed surface is separate follow-up work, not part of this decision. class-validatorbody validation (issue #283) andschemaare two validation mechanisms that now overlap. This ADR does not resolve which one an app should use going forward, or whether theclass-validatorpath is deprecated — that is scoped to the@kavo/nestmigration follow-up, not core's contract.dtois removed, not deprecated. Every entity currently configured withdtoneeds aschemaequivalent; there is no dual-read period. Every ADR that referencesdtoin passing (0006, 0009, 0011, 0014, 0019, 0020, 0021, 0023, 0024, 0026, 0029, 0031, 0032, 0033, 0034, 0036, 0042, 0044, 0046, 0048, 0050, 0052) needs auditing for wording that assumesdtostill exists; that audit is follow-up work, not part of this ADR.@kavo/graphqland@kavo/mcpboth read DTO metadata offcreateCrudtoday and are not addressed by this ADR — their migration is separate follow-up work.
Update (2026-09-19)
dto is now fully removed, not just superseded — see docs/superpowers/specs/2026-09-18-remove-dto-design.md. The follow-ups this ADR deferred are resolved as follows:
schemaslots accept either aKavoSchemavalidator or a plain class (SchemaClass, narrowed by its runtime key set, never validated). TheSchemaLikeunion is the one slot type;EntityConfig.dto,OperationConfig.dto,DefaultDtoResolver, and theDto*Ofinference helpers are deleted, andSchemaInputOf/SchemaOutputOf/SchemaQueryOfno longer fall back to them.- The
{ fields }shorthand and thecreate.fields/update.fieldswritable fallback moved ontoDefaultSchemaResolver. - The engine
safeParses only validator-shaped slots; a class-shaped slot narrows in the serializer/deserializer and is otherwise left alone. @kavo/nestwritesdesign:paramtypes(so a globalValidationPipecan bind) only for a class-shapedschema.input.<slot>; OpenAPI reflects a class's key set and calls a validator's optionaltoJSONSchema().- A per-operation override naming a field the operation lacks (
deleteOne: { schema: { output } }) is now rejected at bootstrap only; the old type-levelPicknarrowing is gone.