Zum Inhalt springen
CASOON

API-Design Best Practices: REST und tRPC in der Praxis

Wann REST ausreicht, wann tRPC die bessere Wahl ist – und was beide falsch machen können

11 Minuten
API-Design Best Practices: REST und tRPC in der Praxis
#API #TypeScript #Backend #Architektur

API-Design ist eine der Entscheidungen, die sich am schwersten rückgängig machen lässt. Eine API, die einmal in Betrieb ist, hat Clients. Clients haben Erwartungen. Änderungen erzeugen Brüche. Das ist der Grund, warum schlechte Designentscheidungen hier besonders teuer werden – nicht beim ersten Deployment, sondern sechs Monate später, wenn eine dritte Partei integriert ist und das Fehlverhalten von Anfang an mitschleppt.

Dieser Artikel beschäftigt sich mit den häufigsten Problemen in der Praxis: REST-Anti-Pattern, die Clients unnötig komplex machen, und wann tRPC tatsächlich hilft.

Was REST wirklich bedeutet — und warum es so oft missverstanden wird

REST ist kein Standard, kein Protokoll und keine Bibliothek. Es ist ein Architekturstil, beschrieben von Roy Fielding in seiner Dissertation von 2000. Was in der Praxis als „REST” bezeichnet wird, ist meistens ein HTTP-basiertes API mit JSON – und das ist in Ordnung, solange man die Grundprinzipien versteht.

Das Kernprinzip: Ressourcen, nicht Aktionen. Eine REST-API beschreibt Entitäten und deren Zustände, nicht Operationen.

Falsches Denken:

POST /api/createUser
POST /api/deleteUser?id=42
GET  /api/getUserById?id=42
POST /api/updateUserEmail

Ressourcenorientiertes Denken:

POST   /api/users          → Neuen User anlegen
DELETE /api/users/42       → User 42 löschen
GET    /api/users/42       → User 42 abrufen
PATCH  /api/users/42       → User 42 partial update (z.B. E-Mail)
PUT    /api/users/42       → User 42 vollständig ersetzen

Das ist keine Geschmacksfrage. Verb-in-URL-Designs machen Caching unmöglich, erschweren API-Dokumentation und signalisieren Clients nicht, was idempotent ist.

Statuscodes: Was sie bedeuten, nicht was sich “irgendwie richtig anfühlt”

Das zweithäufigste Problem in der Praxis: Statuscodes werden falsch oder willkürlich gesetzt.

StatuscodeBedeutungHäufige Fehlanwendung
200 OKRequest erfolgreich, Antwort enthält ErgebnisWird für Fehler mit Fehlerbeschreibung im Body verwendet
201 CreatedRessource wurde erstelltWird durch 200 ersetzt obwohl Location-Header nötig wäre
204 No ContentErfolgreich, kein BodyWird bei DELETE ignoriert
400 Bad RequestClient-seitiger Fehler (Validierung)Wird für alles non-200 verwendet
401 UnauthorizedNicht authentifiziertWird mit 403 verwechselt
403 ForbiddenAuthentifiziert, aber keine BerechtigungWird mit 401 verwechselt
404 Not FoundRessource existiert nichtWird für “User hat keine Berechtigung, das zu sehen” verwendet
409 ConflictZustandskonflikt (z.B. doppelter Username)Wird durch 400 ersetzt
422 Unprocessable EntitySemantisch ungültig (korrekte Syntax, aber falscher Inhalt)Kaum bekannt
429 Too Many RequestsRate Limit überschrittenFehlt oft komplett

Idempotenz — der unterschätzte Garant für Zuverlässigkeit

Idempotenz bedeutet: Dieselbe Operation kann beliebig oft wiederholt werden und das Ergebnis ändert sich nicht. Das ist keine akademische Eigenschaft, sondern praktisch relevant bei Netzwerkfehlern und Timeouts.

  • GET, PUT, DELETE müssen idempotent sein
  • POST ist nicht idempotent — wiederholter Aufruf kann mehrere Ressourcen anlegen
  • PATCH ist es je nach Implementierung

Wenn ein Client einen DELETE-Request schickt und keine Antwort bekommt (Timeout), kann er sicher wiederholen. Wenn der Client einen POST schickt und keine Antwort bekommt, ist unklar ob die Ressource angelegt wurde.

Das Muster für idempotente POST-Requests: Idempotency-Key-Header.

// Client schickt einen eindeutigen Key mit
const response = await fetch('/api/orders', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify(orderData),
});

// Server speichert Key → Response im Cache (z.B. Redis, 24h)
// Zweiter Request mit gleichem Key gibt dieselbe Response zurück
// ohne die Bestellung ein zweites Mal anzulegen

Typische REST-Anti-Pattern aus der Praxis

Anti-Pattern 1: Alles in einem Endpoint

// Schlecht: Ein Endpoint für alles
GET /api/data?type=users&action=list&filter=active&include=orders

// Besser: Separate Ressourcen
GET /api/users?status=active
GET /api/users/42/orders

