Skip to content

04 — Schema System (formerly the DTO system)

Every REST verb has an independent, optional data contract. Zero config means entity-derived defaults; configuring a schema slot narrows exactly one slot. A slot takes either a plain class — a shape for typing, serialization, and Swagger docs, with no validation attached — or a validator (KavoSchema, one safeParse method), which additionally validates request bodies (ADR-0055). There is no separate dto config key: schema absorbed it.

1. The six slots and their defaults

SlotVerb / contextDefault when omitted
createPOST bodyEntity minus generated + relation fields
updatePUT bodySame default as create
patchPATCH bodyPartial<update> if update registered, else Partial<Entity>
queryGET list inputGeneric QueryContext<Entity>
itemAny single-resource responseEntity, subject to field selection
listElement type in ListResultDto.itemsSame as item's resolved type

Restore reuses item/list; no additional slots exist. §8 below adds a second, narrower tier of override — one per operation, not per slot — without introducing a slot of its own. The list envelope's meta bag is deliberately not a slot: it carries the caller's own data rather than entity data, so it has no schema and never passes through the serializer (doc 07 §3.1). Having no schema is also why it is the envelope's one optional field — with nothing to project there is nothing to emit until a handler contributes, so the key stays off the response rather than shipping as {}.

2. Resolution algorithm

