API-Konzepte, die jeder Software Engineer kennen sollte

Zu diesem Artikel gibt es ein kurzes Video:TikTok

1. Übersicht

Eine API aufzurufen ist einfach — Request senden, JSON lesen, fertig. Aber eine API zu entwerfen, die andere Teams oder du selbst nach 6 Monaten immer noch verwenden können, ohne den Code lesen zu müssen, um das Verhalten zu erraten — das ist eine ganz andere Sache. Dieser Artikel fasst die API-Konzepte zusammen, die jeder Backend Engineer verstehen sollte: von der HTTP-Grundlage, über Architekturstile (REST, GraphQL, gRPC, Webhooks, WebSocket), bis hin zu nachhaltigem Design, Sicherheit, Zuverlässigkeit und Dokumentation. Nach dem Lesen wirst du eine konkrete Checkliste haben, um deine API zu überprüfen, bevor du sie anderen zur Verfügung stellst.

2. HTTP-Grundlagen: Methoden, Status-Codes, Request/Response

Bevor wir über REST oder GraphQL sprechen, musst du die grundlegende HTTP-Semantik richtig verwenden. Hier machen viele APIs “die wie REST aussehen” aber eigentlich völlig falsch sind.

@RestController
@RequestMapping("/api/orders")
class OrderController {

    private final OrderService orderService;

    OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    ResponseEntity<Order> getOrder(@PathVariable Long id) {
        return orderService.findById(id)
                .map(ResponseEntity::ok)
                .orElseGet(() -> ResponseEntity.notFound().build());
    }

    @PostMapping
    ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
        Order created = orderService.create(request);
        return ResponseEntity.created(URI.create("/api/orders/" + created.id())).body(created);
    }

    @PutMapping("/{id}")
    ResponseEntity<Order> replaceOrder(@PathVariable Long id, @RequestBody Order order) {
        return ResponseEntity.ok(orderService.replace(id, order));
    }

    @DeleteMapping("/{id}")
    ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
        orderService.cancel(id);
        return ResponseEntity.noContent().build();
    }
}

Häufige Fehler:

  • GET muss sicher sein (ändert keinen State) und idempotent. Wenn dein GET-Endpoint einen Zähler erhöht oder wichtiges Logging durchführt, verletzt du den stillschweigenden Vertrag mit dem Client (und mit Proxies/CDN-Cache).
  • PUT ersetzt die komplette Ressource, PATCH aktualisiert nur die Änderungen. PUT zu verwenden, um ein einzelnes Feld zu aktualisieren, ist semantisch falsch, auch wenn der Code funktioniert.
  • Status-Codes müssen die richtige Bedeutung ausdrücken: 201 Created bei neuer Erstellung (mit Location-Header), 204 No Content bei erfolgreichem Löschen ohne Body, 409 Conflict bei State-Konflikten — nicht einfach 400 oder 500 für jeden Fehler.

Hinweis: Alle Responses mit 200 OK zu beantworten und ein "success": false Feld im Body zu haben ist ein verbreitetes Anti-Pattern. Clients, Load Balancer und APM-Tools verlassen sich auf Status-Codes, um zu entscheiden, ob sie wiederholen oder warnen sollen — Fehler im Body zu verstecken, zerstört die ganze Kommunikation.

3. API-Stil wählen: REST, GraphQL, gRPC, Webhook, WebSocket

Jeder Architekturstil löst ein anderes Problem. Die falsche Wahl ist kein technischer Fehler — es ist ein Missverständnis des Use Cases.

3.1. REST — Standard für CRUD über HTTP

Geeignet, wenn Ressourcen klar sind (Order, Customer, Invoice) und Clients vielfältig sind (Web, Mobile, Third-Party). Das Beispiel in Abschnitt 2 ist eine Standard-REST.

3.2. GraphQL — wenn der Client flexible Felder braucht

type Order {
  id: ID!
  customer: Customer!
  total: Float!
  items: [OrderItem!]!
}

