Reusable Schema Components and $ref Cycles
This page belongs to OpenAPI Schema Composition in the API Design Fundamentals & Architecture reference. Composition keywords decide what a payload means; the component library decides whether anyone can maintain the document that expresses it. This page covers naming, file splitting, cycle detection and bundling — the four decisions that determine whether a growing spec stays generatable.
The decision trigger
| Symptom | Underlying cause |
|---|---|
Generated type names like InlineObject7 churn on every regeneration |
schemas defined inline instead of in components/schemas |
| Generator hangs or blows the stack | an eagerly expanded reference cycle |
| A consumer cannot load your published spec | multi-file document published unbundled |
| The same object is defined three times with drifting fields | no shared component, copy-paste reuse |
A $ref resolves in the editor but not in CI |
relative path resolved from a different working directory |
| Changing one schema breaks unrelated endpoints | a component reused across bounded contexts it should not span |
Spec snippet: a component library that resolves
# openapi.yaml (OpenAPI 3.1.0) — root document, domain-split components
openapi: 3.1.0
info:
title: Workspace API
version: "5.2.0"
paths:
/folders/{folderId}:
get:
operationId: getFolder
parameters:
- $ref: "./components/parameters.yaml#/FolderId"
responses:
"200":
description: The folder and its immediate children.
content:
application/json:
schema:
$ref: "./components/folder.yaml#/Folder"
components:
schemas:
# Re-exported so the bundled document exposes stable, named types.
Folder: { $ref: "./components/folder.yaml#/Folder" }
FolderChild: { $ref: "./components/folder.yaml#/FolderChild" }
Problem: { $ref: "./components/errors.yaml#/Problem" }
# components/folder.yaml — a deliberately recursive structure
Folder:
type: object
required: [id, name, children]
properties:
id: { type: string, format: uuid }
name: { type: string, maxLength: 255 }
parent: { $ref: "#/FolderRef" } # cycle broken by a reference type
children:
type: array
items: { $ref: "#/FolderChild" }
FolderChild:
oneOf:
- $ref: "#/Folder" # genuine recursion: folders nest
- $ref: "#/Document"
discriminator:
propertyName: kind
mapping:
folder: "#/Folder"
document: "#/Document"
FolderRef:
type: object # a cheap, non-recursive pointer
required: [id]
properties:
id: { type: string, format: uuid }
name: { type: string }
The key move is the asymmetry: downward nesting (children) recurses, because a client rendering a tree needs it, while upward nesting (parent) is flattened to a FolderRef carrying just an id and a label. A naive model that makes parent a full Folder creates a two-way cycle where serialising one folder can serialise the entire tree twice.
Step-by-step: keeping the library resolvable
Step 1 — Treat component keys as public API
The key under components/schemas becomes the class, interface or struct name in every generated SDK. Rename FolderResponse to Folder and you have renamed a type in every consumer’s code. Pick domain nouns, not endpoint-derived names, and change them under the same discipline you apply to removing a field — see Detecting Breaking Changes with oasdiff.
Step 2 — Split by domain boundary
One file per bounded context, not one file per hundred lines. When a $ref crosses a file boundary it should represent a real dependency between domains, which makes an accidental coupling visible in review. This mirrors the service boundaries described in Resource Modeling Best Practices.
Step 3 — Detect cycles deliberately
Cycles are legal, so the goal is not to eliminate them but to know about them. This script walks the reference graph and prints every strongly connected component:
# ref_cycles.py — report reference cycles in a bundled OpenAPI document
import json, sys
from collections import defaultdict
doc = json.load(open(sys.argv[1]))
schemas = doc["components"]["schemas"]
edges = defaultdict(set)
def walk(name, node):
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/components/schemas/"):
edges[name].add(ref.rsplit("/", 1)[1])
for v in node.values():
walk(name, v)
elif isinstance(node, list):
for v in node:
walk(name, v)
for name, schema in schemas.items():
walk(name, schema)
# Iterative DFS that reports each cycle once, by the path that closes it.
seen, stack, on_stack = set(), [], set()
def dfs(node, path):
if node in on_stack:
cycle = path[path.index(node):] + [node]
print("cycle:", " → ".join(cycle))
return
if node in seen:
return
seen.add(node); on_stack.add(node)
for nxt in sorted(edges[node]):
dfs(nxt, path + [nxt])
on_stack.discard(node)
for name in sorted(schemas):
dfs(name, [name])
Run it in CI and fail the build when a new cycle appears that is not on an allow-list. A cycle you chose is a design; a cycle you discovered during a generator crash is an incident.
Step 4 — Break unbounded recursion at the contract level
Where a client genuinely cannot handle arbitrary depth — a mobile app rendering a tree, a CSV export, a UI table — cap the depth in the contract rather than hoping the data stays shallow:
// folder-tree.ts — server-side depth cap that matches the documented contract
const MAX_DEPTH = 3; // documented in the spec description for `children`
export function serialiseFolder(folder: FolderRow, depth = 0): Folder {
return {
id: folder.id,
name: folder.name,
parent: folder.parentId ? { id: folder.parentId, name: folder.parentName } : undefined,
children:
depth >= MAX_DEPTH
? [] // truncated: client refetches by id
: folder.children.map(c => serialiseFolder(c, depth + 1)),
children_truncated: depth >= MAX_DEPTH && folder.children.length > 0,
};
}
The children_truncated flag is the part teams forget. Without it, a truncated tree is indistinguishable from an empty one, and clients silently render half a folder structure.
Step 5 — Bundle before publishing
# Author in many files; publish exactly one self-contained document.
npx @redocly/cli bundle openapi.yaml --output dist/openapi.yaml --dereferenced=false
# Verify the bundle has no remaining external references
grep -n '\$ref: "\./' dist/openapi.yaml && echo "external refs remain" || echo "self-contained"
Keep --dereferenced=false so internal $refs survive: fully dereferencing inlines every schema, which destroys component names and produces the InlineObject type churn you split the files up to avoid.
Cycles are legal, so the useful distinction is between the ones you chose and the ones you discovered during a generator crash:
Standard compliance
| Concern | Clause | Note |
|---|---|---|
$ref resolution |
JSON Schema Core 2020-12 §8.2.3 | URI-reference resolved against the base URI |
| Recursive schemas | JSON Schema Core 2020-12 §8.2.1 | Legal; evaluated lazily |
$ref with siblings |
JSON Schema Core 2020-12 §8.2.3.1 | Allowed in OpenAPI 3.1, ignored in 3.0 |
| Component key charset | OpenAPI 3.1 §4.8.7 | ^[a-zA-Z0-9._-]+$ — keep it identifier-safe |
$id / base URI |
JSON Schema Core 2020-12 §8.2.1 | Changes what relative refs resolve against |
The component-key charset rule has a practical consequence: a key containing a dot or dash is legal in the document but must be mangled into an identifier by every generator, and each one mangles differently. Stick to PascalCase letters and digits and the generated names stay predictable.
SDK / codegen downstream effect
Cycles do not break generation — they change the shape of what is generated. A downward-recursive Folder produces a self-referential type in every language, which is exactly what you want:
// TypeScript, from the recursive Folder schema
export interface Folder {
id: string;
name: string;
- parent?: Folder; // two-way cycle: infinite in a serialiser
+ parent?: FolderRef; // flattened upward reference
children: FolderChild[]; // downward recursion is fine
}
In Go the same schema needs a pointer to break the value-type cycle (Parent *FolderRef), and in Python a forward reference resolved with model_rebuild(). Generators handle all three automatically when the cycle is expressible; what they cannot handle is a cycle through allOf, because expanding an allOf requires knowing the full member set, which the cycle makes unknowable. If a cycle must exist, route it through a property, never through a composition keyword. The naming and publishing implications carry through into Client SDK Generation Pipelines.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Defining request/response schemas inline | Put every reusable shape under components/schemas with a deliberate key |
| Publishing a multi-file document | Bundle to one self-contained file; keep internal $refs |
| Fully dereferencing during bundle | Use --dereferenced=false so component names survive |
A cycle routed through allOf |
Route cycles through a property, never a composition keyword |
Full parent objects on tree nodes |
Flatten upward references to an id-and-label reference type |
| Truncating deep trees silently | Ship an explicit *_truncated flag in the contract |
FAQ
Are circular $ref references legal in OpenAPI?
Yes. JSON Schema explicitly permits recursive references and validators evaluate them lazily, so a Folder containing Folder children validates without special handling. The constraint is tooling and serialisation, not legality: generators that expand references eagerly can loop, and a server that serialises both parent and children as full objects can emit the same node many times or run out of memory. Treat every cycle as a deliberate design decision, list it in an allow-list, and fail CI when an unlisted one appears.
Should I split my OpenAPI document into multiple files?
Split the source once it passes a few thousand lines — reviewing a 12,000-line YAML diff is how contract mistakes get merged. But publish exactly one bundled, self-contained document. Consumers, mock servers, contract-test runners and several generators cannot fetch sibling files, and a published multi-file spec quietly makes every consumer responsible for reconstructing your build. Bundling is one command in CI and removes the whole class of problem.
Why do component names keep changing in my generated SDK?
Because those schemas are not components. When a schema is defined inline in a request body or response, the generator has no name to use, so it derives one from the path, method and index — and that derived name changes when endpoints are added, removed or reordered. The result is a diff full of type renames that look like breaking changes and hide the real ones. Every shape that a consumer names in their code belongs under components/schemas with a key you chose on purpose.
Related
- OpenAPI Schema Composition — up-link: the section covering
allOf,oneOf,anyOfand discriminators - API Design Fundamentals & Architecture — up-link: the parent reference for resource modeling and contract-first design
- allOf vs oneOf for Polymorphic Payloads — the applicator choice these components are assembled with
- Enforcing Style Guides Across Monorepo Specs — linting a component library that spans many services
- Publishing Generated SDKs from GitHub Actions — where the bundled document is consumed