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.

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
$defstragen keine Semantik.address,Addressundaddrsind alle zulässig, einigen Sie sich also auf einen Stil. - Vor 2019-09 hieß das Schlüsselwort
definitions. Beide lassen sich per$refansteuern, aber der Strict Mode meldetdefinitionswomöglich als unbekanntes Schlüsselwort. $defskann jedes Schema enthalten, einschließlich einesallOf-Fragments oder eines eigenständigen Subschema mit eigener$id.- Behandeln Sie
$defsnicht 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:
- Ein
properties-Eintrag innerhalb vonifscheitert nicht, wenn der Schlüssel fehlt. Paaren Sie ihn mitrequired, um „kind existiert und ist gleich image“ auszudrücken. ifselbst erzeugt nie einen Validierungsfehler. Scheitert es, geht es einfach zuelse, es kann schlechte Daten also nicht allein blockieren.- Ein einsames
thenoderelseist wirkungslos. Beide gelten nur neben einemif.
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
nullsein, schreiben Sie"type": ["object", "null"]oder wickeln Sie es inoneOf.$referbt 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.