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.

What belongs in components, and what does not

The instinct to extract every repeated shape into components produces a document where nothing is readable in place and every schema is three references away from its usage. The useful test is not repetition but identity: a schema belongs in components when several operations mean the same thing by it, not merely when they happen to have similar fields.

Extract on identity, not similarity Shapes that mean the same thing everywhere belong in components, while shapes that merely look alike should stay inline so they can diverge. do these two usages have to change together? yes — extract it Money, Problem, PageInfo one definition, many references divergence would be a bug no — leave it inline two similar-looking filters an address that will diverge coupling them creates future work

Getting this wrong in the direction of over-extraction is the more expensive mistake, and it is the more common one because extraction feels like tidying. A shared component couples every operation that references it: changing it means agreeing the change with everyone who depends on it, and splitting it later means updating every reference. Two schemas that merely resemble each other today are far cheaper duplicated than coupled.

Composition keywords express different intentions

allOf, oneOf and anyOf are frequently used interchangeably and mean quite different things to a code generator. allOf composes — the value satisfies every subschema, which is how extension and shared base fields are expressed. oneOf discriminates — the value matches exactly one variant, which is how polymorphism is expressed and which generators turn into union types or class hierarchies. anyOf is the loosest and produces the least useful generated code, because a value satisfying several branches gives the generator nothing to select on.

Composition keywords and generated code allOf composes fields into one type, oneOf produces a discriminated union, and anyOf gives a generator nothing to select on. allOf satisfies every subschema shared base fields generates: one merged type oneOf + discriminator exactly one variant named by a property generates: a usable union anyOf one or more branches nothing to select on generates: awkward or opaque code

The practical rule is to use oneOf with a discriminator wherever you mean polymorphism, allOf wherever you mean composition, and to treat a reach for anyOf as a signal that the model is not yet decided. Generators can produce good code from the first two and rarely from the third, so the choice shows up directly in the ergonomics of every SDK you publish.

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.

Constraints are documentation that tooling can use

A schema that declares only types is a weak contract and a weak input to everything downstream. type: string tells a reader nothing about what a valid value looks like, gives a mock server no basis for producing a realistic example, gives a property-based test generator no shape to work with, and gives a validator nothing to reject. Adding format, pattern, minLength, maximum, and enum where they genuinely apply improves all four at once.

The gain is largest for identifiers and enumerations, which is also where teams most often leave the schema loose. An identifier with a pattern documents its shape, lets a mock generate something that looks real, and rejects an obviously malformed value at the edge instead of somewhere deeper in the system. An enumerated field with its values listed removes an entire category of “what are the valid values?” support question and produces a real enum type in every generated SDK rather than a bare string.

There is a limit worth respecting. Constraints that encode current business rules rather than genuine contract properties become breaking changes the moment the rule changes — a maxLength chosen to match today’s database column, an enum that has to be extended every quarter. The test is whether the constraint is a property of the interface or an artefact of the implementation; the first belongs in the schema, the second does not.

Nullability, optionality, and the difference

Three states are routinely conflated, and generated clients treat them very differently: a property that is absent, a property present with a null value, and a property that must always be present. OpenAPI 3.1 expresses the second with a type array including 'null', and the difference from simple optionality matters because a field that is sometimes missing and a field that is sometimes null produce different code in every strictly-typed language.

The practical consequence is that “optional” needs deciding rather than defaulting. If a field is genuinely absent when it has no value, leave it out of required and do not make it nullable. If it is always present but sometimes empty, mark it required and nullable. Choosing one and applying it consistently across the API matters more than which one you choose, because a consumer who learns your convention once should not have to check it per field.

The place this bites hardest is pagination cursors and similar optional-by-nature values, where a field that is present-and-null on the last page and absent on every other page will break a strict client the first time it encounters the shape it was not built for.

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.

Keeping components navigable as they grow

A components section that has grown past a few dozen entries needs organisation of its own, or it becomes a flat list nobody can find anything in. Two conventions do most of the work and cost nothing to adopt early.

Name schemas for the domain concept rather than for their position in a request. Invoice is findable and reusable; CreateInvoiceRequestBody is neither, and it forces a second nearly identical schema for the response. Where a request genuinely differs from the stored representation — omitting server-assigned fields, say — express that with composition rather than a parallel definition, so the two cannot drift.

Split the file once it is unpleasant to scroll. A components section large enough to be unnavigable is a signal to move each schema into its own file and reference it, which makes diffs readable, lets ownership be expressed per file, and turns “who changed Invoice?” into a question with an answer. The bundling step that reassembles them is one command and is needed anyway for renderers and generators.

Finally, prune. Schemas outlive the operations that referenced them, and an unreferenced component sits in the document indefinitely — published, rendered in some tools, and occasionally edited by someone who assumes it is live. A check that lists components nothing references turns that into a build-time observation rather than a discovery made much later.

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.

A components section that is pruned, well named and split by ownership stays useful indefinitely; one that only ever grows becomes a place schemas go to be forgotten.