Defining Reusable JSON Schema Components

This guide is part of OpenAPI & AsyncAPI Schema Authoring, and it covers the foundation everything else builds on: reusable data models defined once under components/schemas and referenced everywhere with $ref. Centralizing models stops the slow drift where the same User object is redefined slightly differently in three endpoints, keeps generated SDKs internally consistent, and gives your portal a single, authoritative description of every shape your API exchanges.

We will work in OpenAPI 3.1, which aligns with JSON Schema Draft 2020-12 and removes the long-standing impedance mismatch between the two. The same composition primitives — $ref, allOf, oneOf, anyOf, and discriminator — apply to AsyncAPI message payloads, so the discipline you establish here pays off across both synchronous and event-driven contracts. This page sets up the environment, lays out a maintainable namespace, walks through composition, wires validation into CI, and ends with the resolution failures you are most likely to debug.

Composing schemas with $ref and allOf A base schema is referenced by request and response schemas, which the bundler resolves into a single document. User (base) components/schemas CreateUserRequest $ref + extra fields UserResponse allOf base + id bundled.yaml

Prerequisites & Environment Setup

Pin the toolchain so local and CI validation produce identical results. The Redocly CLI bundles and lints multi-file specs, and Spectral enforces custom organizational rules.

node --version    # v20.x LTS

npm install --save-dev \
  @redocly/cli@^2 \
  @stoplight/[email protected]

Establish a domain-oriented directory layout from day one. Organizing by domain rather than by HTTP verb gives each team clear ownership and prevents the naming collisions that plague flat schema files:

openapi.yaml
components/
  schemas/
    User.yaml
    Order.yaml
    Payment.yaml
    shared/
      Pagination.yaml
      Error.yaml
.spectral.yaml

If a schema will be referenced from more than one file, set its $id. An $id anchors resolution to an absolute URI, which removes the ambiguity that relative $ref paths develop once a spec is bundled from a different working directory. Schemas that are only used within a single file can rely on local pointers.

Core Configuration

A well-formed component is fully typed, fully described, and constrained. Every schema under components/schemas should carry a description (your portal renders it, and a lint rule should require it), declare its required fields explicitly, and use enum or format to tighten loose strings.

# openapi.yaml (OpenAPI 3.1.0)
openapi: 3.1.0
info:
  title: Example API
  version: 1.4.0
components:
  schemas:
    User:
      type: object
      description: A registered user account.
      required: [id, email]
      additionalProperties: false   # reject unknown fields; catches typos in clients
      properties:
        id:
          type: string
          format: uuid
          description: Unique account identifier.
        email:
          type: string
          format: email
          description: Primary contact address.
        status:
          type: string
          enum: [active, suspended, archived]
          description: Current account lifecycle state.
        deletedAt:
          type: [string, "null"]    # 3.1 nullable syntax, not the 3.0 `nullable: true`
          format: date-time
          description: Soft-delete timestamp, null when active.

Two choices in that file are worth calling out. additionalProperties: false makes the contract strict — clients sending unexpected fields fail validation rather than silently dropping data, which surfaces integration bugs early. The type: [string, "null"] form is the OpenAPI 3.1 way to express nullability; the old nullable: true keyword is gone in 3.1, and mixing the two is a frequent migration mistake. Reusable payload shapes defined this way are exactly what example payload management attaches sample data to, so keep schema and example concerns cleanly separated.

Integration Pattern

Lint on every pull request and bundle before linting so cross-file $ref pointers are actually resolved. The Spectral ruleset below enforces that every component schema has a description; the workflow runs it against a bundled artifact.

# .spectral.yaml
extends: ["spectral:oas"]
rules:
  schema-description-required:
    description: "All component schemas must include a description."
    message: "Schema '{{path}}' is missing a description."
    severity: error
    given: "$.components.schemas.*"
    then:
      field: description
      function: truthy
# .github/workflows/schema-lint.yml
name: Schema Validation
on:
  pull_request:
    paths: ['openapi.yaml', 'components/**', '.spectral.yaml']
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci

      - name: Bundle (resolves all $ref pointers)
        run: npx @redocly/cli bundle openapi.yaml --output dist/bundled.yaml

      - name: Redocly structural lint (3.1 ruleset)
        run: npx @redocly/cli lint dist/bundled.yaml

      - name: Spectral organizational rules
        run: >
          npx @stoplight/spectral-cli lint dist/bundled.yaml
          --ruleset .spectral.yaml --fail-severity error

Bundling first matters: a $ref that points at a missing file or a typo’d anchor will fail the bundle step with a clear path, whereas linting the unbundled root can silently skip unresolved references. Running both Redocly’s structural lint and Spectral’s custom rules covers two different classes of problem — Redocly catches spec-level structural errors, Spectral enforces your house style. The deeper composition strategies behind these rules are covered in best practices for JSON Schema composition in APIs.

