JSON Toolkit

JSON Schema wiederverwenden: $ref, $defs und allOf/oneOf-Komposition

Fazit: $ref löst Wiederverwendung, allOf und oneOf lösen Komposition, und beides zu stapeln ist die Stelle, an der es bricht. Behandelt $id, $defs, Bedingungen, Rekursion, Bündelung und Debugging.

Fortgeschritten~11 Min. LesezeitVeröffentlicht 2026-10-31Aktualisiert 2026-10-31
Diagramm der JSON-Schema-Wiederverwendung und -Komposition mit $ref, $defs und allOf/oneOf

Kurz gesagt: Ziehen Sie gemeinsame Strukturen zuerst mit $ref in $defs heraus und greifen Sie dann zu allOf/oneOf; können Zweige gleichzeitig passen, verwenden Sie anyOf oder einen const-Tag, und reservieren Sie oneOf für Fälle, die wirklich genau eine Übereinstimmung verlangen.

Copy-Paste spart zehn Minuten und kostet Sie drei Monate später einen stillen Fork, wenn niemand mehr weiß, welche Kopie korrekt ist.

1. Drei Ebenen der Wiederverwendung – vor dem Aufteilen in Dateien eine wählen

Der erste Impuls, sobald Felder sich vermehren, ist, jede Beschränkung in eine einzige Datei zu stopfen. Das ist der schnellste Weg zu liefern, aber jenseits von ein paar hundert Zeilen muss jede Feldänderung an drei Stellen gespiegelt werden. Eine vergessen und Sie bekommen „lokal passt es, in der Produktion ein 500“. Wiederverwendung heißt, einen Fakt einmal zu schreiben und überall sonst darauf zu verweisen.

JSON Schema bietet drei Ebenen, jede mit anderer Reichweite:

Ebene Träger Reichweite Typische Verwendung
Unterstruktur $defs + $ref Eine Schemadatei Adresse, Geld, Paginierung, Fehlerformen
Datei Eigenständiges Schema + $id Mehrere Dateien in einem Dienst Benutzer-, Bestell-, Produktentitäten
Vertragsbibliothek Schema-Repository + gebündeltes Artefakt Mehrere Dienste, mehrere Sprachen OpenAPI-Components, veröffentlichte Verträge

Für die meisten Projekte genügt die erste Ebene. Das Aufteilen in Dateien zahlt sich erst aus, wenn zwei Dienste wirklich denselben Vertrag teilen oder das Schema ein veröffentlichtes Artefakt ist, das eine andere Sprache konsumiert. Zu früh aufzuteilen macht die $id-Auflösung und relative Pfade schwerer zu debuggen, und diese Kosten übersteigen meist die Ersparnis durch vermiedene Duplikation.

Eine einfache Faustregel: Erscheint eine Struktur an zwei Stellen, ist eine Kopie vertretbar; erscheint sie an einer dritten, heben Sie sie in $defs; erst wenn eine andere Sprache oder ein anderes Repository sie lesen muss, verdient sie eine eigenständige Datei mit eigener $id.

2. $id setzt Identität, $ref führt den Sprung aus

$id ist kein Kommentar. Es deklariert die Basis-URI des Schemas. Ein $ref-Wert ist eine URI-Referenz: Er wird gegen die aktuelle Basis-URI zu einer absoluten URI aufgelöst, und dann lokalisiert das Fragment nach # einen Knoten. Ist die Basis-URI falsch, zeigen alle relativen Referenzen woanders hin, doch die Fehlermeldung sagt meist nur „can't resolve reference“, ohne den falschen Host zu nennen.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/order.json",
  "$defs": {
    "address": {
      "$anchor": "address",
      "type": "object",
      "required": ["city"],
      "properties": {
        "city": { "type": "string", "minLength": 1 },
        "detail": { "type": "string" }
      }
    }
  },
  "type": "object",
  "required": ["shipTo"],
  "properties": {
    "shipTo": { "$ref": "#/$defs/address" },
    "billTo": { "$ref": "#address" }
  }
}

