Skip to main content

Reusing Schemas with OpenAPI components and $ref

An OpenAPI document that defines the same Error object in fifteen different response bodies is a maintenance time bomb. A field rename, a required-array change, or a new status property has to be applied in fifteen places — and it never is. Within weeks the copies diverge and clients hit runtime mismatches the spec never predicted. This guide is part of the OpenAPI Specification Deep Dive series and shows exactly how to extract duplicated inline schemas into components, reference them with $ref, compose extensions with allOf, share parameters and responses, and bundle a multi-file spec for tooling.

Symptom

The failure mode is silent at first. A developer copies an inline schema from one path to another and tweaks one field. Linters that validate structural correctness say nothing — the copies are individually valid. Then a review finds that the amount field is integer in three operations and number in two others, or that createdAt is required in some responses but not others. The contract has drifted without any tooling catching it.

In CI the symptom surfaces during consumer-contract verification or compile-time type generation from OpenAPI:

error: Schema mismatch on GET /orders/{id} response 200.
  Field 'amount': declared integer in /paths/~1orders~1{id}/get/responses/200,
  declared number in /components/schemas/Order.
  Consumer types were generated from the component definition.

The consumer generated types from one copy of the schema; the actual response body follows a different copy. The structural gap only appears at runtime.

Root Cause

OpenAPI allows schema objects to be defined inline anywhere a schema is valid — inside requestBody.content, responses, parameters, and nested inside other schemas. When a team grows the spec incrementally, the natural habit is to copy the nearest similar definition and adjust it. There is no mechanism in the document format itself to enforce that two logically identical schemas remain in sync; that enforcement lives in the toolchain, and it has to be opted in to deliberately.

The components object is that enforcement mechanism. It acts as a named library: every definition under components.schemas, components.parameters, components.responses, components.headers, and components.requestBodies can be referenced from anywhere in the document via a $ref pointer. A $ref is a JSON Reference — at validation and codegen time, the tool resolves the pointer and substitutes the referenced definition. There is one authoritative copy and zero risk of drift between copies.

Step-by-Step Fix

The following steps use OpenAPI 3.1.1, Spectral 6.11, and Redocly CLI 1.25. Install once:

npm install --save-dev @stoplight/spectral-cli@6.11 @redocly/cli@1.25

Step 1: Extract duplicated schemas to components.schemas

Start with the most widely duplicated type. Here a bare Error object appears inline in five response definitions. Extract it once:

# Before: inline in every 4xx response — five copies, already drifted
paths:
  /orders/{id}:
    get:
      responses:
        '404':
          description: Not found
          content:
            application/json:
              schema:
                type: object
                required: [code, message]
                properties:
                  code: { type: string }
                  message: { type: string }
  /payments:
    post:
      responses:
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                required: [code, message, details]   # drift: this copy added 'details'
                properties:
                  code: { type: string }
                  message: { type: string }
                  details: { type: array, items: { type: string } }

After identifying the canonical shape, move it to components.schemas and decide whether details belongs on every error or only on validation errors:

# After: one authoritative definition
components:
  schemas:
    Error:
      type: object
      description: Standard error envelope returned on all 4xx and 5xx responses.
      additionalProperties: false          # rejects undocumented fields — prevents silent extension
      required: [code, message]
      properties:
        code:
          type: string
          description: Machine-readable error code, e.g. ORDER_NOT_FOUND.
        message:
          type: string
          description: Human-readable summary.
        details:
          type: array
          items: { type: string }
          description: Optional list of field-level validation messages.

Why this works: additionalProperties: false makes the contract closed. A new field cannot quietly appear in one response but not another. Every tool that validates against this schema will reject undocumented keys.

Step 2: Replace inline copies with $ref

Replace every inline occurrence with a JSON Reference to the component:

paths:
  /orders/{id}:
    get:
      responses:
        '404':
          description: Not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'   # pointer to the single definition
  /payments:
    post:
      responses:
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'   # same pointer — no divergence possible

The # prefix means the target is in the same document. The path after # is a JSON Pointer: /components/schemas/Error navigates the document object tree. Tools resolve this at validation time and substitute the full schema in place of the $ref.

Step 3: Compose extensions with allOf

When one schema must extend another — adding required fields without re-declaring the base — use allOf. This is the OpenAPI pattern for inheritance without copy-paste.

A ValidationError is an Error plus a mandatory fields array:

components:
  schemas:
    ValidationError:
      description: Returned on 422 when request body fails schema validation.
      allOf:
        - $ref: '#/components/schemas/Error'        # inherit all Error properties and required array
        - type: object
          additionalProperties: false
          required: [fields]                        # additional required field
          properties:
            fields:
              type: array
              description: Per-field validation failures.
              items:
                type: object
                additionalProperties: false
                required: [field, reason]
                properties:
                  field:  { type: string }
                  reason: { type: string }
