Skip to main content

Splitting a Large OpenAPI File Across Multiple Documents

Problem: openapi.yaml is twelve thousand lines. Every pull request touching the API conflicts with every other one, reviewers cannot see what changed, and editors take a second to respond to a keystroke. This guide is part of the OpenAPI Specification Deep Dive and covers splitting the document without losing the single artifact consumers depend on.

The goal is a split source and a single published file. Those are different things, and conflating them is where most attempts go wrong.

Root Cause: One File Is Both the Source and the Artifact

A single file is convenient for consumers and hostile to authors. Splitting for authors while continuing to publish one bundled file gives you both, at the cost of one build step.

Split to author, bundle to publish Per-resource path files and per-domain schema files are referenced by a thin root document; a bundler merges them into one published file that generators, mocks and diff tools consume. authored openapi.yaml (root) paths/orders.yaml paths/customers.yaml schemas/order.yaml schemas/problem.yaml redocly bundle a build step dist/openapi.yaml one file, committed and published generators, mocks, diff tools and consumers all read this one

Step 1: Split by Resource, Not by Section

The instinct is to make a directory per OpenAPI section — one for paths, one for schemas, one for parameters. It is worse than useless: adding a field to an order touches four directories.

Adding one field: how many files move Splitting by OpenAPI section spreads one logical change across four directories; splitting by resource keeps the same change inside a single file, which is what removes merge conflicts. split by section paths/all-paths.yaml schemas/all-schemas.yaml parameters/all-parameters.yaml examples/all-examples.yaml four files, four conflict surfaces split by resource paths/orders.yaml schemas/order.yaml nothing else moves customers, invoices untouched two files, both owned by one team

Split by resource, so a change stays in one place:

api/
├── openapi.yaml               # root: info, servers, security, path map
├── paths/
│   ├── orders.yaml            # every /orders operation
│   ├── orders-by-id.yaml      # every /orders/{id} operation
│   └── customers.yaml
├── schemas/
│   ├── order.yaml
│   ├── customer.yaml
│   ├── money.yaml
│   └── problem.yaml
└── responses/
    └── errors.yaml            # shared 4xx/5xx responses
# openapi.yaml — the root stays small enough to read in one screen
openapi: 3.1.1
info:
  title: Orders API
  version: 2.15.0
servers:
  - url: https://api.example.com/v2
security:
  - oauth2: [orders:read]
paths:
  /orders:
    $ref: './paths/orders.yaml'
  /orders/{id}:
    $ref: './paths/orders-by-id.yaml'
  /customers/{id}:
    $ref: './paths/customers.yaml'
components:
  responses:
    $ref: './responses/errors.yaml'
# paths/orders.yaml — a Path Item Object, not a whole document
get:
  operationId: listOrders
  summary: List orders
  responses:
    '200':
      description: A page of orders.
      content:
        application/json:
          schema:
            type: object
            required: [data]
            properties:
              data:
                type: array
                items: { $ref: '../schemas/order.yaml' }
    '401': { $ref: '../responses/errors.yaml#/Unauthorized' }
post:
  operationId: createOrder
  requestBody:
    required: true
    content:
      application/json:
        schema: { $ref: '../schemas/order.yaml#/$defs/CreateOrder' }
  responses:
    '201':
      description: Created.
      content:
        application/json:
          schema: { $ref: '../schemas/order.yaml' }

Relative paths are resolved from the file containing the reference, which is why ../schemas/ appears inside paths/. Getting this wrong is the most common first-attempt error, and it surfaces as a confusing “could not resolve” from whichever tool runs first.

Step 2: Namespace Component Keys Before Bundling

When the bundler merges files, component keys land in one namespace. Two files each defining Error collide, and bundlers resolve collisions by renaming or by overwriting — neither of which you want to discover from a generated client.

# schemas/problem.yaml — one definition, one name, used everywhere
$id: https://schemas.example.com/problem.yaml
title: Problem
type: object
required: [type, title, status]
properties:
  type: { type: string, format: uri }
  title: { type: string }
  status: { type: integer }
  detail: { type: string }

Where two domains genuinely need different shapes, name them for their domain — OrderValidationProblem and BillingValidationProblem — rather than relying on file paths to keep them apart.

Step 3: Bundle as a Build Step

{
  "scripts": {
    "spec:bundle": "redocly bundle api/openapi.yaml -o dist/openapi.yaml",
    "spec:lint": "redocly lint api/openapi.yaml",
    "spec:check": "npm run spec:bundle && git diff --exit-code dist/openapi.yaml"
  }
}

Bundling — not dereferencing — is the operation you want. A bundler merges files and rewrites external references into internal ones while preserving recursion; a dereferencer tries to inline everything and never terminates on a self-referencing schema, the failure covered in fixing OpenAPI $ref circular reference errors.

$ npm run spec:bundle
bundling api/openapi.yaml...
📦 Created a bundle for api/openapi.yaml at dist/openapi.yaml 142ms.
Bundle, do not dereference Bundling merges files and converts external references to internal pointers, preserving a recursive schema. Dereferencing expands every reference in place and never terminates on recursion. split source external $refs bundle refs become #/components/… dereference refs expanded in place one file, recursion intact every tool can read it stack overflow on a self-referencing schema

