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' }
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
-
$refsiblings ignored in OpenAPI 3.0. In 3.0, a$refobject is replaced wholesale during resolution — any sibling keywords (description,example,nullable) are silently discarded by conforming tools. If you need to annotate a$refin 3.0, wrap it:allOf: [{ $ref: '#/...' }, { description: 'Override description here' }]. In OpenAPI 3.1 (JSON Schema 2020-12),$refis composable and siblings are honoured. -
Circular references require care during bundling, not authoring. A schema that references itself — a
TreeNodewith achildrenproperty oftype: 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. -
allOfdoes not mergeadditionalProperties. If the base schema declaresadditionalProperties: falseand the extension object also declaresadditionalProperties: false, JSON Schema evaluates each subschema independently against the instance. A property defined only in the extension will fail the base schema’sadditionalProperties: falsecheck. The safe pattern: declareadditionalProperties: falseonly on the outermostallOfobject (the one that lists all properties), or omit it from the base and control it at the composed type. Alternatively, pair withunevaluatedProperties: falsein OpenAPI 3.1, which is evaluated after allallOfbranches 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.