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.

Choosing a JSON Schema composition keyword A base schema is reused with $ref, extended with allOf, or branched with a discriminated oneOf, all feeding typed SDK output. base schema components.schemas $ref reuse as-is allOf extend / merge oneOf + discriminator typed union typed SDK no any types

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.

  • $ref substitutes one schema for another — pure reuse, no merging.
  • allOf requires every subschema to validate, so it merges constraints.
  • oneOf requires exactly one subschema to validate, modeling mutually exclusive variants.
  • anyOf requires 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.