Der erste Ansatz erzeugt “God Endpoints”. Sie sind schwer zu cachen, schwer zu dokumentieren und schwer zu testen.

Anti-Pattern 2: Keine konsistente Fehlerstruktur

// Verschiedene Endpoints antworten unterschiedlich bei Fehlern:
{ "error": "User not found" }
{ "message": "Validation failed", "fields": [...] }
{ "success": false, "reason": "..." }

// Besser: Einheitliche Fehlerstruktur (angelehnt an RFC 7807)
interface ApiError {
  type: string;       // URI zur Fehlerdefinition
  title: string;      // Kurze Beschreibung
  status: number;     // HTTP-Statuscode
  detail?: string;    // Detaillierte Erklärung
  instance?: string;  // Pfad zum betroffenen Request
}

Anti-Pattern 3: Fehlende Versionierung

APIs ohne Versionsstrategie müssen Breaking Changes rückwärtskompatibel halten oder Clients brechen. Die einfachste Lösung: URL-Versionierung.

/api/v1/users
/api/v2/users

Alternativ: Header-Versionierung (Accept: application/vnd.api+json;version=2), aber das ist für Browser-Clients unhandlich.

Anti-Pattern 4: Validierung nur im Frontend

Frontend-Validierung verbessert UX, schützt aber keine API. Alles, was über HTTP kommt, muss serverseitig validiert werden. Das gilt besonders für tRPC-Projekte, weil die Typsicherheit im Client leicht die Illusion erzeugt, ungültige Eingaben seien unmöglich.

// Schlecht: TypeScript-Typ existiert nur zur Compile-Zeit
type CreateUserInput = {
  email: string;
  role: 'user' | 'admin';
};

// Besser: Runtime-Schema als Grenze der API
const createUserSchema = z.object({
  email: z.string().email(),
  role: z.enum(['user', 'admin']).default('user'),
});

Die Regel: TypeScript beschreibt Entwicklerabsicht. Zod, Valibot oder eine vergleichbare Runtime-Validierung schützt die Systemgrenze.

Anti-Pattern 5: Fehlende Pagination und Filter-Semantik

GET /api/users ist am Anfang harmlos. Nach einem Jahr sind es 80.000 Datensätze und ein Client lädt versehentlich alles. Gute APIs definieren früh:

GET /api/users?limit=50&cursor=eyJpZCI6IjQyIn0
GET /api/users?status=active&created_after=2026-01-01

Cursor-Pagination ist für wachsende Datenmengen stabiler als Offset-Pagination, weil neue Datensätze die Seiten nicht verschieben. Für Admin-Tabellen reicht Offset oft aus. Für Feeds, Logs, Events und große Listen ist Cursor meistens die bessere Wahl.

Ein kleines API-Design-Dokument spart Wochen

Vor der ersten Implementierung reichen oft zehn Entscheidungen:

EntscheidungBeispiel
Ressourcen-Namenplural, englisch, kebab-case oder snake_case
Versionierung/api/v1 oder Header
FehlerformatRFC-7807-ähnlich, einheitliche Felder
AuthentifizierungBearer Token, Session Cookie, API Key
PaginationCursor für große Listen, Limit-Obergrenze
Sortierungsort=-created_at,name oder feste Parameter
IdempotenzHeader für kritische POST-Operationen
DatumsformatISO 8601, UTC
DeprecationHeader, Changelog, Mindestlaufzeit
ObservabilityRequest-ID und strukturierte Logs

Das Dokument muss nicht perfekt sein. Es muss nur verhindern, dass jeder Endpoint seine eigenen Regeln erfindet.

tRPC: Was es löst und was es kostet

tRPC ist kein API-Standard, sondern eine Bibliothek für TypeScript-Projekte, die Frontend und Backend teilen. Das Versprechen: End-to-End-Typsicherheit ohne Codegenerierung, ohne OpenAPI-Schema, ohne separaten Client-Generator.

// Server: Router definieren
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  user: t.router({
    getById: t.procedure
      .input(z.object({ id: z.string() }))
      .query(async ({ input }) => {
        const user = await db.users.findUnique({ where: { id: input.id } });
        if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
        return user;
      }),

    create: t.procedure
      .input(z.object({
        name: z.string().min(1),
        email: z.string().email(),
      }))
      .mutation(async ({ input }) => {
        return db.users.create({ data: input });
      }),
  }),
});

export type AppRouter = typeof appRouter;
// Client: Vollständig typsiert, ohne generierten Code
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './server';

const trpc = createTRPCClient<AppRouter>({
  links: [httpBatchLink({ url: '/api/trpc' })],
});

// TypeScript kennt den Rückgabetyp automatisch
const user = await trpc.user.getById.query({ id: '42' });
//    ^^ Typ ist automatisch aus dem Server-Router abgeleitet

const newUser = await trpc.user.create.mutate({
  name: 'Jane',
  email: 'jane@example.com',
});

Das ist der echte Mehrwert: Keine OpenAPI-Spec, kein openapi-typescript, kein graphql-codegen. Der Server-Router ist die Single Source of Truth für Types.

Wann tRPC überdimensioniert ist