Step 4: Gate the Bundle in CI

A committed bundle that nobody regenerates is worse than no bundle — consumers read a stale contract. One CI step removes the possibility:

# .github/workflows/spec.yml
- name: Lint the split source
  run: npx @redocly/cli@1.25 lint api/openapi.yaml --format=stylish

- name: Rebundle and fail on drift
  run: |
    npx @redocly/cli@1.25 bundle api/openapi.yaml -o dist/openapi.yaml
    git diff --exit-code dist/openapi.yaml \
      || { echo "::error::dist/openapi.yaml is stale — run npm run spec:bundle"; exit 1; }

- name: Diff the bundle against the released contract
  run: |
    git show origin/main:dist/openapi.yaml > /tmp/baseline.yaml
    oasdiff breaking /tmp/baseline.yaml dist/openapi.yaml --fail-on ERR

The third step matters as much as the second: the breaking-change gate must run on the bundle, not on the root file. Diffing the root compares a document full of external pointers and sees nothing when a referenced schema loses a field.

Verification

# Every reference resolves.
npx @redocly/cli@1.25 lint api/openapi.yaml
# ✓ no errors

# The bundle is self-contained: no external refs survive.
grep -c "\$ref: '\.\./" dist/openapi.yaml
# 0

# The bundle is current.
npm run spec:check
# (no output, exit 0)

# Generators read the bundle, not the split source.
npx openapi-typescript@7 dist/openapi.yaml -o src/api.d.ts && npx tsc --noEmit

The grep is the one to keep: a surviving relative reference means a consumer fetching the published file gets a document that cannot be resolved.

Edge Cases and Caveats

  • Path Item files are not documents. paths/orders.yaml contains a Path Item Object — get:, post: at the top level — with no openapi: key. Editors that validate every YAML file as a whole document will complain; point them at the root only.
  • Anchors and fragments. $ref: './responses/errors.yaml#/Unauthorized' addresses a fragment within a file. Keep fragment paths shallow; a four-level fragment is a sign the file wants splitting further.
  • Editor tooling. Most OpenAPI editors resolve external refs, but not all resolve them the same way. If autocomplete stops working, run the bundler in watch mode and point the editor at dist/.
  • Ordering churn. Some bundlers sort keys differently between versions, which makes the committed bundle diff noisily. Pin the bundler version exactly, or the gate fails on somebody else’s upgrade.
  • Very large bundles. Past a few megabytes, generators and mock servers slow noticeably. At that scale, consider splitting into more than one API — a document that big usually describes more than one product.

When Splitting Is the Wrong Answer

Splitting solves an authoring problem. It does not solve the problems teams often hope it will, and recognising that saves a fortnight.

A spec that is hard to review is not fixed by splitting it. If a change touches twelve endpoints because the schemas are duplicated, splitting spreads the same twelve edits across twelve files. The fix is extraction into shared components, which reduces the change to one edit — with or without a split.

A spec that is slow to lint is rarely fixed by splitting either, because the bundle is what gets linted. If linting takes minutes, the cause is usually a very large number of operations or a pathological composition, and the remedy is either a faster ruleset or a genuinely separate API.

Two teams editing one document is an ownership problem. Splitting the file gives each team its own path file, which reduces merge conflicts but does not decide who approves a change to a shared schema. Solve the ownership question first — a CODEOWNERS entry per directory — and the split then reinforces it.

The honest test is whether the pain is conflicts and navigation or duplication and review. The first is what splitting fixes. The second needs components, and no amount of file structure substitutes for them.

Migrating an Existing Monolithic Document

Do the split in one commit, not incrementally. A half-split document has references pointing both ways and no clear rule about where a new endpoint belongs, and that state tends to persist for months. Extract every resource in one pass, verify the bundle is byte-equivalent to the original where the tooling permits, and land it as a mechanical change with no content edits mixed in. Reviewers can then confirm the bundle is unchanged and approve the restructuring without reading twelve thousand lines.

Frequently Asked Questions

Should the bundled file be committed?

Yes, and gated. Committing it makes the reviewable diff visible in every pull request and gives consumers one file to fetch; a CI check that rebundles and fails on a difference keeps it honest.

How should the files be organised?

By resource, not by OpenAPI section. paths/orders.yaml holding every order operation keeps a change to orders inside one file; a directory per section spreads one logical change across four.

Do external refs work with every tool?

No. Validators and generators vary in how they resolve relative paths, and some cannot follow refs across files at all. Bundling before you hand the document to any tool removes the question entirely.

How do I avoid duplicate component names when bundling?

Namespace the keys — OrdersError and BillingError rather than two Errors. A bundler that hits a collision either renames silently or overwrites, and both outcomes are worse than the naming discipline.

What about circular references between files?

They are legal and bundling preserves them as pointers. What breaks is full dereferencing, which tries to inline them forever, so use a bundler rather than a dereferencer in every pipeline step.