shipTo verwendet einen JSON Pointer, während billTo einen Anker verwendet; beide landen auf demselben Knoten. Die Tabelle unten ordnet jede Form ihrer Auflösung zu:

Form Zeigt auf Draft Hinweise
# Wurzel des aktuellen Schemas Alle Häufig für Rekursion
#/$defs/address Knoten unter $defs 2019-09 und später Am klarsten; bevorzugt
#address Benannter Anker $anchor in 2020-12 Draft-07 schrieb $id: "#address"
common.json Datei relativ zur Basis-URI Alle Muss registriert oder gebündelt sein
https://example.com/x.json Absolute URI Alle In der Produktion kein Abruf zur Laufzeit

Eine subtile Falle: Deklariert ein Subschema innerhalb von $defs eine eigene $id, wird diese zur neuen Basis-URI für alles darunter. Fügen Sie ein aus einer anderen Datei kopiertes Schema ein, und die übrig gebliebene $id leitet seine relativen Referenzen zum ursprünglichen Host um – und der Validator merkt es erst, wenn er sie tatsächlich aufzulösen versucht.

3. $defs ist ein Container, keine Validierungsstelle

$defs ist eine gewöhnliche Map. Nichts darin wirkt, bis ein $ref dorthin zeigt. Eine required-Liste oben in $defs ohne eingehende Referenz validiert exakt nichts, so streng sie auch aussieht. Das ist die Spezifikation, wie sie gedacht ist, kein Validator-Bug.

{
  "$defs": {
    "money": {
      "type": "string",
      "pattern": "^[0-9]+\\.[0-9]{2}$",
      "description": "A two-decimal money string that avoids floating-point drift"
    },
    "page": { "type": "integer", "minimum": 1 }
  },
  "properties": {
    "total": { "$ref": "#/$defs/money" },
    "page": { "$ref": "#/$defs/page" }
  }
}

Einige Konventionen lohnt es sich im Team festzulegen:

  • Schlüssel innerhalb von $defs tragen keine Semantik. address, Address und addr sind alle zulässig, einigen Sie sich also auf einen Stil.
  • Vor 2019-09 hieß das Schlüsselwort definitions. Beide lassen sich per $ref ansteuern, aber der Strict Mode meldet definitions womöglich als unbekanntes Schlüsselwort.
  • $defs kann jedes Schema enthalten, einschließlich eines allOf-Fragments oder eines eigenständigen Subschema mit eigener $id.
  • Behandeln Sie $defs nicht als Template-Vererbung. Es speichert nur Definitionen; die Bedeutung kommt weiterhin aus dem referenzierenden Kontext.

4. allOf, anyOf und oneOf sind nicht austauschbar

Alle drei beinhalten mehrere Zweige, aber ihre Akzeptanzregeln unterscheiden sich. Passt Daten auf zwei Zweige, bestehen allOf und anyOf, während oneOf scheitert – der häufigste Grund, aus dem Leute zu dem Schluss kommen „das Schema ist kaputt“.

Schlüsselwort Mindestübereinstimmungen Höchstübereinstimmungen Daten passen auf zwei Zweige Fehlerqualität Typische Verwendung
allOf Alle Unbegrenzt Bestehen Gut, eine Beschränkung nach der anderen Beschränkungsfragmente kombinieren
anyOf 1 Unbegrenzt Bestehen Schlecht, Zweige häufen sich „E-Mail oder Telefon, eines davon“
oneOf 1 1 Scheitern Schlecht Diskriminierte Unions
not 0 0 Scheitern Am schlechtesten Wenige Werte ausschließen; selten verwendet
{
  "allOf": [
    { "$ref": "#/$defs/auditable" },
    { "$ref": "#/$defs/softDeletable" },
    { "required": ["email"] }
  ],
  "unevaluatedProperties": false
}

