Reusing Schemas Across Multiple OpenAPI Files

This guide is part of Defining JSON Schema Components within OpenAPI & AsyncAPI Schema Authoring. It covers sharing component schemas across several services’ specifications without creating a distributed coupling problem. The trigger is familiar in any estate with more than a handful of APIs: Money, Address, Problem, and PageInfo have been redefined slightly differently in six places, and the differences are accidental rather than intentional.

Shared schemas, bundled per service Each service references a pinned version of the shared schema package, and bundling resolves those references so consumers receive one self-contained document. api-schemas v2.3.0 Money · Address · Problem billing/api.yaml pins v2.3.0 identity/api.yaml pins v2.3.0 shipping/api.yaml pins v2.1.0 — drift each service bundles its own self-contained document — consumers never see the shared package

Problem & Context

Copy-paste is how shared types actually spread. Someone needs a Money object, finds one in a neighbouring service’s specification, pastes it, and adjusts a field name. Six months later there are five Money schemas: three with amount as an integer of minor units, one as a decimal string, one with a currency enum that is missing two currencies the business now supports.

The cost lands on consumers. An SDK generated from each specification produces five incompatible Money types, so code that moves an amount between two of your APIs has to translate. Every one of those translations is a place a rounding bug can live.

The obvious fix — one shared file everyone references — introduces a different failure if done carelessly. A $ref pointing at a branch means every service’s contract changes the moment someone merges to that branch, with no review and no release. The working arrangement is a shared set that is versioned, referenced by pin, and resolved away at bundle time so consumers never see it.

Step-by-Step Solution

1. Decide what is genuinely shared

The test is whether the type must change everywhere at once. If two services could reasonably evolve their version independently, they should each own one:

SHARE     Money        — a rounding difference between services is a bug
SHARE     Problem      — RFC 9457 error shape, identical by definition
SHARE     PageInfo     — pagination envelope, identical by convention
COPY      Address      — billing needs a tax region, shipping needs a delivery window
COPY      Customer     — every service means something different by it

Getting this wrong in the direction of over-sharing is the expensive mistake: a shared type that different teams need to change in different directions turns every schema edit into a cross-team negotiation.

2. Publish the shared schemas as a versioned artifact

Its own repository, its own tags, published the same way as any other contract:

# api-schemas/schemas/Money.yaml
type: object
required: [amount_cents, currency]
additionalProperties: false
properties:
  amount_cents:
    type: integer
    format: int64
    description: Amount in the currency's minor unit. Never a floating-point value.
    example: 24900
  currency:
    type: string
    pattern: '^[A-Z]{3}$'
    description: ISO 4217 code.
    example: EUR
git tag -a v2.3.0 -m 'Money: widen currency pattern to all ISO 4217 codes'
git push origin v2.3.0

3. Reference them correctly from each service

Two shapes work. Vendored-and-pinned is the more robust:

# billing/openapi/api.yaml — relative reference into a vendored copy
components:
  schemas:
    Invoice:
      type: object
      properties:
        total:
          $ref: '../shared/v2.3.0/schemas/Money.yaml'
# or a pinned remote reference — note the TAG, never a branch
        total:
          $ref: 'https://schemas.acme.com/api-schemas/2.3.0/Money.yaml'
# Vendor the pinned version as a build step, so builds are reproducible offline.
SHARED_VERSION=2.3.0
curl -fsSL "https://schemas.acme.com/api-schemas/${SHARED_VERSION}.tar.gz" \
  | tar -xz -C shared/ && mv "shared/api-schemas-${SHARED_VERSION}" "shared/v${SHARED_VERSION}"

4. Bundle per service before anything consumes it

Every downstream tool gets one self-contained document, with the shared references resolved away:

npx @redocly/cli@2 bundle billing/openapi/api.yaml -o dist/billing.bundled.yaml
grep -c 'shared/v2.3.0' dist/billing.bundled.yaml
0

Zero is the goal: no trace of the shared package survives into what consumers receive.

5. Detect drift in CI

Two checks. One catches a service left behind; the other catches a local edit to a vendored file:

#!/usr/bin/env bash
# check-shared-schemas.sh — pinned version current, and vendored copy unmodified.
set -euo pipefail

