API-Konzepte, die jeder Software Engineer kennen sollte
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:
GETmuss sicher sein (ändert keinen State) und idempotent. Wenn deinGET-Endpoint einen Zähler erhöht oder wichtiges Logging durchführt, verletzt du den stillschweigenden Vertrag mit dem Client (und mit Proxies/CDN-Cache).PUTersetzt die komplette Ressource,PATCHaktualisiert nur die Änderungen.PUTzu 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 Createdbei neuer Erstellung (mitLocation-Header),204 No Contentbei erfolgreichem Löschen ohne Body,409 Conflictbei State-Konflikten — nicht einfach400oder500für jeden Fehler.
Hinweis: Alle Responses mit
200 OKzu beantworten und ein"success": falseFeld 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.