API-Design und Verträge Teil 1: Stile und Kompatibilität

Track Konzepte · Architektur und Evolution · ca. 40 Min.

Worum es geht

Zwei Teams, zwei Systeme, eine Schnittstelle dazwischen. Heute fügt das eine Team ein Feld hinzu, morgen entfernt es eins, und irgendwo stürzt eine App ab, die niemand auf dem Schirm hatte. Das ist Evolution von Verträgen (contract evolution): Jede Schnittstelle ist ein Versprechen, und Versprechen ändern sich, ohne dass alle Beteiligten gleichzeitig aktualisieren können.

Am Ende dieses ersten Teils kannst du:

  • REST, GraphQL und gRPC nach Randbedingungen auswählen,
  • Backward und Forward Compatibility an einer Schema-Änderung entscheiden (und einen Prüfer dafür schreiben),
  • ein Feld mit Abkündigung (Deprecation) sicher ersetzen.

Teil 2 (Migration und Systemdesign) wendet das auf ganze Systeme an: schrittweise Migration und Überschlagsrechnung.

Plane 40 Minuten ein: ca. 15 Minuten Lesen, ca. 25 Minuten für drei Übungen. Eine gute Pause ist nach Übung 2.

Von JS/TS her gedacht

Du kennst das aus TypeScript: Ein Interface ist ein Vertrag, aber nur innerhalb eines Builds. Sobald Daten über das Netz gehen (HTTP, Queue, Datei), prüft kein Compiler mehr, ob beide Seiten dieselbe Version des Typs kennen.

Konzept JS/TS Python (hier)
Vertrag interface Zahlung { id: number } Schema als dict (oder Pydantic, siehe KI-Track)
Unbekannte Felder ignorieren Destrukturieren: const {id, betrag} = msg msg["id"], der Rest wird nie gelesen
Fehlendes Feld mit Default msg.waehrung ?? "EUR" msg.get("waehrung", "EUR")
Vertrags-Test Pact (JS-Bibliothek für Contract Tests, bitte prüfen) selbst gebauter Prüfer (Schritt 2)
Mobile-API mit freier Feldwahl GraphQL-Client (Apollo) Server-Seite z. B. Strawberry (bitte prüfen)

Der wichtigste Trick ist der tolerante Leser (tolerant reader): Lies nur, was du brauchst, ignoriere den Rest, setze für Fehlendes einen Default. Hier zwei Leser, einer für das alte und einer für das neue Format einer Zahlung, ausgeführt mit Node:

const alt = {id: 7, betrag: 1200};                    // geschrieben mit Schema v1
const neu = {id: 8, betrag: 900, waehrung: "EUR"};    // geschrieben mit Schema v2
const leseV1 = (m) => ({id: m.id, betrag: m.betrag});
const leseV2 = (m) => ({id: m.id, betrag: m.betrag, waehrung: m.waehrung ?? "EUR"});
console.log(leseV1(neu));
console.log(leseV2(alt));

Ausgabe:

{ id: 8, betrag: 900 }
{ id: 7, betrag: 1200, waehrung: 'EUR' }

Der alte Leser kommt mit der neuen Nachricht klar (er ignoriert waehrung), und der neue Leser kommt mit der alten klar (er setzt einen Default). Merke dir dieses Bild, es ist der Kern von Schritt 2.

Konzept

Schritt 1: REST, GraphQL, gRPC und die Frage nach der Version

Drei Stile für Schnittstellen, die du auswählen können musst. Keiner ist “der beste”, jeder löst ein anderes Problem:

REST (Representational State Transfer) GraphQL gRPC
Idee Ressourcen mit URLs, Verben über HTTP-Methoden (GET /bestellungen/17) Ein Endpunkt, der Client beschreibt in einer Abfrage genau die Felder, die er will Funktionsaufrufe über ein binäres Protokoll, Schema in einer .proto-Datei (Protocol Buffers)
Stärke Einfach, mit curl testbar, HTTP-Caching (CDN) funktioniert, jede Sprache kann es Kein Over-Fetching (zu viele Felder) und kein Under-Fetching (zu viele Requests) Kleine Nachrichten, schnell, Code für Client und Server wird aus dem Schema erzeugt, Streaming eingebaut
Schwäche Viele Requests oder zu große Antworten, wenn Clients verschiedene Ausschnitte brauchen Caching schwieriger, ein teurer Query kann den Server belasten, mehr Aufwand im Server Browser und curl brauchen Hilfsmittel, Antworten nicht von Hand lesbar
Typischer Einsatz Öffentliche APIs, einfache Dienste Mobile Apps und Frontends mit wechselnden Bildschirmen Interne Service-zu-Service-Aufrufe mit hohem Durchsatz

Over-Fetching heißt: Du bekommst 40 Felder und brauchst 3. Under-Fetching heißt: Für einen Bildschirm brauchst du fünf Requests nacheinander. GraphQL löst beides, weil der Client die Form der Antwort bestimmt. REST und gRPC liefern, was der Server festlegt.

Versionierung (versioning) beantwortet die Frage: Was tue ich, wenn ich eine Änderung nicht kompatibel hinbekomme? Zwei verbreitete Wege:

Version in der URL Version im Header
Beispiel GET /v2/bestellungen/17 GET /bestellungen/17 mit Accept: application/vnd.shop.v2+json
Vorteil Sichtbar, im Browser und in Logs sofort erkennbar, einfach zu routen URL bleibt stabil (“eine Ressource, eine Adresse”)
Nachteil Die Adresse einer Ressource ändert sich Versteckt, schwerer zu testen und zu cachen

Die beste Versionierung ist die, die du nicht brauchst: Wenn du Änderungen additiv machst (Felder hinzufügen, nichts entfernen, nichts umdeuten), bleibt die Schnittstelle kompatibel. Eine neue Version ist der letzte Ausweg, denn du musst die alte noch lange mitbetreiben.

Zwei Integrationsmuster noch kurz, damit du die Begriffe einordnen kannst:

  • API Gateway: ein gemeinsamer Eingang vor mehreren Services. Er übernimmt Dinge, die alle brauchen (Authentifizierung, Rate Limiting, Routing). Im nächsten Schritt wirst du sehen: Er ist auch der Ort, an dem die schrittweise Migration stattfindet.
  • BFF (Backend for Frontend): ein eigener kleiner Backend-Dienst pro Frontend-Art (z. B. einer für die Mobile-App, einer für die Web-App). Er schneidet die Daten genau so zu, wie dieses Frontend sie braucht. Das ist die REST-Antwort auf Under- und Over-Fetching. Der Preis: ein zusätzlicher Dienst, den jemand pflegen muss.

Schritt 2: Verträge und Kompatibilität

Ein Vertrag (contract) beschreibt, welche Felder mit welchen Typen eine Nachricht hat und welche davon Pflicht sind. Weil Sender (Writer, Producer) und Empfänger (Reader, Consumer) nicht gleichzeitig aktualisiert werden, laufen zeitweise verschiedene Schema-Versionen nebeneinander. Zwei Richtungen sind wichtig:

  • Backward Compatible (rückwärtskompatibel): Ein neuer Leser kann Daten lesen, die mit dem alten Schema geschrieben wurden. Wichtig, wenn alte Daten oder alte Sender noch existieren (Queue mit Altlasten, gespeicherte Events).
  • Forward Compatible (vorwärtskompatibel): Ein alter Leser kann Daten lesen, die mit dem neuen Schema geschrieben wurden. Wichtig, wenn du den Sender zuerst aktualisierst und alte Empfänger noch laufen.

Ob ein Leser mit den Daten eines Schreibers zurechtkommt, entscheiden drei Regeln für jedes Feld, das der Leser kennt:

  1. Der Leser verlangt das Feld als Pflicht, aber der Schreiber hat es nicht (oder nur als optional, es kann also fehlen): Der Leser scheitert.
  2. Beide kennen das Feld, aber mit verschiedenen Typen: Der Leser scheitert.
  3. Der Leser kennt das Feld optional und der Schreiber hat es nicht: kein Problem (Default). Felder, die nur der Schreiber kennt, ignoriert der Leser.

Durchgerechnet an einem Beispiel. Schema v1 einer Zahlung: id (int, Pflicht), betrag (int, Pflicht). Schema v2 fügt waehrung (string) hinzu.

Änderung Backward (Leser v2, Daten v1) Forward (Leser v1, Daten v2)
waehrung als Pflicht hinzufügen nein: Leser v2 verlangt waehrung, alte Daten haben es nicht (Regel 1) ja: Leser v1 ignoriert das unbekannte Feld
waehrung als optional hinzufügen ja: fehlt es, nimmt Leser v2 den Default (Regel 3) ja: Leser v1 ignoriert es

Das ist der Grund für die Faustregel “Felder nur optional hinzufügen”. Ein neues Pflichtfeld bricht alle alten Daten.

