API-Design-Patterns
Konventionen und Best Practices für die Gestaltung konsistenter, entwicklerfreundlicher REST-APIs.
Wann zu aktivieren
- Entwerfen neuer API-Endpunkte
- Überprüfen bestehender API-Verträge
- Hinzufügen von Paginierung, Filterung oder Sortierung
- Implementieren von Fehlerbehandlung für APIs
- Planen einer API-Versionierungsstrategie
- Erstellen öffentlicher oder partnerorientierter APIs
Ressourcendesign
URL-Struktur
# Ressourcen sind Nomen, Plural, Kleinbuchstaben, kebab-case
GET /api/v1/users
GET /api/v1/users/:id
POST /api/v1/users
PUT /api/v1/users/:id
PATCH /api/v1/users/:id
DELETE /api/v1/users/:id
# Unterressourcen für Beziehungen
GET /api/v1/users/:id/orders
POST /api/v1/users/:id/orders
# Aktionen, die nicht auf CRUD abbildbar sind (Verben sparsam verwenden)
POST /api/v1/orders/:id/cancel
POST /api/v1/auth/login
POST /api/v1/auth/refresh
Benennungsregeln
# GUT
/api/v1/team-members # kebab-case für mehrteilige Ressourcen
/api/v1/orders?status=active # Query-Parameter für Filterung
/api/v1/users/123/orders # Verschachtelte Ressourcen für Besitzer
# SCHLECHT
/api/v1/getUsers # Verb in URL
/api/v1/user # Singular (Plural verwenden)
/api/v1/team_members # snake_case in URLs
/api/v1/users/123/getOrders # Verb in verschachtelter Ressource
HTTP-Methoden und Statuscodes
Methodensemantik
| Methode | Idempotent | Sicher | Verwendung |
|---|---|---|---|
| GET | Ja | Ja | Ressourcen abrufen |
| POST | Nein | Nein | Ressourcen erstellen, Aktionen auslösen |
| PUT | Ja | Nein | Vollständigen Ersatz einer Ressource |
| PATCH | Nein* | Nein | Partielle Aktualisierung einer Ressource |
| DELETE | Ja | Nein | Ressource entfernen |
*PATCH kann bei korrekter Implementierung idempotent gemacht werden
Statuscode-Referenz
# Erfolg
200 OK — GET, PUT, PATCH (mit Antwortbody)
201 Erstellt — POST (Location-Header einfügen)
204 Kein Inhalt — DELETE, PUT (kein Antwortbody)
# Client-Fehler
400 Ungültige Anfrage — Validierungsfehler, fehlerhaftes JSON
401 Nicht autorisiert — Fehlende oder ungültige Authentifizierung
403 Verboten — Authentifiziert, aber nicht autorisiert
404 Nicht gefunden — Ressource existiert nicht
409 Konflikt — Doppelter Eintrag, Statuskonflikt
422 Nicht verarbeitbar — Semantisch ungültig (gültiges JSON, schlechte Daten)
429 Zu viele Anfragen — Ratenbegrenzung überschritten
# Server-Fehler
500 Interner Serverfehler — Unerwarteter Fehler (niemals Details preisgeben)
502 Schlechtes Gateway — Upstream-Dienst fehlgeschlagen
503 Dienst nicht verfügbar — Vorübergehende Überlastung, Retry-After-Header einfügen
Häufige Fehler
# SCHLECHT: 200 für alles
{ "status": 200, "success": false, "error": "Nicht gefunden" }
# GUT: HTTP-Statuscodes semantisch verwenden
HTTP/1.1 404 Nicht gefunden
{ "error": { "code": "not_found", "message": "Benutzer nicht gefunden" } }
# SCHLECHT: 500 für Validierungsfehler
# GUT: 400 oder 422 mit Felddetails
# SCHLECHT: 200 für erstellte Ressourcen
# GUT: 201 mit Location-Header
HTTP/1.1 201 Erstellt
Location: /api/v1/users/abc-123
Antwortformat
Erfolgsantwort
{
"data": {
"id": "abc-123",
"email": "[email protected]",
"name": "Alice",
"created_at": "2025-01-15T10:30:00Z"
}
}
Sammlungsantwort (mit Paginierung)
{
"data": [
{ "id": "abc-123", "name": "Alice" },
{ "id": "def-456", "name": "Bob" }
],
"meta": {
"total": 142,
"page": 1,
"per_page": 20,
"total_pages": 8
},
"links": {
"self": "/api/v1/users?page=1&per_page=20",
"next": "/api/v1/users?page=2&per_page=20",
"last": "/api/v1/users?page=8&per_page=20"
}
}
Fehlerantwort
{
"error": {
"code": "validation_error",
"message": "Anfragenvalidierung fehlgeschlagen",
"details": [
{
"field": "email",
"message": "Muss eine gültige E-Mail-Adresse sein",
"code": "invalid_format"
},
{
"field": "age",
"message": "Muss zwischen 0 und 150 liegen",
"code": "out_of_range"
}
]
}
}
Antwortumhüllungsvarianten
// Option A: Umhüllung mit Daten-Wrapper (empfohlen für öffentliche APIs)
interface ApiResponse<T> {
data: T;
meta?: PaginationMeta;
links?: PaginationLinks;
}
interface ApiError {
error: {
code: string;
message: string;
details?: FieldError[];
};
}
// Option B: Flache Antwort (einfacher, üblich für interne APIs)
// Erfolg: Ressource direkt zurückgeben
// Fehler: Fehlerobjekt zurückgeben
// Unterscheidung durch HTTP-Statuscode
Paginierung
Offset-basiert (Einfach)
GET /api/v1/users?page=2&per_page=20
# Implementierung
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 20;
Vorteile: Einfach zu implementieren, unterstützt "zu Seite N springen" Nachteile: Langsam bei großen Offsets (OFFSET 100000), inkonsistent bei gleichzeitigen Einfügungen
Cursor-basiert (Skalierbar)
GET /api/v1/users?cursor=eyJpZCI6MTIzfQ&limit=20
# Implementierung
SELECT * FROM users
WHERE id > :cursor_id
ORDER BY id ASC
LIMIT 21; -- einen zusätzlichen abrufen, um has_next zu bestimmen
{
"data": [...],
"meta": {
"has_next": true,
"next_cursor": "eyJpZCI6MTQzfQ"
}
}
Vorteile: Konsistente Leistung unabhängig von der Position, stabil bei gleichzeitigen Einfügungen Nachteile: Kann nicht zu einer beliebigen Seite springen, Cursor ist undurchsichtig
Wann welches verwenden
| Anwendungsfall | Paginierungstyp |
|---|---|
| Admin-Dashboards, kleine Datensätze (<10K) | Offset |
| Unendliches Scrollen, Feeds, große Datensätze | Cursor |
| Öffentliche APIs | Cursor (Standard) mit Offset (optional) |
| Suchergebnisse | Offset (Benutzer erwarten Seitenzahlen) |
Filterung, Sortierung und Suche
Filterung
# Einfache Gleichheit
GET /api/v1/orders?status=active&customer_id=abc-123
# Vergleichsoperatoren (Klammernotation verwenden)
GET /api/v1/products?price[gte]=10&price[lte]=100
GET /api/v1/orders?created_at[after]=2025-01-01
# Mehrere Werte (kommagetrennt)
GET /api/v1/products?category=electronics,clothing
# Verschachtelte Felder (Punktnotation)
GET /api/v1/orders?customer.country=US
Sortierung
# Einzelfeld (Präfix - für absteigend)
GET /api/v1/products?sort=-created_at
# Mehrere Felder (kommagetrennt)
GET /api/v1/products?sort=-featured,price,-created_at
Volltextsuche
# Such-Query-Parameter
GET /api/v1/products?q=wireless+headphones
# Feldspezifische Suche
GET /api/v1/users?email=alice
Spärliche Fieldsets
# Nur angegebene Felder zurückgeben (reduziert Payload)
GET /api/v1/users?fields=id,name,email
GET /api/v1/orders?fields=id,total,status&include=customer.name
Authentifizierung und Autorisierung
Token-basierte Authentifizierung
# Bearer-Token im Authorization-Header
GET /api/v1/users
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
# API-Schlüssel (für Server-zu-Server-Kommunikation)
GET /api/v1/data
X-API-Key: sk_live_abc123
Autorisierungsmuster
// Ressourcenebene: Besitzprüfung
app.get("/api/v1/orders/:id", async (req, res) => {
const order = await Order.findById(req.params.id);
if (!order) return res.status(404).json({ error: { code: "not_found" } });
if (order.userId !== req.user.id) return res.status(403).json({ error: { code: "forbidden" } });
return res.json({ data: order });
});
// Rollenbasiert: Berechtigungsprüfung
app.delete("/api/v1/users/:id", requireRole("admin"), async (req, res) => {
await User.delete(req.params.id);
return res.status(204).send();
});
Ratenbegrenzung
Header
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1640000000
# Bei Überschreitung
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{
"error": {
"code": "rate_limit_exceeded",
"message": "Ratenbegrenzung überschritten. Versuchen Sie es in 60 Sekunden erneut."
}
}
Ratenbegrenzungsstufen
| Stufe | Limit | Fenster | Anwendungsfall |
|---|---|---|---|
| Anonym | 30/min | Pro IP | Öffentliche Endpunkte |
| Authentifiziert | 100/min | Pro Benutzer | Standard-API-Zugriff |
| Premium | 1000/min | Pro API-Schlüssel | Kostenpflichtige API-Pläne |
| Intern | 10000/min | Pro Dienst | Dienst-zu-Dienst-Kommunikation |
Versionierung
Versionierung im URL-Pfad (Empfohlen)
/api/v1/users
/api/v2/users
Vorteile: Explizit, einfach zu routen, cachebar Nachteile: URL ändert sich zwischen Versionen
Versionierung im Header
GET /api/users
Accept: application/vnd.myapp.v2+json
Vorteile: Saubere URLs Nachteile: Schwerer zu testen, leicht zu vergessen
Versionierungsstrategie
1. Beginnen Sie mit /api/v1/ — versionieren Sie nicht, bis es nötig ist
2. Behalten Sie maximal 2 aktive Versionen (aktuell + vorherige)
3. Einstellungszeitplan:
- Einstellung ankündigen (6 Monate Vorankündigung für öffentliche APIs)
- Sunset-Header hinzufügen: Sunset: Sat, 01 Jan 2026 00:00:00 GMT
- Nach dem Sunset-Datum 410 Gone zurückgeben
4. Nicht rückwärtskompatible Änderungen benötigen keine neue Version:
- Neue Felder zu Antworten hinzufügen
- Neue optionale Query-Parameter hinzufügen
- Neue Endpunkte hinzufügen
5. Rückwärtskompatible Änderungen erfordern eine neue Version:
- Felder entfernen oder umbenennen
- Feldtypen ändern
- URL-Struktur ändern
- Authentifizierungsmethode ändern
Implementierungspattern
TypeScript (Next.js API Route)
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";
const createUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = createUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({
error: {
code: "validation_error",
message: "Anfragenvalidierung fehlgeschlagen",
details: parsed.error.issues.map(i => ({
field: i.path.join("."),
message: i.message,
code: i.code,
})),
},
}, { status: 422 });
}
const user = await createUser(parsed.data);
return NextResponse.json(
{ data: user },
{
status: 201,
headers: { Location: `/api/v1/users/${user.id}` },
},
);
}
Python (Django REST Framework)
from rest_framework import serializers, viewsets, status
from rest_framework.response import Response
class CreateUserSerializer(serializers.Serializer):
email = serializers.EmailField()
name = serializers.CharField(max_length=100)
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "email", "name", "created_at"]
class UserViewSet(viewsets.ModelViewSet):
serializer_class = UserSerializer
permission_classes = [IsAuthenticated]
def get_serializer_class(self):
if self.action == "create":
return CreateUserSerializer
return UserSerializer
def create(self, request):
serializer = CreateUserSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = UserService.create(**serializer.validated_data)
return Response(
{"data": UserSerializer(user).data},
status=status.HTTP_201_CREATED,
headers={"Location": f"/api/v1/users/{user.id}"},
)
Go (net/http)
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "Ungültiger Anforderungstext")
return
}
if err := req.Validate(); err != nil {
writeError(w, http.StatusUnprocessableEntity, "validation_error", err.Error())
return
}
user, err := h.service.Create(r.Context(), req)
if err != nil {
switch {
case errors.Is(err, domain.ErrEmailTaken):
writeError(w, http.StatusConflict, "email_taken", "E-Mail bereits registriert")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Interner Fehler")
}
return
}
w.Header().Set("Location", fmt.Sprintf("/api/v1/users/%s", user.ID))
writeJSON(w, http.StatusCreated, map[string]any{"data": user})
}
API-Design-Checkliste
Bevor Sie einen neuen Endpunkt ausliefern:
- Ressourcen-URL folgt den Benennungskonventionen (Plural, kebab-case, keine Verben)
- Korrekte HTTP-Methode verwendet (GET für Lese-, POST für Erstellungsvorgänge usw.)
- Angemessene Statuscodes zurückgegeben (nicht 200 für alles)
- Eingabe mit Schema validiert (Zod, Pydantic, Bean Validation)
- Fehlerantworten folgen einem Standardformat mit Codes und Meldungen
- Paginierung für Listenendpunkte implementiert (Cursor oder Offset)
- Authentifizierung erforderlich (oder explizit als öffentlich markiert)
- Autorisierung geprüft (Benutzer kann nur auf eigene Ressourcen zugreifen)
- Ratenbegrenzung konfiguriert
- Antwort gibt keine internen Details preis (Stacktraces, SQL-Fehler)
- Konsistente Benennung mit bestehenden Endpunkten (camelCase vs snake_case)
- Dokumentiert (OpenAPI/Swagger-Spezifikation aktualisiert)