tRPC funktioniert nur, wenn Client und Server TypeScript verwenden und aus derselben Codebasis stammen oder denselben Typ-Export teilen. Das limitiert die Anwendbarkeit erheblich.

KriteriumtRPC geeignetREST geeignet
ClientsNur TypeScriptBeliebige Sprachen/Clients
API-ZugangIntern (Mono-/Multi-Repo)Öffentlich oder Dritte
Typ-SharingTypeScript-MonorepoNicht relevant
CachingEingeschränkt (POST-basiert)HTTP-Caching vollständig
LernkurveMittelNiedrig (bekanntes Protokoll)
DokumentationAutomatisch (TypeScript)Manuelle OpenAPI/Swagger
Browser-CachingNicht ohne AdapterGET-Requests nativ cachebar

tRPC-Grenzen, die Teams früh merken

tRPC ist stark, solange die API ein internes Produktinterface ist. Es wird schwächer, sobald externe Konsumenten dazukommen:

  • Ein Partner in Python, Go oder Java kann mit TypeScript-Typen nichts anfangen.
  • Öffentliche Dokumentation entsteht nicht automatisch in der Form, die Dritte erwarten.
  • HTTP-Semantik ist weniger sichtbar, weil viele Operationen über denselben Transport laufen.
  • Breaking Changes können schnell durchrutschen, wenn Client und Server immer gleichzeitig deployt werden.

Das ist kein Argument gegen tRPC. Es ist ein Argument, die Grenze sauber zu ziehen: tRPC für interne, gemeinsam versionierte TypeScript-Clients; REST oder OpenAPI für alles, was außerhalb der eigenen Codebasis stabil konsumiert werden soll.

API-Tests: Was wirklich geprüft werden sollte

Eine gute API braucht nicht für jeden Controller drei Testebenen. Wichtig sind die Vertragsstellen:

it('returns 409 when email already exists', async () => {
  await request(app)
    .post('/api/v1/users')
    .send({ email: 'a@example.com', name: 'Alice' })
    .expect(201);

  const response = await request(app)
    .post('/api/v1/users')
    .send({ email: 'a@example.com', name: 'Alice' })
    .expect(409);

  expect(response.body).toMatchObject({
    status: 409,
    title: 'Conflict',
  });
});

Der Test prüft nicht die Datenbankimplementierung. Er prüft das öffentliche Verhalten: Statuscode, Fehlerformat, Konfliktlogik. Genau diese Dinge dürfen Clients erwarten.

REST vs. tRPC vs. GraphQL: Die Entscheidungsmatrix

RESTtRPCGraphQL
TypsicherheitNur mit OpenAPI + CodegenNativ (TypeScript)Nativ (Schema)
Client-FlexibilitätSehr hochNur TypeScriptHoch (alle, die Schema nutzen)
CachingVollständig (HTTP)EingeschränktEingeschränkt (ohne Persisted Queries)
LernkurveNiedrigMittelHoch
Öffentliche APIJaNeinBedingt
Datenfetching-EffizienzMehrere RequestsBatchingPräzise Queries
Infrastruktur-KomplexitätNiedrigNiedrigMittel-Hoch

GraphQL löst ein echtes Problem — Over- und Underfetching bei komplexen, verschachtelten Datenstrukturen. Dafür bringt es Komplexität: Schema-Management, N+1-Probleme, Cache-Invalidierung, Toolchain. Für die meisten Projekte lohnt dieser Overhead nicht.

Die praktische Entscheidungsregel:

  • Öffentliche API oder mehrsprachige Clients: REST mit vernünftiger OpenAPI-Dokumentation
  • TypeScript-Monorepo, internes API: tRPC
  • Komplexe, hochflexible Datengraphen mit vielen unterschiedlichen Clients: GraphQL, aber mit dem Bewusstsein für den Preis

Für Real-Time-Anteile in einer API — Live-Updates, Streaming — ergänzen sich tRPC und REST gut mit den Ansätzen aus dem Artikel zu Real-Time Architektur mit WebSockets und SSE. Wie man APIs sinnvoll testet, ohne jede Schicht einzeln zu verifizieren, behandelt der Artikel zu Testframeworks im Vergleich.

Die API, die man in einem Jahr noch versteht

Das wichtigste Qualitätsmerkmal einer API ist nicht Eleganz — es ist Vorhersehbarkeit. Clients sollen sich keine Sonderfälle merken müssen. Fehler sollen einheitlich behandelt werden. Die Semantik von Statuscode 404 soll nicht je Endpoint unterschiedlich sein.

Das klingt trivial. In der Praxis entstehen diese Inkonsistenzen fast immer, wenn kein gemeinsames Fehlerformat definiert wurde, wenn mehrere Entwickler unabgestimmt an Endpoints arbeiten, oder wenn REST als “HTTP mit JSON” verstanden wird anstatt als Architekturstil.

Ein kurzes API-Design-Dokument — eine halbe Seite mit Statuscode-Konventionen, Fehlerstruktur und Namenskonventionen — verhindert die meisten dieser Probleme, bevor sie entstehen.