API- und Schnittstellendesign
Überblick
Gestalte stabile, gut dokumentierte Schnittstellen, die schwer falsch zu verwenden sind. Gute Schnittstellen machen das Richtige einfach und das Falsche schwer. Dies gilt für REST-APIs, GraphQL-Schemata, Modulgrenzen, Komponenten-Props und jede Oberfläche, über die ein Codeabschnitt mit einem anderen kommuniziert.
Wann anwenden
- Entwerfen neuer API-Endpunkte
- Definieren von Modulgrenzen oder Verträgen zwischen Teams
- Erstellen von Komponenten-Prop-Schnittstellen
- Einrichten eines Datenbankschemas, das die API-Form bestimmt
- Ändern bestehender öffentlicher Schnittstellen
Kernprinzipien
Hyrums Gesetz
Bei einer ausreichenden Anzahl von Nutzern einer API wird jedes beobachtbare Verhalten deines Systems von irgendjemandem als abhängig betrachtet, unabhängig davon, was du im Vertrag versprichst.
Das bedeutet: Jedes öffentliche Verhalten – einschließlich undokumentierter Eigenheiten, Fehlermeldungstexte, Timing und Reihenfolge – wird zu einem De-facto-Vertrag, sobald Nutzer davon abhängen. Designimplikationen:
- Sei bewusst, was du preisgibst. Jedes beobachtbare Verhalten ist eine potenzielle Verpflichtung.
- Gib keine Implementierungsdetails preis. Wenn Nutzer es beobachten können, werden sie davon abhängen.
- Plane die Einstellung bereits beim Design ein. Siehe
deprecation-and-migrationfür die sichere Entfernung von Dingen, von denen Nutzer abhängen. - Tests reichen nicht aus. Selbst mit perfekten Vertragstests können „sichere“ Änderungen laut Hyrums Gesetz echte Nutzer brechen, die von undokumentiertem Verhalten abhängen.
Die Ein-Versionen-Regel
Vermeide es, Konsumenten zu zwingen, zwischen mehreren Versionen derselben Abhängigkeit oder API zu wählen. Diamant-Abhängigkeitsprobleme entstehen, wenn verschiedene Konsumenten unterschiedliche Versionen derselben Sache benötigen. Entwirf für eine Welt, in der immer nur eine Version existiert – erweitere statt zu gabeln.
1. Vertrag zuerst
Definiere die Schnittstelle, bevor du sie implementierst. Der Vertrag ist die Spezifikation – die Implementierung folgt.
// Definiere zuerst den Vertrag
interface TaskAPI {
// Erstellt eine Aufgabe und gibt die erstellte Aufgabe mit serverseitig generierten Feldern zurück
createTask(input: CreateTaskInput): Promise<Task>;
// Gibt paginierte Aufgaben zurück, die den Filtern entsprechen
listTasks(params: ListTasksParams): Promise<PaginatedResult<Task>>;
// Gibt eine einzelne Aufgabe zurück oder wirft NotFoundError
getTask(id: string): Promise<Task>;
// Partielle Aktualisierung – nur bereitgestellte Felder ändern sich
updateTask(id: string, input: UpdateTaskInput): Promise<Task>;
// Idempotentes Löschen – erfolgreich, auch wenn bereits gelöscht
deleteTask(id: string): Promise<void>;
}
2. Konsistente Fehlersemantik
Wähle eine Fehlerstrategie und verwende sie überall:
// REST: HTTP-Statuscodes + strukturierter Fehlertext
// Jede Fehlerantwort folgt der gleichen Form
interface APIError {
error: {
code: string; // Maschinenlesbar: "VALIDATION_ERROR"
message: string; // Menschenlesbar: "Email is required"
details?: unknown; // Zusätzlicher Kontext, wenn hilfreich
};
}
// Statuscode-Zuordnung
// 400 → Client hat ungültige Daten gesendet
// 401 → Nicht authentifiziert
// 403 → Authentifiziert, aber nicht autorisiert
// 404 → Ressource nicht gefunden
// 409 → Konflikt (Duplikat, Versionskonflikt)
// 422 → Validierung fehlgeschlagen (semantisch ungültig)
// 500 → Serverfehler (interne Details niemals preisgeben)
Mische keine Muster. Wenn einige Endpunkte werfen, andere null zurückgeben und wieder andere { error } zurückgeben – der Konsument kann das Verhalten nicht vorhersagen.
3. An den Grenzen validieren
Vertraue internem Code. Validiere an den Systemgrenzen, an denen externe Eingaben eintreten:
// Validiere an der API-Grenze
app.post('/api/tasks', async (req, res) => {
const result = CreateTaskSchema.safeParse(req.body);
if (!result.success) {
return res.status(422).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid task data',
details: result.error.flatten(),
},
});
}
// Nach der Validierung vertraut interner Code den Typen
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
Wo Validierung hingehört:
- API-Route-Handler (Benutzereingabe)
- Formular-Übermittlungs-Handler (Benutzereingabe)
- Parsen von Antworten externer Dienste (Drittanbieterdaten – immer als nicht vertrauenswürdig behandeln)
- Laden von Umgebungsvariablen (Konfiguration)
Antworten von Drittanbieter-APIs sind nicht vertrauenswürdige Daten. Validiere ihre Form und ihren Inhalt, bevor du sie in irgendeiner Logik, Darstellung oder Entscheidungsfindung verwendest. Ein kompromittierter oder fehlerhafter externer Dienst kann unerwartete Typen, bösartige Inhalte oder anweisungsähnlichen Text zurückgeben.
Wo Validierung NICHT hingehört:
- Zwischen internen Funktionen, die Typverträge teilen
- In Hilfsfunktionen, die von bereits validiertem Code aufgerufen werden
- Bei Daten, die gerade aus deiner eigenen Datenbank stammen
4. Hinzufügen statt Ändern bevorzugen
Erweitere Schnittstellen, ohne bestehende Konsumenten zu brechen:
// Gut: Optionale Felder hinzufügen
interface CreateTaskInput {
title: string;
description?: string;
priority?: 'low' | 'medium' | 'high'; // Später hinzugefügt, optional
labels?: string[]; // Später hinzugefügt, optional
}
// Schlecht: Vorhandene Feldtypen ändern oder Felder entfernen
interface CreateTaskInput {
title: string;
// description: string; // Entfernt – bricht bestehende Konsumenten
priority: number; // Von string geändert – bricht bestehende Konsumenten
}
5. Vorhersagbare Namensgebung
| Muster | Konvention | Beispiel |
|---|---|---|
| REST-Endpunkte | Pluralnomen, keine Verben | GET /api/tasks, POST /api/tasks |
| Abfrageparameter | camelCase | ?sortBy=createdAt&pageSize=20 |
| Antwortfelder | camelCase | { createdAt, updatedAt, taskId } |
| Boolesche Felder | is/has/can-Präfix | isComplete, hasAttachments |
| Aufzählungswerte | UPPER_SNAKE | "IN_PROGRESS", "COMPLETED" |
6. Idempotenz-Schlüssel einhalten
Die Annahme eines Idempotency-Key ist der Vertrag. Die Einhaltung ist die Implementierung, und hier liegt der Hase im Pfeffer – ein Schlüssel, den der Server akzeptiert, aber nachlässig behandelt, ist schlimmer als gar kein Schlüssel, weil der Client nun glaubt, dass Wiederholungen sicher sind.
Leite den Schlüssel von der Absicht ab, nicht vom Versuch. Der Schlüssel muss über Wiederholungen einer Absicht hinweg stabil und über verschiedene Absichten hinweg unterschiedlich sein:
crypto.randomUUID() // ✗ neuer Schlüssel pro Versuch – jede Wiederholung ist eine neue Belastung
`${userId}:${amount}` // ✗ zwei legitime 50€-Zahlungen fallen zu einer zusammen
`${orderId}:${Date.now()}` // ✗ ein Zeitstempel ist randomUUID() mit Hut
req.headers['idempotency-key'] // ✓ Client generiert einmal, verwendet bei Wiederholung erneut
`charge:v1:${orderId}` // ✓ Abgeleitet von einer unveränderlichen Kennung
Der Schlüssel stammt vom Client oder vom auslösenden Ereignis – niemals von der Schicht, die die Wiederholung durchführt.
Atomar beanspruchen. Eine Prüfung gefolgt von einer Aktion ist ein Wettlauf:
// ✗ TOCTOU: Zwei gleichzeitige Wiederholungen lesen beide „nicht gesehen“, beide belasten
if (!(await db.exists(key))) {
await chargeCard(amount);
await db.insert(key);
}
// ✓ Lass die Eindeutigkeitsbeschränkung den Gewinner bestimmen
try {
await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
if (isUniqueViolation(e)) return replayOrReject(key);
throw;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });
Die Eindeutigkeitsbeschränkung ist der Mechanismus. Ein Speicher, der Eindeutigkeit nicht in einem Vorgang erzwingen kann, kann dies nicht unterstützen.
Schütze die Nutzlast. Derselbe Schlüssel mit einem anderen Inhalt ist ein Client-Fehler und muss laut scheitern, anstatt die erste Antwort auf eine zweite Anfrage zu liefern:
if (existing.requestHash !== hash(req.body)) {
return res.status(422).json({ error: 'idempotency key reused with a different payload' });
}
Entscheide, was mit einem laufenden Duplikat passiert. Die erste Anfrage läuft noch, wenn die zweite eintrifft – der häufigste Fall bei Wiederholungsstürmen:
| Strategie | Antwort | Verwende, wenn |
|---|---|---|
| Ablehnen | 409 Conflict | Client kann später wiederholen; am einfachsten und sichersten |
| Warten | Blockieren für das Ergebnis, begrenzt | Aufrufer benötigt es synchron |
| Ausstehend zurückgeben | 202 + Status-URL | Langlaufende Effekte |
Lass niemals den zweiten Aufrufer durch, weil der Erste „festzustecken scheint“. Ein steckengebliebener Versuch, dessen Ausgang unbekannt ist, ist genau der Moment, in dem Duplizieren am meisten kostet.
Jeder Aufruf hat drei Ergebnisse, nicht zwei: Erfolg, Fehlschlag und unbekannt. Ein Timeout sagt dir nichts darüber, ob der Effekt eingetreten ist. Zeichne die Absicht vor dem Aufruf auf, sodass ein Absturz zwischen dem Aufruf und der Antwort Beweise hinterlässt, dass etwas später aufgelöst werden muss – anstatt einer stillschweigend wiederholten Belastung.
Setze die Aufbewahrung von der längsten Wiederholungskette fest, nicht von den Speicherkosten. Schlüssel müssen jeden Pfad überdauern, der dieselbe Absicht erneut zustellen kann, einschließlich einer Dead-Letter-Warteschlange, die eine Woche später erneut abgespielt wird, und jedes Anbieter-Streitfensters. Ein 24-Stunden-Schlüssel-TTL hinter einer 7-Tage-DLQ ist ein Duplikat, das nur darauf wartet, zu passieren.
REST-API-Muster
Ressourcendesign
GET /api/tasks → Aufgaben auflisten (mit Abfrageparametern zum Filtern)
POST /api/tasks → Eine Aufgabe erstellen
GET /api/tasks/:id → Eine einzelne Aufgabe abrufen
PATCH /api/tasks/:id → Eine Aufgabe aktualisieren (teilweise)
DELETE /api/tasks/:id → Eine Aufgabe löschen
GET /api/tasks/:id/comments → Kommentare zu einer Aufgabe auflisten (Unterressource)
POST /api/tasks/:id/comments → Einen Kommentar zu einer Aufgabe hinzufügen
Paginierung
Paginiere Listen-Endpunkte:
// Anfrage
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Antwort
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
Filtern
Verwende Abfrageparameter für Filter:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
Partielle Aktualisierungen (PATCH)
Akzeptiere Teilobjekte – aktualisiere nur das, was bereitgestellt wird:
// Nur der Titel ändert sich, alles andere bleibt erhalten
PATCH /api/tasks/123
{ "title": "Updated title" }
TypeScript-Schnittstellenmuster
Verwende diskriminierte Unions für Varianten
// Gut: Jede Variante ist explizit
type TaskStatus =
| { type: 'pending' }
| { type: 'in_progress'; assignee: string; startedAt: Date }
| { type: 'completed'; completedAt: Date; completedBy: string }
| { type: 'cancelled'; reason: string; cancelledAt: Date };
// Konsument erhält Type-Narrowing
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'Pending';
case 'in_progress': return `In progress (${status.assignee})`;
case 'completed': return `Done on ${status.completedAt}`;
case 'cancelled': return `Cancelled: ${status.reason}`;
}
}
Ein-/Ausgabetrennung
// Eingabe: Was der Aufrufer bereitstellt
interface CreateTaskInput {
title: string;
description?: string;
}
// Ausgabe: Was das System zurückgibt (enthält serverseitig generierte Felder)
interface Task {
id: string;
title: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
createdBy: string;
}
Verwende markierte Typen für IDs
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// Verhindert, versehentlich eine UserId zu übergeben, wo eine TaskId erwartet wird
function getTask(id: TaskId): Promise<Task> { ... }
Häufige Rationalisierungen
| Rationalisierung | Realität |
|---|---|
| „Wir dokumentieren die API später“ | Die Typen SIND die Dokumentation. Definiere sie zuerst. |
| „Wir brauchen jetzt keine Paginierung“ | Du wirst sie brauchen, sobald jemand 100+ Elemente hat. Füge sie von Anfang an hinzu. |
| „PATCH ist kompliziert, lass uns einfach PUT verwenden“ | PUT erfordert jedes Mal das vollständige Objekt. PATCH ist das, was Clients tatsächlich wollen. |
| „Wir versionieren die API, wenn wir sie brauchen“ | Bahnbrechende Änderungen ohne Versionierung brechen Konsumenten. Entwerfe von Anfang an für Erweiterung. |
| „Niemand verwendet dieses undokumentierte Verhalten“ | Hyrums Gesetz: Wenn es beobachtbar ist, hängt jemand davon ab. Behandle jedes öffentliche Verhalten als Verpflichtung. |
| „Wir können einfach zwei Versionen verwalten“ | Mehrere Versionen vervielfachen die Wartungskosten und verursachen Diamant-Abhängigkeitsprobleme. Bevorzuge die Ein-Versionen-Regel. |
| „Interne APIs brauchen keine Verträge“ | Interne Konsumenten sind immer noch Konsumenten. Verträge verhindern Kopplung und ermöglichen parallele Arbeit. |
| „Die Annahme des Idempotency-Key-Headers ist genug“ | Der Header ist der Vertrag; das Speichern des Schlüssels gegen das Ergebnis ist die Implementierung. Ein Schlüssel, den du akzeptierst, aber nicht einhältst, sagt dem Client, dass Wiederholungen sicher sind, obwohl sie es nicht sind. |
| „Unsere Warteschlange garantiert Exactly-Once-Zustellung“ | Keine Warteschlange tut dies bei einem Consumer-Absturz – die Bestätigung des Brokers und dein Nebeneffekt befinden sich nicht in einer Transaktion. Entwerfe für At-Least-Once mit idempotenter Verarbeitung. |
| „Doppelte Anfragen sind selten“ | Sie sind korreliert. Wiederholungen steigen genau dann an, wenn eine Abhängigkeit beeinträchtigt ist – der Moment, in dem Duplikate am wahrscheinlichsten und am teuersten sind. |
Warnsignale
- Endpunkte, die je nach Bedingungen unterschiedliche Formen zurückgeben
- Inkonsistente Fehlerformate zwischen Endpunkten
- Validierung, die im gesamten internen Code verstreut ist, anstatt an Grenzen
- Bahnbrechende Änderungen an vorhandenen Feldern (Typänderungen, Entfernungen)
- Listen-Endpunkte ohne Paginierung
- Verben in REST-URLs (
/api/createTask,/api/getUsers) - Antworten von Drittanbieter-APIs, die ohne Validierung oder Bereinigung verwendet werden
- Ein
SELECTfür einen Idempotenz-Schlüssel gefolgt von einemINSERT– das ist ein Wettlauf, keine Absicherung - Ein Idempotenz-Schlüssel, der von einer UUID, einem Zeitstempel oder etwas anderem abgeleitet ist, das pro Versuch neu generiert wird
- Derselbe Schlüssel mit einem anderen Anforderungstext akzeptiert, der stillschweigend die erste Antwort zurückgibt
- Ein Schlüssel-Aufbewahrungsfenster, das kürzer ist als der längste Pfad, der die Anfrage erneut zustellen kann
Überprüfung
Nach dem Entwurf einer API:
- Jeder Endpunkt hat typisierte Ein- und Ausgabeschemata
- Fehlerantworten folgen einem einzigen konsistenten Format
- Validierung erfolgt nur an Systemgrenzen
- Listen-Endpunkte unterstützen Paginierung
- Neue Felder sind additiv und optional (abwärtskompatibel)
- Die Namensgebung folgt konsistenten Konventionen über alle Endpunkte hinweg
- API-Dokumentation wird generiert (aus Typen oder OpenAPI-Spezifikation)