Wann REST ausreicht, wann tRPC die bessere Wahl ist – und was beide falsch machen können
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.
| Statuscode | Bedeutung | Häufige Fehlanwendung |
|---|---|---|
| 200 OK | Request erfolgreich, Antwort enthält Ergebnis | Wird für Fehler mit Fehlerbeschreibung im Body verwendet |
| 201 Created | Ressource wurde erstellt | Wird durch 200 ersetzt obwohl Location-Header nötig wäre |
| 204 No Content | Erfolgreich, kein Body | Wird bei DELETE ignoriert |
| 400 Bad Request | Client-seitiger Fehler (Validierung) | Wird für alles non-200 verwendet |
| 401 Unauthorized | Nicht authentifiziert | Wird mit 403 verwechselt |
| 403 Forbidden | Authentifiziert, aber keine Berechtigung | Wird mit 401 verwechselt |
| 404 Not Found | Ressource existiert nicht | Wird für “User hat keine Berechtigung, das zu sehen” verwendet |
| 409 Conflict | Zustandskonflikt (z.B. doppelter Username) | Wird durch 400 ersetzt |
| 422 Unprocessable Entity | Semantisch ungültig (korrekte Syntax, aber falscher Inhalt) | Kaum bekannt |
| 429 Too Many Requests | Rate Limit überschritten | Fehlt 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,DELETEmüssen idempotent seinPOSTist nicht idempotent — wiederholter Aufruf kann mehrere Ressourcen anlegenPATCHist 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:
| Entscheidung | Beispiel |
|---|---|
| Ressourcen-Namen | plural, englisch, kebab-case oder snake_case |
| Versionierung | /api/v1 oder Header |
| Fehlerformat | RFC-7807-ähnlich, einheitliche Felder |
| Authentifizierung | Bearer Token, Session Cookie, API Key |
| Pagination | Cursor für große Listen, Limit-Obergrenze |
| Sortierung | sort=-created_at,name oder feste Parameter |
| Idempotenz | Header für kritische POST-Operationen |
| Datumsformat | ISO 8601, UTC |
| Deprecation | Header, Changelog, Mindestlaufzeit |
| Observability | Request-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.
| Kriterium | tRPC geeignet | REST geeignet |
|---|---|---|
| Clients | Nur TypeScript | Beliebige Sprachen/Clients |
| API-Zugang | Intern (Mono-/Multi-Repo) | Öffentlich oder Dritte |
| Typ-Sharing | TypeScript-Monorepo | Nicht relevant |
| Caching | Eingeschränkt (POST-basiert) | HTTP-Caching vollständig |
| Lernkurve | Mittel | Niedrig (bekanntes Protokoll) |
| Dokumentation | Automatisch (TypeScript) | Manuelle OpenAPI/Swagger |
| Browser-Caching | Nicht ohne Adapter | GET-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
| REST | tRPC | GraphQL | |
|---|---|---|---|
| Typsicherheit | Nur mit OpenAPI + Codegen | Nativ (TypeScript) | Nativ (Schema) |
| Client-Flexibilität | Sehr hoch | Nur TypeScript | Hoch (alle, die Schema nutzen) |
| Caching | Vollständig (HTTP) | Eingeschränkt | Eingeschränkt (ohne Persisted Queries) |
| Lernkurve | Niedrig | Mittel | Hoch |
| Öffentliche API | Ja | Nein | Bedingt |
| Datenfetching-Effizienz | Mehrere Requests | Batching | Präzise Queries |
| Infrastruktur-Komplexität | Niedrig | Niedrig | Mittel-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.