Zum Inhalt springen

REST-API-Design: mehr als nur die Grundlagen

Sobald die HTTP-Verben sitzen, beginnt die eigentliche Arbeit: Pagination, Filterung, Fehlerformate und Versionierung, die APIs angenehm machen.

3 Min. Lesezeit
Diagramm des HTTP-Request-Response-Ablaufs mit REST-API-Designmustern

Jedes REST-Tutorial behandelt dasselbe: Substantive für Ressourcen, HTTP-Verben für Aktionen, korrekte Statuscodes zurückgeben. Das ist die Grundvoraussetzung. Der schwierige Teil beginnt, wenn echte Clients Tausende von Datensätzen paginieren, nach komplexen Kriterien filtern und Fehler über verschiedene API-Versionen hinweg sauber behandeln müssen.

Ressourcenbenennung, die skaliert

URLs sollten Ressourcen beschreiben, keine Operationen. Die eigentliche Feinheit liegt aber darin, zu entscheiden, wo eine Ressource endet und eine andere beginnt.

tstypescript
// ❌ Verbs in URLs — REST anti-pattern
app.post("/api/createUser", handler);
app.get("/api/getUserOrders", handler);
app.put("/api/updateOrderStatus", handler);
 
// ✅ Resources and sub-resources
app.post("/api/users", createUser);
app.get("/api/users/:userId/orders", getUserOrders);
app.patch("/api/orders/:orderId", updateOrder);

Verschachtele Sub-Ressourcen nur eine Ebene tief. Darüber hinaus solltest du die verschachtelte Ressource zu einer Top-Level-Ressource mit einem Filterparameter machen. /api/users/:userId/orders/:orderId/items/:itemId ist zu tief verschachtelt — verwende stattdessen /api/order-items?orderId=abc.

Pagination richtig gemacht

Offset-basierte Pagination ist einfach, bricht aber bei gleichzeitigen Schreibzugriffen. Cursor-basierte Pagination ist stabil und performant.

tstypescript
// ❌ Offset pagination — skips or duplicates records when data changes
// GET /api/orders?page=3&limit=20
// If an order is inserted while user is on page 2, page 3 shows a duplicate
 
// ✅ Cursor-based pagination — stable regardless of mutations
// GET /api/orders?cursor=eyJpZCI6MTAwfQ&limit=20
 
interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    nextCursor: string | null;
    hasMore: boolean;
    limit: number;
  };
}
 
function paginateOrders(cursor: string | null, limit: number) {
  const decodedCursor = cursor
    ? JSON.parse(Buffer.from(cursor, "base64url").toString())
    : null;
 
  const orders = db.orders.findMany({
    where: decodedCursor ? { id: { gt: decodedCursor.id } } : undefined,
    take: limit + 1,
    orderBy: { id: "asc" },
  });
 
  const hasMore = orders.length > limit;
  const data = hasMore ? orders.slice(0, -1) : orders;
  const nextCursor = hasMore
    ? Buffer.from(JSON.stringify({ id: data.at(-1)!.id })).toString("base64url")
    : null;
 
  return { data, pagination: { nextCursor, hasMore, limit } };
}

Gib immer einen hasMore-Boolean zurück. Clients sollten nie raten müssen, ob es eine nächste Seite gibt.

Filtern und Sortieren

Unterstütze Filterung über Query-Parameter mit einem konsistenten Muster. Erfinde keine eigenen Abfragesprachen.

tstypescript
// GET /api/products?category=electronics&minPrice=100&maxPrice=500&sort=-createdAt
 
interface ProductFilters {
  category?: string;
  minPrice?: number;
  maxPrice?: number;
  search?: string;
  sort?: string;
}
 
function parseSort(sort: string): { field: string; direction: "asc" | "desc" } {
  if (sort.startsWith("-")) {
    return { field: sort.slice(1), direction: "desc" };
  }
  return { field: sort, direction: "asc" };
}
 