allOf verschmilzt keine Objektdefinitionen; es verlangt nur, dass jeder Zweig gilt. Jeder Zweig wird unabhängig ausgewertet, ein additionalProperties: false innerhalb eines Zweigs kennt also nur die von diesem Zweig deklarierten Schlüssel und markiert von einem anderen Zweig deklarierte Felder als zusätzlich. In 2020-12 besteht die Lösung darin, die Richtlinie für unbekannte Eigenschaften auf die oberste Ebene zu legen und unevaluatedProperties: false zu verwenden, das einmal nach allen Zweigen ausgewertet wird.

Verwenden Sie anyOf für „mehrere zulässige Formen“, nicht für „mehrere sich gegenseitig ausschließende Formen“. Können zwei Zweige gleichzeitig passen, erzeugt oneOf das berühmtherrlich nutzlose must match exactly one schema in oneOf.

5. if/then/else und Diskriminator: Bedingungen entscheidbar machen

Bedingte Beschränkungen erzeugen tendenziell bessere Fehler als oneOf, weil der Entscheidungspfad ausdrücklich ist: if besteht, then gilt; sonst gilt else.

{
  "type": "object",
  "required": ["kind"],
  "properties": { "kind": { "enum": ["text", "image"] } },
  "if": {
    "properties": { "kind": { "const": "image" } },
    "required": ["kind"]
  },
  "then": { "required": ["url", "width", "height"] },
  "else": { "required": ["content"] }
}

Drei Details bestimmen, ob es wie beabsichtigt funktioniert:

  1. Ein properties-Eintrag innerhalb von if scheitert nicht, wenn der Schlüssel fehlt. Paaren Sie ihn mit required, um „kind existiert und ist gleich image“ auszudrücken.
  2. if selbst erzeugt nie einen Validierungsfehler. Scheitert es, geht es einfach zu else, es kann schlechte Daten also nicht allein blockieren.
  3. Ein einsames then oder else ist wirkungslos. Beide gelten nur neben einem if.

Mit wachsenden Union-Zweigen wird die Bedingungskette zu einem unwartbaren Nest. Ein robusteres Muster ist, in jedem Zweig ein Tag-Feld festzulegen, sodass die Zweige per Konstruktion gegenseitig ausschließend sind:

{
  "oneOf": [
    {
      "properties": { "kind": { "const": "card" } },
      "required": ["kind", "cardNumber"]
    },
    {
      "properties": { "kind": { "const": "account" } },
      "required": ["kind", "accountId"]
    }
  ]
}

discriminator ist eine OpenAPI-3.x-Erweiterung, kein JSON-Schema-Schlüsselwort. Ajv unterstützt es, wenn die Option discriminator: true aktiviert ist, aber sein Mapping lebt in mapping und ist nicht über Toolchains hinweg portabel. Muss das Schema in mehreren Validatoren funktionieren, bevorzugen Sie das const-Tag-Muster, das von keiner Erweiterung abhängt.

6. Rekursive Referenzen: Bäume, verkettete Listen und Dateien, die sich gegenseitig zitieren

Rekursive Strukturen brauchen keine besondere Syntax – ein $ref auf sich selbst genügt. Validatoren expandieren Referenzen zur Kompilierzeit nicht unendlich; sie rekursieren gemäß der tatsächlichen Datentiefe, sodass ein paar Dutzend Zeilen einen Baum beliebiger Höhe beschreiben.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://bbdrug.com/schemas/tree-node.json",
  "$defs": {
    "node": {
      "type": "object",
      "required": ["name"],
      "properties": {
        "name": { "type": "string", "minLength": 1 },
        "children": {
          "type": "array",
          "items": { "$ref": "#/$defs/node" }
        }
      }
    }
  },
  "$ref": "#/$defs/node"
}