Drei Werkzeuge, die das im Betrieb absichern:

  • Schema Registry: ein zentraler Dienst, der alle Schema-Versionen speichert und beim Registrieren einer neuen Version automatisch prüft, ob sie kompatibel zur vorherigen ist. Eine inkompatible Änderung wird schon beim Veröffentlichen abgelehnt, nicht erst im Betrieb. (Bekannt aus der Kafka-Welt, Produktdetails bitte prüfen.)
  • Consumer-driven Contract Tests (CDC): Nicht der Provider entscheidet, was “kompatibel” heißt, sondern der Consumer schreibt auf, welche Felder und Typen er tatsächlich benutzt. Der Provider testet in seiner CI (Continuous Integration) jede Änderung gegen diese Verträge aller Consumer. So darf der Provider alles ändern, was kein Consumer braucht, und merkt sofort, wenn er etwas bricht, das jemand braucht.
  • Deprecation (Abkündigung): Bevor etwas entfernt wird, wird es als veraltet markiert, die Nutzer werden informiert, die Nutzung wird gemessen, und erst wenn niemand mehr zugreift, wird es entfernt. In HTTP gibt es dafür die Header Deprecation und Sunset (bitte prüfen, ob und wie die Standards aktuell sind).

Ein Consumer-Vertrag ist nur eine Tabelle “Feld, Typ”. Hier ein ausgeführtes Beispiel. Der Consumer (eine Rechnungs-App) liest id, betrag und waehrung. Vier verschiedene Provider-Antworten:

Ausgabe:

v1 []
v2 []
v3 ['betrag: Typ str statt int']
v4 ['waehrung: fehlt']

v2 ist in Ordnung (zusätzliches Feld, das keiner braucht). In v3 ist kunde verschwunden, und der Test bleibt grün, weil der Consumer kunde nie liest. Gebrochen wird nur betrag. Genau das meint “consumer-driven”: Der Vertrag beschreibt die tatsächliche Nutzung, nicht das ganze Schema.

Falle

  1. Pflichtfeld hinzufügen “ist ja nur ein Feld”. Es bricht alle alten Daten und alle Sender, die es noch nicht kennen.
  2. Umbenennen ist keine kleine Änderung. Aus Sicht des Vertrags ist es “Feld entfernen plus Feld hinzufügen”, also in beide Richtungen inkompatibel. Erst neues Feld ergänzen, beide parallel befüllen, alte Leser migrieren, dann das alte Feld abkündigen und entfernen.

Übungen

Übung 1: REST, GraphQL oder gRPC? (Trade-off, ca. 6 Min.)

Drei unabhängige Fälle. Trage ein Tupel mit drei Buchstaben ein, z. B. ("A", "B", "D").

Fall 1. Ein Zahlungsanbieter stellt eine Schnittstelle für unbekannte Partnerfirmen bereit. Die Partner nutzen verschiedene Programmiersprachen und probieren Aufrufe oft zuerst mit curl im Terminal aus. Die Daten ändern sich selten, Antworten sollen von einem CDN (Content Delivery Network) zwischengespeichert werden.

  • A GraphQL, weil jeder Partner mit einer Abfrage genau die Felder bekommt, die er für seine Anwendung braucht.
  • B gRPC, weil das binäre Protokoll schnell ist und der Vertrag durch das Schema strikt erzwungen wird.
  • C Direkter Lesezugriff auf die Datenbank, weil dann keine eigene Schnittstellenschicht gepflegt werden muss.
  • D REST mit JSON, weil URLs mit curl testbar sind und GET-Antworten vom CDN gespeichert werden können.

Fall 2. Eine React-Native-App hat vier Bildschirme, jeder braucht andere Ausschnitte derselben Daten (Profil, Bestellungen, Empfehlungen). Der Startbildschirm macht heute fünf Requests nacheinander über ein langsames Mobilnetz und bekommt viele Felder, die er nie anzeigt. Das Frontend-Team ändert die benötigten Felder fast jede Woche und soll dafür nicht aufs Backend warten. Für einen zusätzlichen Backend-Dienst pro App gibt es weder Personal noch Betrieb.

  • A GraphQL, weil jeder Bildschirm in einer einzigen Abfrage genau seine Felder holt, ohne neue Endpunkte.
  • B REST mit einem eigenen Endpunkt pro Bildschirm, den das Backend-Team nach Wunsch zuschneidet.
  • C gRPC, weil das binäre Format Datenvolumen spart und aus dem Schema Clients erzeugt werden.
  • D REST mit HTTP-Caching, weil wiederholte Abfragen schneller werden und das Format unverändert bleibt.

