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.

Healthy recursion versus a two-way reference cycle On the left, Folder references FolderChild which references Folder again, a single downward cycle that serialises to a finite tree. On the right, Folder references a full parent Folder which references its children, producing a cycle that can serialise the same node repeatedly. Bounded recursion — safe Two-way cycle — unbounded Folder FolderChild FolderRef children[] oneOf parent → id only serialises to a finite tree Folder Folder (parent) parent children[] expands forever same node serialised repeatedly

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.

Authoring in many files, publishing one bundled document Three source files feed a bundle step, which emits one self-contained document. That document is consumed by the SDK generator, the mock server and the documentation site. openapi.yaml folder.yaml errors.yaml bundle keeps internal$refs dist/openapi.yaml self-contained SDK generator mock server docs site

Cycles are legal, so the useful distinction is between the ones you chose and the ones you discovered during a generator crash:

Is this reference cycle safe to keep? A cycle routed through a property can be expressed as a self-referential type in every target language. A cycle routed through a composition keyword cannot be expanded and breaks generators. does the cycle pass through allOf or oneOf? yes no break it expanding a composition keyword needs the full member set, whichthe cycle makes unknowable keep it, with a depth cap a property cycle generates a self-referential type; capserialisation depth and flag truncation list every intentional cycle in an allow-list so a new one fails CI rather than a build

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.