Best Practices for JSON Schema Composition in APIs
A schema that started as one clean User object turns into a maintenance trap once five operations each redefine “user with a role” or “user without an id.” Duplicated structures drift, SDK generators emit any types, and a tightened constraint in one copy silently disagrees with another. Composition — $ref, allOf, oneOf, and the Draft 2020-12 keywords — is how you keep one source of truth. This task guide sits inside Defining JSON Schema Components, part of the broader OpenAPI & AsyncAPI Schema Authoring workflow, and shows the exact patterns plus the CI that keeps them honest.
Problem & Context
JSON Schema composition is deceptively simple: four keywords ($ref, allOf, oneOf, anyOf) and a handful of Draft 2020-12 additions. The trouble is that each keyword means something specific, and mixing them up produces specs that lint clean but generate broken SDKs.
$refsubstitutes one schema for another — pure reuse, no merging.allOfrequires every subschema to validate, so it merges constraints.oneOfrequires exactly one subschema to validate, modeling mutually exclusive variants.anyOfrequires at least one — looser, and rarely what you want for a typed union.
OpenAPI 3.1 aligns with JSON Schema Draft 2020-12, which adds $defs, unevaluatedProperties, $dynamicRef, and $dynamicAnchor. In OpenAPI 3.0.x a recursive $ref causes many parsers to hang, and there is no unevaluatedProperties, so closed composed schemas are awkward. The patterns below assume 3.1 and call out where 3.0 differs.
Step-by-Step Solution
1. Separate base types from API-specific constraints
Put shared domain entities in components.schemas. Use $defs for sub-schemas that are only referenced inside a single parent schema, so they do not pollute the global component namespace:
# openapi.yaml (OpenAPI 3.1.0)
components:
schemas:
User:
type: object
required: [id, email]
properties:
id:
type: string
format: uuid
email:
type: string
format: email
metadata:
$ref: '#/components/schemas/Metadata'
Metadata:
type: object
properties:
createdAt:
type: string
format: date-time
tags:
type: array
items:
type: string
2. Reuse with $ref, extend with allOf
Use $ref when the structure is identical. Reach for allOf only when you genuinely add or merge constraints onto a base:
AdminUser:
allOf:
- $ref: '#/components/schemas/User'
- type: object
required: [role]
properties:
role:
type: string
enum: [admin]
3. Discriminate every oneOf
Attach a discriminator with an explicit mapping to every oneOf. Without it, generators emit generic Any or interface{} instead of typed unions:
PaymentMethod:
oneOf:
- $ref: '#/components/schemas/CreditCard'
- $ref: '#/components/schemas/BankTransfer'
discriminator:
propertyName: type
mapping:
credit_card: '#/components/schemas/CreditCard'
bank_transfer: '#/components/schemas/BankTransfer'
CreditCard:
type: object
required: [type, cardNumber, expiryMonth, expiryYear]
properties:
type:
type: string
enum: [credit_card]
cardNumber:
type: string
pattern: '^\d{16}$'
expiryMonth:
type: integer
minimum: 1
maximum: 12
expiryYear:
type: integer
BankTransfer:
type: object
required: [type, accountNumber, routingNumber]
properties:
type:
type: string
enum: [bank_transfer]
accountNumber:
type: string
routingNumber:
type: string
4. Close composed schemas with unevaluatedProperties
additionalProperties: false does not see across allOf branches, so it rejects fields a merged branch legitimately added. Use unevaluatedProperties: false at the root of the composition instead — it evaluates after all branches:
StrictUser:
allOf:
- $ref: '#/components/schemas/User'
- type: object
properties:
role:
type: string
enum: [admin, viewer, editor]
unevaluatedProperties: false
unevaluatedProperties is a Draft 2020-12 keyword (OpenAPI 3.1 only). In 3.0.x, fall back to additionalProperties: false on each individual branch and accept its limitations.
5. Lint composition rules in CI
Enforce the rules with Spectral so a missing discriminator never reaches a generator:
npx @stoplight/spectral-cli lint openapi.yaml --ruleset .spectral.yaml --fail-severity warn
Expected output when a oneOf is missing its discriminator:
OpenAPI 3.1.0 spec validated
Error: Missing discriminator mapping for oneOf composition (components.schemas.PaymentMethod)
1 error, 0 warnings
6. Validate example payloads before SDK generation
Confirm composed schemas accept the payloads they claim to with ajv-cli:
npm install -g ajv-cli
ajv validate -s components/schemas/PaymentMethod.json -d test/payloads/credit-card.json --strict=false --all-errors
Expected output for a valid payload:
test/payloads/credit-card.json valid
Complete Working Example
One self-contained OpenAPI 3.1 fragment that uses $ref, allOf, a discriminated oneOf, closed composition, and a 3.1-correct nullable field:
# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
title: Payments API
version: 1.0.0
paths:
/charges:
post:
operationId: createCharge
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ChargeRequest'
responses:
'201':
description: Charge created
components:
schemas:
BaseEntity:
type: object
required: [id]
properties:
id:
type: string
format: uuid
note:
type: ['string', 'null'] # 3.1 nullable, not `nullable: true`
ChargeRequest:
allOf:
- $ref: '#/components/schemas/BaseEntity'
- type: object
required: [amount, method]
properties:
amount:
type: integer
minimum: 1
method:
$ref: '#/components/schemas/PaymentMethod'
unevaluatedProperties: false
PaymentMethod:
oneOf:
- $ref: '#/components/schemas/CreditCard'
- $ref: '#/components/schemas/BankTransfer'
discriminator:
propertyName: type
mapping:
credit_card: '#/components/schemas/CreditCard'
bank_transfer: '#/components/schemas/BankTransfer'
CreditCard:
type: object
required: [type, cardNumber]
properties:
type:
type: string
enum: [credit_card]
cardNumber:
type: string
pattern: '^\d{16}$'
BankTransfer:
type: object
required: [type, accountNumber]
properties:
type:
type: string
enum: [bank_transfer]
accountNumber:
type: string
This single file generates a typed PaymentMethod union, rejects unknown fields on ChargeRequest, and lints clean under a discriminator-enforcing ruleset.
Gotchas & Edge Cases
Conflicting required arrays in allOf. When two allOf branches each declare required, validators take the union — a field required in any branch is required overall. This surprises people who expect the outer schema to “win.” Centralize required fields in the base schema so the intent is explicit and SDK output is predictable.
Circular $ref without anchors crashes portal generators. Recursive references without $id/$dynamicAnchor make Swagger UI and Redoc hang. In OpenAPI 3.1 use $dynamicRef and $dynamicAnchor for safe recursion; in 3.0 flatten the structure into a list with a parent-id field instead. Large recursive example payloads compound the problem — see managing large example payloads in API specs.
nullable carried over from 3.0. OpenAPI 3.1 drops nullable: true in favor of type arrays like ['string', 'null']. A spec migrated from 3.0 often keeps the old keyword, which 3.1 silently ignores, leaving the field non-nullable in generated code. Run npx @redocly/cli lint openapi.yaml with the recommended ruleset to surface it.
Reading the generated output in two languages is the only reliable way to evaluate a composed schema, because the same structure that produces natural TypeScript can produce something unusable in a strictly-typed language. Five minutes of looking is worth more than any amount of reasoning in the abstract.
FAQ
Should I use allOf or $ref for schema inheritance?
Use $ref when you want to reuse a schema without modification. Use allOf when you need to extend a base schema with additional properties or constraints — allOf merges every branch, so all listed constraints apply at once.
How do I prevent composition drift in CI?
Run Spectral with a ruleset that enforces a discriminator on every oneOf, checks $ref resolution, and validates example payloads against their schemas using the built-in schema function. Block the pull request on any error-severity finding so drift cannot land on the main branch.
Does OpenAPI 3.1.0 change JSON Schema composition rules?
Yes. It adopts JSON Schema Draft 2020-12, introducing $defs, unevaluatedProperties, $dynamicRef, and $dynamicAnchor. The legacy definitions keyword, nullable, and numeric exclusiveMinimum/exclusiveMaximum are removed or changed, so run npx @redocly/cli lint with version-specific rules to surface deprecated syntax.
Related
- Defining JSON Schema Components
- OpenAPI & AsyncAPI Schema Authoring
- Managing Large Example Payloads in API Specs
- AsyncAPI vs OpenAPI for Event-Driven Architectures
- Handling oneOf Discriminators in Generated SDKs
Compose for the reader as well as the generator. A schema assembled from four levels of allOf may be technically equivalent to a flat definition and is considerably harder to read in a rendered reference, where each level is a link away. Composition earns its cost when the shared part genuinely is shared and changes together; beyond that it produces indirection that every reader pays for and nobody benefits from.
The same applies to depth. Two levels of composition are usually navigable; four are not, and by that point most readers give up and read the example instead — which is a reasonable response and a sign the schema is not doing its job. If the rendered schema table is harder to understand than the example beside it, flatten it.
Test composition against a generator before committing to it. Composition keywords are where generators differ most, and a structure that produces clean TypeScript can produce something awkward or unusable in a strictly-typed language. Generating two target languages and reading the output takes minutes and is the only reliable way to find out; reasoning about it in the abstract reliably produces surprises at the point where an SDK is published and consumers start reporting that the types are unusable.
The rule that keeps composition useful is to compose where things genuinely change together and to duplicate everywhere else, accepting a little repetition in exchange for schemas a reader can hold in their head.
A little duplication is almost always cheaper than an abstraction that turns out to be wrong.
Composition is a tool for expressing genuine shared meaning, and it stops paying the moment it is used for anything else.
Reach for it deliberately, verify the generated output, and keep the depth shallow enough that a reader can follow it.