Drei Grenzen lohnt es sich vorab zu klären:

  • Datentiefe: Das Schema setzt keine Grenze, aber Validierung und Serialisierung verbrauchen beide Stack proportional zur Tiefe. Öffentliche APIs sollten ein Geschäftslimit ergänzen, etwa acht Ebenen, sonst kann eine böswillig tiefe Payload den Stack überlaufen.
  • Gegenseitige Referenzen: A verweist auf B und B auf A ist zulässig, aber beide Schemas müssen zuerst registriert sein; sonst entscheidet die Auflösungsreihenfolge über das Ergebnis.
  • Nullable-Zweige: Kann ein rekursives Feld null sein, schreiben Sie "type": ["object", "null"] oder wickeln Sie es in oneOf. $ref erbt keine Nullability.

7. Dateiübergreifende Referenzen und Bündelung: Schemas niemals in der Produktion abrufen

Dateiübergreifende Referenzen „funktionieren“ in der Entwicklung oft einfach, weil der Validator das Ziel über $id per Netzwerk abruft. Ändern Sie zur Deployment-Zeit die Netzwerkrichtlinie, und die ganze Validierungskette bricht. Die Produktionsregel ist einfach: Jedes referenzierte Schema muss im Prozess registriert oder vorab gebündelt sein.

import Ajv2020 from 'ajv/dist/2020';
import addressSchema from './address.json' with { type: 'json' };
import orderSchema from './order.json' with { type: 'json' };

const ajv = new Ajv2020({ allErrors: true, strict: true });

// Register the referenced file first; the key must equal its $id, or relative refs won't resolve
ajv.addSchema(addressSchema, 'https://bbdrug.com/schemas/address.json');

// Compile the entry schema once, at module scope
export const validateOrder = ajv.compile(orderSchema);

Hat order.json $id: "https://bbdrug.com/schemas/order.json", löst sein "$ref": "address.json" zu https://bbdrug.com/schemas/address.json auf, was zum registrierten Schlüssel passt. Ein einzelnes abweichendes Zeichen ergibt can't resolve reference.

Die drei praktischen Ansätze vergleichen sich so:

Ansatz Wie Stärke Kosten
Zur Laufzeit registrieren addSchema pro Datei Kein Build-Schritt, Dateien bleiben unabhängig Registrierungsreihenfolge und Schlüssel sind manuell
Zur Build-Zeit bündeln Mit einem Ref-Parser zu einer Datei zusammenführen Null Laufzeitabhängigkeiten Größeres Artefakt, unruhige Diffs
Asynchrones Laden loadSchema + compileAsync Bewältigt sich häufig ändernde Schemas Netzwerk- und Cache-Fragen; auf kritischen Pfaden meiden

Für Frontend-Projekte ist Bündeln zur Build-Zeit sicherer. Auf dem Server genügt Registrierung, sofern ein Test belegt, dass jede Referenz aufgelöst wird. In beiden Fällen compile in CI ausführen, damit Referenzfehler vor dem Merge auftauchen und nicht bei der ersten Produktionsanfrage.

8. Debug-Reihenfolge: auflösen, kompilieren, dann Zweige prüfen

Fast jedes $ref-Problem lässt sich in drei Stufen lokalisieren, und die Reihenfolge zählt:

Symptom Wahrscheinliche Ursache Behebung
can't resolve reference ... from id ... Tippfehler in $id, falsche relative Basis, Ziel nicht registriert Die $id und die aufgelöste absolute URI ausgeben und mit den registrierten Schlüsseln vergleichen
schema with key or id already exists Dieselbe $id zweimal registriert oder compile wiederholt aufgerufen Registrierung in die Modulinitialisierung verschieben und einmal ausführen
strict mode: unknown keyword: "definitions" Draft-07-Syntax gemischt In $defs umbenennen; Strict Mode nicht deaktivieren
Daten passen auf zwei oneOf-Zweige Überlappende Zweigbeschränkungen, kein Tag-Feld const in jeden Zweig ergänzen oder zu anyOf wechseln
Beschränkungen in $defs wirken nicht Nichts referenziert sie Den Referenzpfad prüfen; $defs validiert von sich aus nichts