Fall 3. Zwölf interne Services (Go und Python) rufen sich zusammen etwa 20.000-mal pro Sekunde auf. Das Latenzbudget ist knapp. Die Verträge sollen vom Compiler und per Codegenerierung geprüft werden, und einige Aufrufe sollen einen Strom von Updates liefern. Es gibt keine Browser als Aufrufer.

  • A REST mit JSON, weil jeder Entwickler es kennt und die Antworten im Browser lesbar sind.
  • B gRPC, weil Schema-Dateien Code für beide Sprachen erzeugen, Nachrichten klein sind und Streaming eingebaut ist.
  • C GraphQL, weil ein Gateway alle zwölf Services unter einem Schema vereint und Aufrufer Felder wählen.
  • D Eine gemeinsame Datenbank, weil dann zwischen den Services gar keine Aufrufe mehr nötig sind.

Suche in jedem Fall die Randbedingung, die einen Stil ausschließt oder bevorzugt: Wer ruft auf, wie wird getestet, was wird zwischengespeichert, wer ändert was wie oft?

antwort = ("D", "A", "B")
antwort

Fall 1, D (REST). Unbekannte Partner, curl und CDN sprechen für das einfachste, am weitesten verstandene Protokoll mit HTTP-Caching. GraphQL (A) hilft beim Zuschneiden, aber POST-Abfragen lassen sich schwer cachen. gRPC (B) ist von außen mit curl kaum nutzbar. Direkter Datenbankzugriff (C) gibt jede Interna preis und macht jede Schema-Änderung zum Bruch für alle Partner.

Fall 2, A (GraphQL). Wechselnde Felder pro Bildschirm, langsames Netz und Under-/Over-Fetching sind genau das Problem, das GraphQL löst, ohne dass das Backend bei jeder Änderung angefasst wird. Ein Endpunkt pro Bildschirm (B) löst es nur, solange das Backend-Team jede Änderung umsetzt. gRPC (C) spart Bytes, ändert aber nichts an Anzahl und Zuschnitt der Anfragen. HTTP-Caching (D) beschleunigt Wiederholungen, nicht das Zusammensuchen der Felder.

Fall 3, B (gRPC). Interner Verkehr, mehrere Sprachen, Codegenerierung aus dem Schema, hoher Durchsatz und Streaming: das sind die Stärken von gRPC. REST (A) liefert keinen erzwungenen Vertrag und ist größer und langsamer. GraphQL (C) löst das Zuschneidungs-Problem von Frontends, das hier nicht besteht. Eine gemeinsame Datenbank (D) koppelt die Services eng aneinander, sie ist kein Schnittstellen-Stil.

Übung 2: Kompatibilitätsprüfer für Schemas (ca. 12 Min.)

Schreibe kompatibilitaet(alt, neu). Beide Parameter sind Schemas als dict: Der Schlüssel ist der Feldname, der Wert ist {"typ": ..., "pflicht": ...} (Typ ein String wie "int", Pflicht ein bool). Die Funktion gibt ein Tupel (backward, forward) aus zwei bool zurück, nach den drei Regeln aus Schritt 2:

  • backward: Kann ein Leser mit dem neuen Schema Daten lesen, die mit dem alten geschrieben wurden?
  • forward: Kann ein Leser mit dem alten Schema Daten lesen, die mit dem neuen geschrieben wurden?

Ein Beispiel für die Form der Daten (der Check prüft viele weitere Fälle, auch Typänderungen und Umbenennen):

alt = {"id": {"typ": "int", "pflicht": True}}
neu = {"id": {"typ": "int", "pflicht": True}, "tag": {"typ": "string", "pflicht": False}}
# kompatibilitaet(alt, neu) liefert (True, True)

Tipp zum Aufbau: Beide Richtungen sind dieselbe Frage mit vertauschten Rollen. Überlege, welche Hilfsfunktion dir die Hälfte der Arbeit spart, bevor du anfängst.

Gehe für lesbar(leser, schreiber) jedes Feld des Lesers durch. Was muss der Schreiber mitbringen, wenn das Feld Pflicht ist, und was, wenn nicht? Welche Rolle spielt das Pflicht-Flag beim Schreiber?

def lesbar(leser, schreiber):
    for name, feld in leser.items():
        s = schreiber.get(name)
        if s is None:
            if feld["pflicht"]:
                return False          # Pflichtfeld fehlt in den Daten
            continue                  # optional und fehlt: Default
        if s["typ"] != feld["typ"]:
            return False              # gleicher Name, anderer Typ
        if feld["pflicht"] and not s["pflicht"]:
            return False              # Daten können das Feld weglassen
    return True                       # Felder nur beim Schreiber: ignoriert