# Usage — 422 now carries the richer ValidationError, not the generic Error
  /payments:
    post:
      responses:
        '422':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'

Why this works: allOf with a $ref to the base plus an inline object is the standard composition pattern. JSON Schema merges the required arrays from both branches. The extension object carries only the delta — the base remains canonical and unchanged.

Step 4: Hoist shared parameters and responses

Repeated parameters — pagination cursors, path IDs, idempotency keys — have the same drift problem as schemas. Move them to components.parameters and reference from path items or operations.

components:
  parameters:
    OrderId:
      name: id
      in: path
      required: true                          # path parameters MUST be required: true
      description: UUID of the order resource.
      schema: { type: string, format: uuid }

    PageCursor:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor from a previous response.
      schema: { type: string }

    PageLimit:
      name: limit
      in: query
      required: false
      description: Maximum items to return (1–100).
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }

  responses:
    NotFound:
      description: The requested resource does not exist.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

    UnprocessableEntity:
      description: The request body failed schema validation.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }

Reference them at the path-item level (applies to every operation under that path) or at the operation level:

paths:
  /orders/{id}:
    parameters:
      - $ref: '#/components/parameters/OrderId'   # shared by GET, PATCH, DELETE on this path
    get:
      operationId: getOrder
      responses:
        '200':
          description: The order
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Order' }
        '404':
          $ref: '#/components/responses/NotFound'         # response-level $ref

  /orders:
    get:
      operationId: listOrders
      parameters:
        - $ref: '#/components/parameters/PageCursor'
        - $ref: '#/components/parameters/PageLimit'
      responses:
        '200':
          description: Paginated order list
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderPage' }

Step 5: Bundle multi-file refs for tooling

Once a spec grows large enough, splitting it across files improves authoring ergonomics. Put shared types in schemas/common.yaml, domain schemas in schemas/orders.yaml, and so on. Reference them with relative paths:

# openapi.yaml (root document)
components:
  schemas:
    Error:
      $ref: './schemas/common.yaml#/components/schemas/Error'
    Order:
      $ref: './schemas/orders.yaml#/components/schemas/Order'

Before running codegen, linting, or mock servers, resolve every external ref into a single self-contained document. Redocly CLI 1.25 does this while correctly preserving internal recursive references (which a naive full-deref tool would infinite-loop on — the details of that failure are covered in fixing OpenAPI $ref circular reference errors):

# Bundle: resolves external file refs, preserves internal $ref pointers
npx redocly bundle openapi.yaml --output dist/openapi.bundled.yaml

# Verify the bundle is valid and all refs resolved
npx redocly lint dist/openapi.bundled.yaml

The dist/openapi.bundled.yaml file is the artifact you distribute to consumers and feed to codegen tools.

Before / After Comparison

# BEFORE — inline schema duplicated across three operations, already drifted

paths:
  /orders/{id}:
    get:
      responses:
        '404':
          content:
            application/json:
              schema:
                type: object
                required: [code, message]          # copy 1
                properties:
                  code: { type: string }
                  message: { type: string }
  /shipments/{id}:
    get:
      responses:
        '404':
          content:
            application/json:
              schema:
                type: object
                required: [code]                   # copy 2 — 'message' accidentally dropped
                properties:
                  code: { type: string }
                  message: { type: string }
  /payments/{id}:
    delete:
      responses:
        '404':
          content:
            application/json:
              schema:
                type: object
                required: [code, message, traceId] # copy 3 — added field not in other copies
                properties:
                  code: { type: string }
                  message: { type: string }
                  traceId: { type: string }
# AFTER — one component definition, three $ref pointers, zero drift

components:
  schemas:
    Error:
      type: object
      additionalProperties: false
      required: [code, message]
      properties:
        code: { type: string }
        message: { type: string }
        traceId: { type: string }          # optional on all errors, intentionally

paths:
  /orders/{id}:
    get:
      responses:
        '404':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /shipments/{id}:
    get:
      responses:
        '404':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /payments/{id}:
    delete:
      responses:
        '404':
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
$ref resolution from multiple paths to one component Three path response objects each carry a $ref pointer that resolves to the single Error schema defined under components.schemas, ensuring one authoritative definition with no drift.

paths (consumers)

GET /orders/{id} responses.404 $ref GET /shipments/{id} responses.404 $ref DELETE /payments/{id} responses.404 $ref

$ref $ref $ref

components.schemas Error type: object required: [code, message] additionalProperties: false one definition — zero drift

Change the component once. Every $ref resolves to the updated definition. allOf + $ref for extensions — never copy-paste inline schemas.

ValidationError allOf: [$ref Error] + fields allOf

Verification

Run the following sequence locally and in CI. Every command should exit 0:

# 1. Lint the raw multi-file spec (catches unresolved refs, missing required fields)
npx spectral lint openapi.yaml --ruleset .spectral.yaml

# Expected: No results with a severity of 'error' or higher found!

# 2. Bundle to resolve external file refs
npx redocly bundle openapi.yaml --output dist/openapi.bundled.yaml

# Expected: bundled successfully in: dist/openapi.bundled.yaml (1 document)

# 3. Lint the bundle — confirms no ref pointers were left dangling after bundling
npx redocly lint dist/openapi.bundled.yaml

# 4. Verify no inline schema duplicates remain — grep for schemas defined inline
#    outside components (any 'type: object' under paths that are not a $ref)
grep -n "type: object" openapi.yaml | grep -v "^\s*#"
# Review the output: every object type under paths/* should be followed by a $ref,
# not an inline properties block.

A minimal Spectral ruleset that enforces $ref-first discipline:

# .spectral.yaml
extends: ["spectral:oas"]
rules:
  no-$ref-siblings: off              # allow description alongside $ref in 3.1
  oas3-schema: error
  operation-operationId: error
  no-inline-response-schema:
    description: Response schemas must be $ref, not inline definitions.
    given: "$.paths[*][*].responses[*].content[*].schema"
    severity: error
    then:
      function: schema
      functionOptions:
        schema:
          required: ["$ref"]

In CI, add these steps to your pull-request workflow:

# .github/workflows/openapi-lint.yml
name: OpenAPI Schema Governance
on:
  pull_request:
    paths: ['openapi.yaml', 'schemas/**']
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: Lint raw spec
        run: npx spectral lint openapi.yaml --ruleset .spectral.yaml
      - name: Bundle
        run: npx redocly bundle openapi.yaml --output dist/openapi.bundled.yaml
      - name: Lint bundle
        run: npx redocly lint dist/openapi.bundled.yaml

Edge Cases and Caveats

  • $ref siblings ignored in OpenAPI 3.0. In 3.0, a $ref object is replaced wholesale during resolution — any sibling keywords (description, example, nullable) are silently discarded by conforming tools. If you need to annotate a $ref in 3.0, wrap it: allOf: [{ $ref: '#/...' }, { description: 'Override description here' }]. In OpenAPI 3.1 (JSON Schema 2020-12), $ref is composable and siblings are honoured.

  • Circular references require care during bundling, not authoring. A schema that references itself — a TreeNode with a children property of type: array, items: { $ref: '#/components/schemas/TreeNode' } — is completely legal in OpenAPI 3.1. Redocly CLI preserves these internal recursive pointers when bundling. The danger is with tools that attempt to fully dereference (inline) all refs before codegen: they will recurse forever on a self-referential schema. Always generate types from the bundled output (not a fully-dereferenced output), and see fixing OpenAPI $ref circular reference errors for the full resolution recipe.

  • allOf does not merge additionalProperties. If the base schema declares additionalProperties: false and the extension object also declares additionalProperties: false, JSON Schema evaluates each subschema independently against the instance. A property defined only in the extension will fail the base schema’s additionalProperties: false check. The safe pattern: declare additionalProperties: false only on the outermost allOf object (the one that lists all properties), or omit it from the base and control it at the composed type. Alternatively, pair with unevaluatedProperties: false in OpenAPI 3.1, which is evaluated after all allOf branches have been applied — it is the correct keyword for closed-composition in 3.1. The asymmetry between choosing OpenAPI and picking a protocol stack for message contracts is explored in how to choose between OpenAPI and AsyncAPI for microservices.

Frequently Asked Questions

Can I use $ref and sibling keywords together in the same object?

Not in OpenAPI 3.0. In that version $ref replaces the entire object, so any sibling keys are silently ignored. In OpenAPI 3.1, which aligns with JSON Schema Draft 2020-12, $ref is composable — you can pair it with allOf, description, or other keywords. Migrate to 3.1 or use an allOf wrapper in 3.0 if you need to annotate a $ref.

What is the difference between $ref in components and an inline schema?

An inline schema is defined directly inside the path or parameter object where it is used; it cannot be referenced anywhere else. A $ref points to a named definition under components.schemas (or another location), so multiple path items can reference the same definition. Change the component once and every reference updates.

How do I reference a schema in an external file?

Use a relative path in the $ref value: $ref: './schemas/user.yaml'. You can also address a specific fragment: $ref: './schemas/common.yaml#/components/schemas/Error'. Before distributing the spec or running codegen, bundle all external refs into one file with redocly bundle.

Does allOf merge required arrays automatically?

Yes. JSON Schema (and therefore OpenAPI 3.1) merges the required arrays from every allOf branch. If the base schema declares required: [id, createdAt] and the extension adds required: [name], the composed schema requires all three. The properties must still be defined in the schema that carries them.

How do I prevent circular $ref errors when bundling?

Circular refs caused by a schema referencing itself through properties or items are legal and intentional — redocly bundle preserves them rather than inlining them. What causes stack overflows is a naive dereferencer that tries to fully inline a recursive schema. Use redocly bundle (which keeps internal recursive refs as pointers) and generate from the bundle output.