LATEST=$(curl -fsSL https://schemas.acme.com/api-schemas/index.json | jq -r '.latest')
PINNED=$(grep -oP '(?<=shared/v)[0-9]+\.[0-9]+\.[0-9]+' -m1 openapi/api.yaml)

echo "pinned ${PINNED}, latest ${LATEST}"

# a. Is the vendored copy byte-identical to the published version?
curl -fsSL "https://schemas.acme.com/api-schemas/${PINNED}/checksums.txt" -o /tmp/expected.txt
( cd "shared/v${PINNED}" && sha256sum -c /tmp/expected.txt --quiet ) \
  || { echo 'FAIL vendored shared schemas have been modified locally' >&2; exit 1; }
echo 'OK   vendored copy is unmodified'

# b. Warn (do not fail) when a newer shared version exists — upgrading is a decision.
if [ "${PINNED}" != "${LATEST}" ]; then
  echo "::warning::shared schemas ${PINNED} is behind ${LATEST}"
fi
pinned 2.3.0, latest 2.3.0
OK   vendored copy is unmodified
Two kinds of drift A checksum check catches a locally modified vendored copy, while a version comparison surfaces a service that is behind the current shared release. two drift checks, two different failures local edit to a vendored file looks shared, is not silently diverges forever checksum check → FAIL the build pinned behind latest still internally consistent upgrading is a decision version check → WARN, do not block

Complete Working Example

The layout and the build entry point that keeps every service honest:

acme/
├─ api-schemas/                     # its own repo, its own tags
│  ├─ schemas/
│  │  ├─ Money.yaml
│  │  ├─ Problem.yaml
│  │  └─ PageInfo.yaml
│  └─ checksums.txt                 # published per tag
└─ billing/
   ├─ shared/v2.3.0/                # vendored, gitignored, fetched at build time
   ├─ openapi/
   │  ├─ api.yaml                   # $refs into shared/v2.3.0/
   │  └─ components/Invoice.yaml
   └─ scripts/spec.sh
#!/usr/bin/env bash
# scripts/spec.sh — fetch, verify, bundle. The only way to build the contract.
set -euo pipefail

SHARED_VERSION="${SHARED_VERSION:-2.3.0}"
BASE=https://schemas.acme.com/api-schemas
DEST="shared/v${SHARED_VERSION}"

# 1. Fetch the pinned shared package if it is not already vendored.
if [ ! -d "${DEST}" ]; then
  mkdir -p shared
  curl -fsSL "${BASE}/${SHARED_VERSION}.tar.gz" | tar -xz -C shared/
  mv "shared/api-schemas-${SHARED_VERSION}" "${DEST}"
  echo "fetched shared schemas ${SHARED_VERSION}"
fi

# 2. Verify it is exactly what was published — a local edit must fail here.
curl -fsSL "${BASE}/${SHARED_VERSION}/checksums.txt" -o /tmp/shared-checksums.txt
( cd "${DEST}" && sha256sum -c /tmp/shared-checksums.txt --quiet ) || {
  echo "ERROR: ${DEST} does not match the published ${SHARED_VERSION}." >&2
  echo "       Shared schemas are immutable — change them upstream and bump the pin." >&2
  exit 1
}

# 3. Bundle into one self-contained document.
mkdir -p dist
npx @redocly/cli@2 bundle openapi/api.yaml -o dist/api.bundled.yaml
npx @redocly/cli@2 lint dist/api.bundled.yaml

# 4. No trace of the shared package may survive into the published artifact.
if grep -q 'shared/v' dist/api.bundled.yaml; then
  echo 'ERROR: unresolved shared reference in the bundle' >&2
  exit 1
fi

echo "OK   bundled against shared schemas ${SHARED_VERSION}"

Expected output:

fetched shared schemas 2.3.0
OK   bundled against shared schemas 2.3.0

Gitignoring shared/ and fetching it at build time is what makes the checksum check meaningful. A vendored copy that is committed will eventually be edited — someone needs one extra field urgently, edits the local file, and the shared type has silently forked with nothing to detect it.

Gotchas & Edge Cases

A shared schema change is a breaking change for every service at once. Removing a field from Money breaks three APIs simultaneously, and each has its own consumers and release cadence. Treat the shared package with the same discipline as a public contract: version it, diff it, and give services time to upgrade rather than expecting a coordinated release.

Remote $ref at build time is a network dependency. A build that fetches schemas over HTTP fails when the host is unreachable, and reproduces differently depending on what was served that day. Vendor the pinned version into the build once and reference it locally.

Schema $id and $ref interact confusingly. A shared schema that declares its own $id can change how relative references inside it resolve, producing pointers that work in one tool and not another. Keep shared component files free of $id unless you specifically need it.

Bundling can duplicate rather than deduplicate. If two services’ shared references resolve through different relative paths, a bundler may inline two identical copies under different component names. Check the bundled components.schemas key list for near-duplicates after the first bundle.

Nobody owns the shared package by default. A repository that three teams depend on and none maintains accumulates unreviewed additions and stops being trustworthy. Give it a named owning team with an entry in CODEOWNERS before the second service adopts it, and require the same review standard you apply to a public contract.

Over-sharing is harder to undo than under-sharing. Splitting a shared type that three teams depend on requires coordinating three migrations; promoting a duplicated type to shared is one pull request per service. When genuinely unsure, duplicate and revisit in six months.

The share-or-copy decision reduces to one question, asked about the future rather than the present:

Share or copy If a change to the type must reach every service at once it is shared; if services would reasonably diverge, each owns a copy. must a change reach every service at the same moment? yes — share it Money, Problem, PageInfo no — copy it Address, Customer

FAQ

Should every common-looking type be shared?

No. Share only types whose meaning is identical across services and which must change together. Two services with a similar-looking Address that evolve independently should each own theirs; coupling them means every schema change needs cross-team agreement.

Can I reference a schema by URL?

Yes, and you must pin the version in the URL. A reference to a branch means your contract changes when someone else merges, with no review and no release — exactly the failure that versioning exists to prevent. Vendoring the pinned copy is more robust still.

Do consumers ever see the shared package?

No. Bundling resolves every reference into each service’s own document, so consumers receive one self-contained specification and never learn the shared set exists. That is what keeps the shared package an internal implementation detail you can restructure.