def kompatibilitaet(alt, neu):
    backward = lesbar(neu, alt)       # neuer Leser, alte Daten
    forward = lesbar(alt, neu)        # alter Leser, neue Daten
    return backward, forward

kompatibilitaet

Der Schlüssel ist die Prüfung feld["pflicht"] and not s["pflicht"]: Ein Leser, der ein Feld verlangt, darf sich nicht auf einen Schreiber verlassen, der es nur optional liefert. Umbenennen ist Entfernen plus Hinzufügen, deshalb scheitert es in beiden Richtungen, wenn beide Felder Pflicht sind.

Übung 3: Ein Feld sicher ersetzen (Trade-off, ca. 6 Min.)

Das Zahlungs-Team liefert in seiner API das Feld betrag_euro (Dezimalzahl). Es soll durch betrag_cent (ganze Zahl) ersetzt werden. Vier interne Consumer lesen das alte Feld, dazu zwei Partner-Apps, die du nicht steuern kannst und die nur alle drei Monate ein Release machen. Randbedingungen: Kein Consumer darf abstürzen, und die Partner-Apps laufen noch lange mit dem alten Stand. Wie führst du die Änderung durch?

  • A Zusätzlich betrag_cent befüllen, betrag_euro weiter liefern, dessen Nutzung messen und erst bei null entfernen.
  • B Das Feld umbenennen und alle Consumer vorab per Mail informieren, damit alle am Stichtag gleichzeitig umgestellt haben.
  • C Eine neue Version /v2 mit nur dem neuen Feld veröffentlichen und /v1 am selben Tag abschalten, damit Ruhe einkehrt.
  • D Das alte Feld auf 0 setzen, damit nichts abstürzt, und betrag_cent ergänzen, das nur die neuen Consumer lesen.

Welche der Optionen bricht in einem Moment alle Leser des alten Felds, und welche lässt sie ungestört weiterlaufen, bis sie nachweislich umgestellt sind? Achte auch darauf, ob ein Leser die Änderung bemerkt oder still mit falschen Werten weiterarbeitet.

antwort = "A"
antwort

A ist additiv und abkündigend: Beide Felder laufen parallel, die Nutzung des alten wird gemessen, und erst wenn niemand mehr zugreift, wird es entfernt. So brechen weder interne Consumer noch die träge Partner-Apps. B ist ein Umbenennen, also Entfernen plus Hinzufügen, und bricht alle alten Leser am Stichtag. C bricht jeden, der /v1 noch nutzt, am selben Tag. D hält die alten Leser formal am Leben, aber sie lesen still den Betrag 0 und rechnen falsch weiter, was schlimmer ist als ein sichtbarer Absturz.

Merksatz

Verträge ändern sich, darum füge Felder nur optional hinzu, prüfe Backward und Forward Compatibility automatisch und ersetze Felder über Abkündigung statt durch Umbenennen.

Prüfstein

  1. Warum ist das Umbenennen eines Felds keine kleine Änderung, und wie führst du es sicher durch?
  2. Wann wählst du REST, wann GraphQL und wann gRPC?

Weiter geht es in Teil 2: Migration und Systemdesign.


Quelle: quellen/konzeptuebersicht-software-grundlagen.md, Abschnitt “6. Design und Architektur” (API-Design: REST, GraphQL, gRPC, Versionierung, Verträge zwischen Systemen); quellen/konzeptuebersicht-software-fortgeschritten.docx, Schicht 6 (Integrationsmuster API Gateway und BFF; Verträge und Evolution: Backward/Forward Compatibility, Schema Registry, Consumer-driven Contract Tests, Deprecation).

Über die Quelle hinaus (allgemeines Fachwissen): die Definition von Backward und Forward Compatibility und die drei Lese-Regeln, der tolerante Leser, die Vergleichstabelle REST/GraphQL/gRPC und der Vergleich URL gegen Header bei der Versionierung, die Beschreibung von Over- und Under-Fetching, die Arbeitsweise einer Schema Registry und von Consumer-driven Contract Tests (Kafka-Bezug, Pact, Strawberry bitte prüfen), die Header Deprecation und Sunset (bitte prüfen), der Ablauf des Feldersetzens in Übung 3. Alle Zahlen im Text stammen aus dem Ausführen des Codes dieser Lektion, nicht aus Messungen realer Systeme.