type Query {
  order(id: ID!): Order
}
@Controller
class OrderGraphQlController {

    private final OrderService orderService;

    OrderGraphQlController(OrderService orderService) {
        this.orderService = orderService;
    }

    @QueryMapping
    Order order(@Argument Long id) {
        return orderService.findById(id).orElse(null);
    }
}

Der Client wählt die benötigten Felder ({ order(id: 1) { total } } statt 3 REST-Endpoints aufzurufen). Dafür verlierst du HTTP-Caching nach URL und musst dich selbst um N+1-Queries kümmern (mit DataLoader). GraphQL passt zu Apps mit komplexer UI und vielen verschiedenen Clients, die unterschiedliche Daten brauchen — nicht zu einfachen APIs mit wenigen Clients.

3.3. gRPC — wenn du hohe Leistung zwischen Services brauchst

service PaymentService {
  rpc Charge (ChargeRequest) returns (ChargeResponse);
}

message ChargeRequest {
  string order_id = 1;
  int64 amount_cents = 2;
}

gRPC verwendet HTTP/2 + Protobuf (binär, kompakter als JSON) und unterstützt bidirektionales Streaming. Sehr gut für Service-to-Service in Microservices, aber schwer von Hand zu debuggen (nicht mit curl lesbar) und nicht für öffentliche APIs für Browser geeignet.

3.4. Webhook — wenn der Empfänger proaktiv benachrichtigt werden muss

@PostMapping("/webhooks/payment-status")
ResponseEntity<Void> handlePaymentWebhook(@RequestBody PaymentStatusEvent event,
                                           @RequestHeader("X-Signature") String signature) {
    if (!signatureValidator.isValid(event, signature)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    paymentEventHandler.handle(event);
    return ResponseEntity.ok().build();
}

Webhooks kehren die Aufrufrichtung um: Der PaymentService-Server ruft deinen Server auf, wenn es ein Event gibt. Verifiziere immer die Signatur (HMAC), um gefälschte Requests zu vermeiden, und antworte immer schnell mit 200, bearbeite dann asynchron — der Sender versucht normalerweise es erneut, wenn ein Timeout oder Fehler auftritt.

3.5. WebSocket — wenn du Echtzeit-Kommunikation brauchst

Verwende WebSocket für Chat, Live-Tracking, kontinuierlich aktualisierte Dashboards. Nicht für einfache Request-Response-Operationen geeignet, da das Halten einer stabilen Verbindung mehr Ressourcen kostet als REST.

4. Nachhaltiges Design: Naming, Pagination, Versionierung, Error Responses

4.1. Konsistentes Naming

Verwende Pluralformen für Collections (/orders, nicht /order oder /getOrders), verschachtelte Nomen zeigen Beziehungen (/customers/{id}/orders). Vermische nicht camelCase und snake_case in einer API.

4.2. Pagination

@GetMapping
Page<Order> listOrders(@RequestParam(defaultValue = "0") int page,
                        @RequestParam(defaultValue = "20") int size) {
    return orderService.findAll(PageRequest.of(page, size));
}
GET /api/orders?page=1&size=20

Offset-basiert (page/size) ist leicht zu verstehen, aber langsam bei großen Tabellen und kann doppelte/fehlende Records zurückgeben, wenn Einfügungen/Löschungen während der Paginierung auftreten. Cursor-basiert (?after=order_1234) ist stabiler bei großen Datenmengen, aber komplizierter für “zu Seite 10 springen”.

4.3. Versionierung

@RestController
@RequestMapping("/api/v2/orders")
class OrderControllerV2 { /* ... */ }

Versionierung über URI-Pfad ist lesbar und leicht zu routen mit einem Gateway. Versionierung über Header (Accept: application/vnd.myapp.v2+json) ist URL-sauberer, aber schwer im Browser zu debuggen. Welche Methode du wählst, ist weniger wichtig als eine klare Deprecation-Strategie: ankündigen, alte Versionen lange unterstützen, Deprecation- und Sunset-Header senden.

4.4. Standardisierte Fehlerresponses

@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ProblemDetail> handleNotFound(OrderNotFoundException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setProperty("orderId", ex.getOrderId());
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}
{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Order 99 not found",
  "orderId": 99
}

ProblemDetail (von Spring Boot 3.x nativ unterstützt, nach RFC 7807) liefert ein einheitliches Fehlerformat systemweit — der Client muss nur ein Schema parsen, nicht raten, welches Feld die Fehlermeldung enthält.

5. Sicherheit: API-Keys, OAuth2, JWT, Scopes

5.1. API-Keys — einfach, geeignet für vertrauenswürdige Service-zu-Service

@Component
class ApiKeyFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String apiKey = req.getHeader("X-Api-Key");
        if (apiKey == null || !apiKeyRepository.isValid(apiKey)) {
            res.setStatus(HttpStatus.UNAUTHORIZED.value());
            return;
        }
        chain.doFilter(req, res);
    }
}