DefaultSchemaResolver (core/src/schema/entity-schema.ts) resolves each slot independently at bootstrap and caches the result on the resolved config (config.schema) — never per request. Resolution returns the registered class or validator, or null, where null means "use the entity-derived default". resolveWriteAllowlist("create"|"update"|"patch") reads the class-shaped schema.input.create/update slot back off that same resolved map (issue #476 removed the separate top-level create/ update fallback this used to synthesize from). The fallback chains patch → update and list → item are baked in at construction, mirroring the static generic defaults (doc 03 §1), so the type level and the runtime never disagree about which slot follows which.

3. Runtime derivation rules

The metadata seam (EntityMetadata, doc 09 §1) supplies the field list the defaults derive from:

  • Readable projection (item/list default): every scalar column, plus every derived field the entity declares (§7), intersected with select.fields when that key is configured explicitly (ADR-0026) — which is how a column is kept out of every response without configuring a schema at all. Relation properties are excluded unless the request includes them deliberately; a class getter or method never appears on its own unless the adapter reports it as a derived field (§7) — it is not a column. A configured schema.output slot wins outright over the allowlist rather than intersecting with it: it is the narrower, more specific statement.

  • Writable projection (create/update/patch default): every scalar column with generated: false, plus every relation (associable by id, ADR-0014), minus the primary key and the soft-delete marker field, which are excluded regardless of generated — an app-assigned id or a marker column that isn't the ORM's own delete-date column would otherwise be an ordinary writable field with no other guard. The id is fixed metadata, so its exclusion is resolved once; the marker is an ordinary settings key (entity → operation → per-call, like any other), so DefaultDeserializer reads it off context.config.delete.field at deserialize time, per call — the same scope the request's own soft-delete strategy resolves at — not a value baked in once at bootstrap, so a per-operation or per-call override that renames the marker stays covered. Generated columns (auto ids, @CreateDateColumn, versions) can never be written from a request body either — the default deserializer silently strips all of these, which is the safe posture in a system with no validation stage. An explicit write schema class can still name the id or the marker field (a legitimate opt-in for a caller-assigned key); update/patch write paths additionally strip both from the payload before persisting, as defence in depth against reassigning an existing row's identity or soft-delete state that way.

    This default projection can be narrowed further, without a hand-written class, by schema.input.create's (for createOne) and schema.input.update's (for updateOne/patchOne — the two share one list, since both mutate an existing row) own { fields } shorthand — the write-side counterpart to select.fields above, and subject to the same rules: it can only narrow the derived projection, never widen it, so naming the id or the soft-delete marker in the plain array form has no effect; and a hand-written write schema class occupying that same slot wins outright, exactly as a configured item/list schema wins over select.fields — where you register one, it, not the allowlist, is the narrowing statement. Unlike filter.fields/sort.fields/select.fields/ search.fields/include.fields, it has no { exclude: [...] } form — only the plain allowlist array (issue #476 removed the top-level create.fields/update.fields config that form belonged to, along with the config-level default gap-filler; set is the only tool left for forcing a value, and it is unconstrained by this allowlist). Unconfigured, both default to the same base described above, so an entity that never sets either sees no change (issue #259).

  • Embedded objects map to a json-kind column and travel as one opaque value; they are not flattened into sub-fields.

4. Class-shaped slots and schemaShapeKeys

A registered class projects by its runtime key set: the own enumerable properties of new Schema() (schema-shape.ts, cached per class). TypeScript fields only exist at runtime when initialized, so:

ts
class UserListSchema {
  id = 0;
  name = "";
} // projects [id, name]
class BadSchema {
  id!: number;
} // no runtime keys → falls back

A class with no initialized fields degrades to the entity-derived default — the response is still correct, just not narrowed. This keeps schema classes plain (no decorators, no reflection library) at the cost of requiring initializers for narrowing; the tradeoff is documented API.

schemaShapeKeys alone cannot tell "declares zero fields on purpose" apart from "shape unknown" — both are a fresh instance with zero own keys. The { fields } shorthand's own synthesized class carries that intent separately (shorthandFieldsOf, schema-fields-shorthand.ts), which every consumer that needs the distinction — DefaultDeserializer.deserialize, DefaultSerializer's narrowToSchema/include projection — checks first, falling back to schemaShapeKeys only when the slot holds no shorthand tag. { fields: [] } (or its bare-array spelling) therefore narrows to nothing, on both the read and write side, rather than degrading to the derived default the way an equivalently-empty hand-written class does.

A validator-shaped slot has no static key set: the engine safeParses the deserialized body (schema.input, raising SchemaValidationException on failure) or the projected response (schema.output, falling back to the projected value on failure), and projection narrowing does not apply to it.

5. Serialization order (normative)

Schema mapping first, then field selection. select=id,name can only narrow what the resolved schema exposes — selection never widens a projection. Implemented in DefaultSerializer.serializeItem: projection ∩ selection, applied to every item and list element.

6. Included relations

When a response embeds an included relation, the node's shape resolves from the target entity's own configured schema.output.item/list when that entity has a Kavo config, else its entity-derived default. There is no per-include schema slot — the related resource owns its own contract.

7. Derived fields

A field with no backing column is declared on the ORM side — a @VirtualColumn (TypeORM), @Formula (MikroORM), a client extension field (Prisma), or a schema virtual (Mongoose) — not on the Kavo config. The adapter reports it to core as an ordinary FieldMetadata entry carrying a derivedExpression marker; core treats it exactly like a column wherever the adapter can make that true (select.fields, and — on TypeORM/MikroORM only — filter.fields/sort.fields). See ADR-0050 for the full design, including per-adapter differences and why a derived value cannot vary by caller.

A derived field declared on a relation target resolves when that relation is included — the serializer reads the included node's projection from the target's own resolved config through the EntityCatalog (§6), so this composes with no extra machinery.

Static typing of the response is unaffected: the entity-derived ItemDto does not grow the key, and neither does the generated OpenAPI response schema, which falls back to the entity class when no item/list slot is registered. Configuring an item/list schema that names it is how a caller gets it statically typed — and documented — as for any other narrowing.

8. Per-operation override (issue #131)

The six slots above are entity-wide: every operation that reads create reads the same create schema. operations.<id>.schema adds a narrower tier in front of them — a request body, response, or query contract specific to one operation on one entity:

ts
createCrud(User, {
  schema: { output: { item: UserItemSchema } }, // entity-wide default
  operations: {
    findOne: { schema: { output: UserProfileSchema } }, // findOne only
    createOne: { schema: { input: CreateUserRequestSchema, output: UserCreatedSchema } },
  },
});

Fallback order, per field: operations.<id>.schema.<field> → the root schema.input.<slot>/schema.output.<slot> → the entity-derived default. The registry populates descriptor.schemaInput/schemaOutput/schemaQuery from config, and the engine reads descriptor.<field> ?? config.schema.resolve…(...) in that order everywhere a slot is read — including the If-Match canonical-read ETag (doc 20), which now hashes what findOne's own override actually serves.

Which fields apply to which operationinput only where there is a request body, query only where there is a query contract, output anywhere there is a non-void result:

Operationinputoutputquery
createOne
updateOne / patchOne
findOne
findMany✓ (list element)
restoreOne
deleteOne / purgeOne
custom, kind: "write"
custom, kind: "read"

A field outside this table is a bootstrap ConfigurationException — never a silent drop. Unlike the old dto override, this is a runtime check only: OperationSchemaOverride offers all three fields on every operation, so the mismatch (e.g. deleteOne: { schema: { output } }) is caught at createCrud, not by the compiler.

A custom operation (issue #145) is the one place the rule is runtime-only. Which fields apply follows from its declared kind, which is a value in the same object rather than a fact about the key, so CustomOperationConfig offers all three and the mismatch is caught at bootstrap. It also has no root schema slot of its own: output falls back to the entity's item/list slot and input to the entity's writable projection, which is what makes operations.<id>.schema the only way to give it a shape of its own.

That fallback is the right default for a result that is a row, and a trap for one that is not: a handler returning { applied, skus } against an entity with neither column serialized to {}, silently, while the static types promised the shape (#181). The engine now refuses a custom operation whose non-empty result projects to zero keys, naming the operation and pointing at schema.output (doc 07 §1a). A result that is a narrower entity shape is still served as-is; only zero intersection is treated as a declaration mistake.

query's effect is typing only, like the root query slot (§1): there is no validation subsystem, and the query normalizer parses wire params structurally against the allowlists regardless of which schema is configured. Both slots exist so a programmatic caller (KavoService.findOne/findMany) gets a precise parameter type, and so @kavo/nest can build accurate @ApiBody/@ApiResponse schemas — operations.<id>.schema.input/output change what descriptor.schemaInput/ schemaOutput documents, ahead of the root slot, the same way they change what the engine actually deserializes and serializes.

This is per-entity and per-operation only, matching the rest of the schema system: no global default, and no override shared across entities or across operations.