JSON ist flexibel – zu flexibel. Ohne klare Regeln entstehen Schemata, bei denen
niemand mehr weiß, ob null nicht vorhanden oder explizit leer bedeutet.
Felder wechseln ihren Typ, Enums bekommen neue Werte, und plötzlich crashen
Clients in Produktion.
Dieser Artikel hilft dir, stabile JSON-Schemata zu designen und klare Feldsemantik zu etablieren. Am Ende hast du JSON-Design-Regeln und ein Beispiel-Schema, das als Vorlage für deine API dient.
Zielbild
Nach diesem Artikel kannst du:
- Die Semantik von
null, fehlendem Feld und leerem Wert klar definieren - Typstabilität garantieren und Breaking Changes vermeiden
- Datum/Zeit, Geld und andere komplexe Typen korrekt modellieren
- Mit großen Payloads und Partial Responses umgehen
- Ein konsistentes JSON-Schema für deine API etablieren
Kernfragen
Bevor du weiterliest, versuche diese Fragen für dein Projekt zu beantworten:
- Was bedeutet
null? Nicht gesetzt, explizit gelöscht, oder beides? - Was passiert bei fehlenden Feldern? Ignorieren, Default, Fehler?
- Können sich Feldtypen ändern? String zu Number, Array zu Object?
- Wie modelliert ihr Datum und Geld? ISO 8601? Strings oder Numbers für Beträge?
- Wie geht ihr mit großen Responses um? Alles auf einmal oder Pagination?
Null, fehlendes Feld und leerer Wert
Das ist der häufigste Stolperstein in JSON-APIs. Drei Zustände, die oft verwechselt werden:
Die drei Zustände
| Zustand | JSON | Bedeutung |
|---|---|---|
| Feld vorhanden mit Wert | "name": "Max" |
Wert ist gesetzt |
| Feld vorhanden mit null | "name": null |
Wert ist explizit leer/gelöscht |
| Feld fehlt | (kein name) |
Wert ist unbekannt oder bewusst nicht geliefert |
Für Responses
Empfehlung: In Default-Responses Felder immer inkludieren, auch wenn
null. Das macht Schemata vorhersagbar. Fehlende Felder sollten nur bei
Sparse Fieldsets vorkommen und bedeuten dann nicht angefragt.
{
"id": "user_abc123",
"name": "Max Mustermann",
"nickname": null,
"deleted_at": null
}
Anti-Pattern: Felder weglassen, wenn sie null sind (außer bei
Sparse Fieldsets). Clients wissen dann nicht, ob das Feld existiert oder ob sie
eine alte API-Version nutzen.
Für Requests (Updates)
Bei PATCH-Requests wird es kompliziert. Drei Strategien:
Strategie 1: Nur gesendete Felder ändern (Standard)
{
"name": "Neuer Name"
}
→ Nur name wird geändert, andere Felder bleiben unverändert.
Strategie 2: Explizites Null zum Löschen
{
"nickname": null
}
→ nickname wird auf null gesetzt (gelöscht).
Strategie 3: Merge-Patch (RFC 7396)
Kombiniert beide: Gesendete Felder werden gesetzt, null löscht.
{
"name": "Neuer Name",
"nickname": null
}
→ name wird gesetzt, nickname wird gelöscht.
Regel: Dokumentiere explizit, welche Strategie gilt. Partial Update ist nicht selbsterklärend.
Typstabilität garantieren
Feldtypen dürfen sich nicht ändern. Ein Feld, das heute ein String ist, muss morgen auch ein String sein.
Breaking Changes bei Typen
| Änderung | Breaking? | Beispiel |
|---|---|---|
| String → Number | Ja | "count": "5" → "count": 5 |
| Number → String | Ja | Umgekehrt |
| Object → Array | Ja | "tags": {} → "tags": [] |
| Single → Array | Ja | "author": "Max" → "author": ["Max"] |
| Neuer Enum-Wert | Nein* | status bekommt neuen Wert "archived" |
| Enum-Wert entfernen | Ja | status verliert Wert "draft" |
*Clients sollten unbekannte Enum-Werte tolerieren, aber viele tun es nicht.
Regeln für Typstabilität
- Typ einmal festlegen, nie ändern. String bleibt String.
- Arrays von Anfang an. Wenn ein Feld mehrere Werte haben könnte, mach es sofort zum Array.
- Enums erweitern, nie reduzieren. Neue Werte sind OK, alte müssen bleiben.
- Versionierung bei Typänderungen. Wenn du den Typ ändern musst, neue API-Version.
Defensive Clients (Tolerant Reader)
Dokumentiere, dass Clients:
- Unbekannte Felder ignorieren sollen
- Unbekannte Enum-Werte tolerieren sollen
- Nicht auf Feldreihenfolge verlassen sollen
{
"id": "user_abc123",
"name": "Max",
"new_field_v2": "something"
}
→ Client ignoriert das
Datum und Zeit
Datum/Zeit ist ein Minenfeld. Zeitzonen, Formate, Präzision – alles kann schiefgehen.
ISO 8601 – Der einzige akzeptable Standard
{
"created_at": "2026-01-27T14:30:00Z",
"updated_at": "2026-01-27T15:45:00+01:00",
"date_of_birth": "1990-05-15"
}
Regeln:
- Timestamps: ISO 8601 mit Zeitzone (
2026-01-27T14:30:00Z) - Nur Datum: ISO 8601 ohne Zeit (
2026-01-27) - UTC bevorzugen:
Zist eindeutig, Offsets können verwirrend sein - Millisekunden optional:
2026-01-27T14:30:00.123Zwenn Präzision nötig
Anti-Patterns bei Datumsformaten
| Format | Problem |
|---|---|
| Unix Timestamp | 1706363400 – Sekunden oder Millisekunden? Unlesbar |
| Lokales Format | 27.01.2026 – Welche Zeitzone? Welches Locale? |
| Ohne Zeitzone | 2026-01-27T14:30:00 – UTC? Lokal? Unklar |
| Gemischte Formate | created_at als ISO, updated_at als Unix – Chaos |
Ausnahme: JWT-Claims (
exp,iat,nbf) verwenden per RFC 7519 Unix Timestamps (Sekunden seit Epoch). Das ist standardkonform und keine Abweichung von dieser Regel.
Zeitspannen und Dauer
Für Dauer nutze ISO 8601 Duration oder Sekunden:
{
"duration": "PT1H30M",
"timeout_seconds": 5400
}
Empfehlung: Einfache Sekunden sind oft praktikabler als ISO Duration.
Geld und Währung
Geld in APIs falsch zu modellieren führt zu Rundungsfehlern, falschen Beträgen und verärgerten Kunden.
Die Lösung: Kleinste Einheit als Integer
{
"amount": 1999,
"currency": "EUR"
}
Das bedeutet 19,99 EUR (1999 Cent).
Warum Integer?
- Keine Floating-Point-Rundungsfehler
- Exakte Arithmetik
- Keine Diskussion über Dezimalstellen
Währung immer mitliefern
Niemals Beträge ohne Währung. Auch nicht nach dem Motto: ist ja klar, dass wir Euro meinen.
{
"price": {
"amount": 1999,
"currency": "EUR"
},
"shipping": {
"amount": 499,
"currency": "EUR"
}
}
Dezimalstellen pro Währung
Nicht alle Währungen haben 2 Dezimalstellen:
| Währung | Dezimalstellen | 10,00 in kleinster Einheit |
|---|---|---|
| EUR, USD | 2 | 1000 |
| JPY | 0 | 10 |
| KWD | 3 | 10000 |
Regel: Client muss wissen, welche Währung wie viele Dezimalstellen hat, um korrekt anzuzeigen.
Anti-Patterns bei Geldformaten
| Modell | Problem |
|---|---|
"price": 19.99 |
Float – Rundungsfehler möglich |
"price": "19.99" |
String – Besser, aber Parsing nötig |
"price": 1999 ohne Währung |
Was ist das? Cent? Yen? |
Große Payloads handhaben
Nicht jeder Response braucht alle Daten. Große Payloads kosten Bandbreite und Parsing-Zeit.
Sparse Fieldsets
Lass Clients angeben, welche Felder sie brauchen:
GET /users/user_abc123?fields=id,name,email
Response enthält nur die angefragten Felder:
{
"id": "user_abc123",
"name": "Max Mustermann",
"email": "max@example.com"
}
Regel: Die ID ist immer inkludiert, auch wenn nicht angefragt. Erlaube nur eine Allowlist von Feldern und validiere jede Anfrage gegen AuthZ.
Expansion (aus Teil 4)
Umgekehrt: Standardmäßig kompakt, bei Bedarf expandieren:
GET /orders/order_abc123?expand=customer,items
Pagination für Listen
Listen immer paginieren, nie alles auf einmal:
{
"data": [
{
"id": "user_001",
"name": "Max"
},
{
"id": "user_002",
"name": "Anna"
}
],
"pagination": {
"cursor": "abc123",
"has_more": true
}
}
Details folgen in Teil 7.
Binary Data und Dateien
Große Binärdaten (Bilder, PDFs) nicht inline in JSON:
Anti-Pattern:
{
"id": "doc_abc123",
"content": "base64encodedcontent"
}
Besser: URL zum Download:
{
"id": "doc_abc123",
"download_url": "/documents/doc_abc123/content",
"content_type": "application/pdf",
"size_bytes": 1048576
}
Envelope vs. Raw Response
Zwei Stile für API-Responses:
Envelope (Wrapper)
{
"data": {
"id": "user_abc123",
"name": "Max"
},
"meta": {
"request_id": "req_xyz",
"timestamp": "2026-01-27T14:30:00Z"
}
}
Pro: Platz für Metadaten, konsistente Struktur.
Contra: Mehr Nesting, data. Prefix überall.
Raw (ohne Wrapper)
{
"id": "user_abc123",
"name": "Max"
}
Pro: Einfacher, direkter Zugriff. Contra: Metadaten müssen in Header oder fehlen.
Für Listen
Envelope macht bei Listen mehr Sinn wegen Pagination:
{
"data": [
{
"id": "user_001",
"name": "Max"
},
{
"id": "user_002",
"name": "Anna"
}
],
"pagination": {
"cursor": "abc123",
"has_more": true
}
}
Empfehlung: Envelope für Listen, Raw für Einzelressourcen. Oder konsequent Envelope überall.
Konsistente Feldnamen
Feldnamen sollten vorhersagbar sein. Ein paar bewährte Konventionen:
Standardfelder
| Feld | Bedeutung | Typ |
|---|---|---|
id |
Eindeutiger Identifier | String |
created_at |
Erstellungszeitpunkt | ISO 8601 |
updated_at |
Letzte Änderung | ISO 8601 |
deleted_at |
Soft-Delete-Zeitpunkt | ISO 8601 oder null |
Referenzen
Suffix _id für Fremdschlüssel:
{
"id": "order_abc123",
"customer_id": "user_xyz789",
"product_ids": [
"prod_001",
"prod_002"
]
}
Booleans
Präfix is_, has_, can_ für Klarheit:
{
"is_active": true,
"has_verified_email": false,
"can_edit": true
}
Counts und Aggregates
Suffix _count für Zähler:
{
"id": "user_abc123",
"orders_count": 42,
"followers_count": 1337
}
Regeln & Anti-Patterns
Do
- Felder immer inkludieren, auch wenn
null - Typen einmal festlegen, nie ändern
- ISO 8601 für alle Datum/Zeit-Werte
- Geld als Integer in kleinster Einheit + Währung
- Große Binärdaten als URL, nicht inline
- Konsistente Feldnamen mit klaren Konventionen
Don't
- Felder weglassen wenn
null(Schema wird unvorhersagbar) - Feldtypen ändern (String → Number)
- Unix Timestamps oder lokale Datumsformate
- Geld als Float
- Base64-Blobs in JSON
- Feldnamen je nach Endpoint unterschiedlich
Artefakt: JSON-Design-Regeln + Beispiel-Schema
# JSON-Design-Regeln
## Allgemein
- Encoding: UTF-8
- Feldnamen: snake_case
- Null-Handling: Felder immer inkludieren, `null` = explizit leer
- Unbekannte Felder: Clients ignorieren sie (Tolerant Reader)
## Typen
| Konzept | Typ | Format | Beispiel |
|-----------|---------|-------------------|---------------------------------------|
| ID | String | Prefixed ULID | `"user_01ARZ3NDEK"` |
| Timestamp | String | ISO 8601 + TZ | `"2026-01-27T14:30:00Z"` |
| Date | String | ISO 8601 | `"2026-01-27"` |
| Geld | Object | Amount + Currency | `{"amount": 1999, "currency": "EUR"}` |
| Boolean | Boolean | — | `true`, `false` |
| Enum | String | UPPER_SNAKE | `"PENDING"`, `"COMPLETED"` |
## Request-Semantik (PATCH)
- Nur gesendete Felder werden geändert
- `null` setzt Feld auf null (löscht Wert)
- Fehlende Felder bleiben unverändert
## Beispiel-Schema: User
```json
{
"id": "user_01ARZ3NDEK",
"email": "max@example.com",
"name": "Max Mustermann",
"nickname": null,
"role": "ADMIN",
"is_active": true,
"has_verified_email": true,
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-27T14:30:00Z",
"deleted_at": null,
"orders_count": 42,
"default_address_id": "addr_01ARZ3NDEK"
}
```
## Beispiel-Schema: Order
```json
{
"id": "order_01ARZ3NDEK",
"customer_id": "user_01ARZ3NDEK",
"status": "PENDING",
"total": {
"amount": 4999,
"currency": "EUR"
},
"items": [
{
"id": "item_01ARZ3NDEK",
"product_id": "prod_01ARZ3NDEK",
"quantity": 2,
"unit_price": {
"amount": 1999,
"currency": "EUR"
}
}
],
"created_at": "2026-01-27T14:30:00Z",
"updated_at": "2026-01-27T14:30:00Z"
}
```
Checkliste
Bevor du zum nächsten Artikel gehst, prüfe:
- [ ] Null-Handling ist dokumentiert (null vs. fehlendes Feld)
- [ ] PATCH-Semantik ist klar (was passiert bei null, fehlendem Feld)
- [ ] Typen sind stabil und dokumentiert
- [ ] Datum/Zeit nutzt ISO 8601 mit Zeitzone
- [ ] Geld ist als Integer + Währung modelliert
- [ ] Große Payloads haben Strategie (Sparse Fields, Expansion, Pagination)
- [ ] Binärdaten werden als URL referenziert, nicht inline
- [ ] Feldnamen-Konventionen sind dokumentiert
- [ ] Beispiel-Schemata existieren für Kern-Ressourcen
Wie es weitergeht
Im nächsten Teil geht es um Pagination, Filtering und Sorting: Cursor vs. Offset, sichere Filter, und wie du verhinderst, dass Clients deine Datenbank in die Knie zwingen.
Alle Teile der Serie: Serie: API-Design