Beim Debuggen lesen Sie schemaPath vor message: Er nennt den gescheiterten Zweig und das Schlüsselwort und ist weit verlässlicher als englische Prosa. Cachen Sie den kompilierten Validator auf Modulebene, statt bei jeder Anfrage neu zu kompilieren. Decken Sie bei jeder Schemaänderung sowohl einen bestehenden als auch einen scheiternden Fall ab – nur den Happy Path zu testen testet nichts. Zur Validatorauswahl und zum Fehlermeldungsdesign siehe JSON Schema: von den Grundlagen bis zur Produktion; zum Einbinden eines Schemas in die Typgenerierung siehe Zweiweg-Synchronisierung von Schema und TypeScript-Typen.

9. Häufige Fragen

Kann $ref neben type oder description stehen?

2020-12 erlaubt $ref neben Schwester-Schlüsselwörtern, und diese Geschwister werden normal ausgewertet. In draft-07 werden die Geschwister ignoriert und nur $ref gilt. Das ist der größte Verhaltensunterschied, wenn dasselbe Schema über Drafts hinweg verwendet wird, bestätigen Sie also vor der Migration, welchen Draft Ihr Validator unterstützt.

Sollte ich $defs oder definitions verwenden?

Verwenden Sie $defs für neue Arbeit; es kam mit 2019-09. definitions ist kein Schlüsselwort in 2020-12. Ein Pointer wie $ref: "#/definitions/foo" kann trotzdem auflösen, weil er reiner JSON Pointer ist, aber der Strict Mode meldet ein unbekanntes Schlüsselwort. Beide Stile zu mischen hinterlässt eine Landmine, die erst in CI explodiert.

Warum lehnt oneOf gültig aussehende Daten ab?

Weil die Daten gleichzeitig zwei Zweige erfüllen. oneOf verlangt genau einen, und ein Zweig, der nur einen Teil des Objekts beschränkt, ist auch dann erfüllt, wenn die Felder des anderen Zweigs fehlen. Ergänzen Sie in jedem Zweig einen const-Tag, um sie gegenseitig ausschließend zu machen.

Laufen rekursive Referenzen endlos?

Nein. Validatoren expandieren Referenzen nicht zur Kompilierzeit; sie rekursieren über die tatsächlichen Daten. Die echten Risiken sind Daten, die tief genug sind, um den Stack zu überlaufen, und falsche Auflösungsreihenfolge zwischen sich gegenseitig referenzierenden Dateien. Ein Geschäftslimit für die Tiefe rekursiver Felder ist wirksamer als jeder Schema-Trick.

Kann die Produktion sich auf entfernte $ref verlassen?

Nicht. Entfernte Referenzen binden die Validierungsverfügbarkeit an das Netzwerk: Domainänderungen, abgelaufene Zertifikate und interne Netzisolierung brechen die Kette alle. Bündeln Sie die referenzierten Schemas ins Artefakt oder registrieren Sie sie beim Start, wenn Sie vorhersehbares Verhalten wollen.

Wie entscheide ich zwischen $defs und separaten Dateien?

Eine Struktur, die an zwei Stellen erscheint, darf einmal kopiert werden; erscheint sie an einer dritten, heben Sie sie in $defs. Erst wenn eine andere Sprache oder ein anderes Repository sie lesen muss, verdient sie eine eigenständige Datei mit eigener $id.

Warum wirken Beschränkungen in $defs nicht?

$defs ist nur ein Container, und Knoten, auf die kein $ref zeigt, nehmen nie an der Validierung teil. Eine required-Liste oben in $defs ohne eingehende Referenz lässt Daten trotzdem passieren – das ist die Spezifikation, kein Validator-Bug.

Sollte ich additionalProperties: false in allOf-Zweige legen?

Nein. Jeder Zweig wird unabhängig ausgewertet, ein Zweig behandelt also von einem anderen Zweig deklarierte Felder als zusätzlich. Der 2020-12-Ansatz verschiebt die Richtlinie für unbekannte Felder auf die oberste Ebene und verwendet unevaluatedProperties: false, ausgewertet einmal nach allen Zweigen.