Technischer Leitfaden

API-Design für Systeme, die länger leben als dein Team

Wie du APIs designst, die Team-Wechsel überleben. Protobuf als Schema-Quelle, tRPC für TypeScript-Stacks, GraphQL in Produktion, Breaking-Change-Management und Error-Contracts.

5. April 202614 Min. LesezeitOronts Engineering Team

Die API, die ihren Schöpfer überlebt

Jede API startet mit einem Team, einem Client und einem Anwendungsfall. Zwei Jahre später hängen 5 Teams daran, 3 externe Partner integrieren sich, und die ursprünglichen Entwickler sind weg. Die Designentscheidungen der API sind jetzt permanent.

Wir haben APIs mit drei verschiedenen Strategien gebaut: Protobuf als Schema-Quelle für sprachübergreifende Systeme, tRPC für reine TypeScript-Stacks und GraphQL für komplexe Client-Anforderungen. Jede war die richtige Wahl für ihren Kontext. Dieser Artikel behandelt, wann du welche nutzen solltest und wie du APIs designst, die Team-Wechsel überleben.

Wie diese APIs in größere Systemdesigns passen, findest du in unserem System-Architecture-Guide und Software-Engineering-Guide.

Protobuf: Schema-Quelle für sprachübergreifende Systeme

Wenn dein System mehrere Sprachen umfasst (TypeScript API, Go Search Service, Python ML-Pipeline), brauchst du ein Schema-Format, das Typen für alle generiert. Protobuf macht genau das.

// proto/product.proto
syntax = "proto3";
package commerce.v1;

message Product {
    string id = 1;
    string name = 2;
    string description = 3;
    int32 price_cents = 4;
    string currency = 5;
    repeated string category_ids = 6;
    ProductStatus status = 7;
    google.protobuf.Timestamp created_at = 8;
}

enum ProductStatus {
    PRODUCT_STATUS_UNSPECIFIED = 0;
    PRODUCT_STATUS_DRAFT = 1;
    PRODUCT_STATUS_ACTIVE = 2;
    PRODUCT_STATUS_ARCHIVED = 3;
}

service ProductService {
    rpc GetProduct(GetProductRequest) returns (Product);
    rpc ListProducts(ListProductsRequest) returns (ListProductsResponse);
    rpc CreateProduct(CreateProductRequest) returns (Product);
}

Warum Tags für die Weiterentwicklung wichtig sind

Protobuf-Felder werden durch Tag-Nummern (1, 2, 3...) identifiziert, nicht durch Namen. Das bedeutet: Du kannst ein Feld umbenennen, ohne bestehende Clients zu brechen. Du kannst neue Felder (neue Tag-Nummern) hinzufügen, ohne alte Clients zu brechen. Du kannst Felder als deprecated markieren, ohne sie zu entfernen.

// Sichere Weiterentwicklung: Feld hinzufügen
message Product {
    string id = 1;
    string name = 2;
    string description = 3;
    int32 price_cents = 4;
    string currency = 5;
    repeated string category_ids = 6;
    ProductStatus status = 7;
    google.protobuf.Timestamp created_at = 8;
    string sku = 9;          // NEU: hinzugefügt, ohne bestehende Clients zu brechen
    string brand = 10;       // NEU: alte Clients ignorieren unbekannte Felder einfach
}

Regeln für sichere Protobuf-Evolution:

  • Niemals eine Tag-Nummer wiederverwenden (auch nicht nach Entfernung eines Feldes)
  • Niemals den Typ eines Feldes ändern
  • Neue Felder mit neuen Tag-Nummern hinzufügen
  • Veraltete Felder mit reserved markieren, um versehentliche Wiederverwendung zu verhindern

Code-Generierung

