API- und Interface-Design
Überblick
Entwerfen Sie stabile, gut dokumentierte Schnittstellen, die schwer zu missbrauchen 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 verwenden
- Entwerfen neuer API-Endpunkte
- Definieren von Modulgrenzen oder Verträgen zwischen Teams
- Erstellen von Komponenten-Prop-Schnittstellen
- Festlegen von Datenbankschemata, die die API-Form bestimmen
- Ändern vorhandener öffentlicher Schnittstellen
Kernprinzipien
Hyrums Gesetz
Bei einer ausreichenden Anzahl von Benutzern einer API werden alle beobachtbaren Verhaltensweisen Ihres Systems von irgendjemandem als Abhängigkeit betrachtet, unabhängig davon, was Sie im Vertrag versprechen.
Das bedeutet: Jedes öffentliche Verhalten – einschließlich undokumentierter Eigenheiten, Fehlermeldungstexte, Zeitverhalten und Reihenfolge – wird zu einem De-facto-Vertrag, sobald Benutzer darauf angewiesen sind. Design-Implikationen:
- Seien Sie bewusst, was Sie offenlegen. Jedes beobachtbare Verhalten ist eine potenzielle Verpflichtung.
- Geben Sie keine Implementierungsdetails preis. Wenn Benutzer es beobachten können, werden sie sich darauf verlassen.
- Planen Sie die Abschaffung bereits beim Design. Siehe
deprecation-and-migrationfür die sichere Entfernung von Dingen, von denen Benutzer abhängen. - Tests sind nicht genug. Selbst mit perfekten Vertragstests können „sichere“ Änderungen gemäß Hyrums Gesetz echte Benutzer brechen, die von undokumentiertem Verhalten abhängen.
Die Ein-Versionen-Regel
Vermeiden Sie es, Verbraucher zu zwingen, zwischen mehreren Versionen derselben Abhängigkeit oder API zu wählen. Diamant-Abhängigkeitsprobleme entstehen, wenn verschiedene Verbraucher unterschiedliche Versionen derselben Sache benötigen. Entwerfen Sie für eine Welt, in der immer nur eine Version existiert – erweitern Sie, anstatt zu verzweigen.
1. Vertrag zuerst
Definieren Sie die Schnittstelle, bevor Sie sie implementieren. Der Vertrag ist die Spezifikation – die Implementierung folgt.
// Definieren Sie 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ählen Sie eine Fehlerstrategie und verwenden Sie sie überall:
// REST: HTTP-Statuscodes + strukturierter Fehlerbody
// Jede Fehlerantwort folgt derselben Form
interface APIError {
error: {
code: string; // Maschinenlesbar: "VALIDATION_ERROR"
message: string; // Menschenlesbar: "E-Mail ist erforderlich"
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 (niemals interne Details preisgeben)
Mischen Sie keine Muster. Wenn einige Endpunkte werfen, andere null zurückgeben und wieder andere { error } – der Verbraucher kann das Verhalten nicht vorhersagen.
3. An Grenzen validieren
Vertrauen Sie internem Code. Validieren Sie an Systemgrenzen, wo externe Eingaben eintreten:
// An der API-Grenze validieren
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: 'Ungültige Aufgabendaten',
details: result.error.flatten(),
},
});
}
// Nach der Validierung vertraut der interne Code den Typen
const task = await taskService.create(result.data);
return res.status(201).json(task);
});
Wo Validierung hingehört:
- API-Routen-Handler (Benutzereingabe)
- Formularübermittlungs-Handler (Benutzereingabe)
- Parsen externer Serviceantworten (Drittanbieterdaten – immer als nicht vertrauenswürdig behandeln)
- Laden von Umgebungsvariablen (Konfiguration)
Antworten von Drittanbieter-APIs sind nicht vertrauenswürdige Daten. Validieren Sie ihre Form und ihren Inhalt, bevor Sie sie in einer Logik, Darstellung oder Entscheidungsfindung verwenden. Ein kompromittierter oder fehlfunktionierender 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 Ihrer eigenen Datenbank stammen
4. Erweiterung gegenüber Änderung bevorzugen
Erweitern Sie Schnittstellen, ohne bestehende Verbraucher 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 Verbraucher
priority: number; // Von String geändert – bricht bestehende Verbraucher
}
5. Vorhersagbare Benennung
| 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 |
| Enum-Werte | UPPER_SNAKE | "IN_PROGRESS", "COMPLETED" |
6. Einhaltung eines Idempotenzschlüssels
Das Akzeptieren eines Idempotency-Key ist der Vertrag. Die Einhaltung ist die Implementierung, und hier wird das Geld verloren – ein Schlüssel, den der Server akzeptiert, aber nachlässig behandelt, ist schlimmer als kein Schlüssel, weil der Client jetzt glaubt, dass Wiederholungen sicher sind.
Leiten Sie den Schlüssel von der Absicht ab, nicht vom Versuch. Der Schlüssel muss über Wiederholungen einer Absicht stabil und über unterschiedliche Absichten hinweg unterschiedlich sein:
crypto.randomUUID() // ✗ neuer Schlüssel pro Versuch – jede Wiederholung ist eine neue Belastung
`${userId}:${amount}` // ✗ zwei legitime $50-Belastungen fallen zu einer zusammen
`${orderId}:${Date.now()}` // ✗ ein Zeitstempel ist randomUUID() mit einem Hut
req.headers['idempotency-key'] // ✓ Client generiert einmal, verwendet bei Wiederholung erneut
`charge:v1:${orderId}` // ✓ abgeleitet von einer unveränderlichen Kennung
Der Schlüssel kommt 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);
}
// ✓ Lassen Sie die eindeutige Einschränkung den Gewinner bestimmen
try {
await db.insert({ key, state: 'in_progress', requestHash });
} catch (e) {
if (isUniqueViolation(e)) return replayOrReject(key);
throw e;
}
const result = await chargeCard(amount);
await db.update({ key, state: 'succeeded', response: result });
Die eindeutige Einschränkung ist der Mechanismus. Ein Speicher, der die Eindeutigkeit nicht in einem Vorgang erzwingen kann, kann dies nicht unterstützen.
Schützen Sie die Nutzlast. Derselbe Schlüssel mit einem anderen Body 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: 'Idempotenzschlüssel mit einer anderen Nutzlast wiederverwendet' });
}
Entscheiden Sie, was ein laufendes Duplikat bekommt. Die erste Anfrage läuft noch, wenn die zweite eintrifft – der häufigste Fall bei Wiederholungsstürmen:
| Strategie | Antwort | Verwenden, wenn |
|---|---|---|
| Ablehnen | 409 Conflict | Client kann später wiederholen; am einfachsten und sichersten |
| Warten | Auf das Ergebnis blockieren, begrenzt | Aufrufer benötigt es synchron |
| Ausstehend zurückgeben | 202 + Status-URL | Langlaufende Effekte |
Lassen Sie niemals den zweiten Aufrufer durch, weil der erste „festzustecken scheint“. Ein stockender Versuch, dessen Schicksal unbekannt ist, ist genau der Moment, in dem Duplizieren am meisten kostet.
Jeder Aufruf hat drei Ergebnisse, nicht zwei: Erfolg, Fehler und unbekannt. Ein Timeout sagt Ihnen nichts darüber, ob der Effekt angewendet wurde. Zeichnen Sie die Absicht vor dem Aufruf auf, damit ein Absturz zwischen dem Aufruf und der Antwort Beweise hinterlässt, dass später etwas aufgelöst werden muss – anstatt einer stillschweigend wiederholten Belastung.
Legen Sie die Aufbewahrungsdauer anhand der längsten Wiederholungskette fest, nicht anhand der Speicherkosten. Schlüssel müssen jeden Pfad überdauern, der dieselbe Absicht erneut zustellen kann, einschließlich einer eine Woche später wiederholten Dead-Letter-Queue und eines beliebigen Anbieter-Streitfensters. Eine 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 → Aufgabe erstellen
GET /api/tasks/:id → Einzelne Aufgabe abrufen
PATCH /api/tasks/:id → Aufgabe aktualisieren (teilweise)
DELETE /api/tasks/:id → Aufgabe löschen
GET /api/tasks/:id/comments → Kommentare zu einer Aufgabe auflisten (Unterressource)
POST /api/tasks/:id/comments → Kommentar zu einer Aufgabe hinzufügen
Paginierung
Paginieren Sie Listenendpunkte:
// Anfrage
GET /api/tasks?page=1&pageSize=20&sortBy=createdAt&sortOrder=desc
// Antwort
{
"data": [...],
"pagination": {
"page": 1,
"pageSize": 20,
"totalItems": 142,
"totalPages": 8
}
}
Filtern
Verwenden Sie Abfrageparameter für Filter:
GET /api/tasks?status=in_progress&assignee=user123&createdAfter=2025-01-01
Teilweise Aktualisierungen (PATCH)
Akzeptieren Sie Teilobjekte – aktualisieren Sie nur das, was bereitgestellt wird:
// Nur der Titel ändert sich, alles andere bleibt erhalten
PATCH /api/tasks/123
{ "title": "Aktualisierter Titel" }
TypeScript-Interface-Muster
Verwenden Sie diskriminierte Unionen 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 };
// Verbraucher erhält Type Narrowing
function getStatusLabel(status: TaskStatus): string {
switch (status.type) {
case 'pending': return 'Ausstehend';
case 'in_progress': return `In Bearbeitung (${status.assignee})`;
case 'completed': return `Erledigt am ${status.completedAt}`;
case 'cancelled': return `Abgebrochen: ${status.reason}`;
}
}
Eingabe/Ausgabe-Trennung
// 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;
}
Verwenden Sie Markentypen für IDs
type TaskId = string & { readonly __brand: 'TaskId' };
type UserId = string & { readonly __brand: 'UserId' };
// Verhindert, dass versehentlich eine UserId übergeben wird, wo eine TaskId erwartet wird
function getTask(id: TaskId): Promise<Task> { ... }
Häufige Rechtfertigungen
| Rechtfertigung | Realität |
|---|---|
| "Wir dokumentieren die API später" | Die Typen SIND die Dokumentation. Definieren Sie sie zuerst. |
| "Wir brauchen jetzt keine Paginierung" | Sie werden sie brauchen, sobald jemand 100+ Elemente hat. Fügen Sie sie von Anfang an hinzu. |
| "PATCH ist kompliziert, verwenden wir einfach PUT" | PUT erfordert jedes Mal das vollständige Objekt. PATCH ist das, was Clients tatsächlich wollen. |
| "Wir versionieren die API, wenn es nötig ist" | Breaking Changes ohne Versionierung brechen Verbraucher. Entwerfen Sie von Anfang an für Erweiterung. |
| "Niemand verwendet dieses undokumentierte Verhalten" | Hyrums Gesetz: Wenn es beobachtbar ist, hängt jemand davon ab. Behandeln Sie jedes öffentliche Verhalten als Verpflichtung. |
| "Wir können einfach zwei Versionen pflegen" | Mehrere Versionen vervielfachen den Wartungsaufwand und erzeugen Diamant-Abhängigkeitsprobleme. Bevorzugen Sie die Ein-Versionen-Regel. |
| "Interne APIs brauchen keine Verträge" | Interne Verbraucher sind immer noch Verbraucher. Verträge verhindern Kopplung und ermöglichen parallele Arbeit. |
| "Das Akzeptieren des Idempotency-Key-Headers ist genug" | Der Header ist der Vertrag; das Speichern des Schlüssels mit dem Ergebnis ist die Implementierung. Ein Schlüssel, den Sie akzeptieren, aber nicht einhalten, sagt dem Client, dass Wiederholungen sicher sind, obwohl sie es nicht sind. |
| "Unsere Warteschlange garantiert Exactly-Once-Zustellung" | Keine Warteschlange tut das bei einem Consumer-Absturz – der Broker-Ack und Ihr Side-Effekt befinden sich nicht in einer Transaktion. Entwerfen Sie 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 über Endpunkte hinweg
- Validierung, die im gesamten internen Code verstreut ist, anstatt an Grenzen
- Breaking Changes an vorhandenen Feldern (Typänderungen, Entfernungen)
- Listenendpunkte 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 Idempotenzschlüssel, gefolgt von einemINSERT– das ist ein Wettlauf, keine Absicherung - Ein Idempotenzschlüssel, der von einer UUID, einem Zeitstempel oder etwas anderem abgeleitet wird, das pro Versuch neu generiert wird
- Derselbe Schlüssel mit einem anderen Anforderungsbody akzeptiert, der stillschweigend die erste Antwort zurückgibt
- Ein Schlüssel-Aufbewahrungsfenster, das kürzer ist als der längste Pfad, der die Anforderung erneut zustellen kann
Überprüfung
Nach dem Entwurf einer API:
- Jeder Endpunkt hat typisierte Eingabe- und Ausgabeschemata
- Fehlerantworten folgen einem einzigen konsistenten Format
- Validierung findet nur an Systemgrenzen statt
- Listenendpunkte unterstützen Paginierung
- Neue Felder sind additiv und optional (abwärtskompatibel)
- Die Benennung folgt konsistenten Konventionen über alle Endpunkte hinweg
- Die API-Dokumentation ist auf dem neuesten Stand
- Idempotenzschlüssel werden atomar beansprucht, Nutzlasten werden hash-überprüft, und das Aufbewahrungsfenster deckt die längste Wiederholungskette ab