Advanced Options

Composition with allOf and discriminated oneOf

Build larger types by composing smaller ones. Use allOf to extend a base, and separate request from response schemas so the two directions version independently:

components:
  schemas:
    CreateUserRequest:
      type: object
      required: [email]
      properties:
        email: { type: string, format: email }
        role:
          type: string
          enum: [admin, viewer]
          default: viewer

    UserResponse:
      allOf:
        - $ref: '#/components/schemas/CreateUserRequest'
        - type: object
          required: [id, createdAt]
          properties:
            id: { type: string, format: uuid }
            createdAt: { type: string, format: date-time }

For genuine polymorphism use oneOf with a discriminator. Without the discriminator, code generators emit ambiguous untyped unions that consumers cannot pattern-match against:

    PaymentMethod:
      oneOf:
        - $ref: '#/components/schemas/CardPayment'
        - $ref: '#/components/schemas/BankPayment'
      discriminator:
        propertyName: kind
        mapping:
          card: '#/components/schemas/CardPayment'
          bank: '#/components/schemas/BankPayment'

Sharing schemas across OpenAPI and AsyncAPI

Aligning with AsyncAPI event-driven patterns, message payloads use the same composition primitives as REST bodies. Extract the shared types into a standalone JSON Schema Draft 2020-12 file and $ref it from both specs so a change to User propagates to every contract at once, rather than being hand-copied and drifting.

Strictness and forward compatibility

Decide an additionalProperties policy per schema. Request bodies usually want false to reject malformed input; event payloads often want it open so producers can add fields without breaking older consumers. Document the choice — a Spectral rule can flag schemas that leave it unset.

Verification & Testing

Reproduce the CI gate locally and confirm the spec round-trips through generation, which is the real test of whether your refs resolve cleanly.

# 1. Bundle; fails loudly on any unresolved $ref
npx @redocly/cli bundle openapi.yaml --output dist/bundled.yaml

# 2. Structural lint against the 3.1 rules
npx @redocly/cli lint dist/bundled.yaml

# 3. House rules
npx @stoplight/spectral-cli lint dist/bundled.yaml --ruleset .spectral.yaml

# 4. Prove the schemas generate a usable client
npx @openapitools/openapi-generator-cli generate \
  -i dist/bundled.yaml -g typescript-axios -o /tmp/sdk

A clean bundle proves every $ref resolves. A clean lint proves structure and house style. A successful SDK generation proves the schemas are not just valid but generatable — discriminators resolve, composition flattens correctly, and there are no name collisions. Treat a generation failure as a contract bug, not a tooling quirk.

Troubleshooting

Unresolved $ref pointers across split files

Relative paths break when the directory layout changes or when CI runs from a different working directory than your machine. Always bundle before validating, and use paths relative to the referencing file. The bundle command reports the exact pointer that failed:

npx @redocly/cli bundle openapi.yaml --output dist/bundled.yaml

Mixing OpenAPI 3.0 and 3.1 schema syntax

OpenAPI 3.1 drops nullable in favor of type: [..., "null"] and changes exclusiveMinimum/exclusiveMaximum from booleans to numbers. A spec that mixes the two renders incorrectly and breaks SDK generation. Validate against the 3.1 rules explicitly with npx @redocly/cli lint --extends recommended and audit any leftover nullable: true.

oneOf/anyOf without a discriminator

Polymorphic payloads without a discriminator force generators into untyped unions, so consumers lose compile-time safety. Add a discriminator with an explicit mapping for every polymorphic schema, and add a Spectral rule that fails when oneOf appears without one.

Circular $ref causes infinite resolution

A schema that references itself directly or through a cycle can hang naive resolvers. In 3.1, model recursive trees with $dynamicRef/$dynamicAnchor; in 3.0, flatten the recursion into a list keyed by a parent ID. Confirm the bundle completes without recursion warnings.

FAQ

Should I define request and response schemas separately?

Yes, separating them prevents mutation conflicts where a field required on input wrongly becomes required on output. It also lets each direction version independently so a response can gain fields without changing the request contract.

How do I handle circular references in JSON Schema?

In OpenAPI 3.1 use $dynamicRef and $dynamicAnchor for safe recursive tree structures. In OpenAPI 3.0 flatten the recursion into a flat list with a parent ID field, and validate with redocly bundle to catch resolution errors before deploying.

Can I reuse the same schema across OpenAPI and AsyncAPI specs?

Yes, extract shared schemas into a standalone JSON Schema Draft 2020-12 file and reference it with $ref from both specs. This keeps a single source of truth across synchronous and asynchronous contracts.

When should I use allOf versus oneOf?

Use allOf to compose a type by merging fragments, such as extending a base object with extra fields. Use oneOf for true polymorphism where a value is exactly one of several variants, and always pair oneOf with a discriminator.