function buildProductQuery(filters: ProductFilters) {
  const where: Record<string, unknown> = {};
 
  if (filters.category) where.category = filters.category;
  if (filters.minPrice) where.price = { gte: filters.minPrice };
  if (filters.maxPrice) where.price = { ...where.price, lte: filters.maxPrice };
  if (filters.search)
    where.name = { contains: filters.search, mode: "insensitive" };
 
  const orderBy = filters.sort
    ? { [parseSort(filters.sort).field]: parseSort(filters.sort).direction }
    : { createdAt: "desc" };
 
  return { where, orderBy };
}

Stelle Sortierfeldern für absteigende Sortierung ein - voran. Erlaube kommagetrennte Werte für Mehrfachsortierung: ?sort=-price,name.

Konsistente Fehlerantworten

Jeder Fehler deiner API sollte derselben Struktur folgen. Clients sollten Fehler mit einem einzigen Parser behandeln können, nicht mit Logik pro Endpoint.

tstypescript
// ❌ Inconsistent errors — different shapes per endpoint
// { error: "Not found" }
// { message: "Validation failed", errors: [...] }
// { status: "error", reason: "Unauthorized" }
 
// ✅ Single error format across the entire API
interface ApiError {
  error: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
  };
}
 
function errorResponse(
  status: number,
  code: string,
  message: string,
  details?: Record<string, string[]>,
): Response {
  return Response.json({ error: { code, message, details } }, { status });
}
 
// Usage
errorResponse(404, "ORDER_NOT_FOUND", "Order abc-123 does not exist");
errorResponse(422, "VALIDATION_ERROR", "Request body is invalid", {
  email: ["must be a valid email address"],
  quantity: ["must be greater than 0"],
});

Verwende maschinenlesbare Fehlercodes (ORDER_NOT_FOUND), nicht nur Nachrichten. Codes erlauben es Clients, ihre Logik zu verzweigen; Nachrichten sind für die menschliche Fehlersuche.

Strategie zur API-Versionierung

Versioniere deine API von Anfang an. Die beiden praktikablen Ansätze:

StrategieURL-BeispielVorteileNachteile
URL-Pfad/api/v2/usersOffensichtlich, leicht zu routenDupliziert Routen
HeaderAccept: application/vnd.api+json;version=2Saubere URLsVersteckt, schwerer zu testen
tstypescript
// URL-based versioning — explicit and simple
import { v1Router } from "./routes/v1";
import { v2Router } from "./routes/v2";
 
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
 
// Shared logic lives in services, not route handlers
// v1 and v2 route handlers call the same service layer
// but shape the response differently

URL-basierte Versionierung ist für die meisten Teams die beste Wahl. Sie ist sofort sichtbar, von CDNs cachebar und mit curl testbar.

Rate-Limiting-Header

Kommuniziere Rate Limits über Standard-Header, damit Clients sich selbst drosseln können, bevor sie an die Grenze stoßen.

tstypescript
function rateLimitHeaders(
  limit: number,
  remaining: number,
  resetAt: Date,
): Record<string, string> {
  return {
    "X-RateLimit-Limit": String(limit),
    "X-RateLimit-Remaining": String(remaining),
    "X-RateLimit-Reset": String(Math.floor(resetAt.getTime() / 1000)),
    "Retry-After": String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)),
  };
}

Wenn ein Client das Rate Limit überschreitet, gib 429 Too Many Requests mit dem Retry-After-Header zurück. Wohlerzogene Clients nutzen das, um sich automatisch zurückzuhalten.

Die wichtigsten Punkte

  1. Verschachtele Sub-Ressourcen nur eine Ebene tief — hebe tiefere Verschachtelung mit Filtern auf Top-Level an
  2. Verwende cursor-basierte Pagination für stabile Ergebnisse bei gleichzeitigen Schreibzugriffen
  3. Standardisiere Fehlerantworten mit maschinenlesbaren Codes, nicht nur Nachrichten
  4. Versioniere von Anfang an — URL-basierte Versionierung ist für die meisten Teams am einfachsten
  5. Kommuniziere Rate Limits über Header, damit Clients sich selbst drosseln können
  6. Filtere und sortiere über Query-Parameter mit einem konsistenten, vorhersehbaren Muster
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX