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.
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.