API-Keys können nicht unterscheiden, wer im Namen von wem anruft — sie authentifizieren nur die App, nicht den Benutzer. Verwende sie nicht zum Schutz personenbezogener Benutzerdaten.

5.2. OAuth2 + JWT — wenn du Berechtigungen nach Scopes brauchst

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.nonladev.com
@PreAuthorize("hasAuthority('SCOPE_orders:write')")
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) {
    return ResponseEntity.ok(orderService.create(request));
}

JWT trägt Claims (User-ID, Scope, exp), die mit dem öffentlichen Schlüssel selbst verifiziert werden können — kein Bedarf, den Auth-Server bei jeder Anfrage zu befragen — darum skaliert es besser als traditionelle Sessions. Aber JWT kann vor Ablauf nicht einfach widerrufen werden: Wenn ein Benutzer gesperrt wird, ist das alte Token immer noch gültig bis exp. Übliche Lösung: kurze exp (5–15 Minuten) mit Refresh-Token oder Blacklist-Check auf Gateway-Ebene.

Hinweis: Scopes sagen nur “was dieses Token darf”, ersetzt aber nicht die Überprüfung von Geschäftsregeln (z.B.: User A hat orders:write, aber keine Berechtigung, die Bestellung von User B zu bearbeiten). Überprüfe immer Ownership auf der Service-Ebene.

6. Zuverlässigkeit: Timeouts, Retry, Idempotency, Rate Limiting, Cache

6.1. Timeouts und Wiederholungen

@Bean
RestClient paymentClient() {
    return RestClient.builder()
            .baseUrl("https://payment.internal")
            .requestFactory(ClientHttpRequestFactorySettings.DEFAULTS
                    .withConnectTimeout(Duration.ofSeconds(2))
                    .withReadTimeout(Duration.ofSeconds(3))
                    .buildRequestFactory())
            .build();
}
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.2.0</version>
</dependency>
@Retry(name = "paymentService", fallbackMethod = "chargeFallback")
Charge charge(ChargeRequest request) {
    return paymentClient.post().uri("/charge").body(request).retrieve().body(Charge.class);
}

Wenn du kein Timeout setzt, frieren Threads ein, wenn Downstream ausfällt und bringen deinen Service zum Absturz durch Dominoeffekt. Unkontrollierte Wiederholungen verursachen Request-Stürme und bringen Downstream schwächer zum Absturz — kombiniere Wiederholungen immer mit exponentiellem Backoff und Circuit Breaker.

6.2. Idempotency-Key für Geldtransaktionen

@PostMapping("/charges")
ResponseEntity<Charge> charge(@RequestHeader("Idempotency-Key") String key,
                               @RequestBody ChargeRequest request) {
    return idempotencyStore.findByKey(key)
            .map(existing -> ResponseEntity.ok(existing))
            .orElseGet(() -> {
                Charge charge = paymentService.charge(request);
                idempotencyStore.save(key, charge);
                return ResponseEntity.ok(charge);
            });
}

