API-Design: Schnittstellen entwerfen, die man nicht bereut
API-Design: Schnittstellen entwerfen, die man nicht bereut
Artikel-Übersicht
Eine Schnittstelle ist ein Versprechen an alle, die sie benutzen — und anders als bei internem Code kann man es nicht einseitig zurücknehmen. Dieser Leitfaden behandelt deshalb die Entscheidungen, die am teuersten sind, wenn sie schiefgehen. Er beginnt mit API-first als Vorgehen, geht dann durch den Ressourcenschnitt, die richtige Verwendung von HTTP-Methoden und Statuscodes und ein einheitliches Fehlerformat. Der ausführlichste Abschnitt gilt der Versionierung, weil dort die häufigste Fehleinschätzung sitzt: Die meisten Teams wissen nicht genau, was überhaupt eine brechende Änderung ist. Es folgen Paginierung mit dem Argument für Cursor statt Offset, Authentifizierung samt der Frage, wie man einen Schlüssel sicher erzeugt und speichert, Schnittstellen zwischen Diensten und ein kurzer Vergleich von REST, GraphQL und gRPC.
Was eine Schnittstelle ist
Eine Schnittstelle ist ein Vertrag: Sie sagt zu, welche Anfragen entgegengenommen werden und was daraufhin zurückkommt.
Der deutsche Begriff Schnittstelle trifft es dabei besser als das englische Kürzel, weil er die Sache benennt — eine Stelle, an der zwei Systeme aneinandergrenzen und sich auf etwas einigen müssen. In Lastenheften und Ausschreibungen steht deshalb fast immer „Schnittstelle" und selten „API".
Der Unterschied zu internem Code ist grundlegend: Eine Klasse kannst du umbenennen, weil du alle Aufrufer kennst. Eine veröffentlichte Schnittstelle kannst du nicht ändern, weil du sie nicht kennst. Alles, was in diesem Artikel folgt, ergibt sich aus dieser einen Asymmetrie.
API-first: die Spezifikation zuerst
Der übliche Weg ist, die Schnittstelle beim Implementieren entstehen zu lassen und hinterher zu dokumentieren. Das erzeugt Schnittstellen, die die innere Struktur nach außen spiegeln — und die sich mit jeder Umstrukturierung mitändern.
API-first dreht die Reihenfolge um: Erst wird die Schnittstelle als Spezifikation geschrieben und abgestimmt, dann implementiert. Drei Dinge werden dadurch möglich:
- Prüfung vor Implementierung. Ein Entwurf lässt sich in einer halben Stunde durchsprechen. Eine fertige Schnittstelle zu ändern kostet Tage.
- Parallele Arbeit. Anbieter und Nutzer können gleichzeitig anfangen, weil beide wissen, worauf sie sich verlassen können.
- Generierte Clients. Aus der Spezifikation lassen sich Client-Bibliotheken und Servergerüste erzeugen — das spart nicht nur Tipparbeit, es garantiert auch, dass beide Seiten dasselbe verstehen. Wie das praktisch geht, steht in OpenAPI Generator: Client und Server aus der Spezifikation erzeugen.
Der Preis ist Disziplin: Wenn die Spezifikation nach der ersten Woche nicht mehr gepflegt wird, hat man zwei Wahrheiten statt einer.
Ressourcen schneiden
REST denkt in Dingen, nicht in Vorgängen. Die Regeln:
- Substantive im Plural, keine Verben.
/rechnungen, nicht/getRechnungoder/rechnungErstellen. - Hierarchie nur, wo sie trägt.
/rechnungen/{id}/positionenist sinnvoll, wenn eine Position ohne ihre Rechnung nicht existiert./kunden/{id}/rechnungen/{id}/positionen/{id}ist eine Zumutung — ab der zweiten Ebene lieber eine eigene Ressource mit Filter. - Ein Bezeichner, ein Ding. Wenn dieselbe Ressource unter zwei Pfaden erreichbar ist, wird eine davon irgendwann anders behandelt.
Und die Ausnahme, um die man nicht herumkommt: Aktionen, die keine Ressource sind. Ein Passwort zurücksetzen, eine Rechnung stornieren, einen Auftrag freigeben. Zwei saubere Wege:
POST /rechnungen/{id}/stornierung ← die Aktion als Ressource modellieren
PATCH /rechnungen/{id} { "status": "storniert" } ← ZustandsänderungDie erste Variante ist meist die bessere, weil sie einen eigenen Rückgabewert und eigene Fehler haben darf. Was man vermeiden sollte, ist ein POST /stornierenRechnung — das ist eine Fernprozedur mit HTTP-Anstrich.
HTTP richtig benutzen
Die Methoden tragen Zusicherungen, die Clients und Zwischenschichten auswerten:
| Methode | Sicher (ändert nichts) | Idempotent (mehrfach = einmal) | Wofür |
|---|---|---|---|
GET |
ja | ja | lesen |
HEAD |
ja | ja | prüfen ohne Inhalt |
PUT |
nein | ja | vollständig ersetzen |
DELETE |
nein | ja | löschen |
PATCH |
nein | nein | teilweise ändern |
POST |
nein | nein | erzeugen, alles Übrige |
Dass PUT und DELETE idempotent sind, ist keine Formalie: Ein Client, dessen Aufruf in eine Zeitüberschreitung läuft, darf ihn wiederholen. Bei POST darf er das nicht — dafür braucht es einen Idempotenzschlüssel.
Und die Statuscodes, mit dem, was der Client daraus ableiten soll:
| Code | Bedeutung | Was der Client tun soll |
|---|---|---|
200 |
erfolgreich, Inhalt folgt | weiterverarbeiten |
201 |
erzeugt | Ort aus dem Location-Feld merken |
204 |
erfolgreich, kein Inhalt | nichts erwarten |
400 |
Anfrage fehlerhaft | Anfrage korrigieren, nicht wiederholen |
401 |
nicht angemeldet | Anmeldung erneuern |
403 |
angemeldet, aber nicht berechtigt | nicht erneut anmelden |
404 |
nicht vorhanden | aufgeben oder anlegen |
409 |
Konflikt mit dem aktuellen Zustand | Zustand neu laden, dann erneut |
422 |
verstanden, fachlich abgelehnt | fachlichen Fehler anzeigen |
429 |
zu viele Anfragen | warten, Retry-After beachten |
5xx |
Serverfehler | mit Backoff wiederholen |
Der häufigste Fehler ist, alles mit 200 zu beantworten und den Fehler in den Rumpf zu legen. Damit funktioniert keine Zwischenschicht mehr: Kein Proxy erkennt den Fehler, keine Wiederholungslogik greift, keine Überwachung schlägt an. Die Fehlerrate im Dashboard bleibt bei null, während die Anwendung reihenweise scheitert.
Die Unterscheidung zwischen 400 und 422 lohnt: 400 heißt, die Anfrage war schon syntaktisch falsch. 422 heißt, sie war formal in Ordnung, aber fachlich nicht zulässig — etwa eine Stornierung einer bereits stornierten Rechnung.
Fehler zurückgeben
Ein einheitliches Fehlerformat für die gesamte Schnittstelle, maschinenlesbar:
{
"type": "https://api.beispiel.de/fehler/rechnung-bereits-storniert",
"title": "Rechnung bereits storniert",
"status": 422,
"detail": "Die Rechnung R-2026-00417 wurde am 12.11.2026 storniert.",
"instance": "/rechnungen/R-2026-00417/stornierung",
"fehlercode": "RECHNUNG_BEREITS_STORNIERT",
"korrelationsId": "9f2c1e44-8b2a-4a1f-9c1d-2b7e5a3f0c11"
}Vier Eigenschaften machen den Unterschied:
- Ein stabiler Fehlercode zum Auswerten. Menschenlesbare Texte ändern sich, Codes dürfen es nicht.
- Eine Korrelations-ID, damit der Anrufer beim Support eine Nummer nennen kann, die in deinen Protokollen auffindbar ist.
- Keine internen Details. Kein Stacktrace, keine SQL-Anweisung, keine Klassennamen. Das ist eine Sicherheitsfrage.
- Dasselbe Format überall. Auch bei
500, auch beim Rahmenwerk-Fehler. Sonst muss jeder Client zwei Varianten behandeln.
Für das Format gibt es einen etablierten Standard — Problem Details for HTTP APIs —, den man übernehmen und um eigene Felder ergänzen kann. Selbst erfinden lohnt nicht.
Versionierung
Die teuerste Entscheidung des ganzen Artikels, und sie beginnt mit einer Frage, die viele nicht sauber beantworten können.
Was ist überhaupt eine brechende Änderung?
| Änderung | Brechend? |
|---|---|
| Neues optionales Feld in der Antwort | nein |
| Neues Pflichtfeld in der Anfrage | ja |
| Feld aus der Antwort entfernen | ja |
| Feld umbenennen | ja — das ist Entfernen plus Hinzufügen |
| Datentyp ändern (Zahl → Zeichenkette) | ja |
| Neuer Wert in einer Aufzählung | ja, in der Praxis — Clients mit switch scheitern |
| Reihenfolge in einem Array ändern | ja, wenn sie zugesagt war |
| Feld optional machen, das Pflicht war | nein für Sender, ja für Empfänger |
| Statuscode für denselben Fall ändern | ja |
| Fehlerformat ändern | ja |
Die Zeile mit der Aufzählung ist die, die am häufigsten überrascht. Formal ist ein neuer Wert additiv — praktisch bricht er jeden Client, der alle bekannten Werte behandelt und sonst eine Ausnahme wirft. Gegenmittel: In der Spezifikation von Anfang an festhalten, dass Aufzählungen erweiterbar sind und unbekannte Werte toleriert werden müssen.
Wie versionieren?
| Weg | Beispiel | Vorteil | Nachteil |
|---|---|---|---|
| Pfad | /v1/rechnungen |
sofort sichtbar, leicht zu routen | die Version steht in jedem Bezeichner |
| Header | Accept: application/vnd.firma.v2+json |
Bezeichner bleiben stabil | unsichtbar, schwer zu testen, Caching-Fallen |
| Parameter | /rechnungen?version=2 |
einfach | vermischt Version und Abfrage |
Meine Empfehlung ist der Pfad, aus einem pragmatischen Grund: Man sieht die Version in jedem Protokolleintrag, in jedem curl-Aufruf und in jeder Fehlermeldung. Die theoretische Reinheit des Headers wiegt das nicht auf.
Wichtiger als die Wahl ist aber die Haltung: Die beste Versionierung ist die, die man nicht braucht. Additive Änderungen brauchen keine neue Version. Wer von Anfang an so entwirft, dass Erweiterungen möglich sind — optionale Felder, tolerante Clients, erweiterbare Aufzählungen —, kommt jahrelang mit v1 aus.
Paginierung, Filtern, Sortieren
Für Paginierung gibt es zwei Wege, und einer davon ist in den meisten Fällen falsch.
Offset (?offset=40&limit=20) ist einfach und bricht, sobald sich die Daten während des Blätterns ändern. Wird ein Eintrag gelöscht, während jemand auf Seite drei ist, überspringt er einen. Wird einer eingefügt, sieht er einen doppelt. Bei großen Datenmengen kommt hinzu, dass die Datenbank für offset=100000 alle vorherigen Zeilen durchgehen muss.
Cursor (?nach=eyJpZCI6NDJ9&limit=20) übergibt eine Position statt einer Nummer. Der Server gibt in der Antwort den Cursor für die nächste Seite mit. Das ist stabil gegenüber Änderungen und bleibt auch bei Millionen Einträgen schnell.
Die Empfehlung: Cursor, sobald die Datenmenge wachsen kann oder sich häufig ändert. Offset nur für kleine, stabile Listen — dort ist es einfacher und völlig ausreichend.
Für Filter und Sortierung lohnt eine früh festgelegte Konvention, weil sie sonst je Endpunkt anders aussieht:
GET /rechnungen?status=offen&erstellt_ab=2026-01-01&sort=-erstellt&limit=20Authentifizierung und Schlüssel
Drei Verfahren, drei Anwendungsfälle:
- API-Schlüssel — einfach, für Server-zu-Server-Verbindungen mit einem festen Gegenüber.
- Token (meist JWT) — für Sitzungen, mit Ablauf und Ansprüchen im Token.
- OAuth — wenn ein Nutzer einer fremden Anwendung Zugriff auf seine Daten gewährt.
API-Schlüssel richtig erzeugen und speichern
Danach wird auffällig oft gesucht, und die Antwort ist kurz:
import secrets
schluessel = "sk_" + secrets.token_urlsafe(32) # ~256 Bit EntropieDrei Regeln dazu:
- Kryptographisch sicherer Zufall, nicht der gewöhnliche Zufallsgenerator und keine Zeitstempel.
- Als Hash speichern, nicht im Klartext. Ein Schlüssel ist ein Passwort. Wird die Datenbank kopiert, sind alle Schlüssel kompromittiert — es sei denn, dort stehen nur Hashes.
- Ein Präfix voranstellen.
sk_oder ähnlich macht Schlüssel in Protokollen und Repositories erkennbar; Suchwerkzeuge finden versehentlich veröffentlichte Schlüssel daran.
Und was ein Schlüssel niemals tun sollte: in einem Abfrageparameter stehen. Der landet in Server- und Proxy-Protokollen.
Schnittstellen zwischen Diensten
Für interne Schnittstellen gelten andere Regeln als für öffentliche — das ist kein Nachlassen, sondern eine andere Kosten-Nutzen-Lage:
- Man kennt alle Aufrufer. Eine brechende Änderung ist abstimmbar, wenn auch nicht kostenlos.
- Ein API-Gateway ist der Ort für Authentifizierung, Ratenbegrenzung und die eine öffentliche Fassade über mehreren Diensten.
- Vertragstests ersetzen die formale Versionierung teilweise: Der Aufrufer beschreibt seine Erwartung, der Anbieter prüft sie im eigenen Testlauf. Wie das zusammenspielt, steht in Microservices bauen.
Ein Sonderfall, der zeigt, wie sich Schnittstellenfragen wiederholen: Beim Model Context Protocol beschreibt ein Server seine Werkzeuge maschinenlesbar, damit ein Sprachmodell sie benutzen kann. Dieselben Fragen — Benennung, Parameter, Fehlerformat, Versionierung —, nur ist der Aufrufer diesmal kein Programm mit festem Code, sondern ein Modell, das aus der Beschreibung schließt. Umso wichtiger sind eindeutige Namen.
REST, GraphQL oder gRPC
| REST | GraphQL | gRPC | |
|---|---|---|---|
| Stärke | einfach, überall unterstützt, cachebar | Client bestimmt die Felder | schnell, typisiert, Streaming |
| Schwäche | Über- und Unterabruf von Daten | Caching und Ratenbegrenzung schwierig | Browser nur über Umwege |
| Gut für | öffentliche Schnittstellen, CRUD | Oberflächen mit vielen Sichten | Dienst-zu-Dienst, hohe Frequenz |
| Schema | OpenAPI (optional) | eingebaut | Protocol Buffers |
Die praktische Empfehlung: REST für alles, was nach außen geht, weil jeder es versteht und die Werkzeuglandschaft vollständig ist. gRPC für Dienst-zu-Dienst-Verkehr mit hoher Frequenz. GraphQL, wenn viele verschiedene Oberflächen unterschiedliche Ausschnitte derselben Daten brauchen — und mit dem Wissen, dass Caching und Missbrauchsschutz dort eigene Arbeit sind.
Häufige Fragen
Was ist eine API kurz erklärt? Ein Vertrag zwischen zwei Systemen: Er sagt zu, welche Anfragen entgegengenommen werden und was zurückkommt.
Wie schneidet man Ressourcen? Nach Dingen, nicht nach Vorgängen. Substantive im Plural, keine Verben im Pfad, Hierarchie nur bis zur zweiten Ebene.
Welche Statuscodes wofür?
2xx für Erfolg, 4xx für Fehler des Aufrufers, 5xx für eigene Fehler. Der häufigste Fehler ist, alles mit 200 zu beantworten — damit funktioniert keine Zwischenschicht mehr.
Wie versioniert man eine Schnittstelle? Am pragmatischsten über den Pfad. Wichtiger als der Weg ist, additive Änderungen so zu gestalten, dass keine neue Version nötig wird.
Wie sieht ein gutes Fehlerformat aus? Einheitlich über die ganze Schnittstelle, mit stabilem Fehlercode, Korrelations-ID und ohne interne Details.
Offset oder Cursor für Paginierung? Cursor, sobald die Daten wachsen oder sich ändern. Offset überspringt und wiederholt Einträge, wenn sich die Liste während des Blätterns verändert.
Zusammenfassung und praktische Tipps
Schnittstellen sind Versprechen, und die teuren Fehler passieren am Anfang. Die wichtigsten Punkte:
- Spezifikation zuerst. Ein Entwurf lässt sich in einer halben Stunde besprechen, eine fertige Schnittstelle nicht mehr ändern.
- Substantive statt Verben, Hierarchie höchstens zwei Ebenen tief.
- Statuscodes ernst nehmen. Alles mit
200zu beantworten macht jede Zwischenschicht blind. - Ein Fehlerformat für alles, mit stabilem Code und Korrelations-ID.
- Wisse, was brechend ist — besonders bei erweiterten Aufzählungen, dem am häufigsten übersehenen Fall.
- Cursor statt Offset, sobald die Liste wächst.
- Schlüssel als Hash speichern, mit Präfix und aus sicherem Zufall.
Mein Rat für den Einstieg: Schreib die Spezifikation für die drei wichtigsten Endpunkte, bevor du eine Zeile Code schreibst, und lass sie von jemandem gegenlesen, der sie später benutzen muss. Diese eine Stunde ist die günstigste Gelegenheit, die du je haben wirst, um eine Schnittstelle zu ändern — danach steigt der Preis mit jedem Tag.
Ressourcen
- OpenAPI Specification – der Standard für die Beschreibung von HTTP-Schnittstellen
- RFC 9457 – Problem Details for HTTP APIs – das etablierte Fehlerformat
- MDN – HTTP-Statuscodes – vollständige Referenz auf Deutsch
- Zalando RESTful API Guidelines – ausführlicher, praxiserprobter Regelsatz