# TypeScript, Go und Python aus derselben .proto-Datei generieren
protoc --ts_out=./gen/ts --go_out=./gen/go --python_out=./gen/py proto/*.proto

Eine Schema-Datei generiert typsichere Clients und Server in jeder Sprache. Schema-Änderungen sind ein Pull Request. Code-Generierung ist ein CI-Schritt. Typ-Fehler sind Compile-Errors, keine Runtime-Bugs.

tRPC: Wenn dein gesamter Stack TypeScript ist

Wenn dein API-Server und alle Clients TypeScript sind, eliminiert tRPC die Schema-Schicht komplett. Typen fließen vom Server zum Client zur Compile-Zeit. Keine Code-Generierung, keine Schema-Dateien, keine OpenAPI-Spec.

// Server: Router mit typisierten Prozeduren definieren
import { router, publicProcedure, protectedProcedure } from './trpc';
import { z } from 'zod';

export const productRouter = router({
    list: publicProcedure
        .input(z.object({
            cursor: z.string().optional(),
            limit: z.number().min(1).max(100).default(20),
            category: z.string().optional(),
        }))
        .query(async ({ input, ctx }) => {
            return ctx.productService.list(input);
        }),

    create: protectedProcedure
        .input(z.object({
            name: z.string().min(1).max(200),
            price: z.number().positive(),
            description: z.string().optional(),
        }))
        .mutation(async ({ input, ctx }) => {
            return ctx.productService.create(ctx.tenantId, input);
        }),
});

// Client: vollständige Typ-Inferenz, keine Code-Generierung
const products = await trpc.product.list.query({
    limit: 10,
    category: 'electronics',
});
// products ist voll typisiert: { items: Product[], nextCursor?: string }

Wann tRPC am besten funktioniert

  • Alle Clients sind TypeScript (Web-App, React Native, Node.js Services)
  • Die API ist intern (nicht für Drittparteien freigegeben)
  • Schnelle Iteration wichtiger ist als formelle API-Contracts
  • Das Team ist klein und arbeitet zusammen (Änderungen an Server und Client passieren gleichzeitig)

Wann tRPC nicht funktioniert

  • Externe Partner müssen integrieren (sie brauchen OpenAPI/Swagger-Docs)
  • Clients sind in anderen Sprachen (mobile native, Go, Python)
  • Du brauchst API-Versionierung für Rückwärtskompatibilität
  • Die API ist ein öffentliches Produkt

Wir setzen tRPC mit Hono für interne TypeScript-APIs ein. Schau dir unseren TypeScript-Backends-Guide für den vollständigen Stack-Vergleich an.

GraphQL: Für komplexe Client-Anforderungen

GraphQL glänzt, wenn Clients flexible Abfragen brauchen: Verschiedene Seiten brauchen verschiedene Daten-Subsets, Mobile braucht weniger Daten als Web, und das Client-Team will Queries iterieren, ohne Backend-Änderungen.

# Client fragt genau das ab, was er braucht
query ProductPage($slug: String!) {
    product(slug: $slug) {
        id
        name
        price
        images { url alt }
        reviews(first: 5, status: APPROVED) {
            items { rating body customerName }
            totalItems
        }
        relatedProducts(first: 4) {
            id name price images { url }
        }
    }
}

Production-GraphQL-Patterns

Persisted Queries: Erlaube keine beliebigen Queries in Produktion. Clients senden einen Query-Hash, der Server schlägt die Query in einer Registry nach. Das verhindert Query-Missbrauch und ermöglicht Caching.

Depth Limiting: Ohne Limits kann ein Client eine tief verschachtelte Query senden, die jede Tabelle in deiner Datenbank joint.

apiOptions: {
    middleware: [depthLimit(10)],
    shopApiPlayground: false,  // In Produktion deaktivieren
}

N+1-Prävention: Nutze DataLoader, um Datenbankabfragen zu bündeln. Ohne das macht eine Query, die 20 Produkte mit ihren Kategorien abruft, 20 einzelne Kategorie-Abfragen.

// DataLoader bündelt N einzelne Queries zu 1
const categoryLoader = new DataLoader(async (ids: string[]) => {
    const categories = await categoryRepo.findByIds(ids);
    return ids.map(id => categories.find(c => c.id === id));
});

Komplexitätsanalyse: Weise jedem Feld Kosten zu. Lehne Queries ab, die ein Komplexitätsbudget überschreiten.

Wie wir GraphQL in Vendure Commerce einsetzen, findest du in unserem Vendure-Production-Guide.

Breaking Changes: Tag-basierte Evolution vs. URL-Versionierung

StrategieWie es funktioniertAm besten für
Tag-basiert (Protobuf)Felder mit neuen Tags hinzufügen, alte Clients ignorieren sieSprachübergreifend, gRPC
URL-Versionierung (/v1/, /v2/)Separate Endpunkte pro VersionREST-APIs mit externen Konsumenten
Header-Versionierung (Accept: application/vnd.api.v2+json)Gleiche URL, Version im HeaderREST-APIs, die saubere URLs wollen
GraphQL (keine Versionierung)Felder hinzufügen, alte mit @deprecated markierenGraphQL-APIs
tRPC (keine Versionierung)Typen entwickeln sich mit der CodebaseInterne TypeScript-APIs

Für die meisten internen APIs: Vermeide URL-Versionierung. Sie verdoppelt deine Wartungsfläche. Füge neue Felder hinzu, markiere alte als deprecated, entferne sie, wenn alle Clients migriert haben.

Für externe APIs (Drittanbieter-Integrationen, öffentliche APIs) ist URL-Versionierung sicherer, weil du nicht kontrollieren kannst, wann Clients updaten.

Error Contracts

Errors sind Teil des API-Contracts. Clients brauchen strukturierte Fehler, auf die sie reagieren können, keine String-Nachrichten, die sie mit Regex parsen.

// Strukturierte Fehlerantwort
interface ApiError {
    code: string;           // Maschinenlesbar: "PRODUCT_NOT_FOUND", "INSUFFICIENT_STOCK"
    message: string;        // Menschenlesbar: "Product with ID xyz not found"
    details?: object;       // Zusätzlicher Kontext für Debugging
    requestId: string;      // Korrelations-ID für Support
}

// Beispielantworten
// 404
{
    "code": "PRODUCT_NOT_FOUND",
    "message": "Product with ID prod_123 not found",
    "requestId": "req_abc456"
}

// 409
{
    "code": "INSUFFICIENT_STOCK",
    "message": "Only 2 units available, requested 5",
    "details": { "available": 2, "requested": 5 },
    "requestId": "req_def789"
}

// 422
{
    "code": "VALIDATION_ERROR",
    "message": "Invalid input",
    "details": {
        "fields": [
            { "field": "price", "error": "must be positive" },
            { "field": "name", "error": "must not be empty" }
        ]
    },
    "requestId": "req_ghi012"
}

Das code-Feld ist der Contract. Clients nutzen es als Switch. Die message ist für Menschen. Die details liefern Kontext. Die requestId ermöglicht dem Support, genau den richtigen Request in den Logs zu finden.

API Governance

Wenn die API wächst, brauchst du Governance, um Inkonsistenz zu verhindern:

RegelWarum
Alle Endpunkte erfordern AuthentifizierungKeine versehentlich öffentlichen Endpunkte
Alle Mutations erfordern Idempotency KeysNetzwerk-Retries erzeugen keine Duplikate
Alle Antworten enthalten requestIdJeder Request ist nachverfolgbar
Alle Fehler nutzen das strukturierte FormatClients können Fehler programmatisch behandeln
Alle List-Endpunkte unterstützen PaginationKeine unbegrenzten Queries
Breaking Changes erfordern ReviewEine Person kann nicht alle Consumer brechen
Deprecations erfordern einen Migrations-Zeitplan"Deprecated" ohne Deadline heißt "wird nie entfernt"

Häufige Fehler

  1. REST vs. GraphQL als Identität. Wähle basierend auf Client-Anforderungen, nicht Team-Präferenz. REST für einfaches CRUD mit externen Konsumenten. GraphQL für flexible Queries mit Frontend-Teams. tRPC für internes TypeScript. Protobuf für sprachübergreifend.

  2. Kein Error Contract. String-Fehlermeldungen, die sich mit jedem Release ändern, brechen jeden Client, der versucht, sie zu behandeln.

  3. Unbegrenzte List-Endpunkte. Ein Endpunkt, der alle 50.000 Produkte in einer Antwort zurückgibt, crasht Clients und überlastet deine Datenbank. Pagination ist Pflicht.

  4. Interne APIs versionieren. Wenn du alle Clients kontrollierst, entwickle die API in-place weiter. Versionierung verdoppelt die Wartung ohne Nutzen.

  5. Kein Deprecation-Zeitplan. Ein Feld als @deprecated markieren ohne Entfernungsdatum heißt, es bleibt für immer. Setze ein Datum, informiere die Consumer, entferne es.

  6. GraphQL ohne Depth Limits. Eine uneingeschränkte GraphQL-API ist ein Denial-of-Service-Vektor. Begrenze Tiefe, Komplexität und Query-Kosten.

Wichtigste Erkenntnisse

  • Protobuf für sprachübergreifende Systeme. Tag-basierte Evolution, Code-Generierung, Wire-Format-Effizienz. Das Schema ist der Contract.

  • tRPC für reine TypeScript-Stacks. Null Code-Generierung, volle Typ-Inferenz, schnellste Iterations-Geschwindigkeit. Funktioniert aber nur, wenn alle Clients TypeScript sind.

  • GraphQL für flexible Client-Anforderungen. Clients fragen genau das ab, was sie brauchen. Aber füge Depth Limits, Persisted Queries und Komplexitätsanalyse für Produktion hinzu.

  • Error Contracts sind genauso wichtig wie Erfolgs-Contracts. Maschinenlesbare Codes, menschenlesbare Nachrichten, Korrelations-IDs und strukturierte Details.

  • In-place weiterentwickeln für interne APIs, versionieren für externe. Felder hinzufügen ist sicher. Felder entfernen erfordert einen Migrations-Zeitplan. URL-Versionierung ist der letzte Ausweg.

Wir designen APIs als Teil unserer Webentwicklung und Custom-Software-Praxis. Wenn du Hilfe mit API-Architektur brauchst, sprich mit unserem Team oder frage ein Angebot an.

Behandelte Themen

API-DesignAPI-VersionierungAPI-StabilitätBreaking ChangesContract-first APItRPCGraphQL-DesignProtobufAPI-Governance

Bereit, produktionsreife KI-Systeme zu bauen?

Unser Team ist spezialisiert auf produktionsreife KI-Systeme. Lass uns besprechen, wie wir deinem Unternehmen helfen können.

Gespräch starten