TS
Thomas Schmitz

Freiberuflicher IT-Berater, Softwareentwickler & DevOps. Praxisnahe Artikel zu API-Design, Architektur und Cloud-Workflows.

Serie: API-Design · Teil 6 von 22

API-Design Teil 6: Request/Response-Design

Was null vs. fehlendes Feld bedeutet, und wie du mit Datum, Geld und großen Payloads umgehst.

Praxisnah Checkliste

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:

  1. Was bedeutet null? Nicht gesetzt, explizit gelöscht, oder beides?
  2. Was passiert bei fehlenden Feldern? Ignorieren, Default, Fehler?
  3. Können sich Feldtypen ändern? String zu Number, Array zu Object?
  4. Wie modelliert ihr Datum und Geld? ISO 8601? Strings oder Numbers für Beträge?
  5. 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

  1. Typ einmal festlegen, nie ändern. String bleibt String.
  2. Arrays von Anfang an. Wenn ein Feld mehrere Werte haben könnte, mach es sofort zum Array.
  3. Enums erweitern, nie reduzieren. Neue Werte sind OK, alte müssen bleiben.
  4. 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: Z ist eindeutig, Offsets können verwirrend sein
  • Millisekunden optional: 2026-01-27T14:30:00.123Z wenn 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

Mehr Beiträge aus dem Blog.