Wenn der Client dieselbe Anfrage erneut sendet (wegen Timeout, Netzwerkfehler) mit denselben Idempotency-Key, erhält er das alte Ergebnis, wird nicht zweimal abgebucht. Dies ist das wichtigste Konzept für alle APIs mit Geldtransaktionen.

@Test
void shouldReturnSameChargeForDuplicateIdempotencyKey() {
    ChargeRequest request = new ChargeRequest("order-1", 10_000L);
    Charge first = client.charge("idem-key-1", request);
    Charge second = client.charge("idem-key-1", request);

    assertThat(second).isEqualTo(first);
    assertThat(paymentGateway.chargeCallCount()).isEqualTo(1);
}

6.3. Rate Limiting und Caching

@Bean
Bucket4jConfiguration rateLimitConfig() {
    return Bucket4jConfiguration.builder()
            .capacity(100)
            .refillGreedy(100, Duration.ofMinutes(1))
            .build();
}
@Cacheable(value = "orders", key = "#id")
Order findById(Long id) {
    return orderRepository.findById(id).orElseThrow();
}

Rate Limiting schützt das System vor Überlastung durch einen fehlerhaften Client (absichtlich oder nicht). Caching reduziert die Belastung des Downstream, aber du musst TTL und Cache-Invalidierung überdenken — veraltete Cache für gerade verarbeitete Order-Daten kann zu Missverständnissen über den State führen und ist schlimmer als kein Cache.

7. Dokumentation, Observability und Contract Testing

<dependency>
    <groupId>org.springdoc</groupId>
    <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    <version>2.6.0</version>
</dependency>
@Operation(summary = "Neue Bestellung erstellen", responses = {
        @ApiResponse(responseCode = "201", description = "Bestellung erfolgreich erstellt")
})
@PostMapping
ResponseEntity<Order> createOrder(@RequestBody CreateOrderRequest request) { /* ... */ }

OpenAPI-Spec wird automatisch aus Annotationen generiert, so bleibt die Spec nicht “out of sync” mit dem tatsächlichen Code — das klassische Problem, wenn die Dokumentation per Hand geschrieben ist und der Code sich ändert, ohne dass die Dokumentation aktualisiert wird.

Contract Testing (Spring Cloud Contract, Pact) stellt sicher, dass Consumer und Provider ihre Vereinbarungen nicht brechen, wenn sie unabhängig bereitgestellt werden:

@Test
void providerShouldHonorOrderContract() {
    Order order = orderService.findById(1L).orElseThrow();

    assertThat(order.id()).isEqualTo(1L);
    assertThat(order.status()).isIn("PENDING", "PAID", "CANCELLED");
}

In Kombination mit Observability (strukturiertes Logging, Trace-ID über Services hinweg über traceparent-Header, Latenz/Fehlerrate-Metriken pro Endpoint) haben Operations-Teams genug Informationen, um Production zu debuggen, statt Rätselspiele mit verstreuten Logs zu spielen.

8. Fazit

API-Design liegt nicht in der Wahl des richtigen Frameworks — es liegt in einer Reihe kleiner Entscheidungen: welche Methode, welcher Status-Code, welche Paginierung, wer darf mit welchem Scope anrufen, wie viele Wiederholungen sind genug. Jede einzelne Entscheidung wirkt einfach, aber zusammen entscheiden sie, ob deine API einfach zu verwenden ist oder ein Albtraum für jedes integrierendes Team. Beherrsche diese Konzepte — HTTP-Semantik, richtige Architekturauswahl für den Use Case, Sicherheit in Schichten, Zuverlässigkeitsmuster und Dokumentation im Sync mit dem Code — und du schaffst eine API, der andere wirklich vertrauen und die lange nutzen.

system-designapispring-bootrestsecurity