← Zurück

Kapitelkompass

Eine API ist ein langfristiger Vertrag

Gute REST-Schnittstellen trennen Transport, Fachmodell und Fehlervertrag und bleiben unter Evolution kompatibel.

RequestAusgangspunktDTOEinordnenUse CaseAbsichernResponseErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • DTOs bewusst schneiden
  • Problem Details nutzen
  • Pagination stabil halten
  • Kompatibel versionieren

Praxisfall

Ein Client sendet eine alte Repräsentation, während der Server ein neues optionales Feld einführt.

Entscheidung

Additive Änderungen bevorzugen; Versionen nur bei wirklich inkompatiblen Vertragsbrüchen.

!

Typisches Risiko

Entities direkt auszuliefern koppelt Datenbank, Security und API-Evolution aneinander.

Schnelle Einordnung
SignalLesartNächster Schritt
EvolutionOptionales Feld oder neuer LinkAdditiv ändern und alte Clients weiter akzeptieren
PaginationGroße, veränderliche ErgebnismengeStabilen Sortierschlüssel und Cursor bevorzugen
IdempotenzClient wiederholt POST nach TimeoutIdempotency Key mit Ergebnis und Ablaufzeit speichern

06. REST API Design, DTOs und Problem Details

REST API Design, DTOs, Validation, Problem Details, API-Versionierung und Idempotency Keys

1. REST API Design: Vertrag statt Controller-Zufall

Praxisproblem

REST APIs werden in Enterprise-Projekten oft als Nebenprodukt eines Controllers gebaut. Am Anfang reicht das. Spaeter entstehen doppelte POST-Wirkungen, unklare Fehlerantworten, Entity-Leaks und inkompatible Client-Integrationen. Der Deep Dive betrachtet REST deshalb als stabilen Vertrag zwischen Systemen, nicht als Sammlung zufaelliger Java-Methoden.

Schlechte Loesung

JAVA
// Schlecht: Entity kommt direkt aus HTTP, Repository wird direkt benutzt, Fehler werden zufaellig.
public HttpResponse create(OrderEntity entity) {
    repository.save(entity);
    return json(200, entity);
}

Bessere Leitidee

Der Controller ist ein Primary Adapter: Er liest HTTP, validiert den API-Vertrag, ruft einen Use Case auf und uebersetzt das Ergebnis in HTTP. Der Domain-Kern bleibt frei von Headern, JSON und Statuscodes.

Diagramm

Checkliste

2. Ressourcenmodell und fachliche Commands

Ressource vs Command

POST /orders beschreibt das Anlegen in der Order-Collection. POST /orders/{id}/cancellations beschreibt einen fachlichen Storno-Command. Gute APIs muessen nicht dogmatisch CRUD sein, aber sie sollten die Fachsprache ausdruecken und keine internen Methoden nach aussen spiegeln.

Beispielroute

TEXT
POST   /orders
GET    /orders/{orderId}
PATCH  /orders/{orderId}
POST   /orders/{orderId}/payment-authorizations
POST   /orders/{orderId}/cancellations
GET    /customers/{customerId}/orders?status=PAID&page=0&size=50

Trade-offs

Zu viele Command-Ressourcen werden unuebersichtlich. Zu generische Endpunkte wie /execute sind noch schlimmer: Sie verstecken Vertrag, Fehler und Security. Ein gutes Ressourcenmodell orientiert sich an fachlichen Statusuebergaengen.

3. DTOs, Mapper und Entity-Leaks

Warum DTOs keine Nebensache sind

DTOs schuetzen den externen Vertrag. JPA-Entities enthalten technische Details, Lazy-Loading-Beziehungen und interne Modellierungsentscheidungen. Sobald eine Entity API-Vertrag wird, wird jede Domain-Aenderung zum Client-Risiko.

Code: Mapper

JAVA
package com.example.restapi;
import java.util.List;
// Pattern: Mapper - trennt API-DTOs und Domain-Objekte bewusst.
public final class OrderMapper {
    public OrderLine toDomainLine(CreateOrderLineRequest dto) {
        return new OrderLine(dto.sku(), dto.quantity(), Money.eur(dto.unitPrice()));
    }
public OrderResponse toResponse(Order order) {
    List<OrderLineResponse> lines = order.lines().stream()
    .map(line -> new OrderLineResponse(
    line.sku(),
    line.quantity(),
    line.unitPrice().amount().toPlainString(),
    line.lineTotal().amount().toPlainString()))
    .toList();
    return new OrderResponse(
    order.id().value(),
    order.customerId(),
    order.status().name(),
    order.total().amount().toPlainString(),
    order.version(),
    lines);
}
}

Produktionshinweis

Automapping ist fuer einfache CRUD-Faelle brauchbar. Bei Geld, Status, Berechtigungen, Datenschutz, Aggregates und fachlichen Fehlern sollte Mapping explizit und testbar bleiben.

4. Validation Deep Dive

Mehrschichtige Validation

Validierung beginnt nicht im Domain-Objekt. Header, Version, Content-Type, Idempotency-Key, DTO-Felder, Business-Regeln und Persistence-Constraints sind unterschiedliche Ebenen. Wer alles zu 400 Bad Request macht, verliert wichtige Semantik.

Diagramm

Code: RequestValidator

JAVA
package com.example.restapi;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
// Pattern: Validator - technische Eingabepruefung bleibt ausserhalb des Domain-Kerns.
public final class RequestValidator {
    public List<ValidationError> validate(CreateOrderRequest request) {
        List<ValidationError> errors = new ArrayList<>();
        if (request == null) {
            errors.add(new ValidationError("request", "body is required"));
            return errors;
        }
    if (request.customerId() == null || request.customerId().isBlank()) {
        errors.add(new ValidationError("customerId", "customerId is required"));
    }
if (request.lines() == null || request.lines().isEmpty()) {
    errors.add(new ValidationError("lines", "at least one line is required"));
    return errors;
}
for (int i = 0; i < request.lines().size(); i++) {
    CreateOrderLineRequest line = request.lines().get(i);
    String prefix = "lines[" + i + "]";
    if (line.sku() == null || line.sku().isBlank()) errors.add(new ValidationError(prefix + ".sku", "sku is required"));
    if (line.quantity() <= 0) errors.add(new ValidationError(prefix + ".quantity", "quantity must be positive"));
    try {
        if (new BigDecimal(line.unitPrice()).signum() < 0) errors.add(new ValidationError(prefix + ".unitPrice", "unitPrice must not be negative"));
    }
catch (RuntimeException ex) {
    errors.add(new ValidationError(prefix + ".unitPrice", "unitPrice must be a decimal number"));
}
}
return errors;
}
}

Fehlerarten

Ebene Beispiel Status Reaktion
Transport Idempotency-Key fehlt 400 Client korrigiert Header
DTO quantity <= 0 400 UI zeigt Feldfehler
Business Kunde gesperrt 409 Prozessalternative
Persistenz Version passt nicht 412/409 neu laden und Konflikt loesen

5. Problem Details und Fehlerkatalog

Fehler als API-Vertrag

Erfolgreiche Responses sind nur die halbe API. Clients brauchen stabile Fehlerstrukturen fuer Validierung, Konflikte, Versionsprobleme und Retry-Entscheidungen. Deshalb gehoert ein Fehlerkatalog zur API-Dokumentation.

Diagramm

Code: ProblemDetailsFactory

JAVA
package com.example.restapi;
import java.util.List;
import java.util.Map;
// Pattern: Factory - zentrale Erzeugung konsistenter Fehlerobjekte.
public final class ProblemDetailsFactory {
    public ProblemDetail validation(String instance, String correlationId, List<ValidationError> errors) {
        return new ProblemDetail(
        "https://errors.example.com/validation-error",
        "Request validation failed",
        400,
        "The request body is syntactically valid JSON but violates API validation rules.",
        instance,
        correlationId,
        errors,
        Map.of("category", "client"));
    }
public ProblemDetail conflict(String instance, String correlationId, String detail) {
    return new ProblemDetail(
    "https://errors.example.com/business-conflict",
    "Business conflict",
    409,
    detail,
    instance,
    correlationId,
    List.of(),
    Map.of("category", "business"));
}
public ProblemDetail preconditionFailed(String instance, String correlationId, String expected, String actual) {
    return new ProblemDetail(
    "https://errors.example.com/precondition-failed",
    "Resource version conflict",
    412,
    "The provided If-Match value does not match the current resource version.",
    instance,
    correlationId,
    List.of(),
    Map.of("expected", expected, "actual", actual));
}
public ProblemDetail unsupportedVersion(String instance, String correlationId, String version) {
    return new ProblemDetail(
    "https://errors.example.com/unsupported-api-version",
    "Unsupported API version",
    406,
    "API version " + version + " is not supported by this endpoint.",
    instance,
    correlationId,
    List.of(),
    Map.of("supported", "2026-01"));
}
}

Fehlerkatalog

Situation Status Typ Client-Reaktion
DTO ungueltig 400 validation-error Eingabe korrigieren
API-Version unbekannt 406 unsupported-api-version Version setzen/Client aktualisieren
Idempotency-Key mit anderem Payload 409 idempotency-conflict neuen Key/Bugfix
ETag passt nicht 412 precondition-failed Ressource neu lesen
fachlicher Konflikt 409 business-conflict Benutzerentscheidung

6. API-Versionierung ohne Chaos

Version als Versprechen

Eine Version sagt: Dieser Vertrag bleibt kompatibel. Ohne Versionierung brechen Clients bei Feldumbenennungen, geaenderter Fehlerstruktur oder Semantikwechseln. Kapitel nutzt X-Api-Version: 2026-01, weil diese Entscheidung testbar und sichtbar ist.

Code: ApiVersion

JAVA
package com.example.restapi;
// Pattern: Version Negotiation - API-Version wird explizit ausgehandelt statt implizit erraten.
public enum ApiVersion {
    V2026_01("2026-01");
    private final String headerValue;
    ApiVersion(String headerValue) {
        this.headerValue = headerValue;
    }
public String headerValue() {
    return headerValue;
}
public static boolean supported(String value) {
    return V2026_01.headerValue.equals(value);
}
}

Strategien

Strategie Beispiel Vorteil Nachteil
URI /v1/orders sichtbar duplizierte Routen
Header X-Api-Version gleiche Ressource gute Doku noetig
Media Type Vendor JSON sauber komplexer
Additiv neue optionale Felder wenig Bruch nicht jede Aenderung ist additiv

7. Idempotency Keys fuer sichere POST-Retries

Warum das wichtig ist

Der Client sendet POST /orders. Die Order wird gespeichert, aber die Antwort geht verloren. Beim Retry darf nicht eine zweite Order entstehen. Ein Idempotency Store merkt sich Key, Payload-Hash und Response. Gleicher Key + gleicher Hash liefert dieselbe Antwort; gleicher Key + anderer Hash ist ein Konflikt.

Diagramm

Code: Store

JAVA
package com.example.restapi;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.function.Supplier;
public final class InMemoryIdempotencyStore implements IdempotencyStore {
    private final Map<String, IdempotencyRecord> records = new LinkedHashMap<>();
    @Override public Optional<IdempotencyRecord> find(String key) {
        return Optional.ofNullable(records.get(key));
    }
@Override public HttpResponse executeOnce(String key, String requestHash, Supplier<HttpResponse> action) {
    IdempotencyRecord existing = records.get(key);
    if (existing != null) {
        if (!existing.requestHash().equals(requestHash)) {
            return HttpResponse.json(409, "{\"error\":\"idempotency key reused with different payload\"}");
        }
    return existing.response().withHeader("Idempotency-Replayed", "true");
}
HttpResponse response = action.get();
if (response.status() >= 200 && response.status() < 300) {
    records.put(key, new IdempotencyRecord(requestHash, response));
}
return response;
}
}

Grenzen

Idempotency Keys ersetzen keine Datenbank-Constraints, kein Locking und keine Outbox. Sie loesen ein klares API-Problem: Wiederholte POST-Versuche duerfen nicht mehrfach fachliche Wirkung erzeugen.

8. ETag und If-Match gegen verlorene Updates

Problem

Zwei Clients lesen dieselbe Order. Client A aktualisiert sie. Client B aktualisiert spaeter mit altem Stand und ueberschreibt A. ETag und If-Match bringen Optimistic Locking an die API-Grenze.

Diagramm

Code: ETag

JAVA
package com.example.restapi;
// Pattern: Optimistic Offline Lock - API nutzt Version/ETag gegen verlorene Updates.
public record ETag(String value) {
    public static ETag from(Order order) {
        return new ETag("W/\"order-" + order.id().value() + "-v" + order.version() + "\"");
    }
}

HTTP-Status

Bei nicht passendem If-Match ist 412 Precondition Failed praeziser als ein generisches 409. Der Client weiss: Ich habe auf einer veralteten Version gearbeitet und muss neu laden.

9. Pagination, Filtering und Sorting

Listen sind Produktionsrisiko

GET /orders ohne Limit ist in Demos bequem und in Produktion riskant. Daten wachsen, Sortierungen werden teuer, Responses werden gross, und Timeouts nehmen zu.

Mindestregeln

TEXT
GET /orders?customerId=C-100&status=PAID&page=0&size=50&sort=createdAt,desc

size braucht ein hartes Maximum. Sortierfelder muessen allowlisted sein. Filter brauchen passende Indizes. Bei grossen Datenmengen ist Cursor Pagination oft stabiler als Offset Pagination.

Checkliste

10. Controller-Code Walkthrough

Rolle des Controllers

Der Controller besitzt nicht die Fachlichkeit. Er prueft HTTP-Regeln, ruft den Use Case auf und baut HTTP-Antworten. Diese Grenze macht Code framework-unabhaengiger und testbarer.

Code: RestOrderController

JAVA
package com.example.restapi;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
// Pattern: Controller/Primary Adapter - uebersetzt HTTP in Use-Case-Aufrufe und zurueck.
public final class RestOrderController {
    private final JsonCodec json;
    private final RequestValidator validator;
    private final ProblemDetailsFactory problems;
    private final OrderApplicationService service;
    private final IdempotencyStore idempotencyStore;
    public RestOrderController(JsonCodec json, RequestValidator validator, ProblemDetailsFactory problems,
    OrderApplicationService service, IdempotencyStore idempotencyStore) {
        this.json = json;
        this.validator = validator;
        this.problems = problems;
        this.service = service;
        this.idempotencyStore = idempotencyStore;
    }
public HttpResponse handle(HttpRequest request) {
    String version = request.header("X-Api-Version").orElse(ApiVersion.V2026_01.headerValue());
    if (!ApiVersion.supported(version)) {
        return problem(problems.unsupportedVersion(request.path(), request.correlationId(), version));
    }
if (request.method() == HttpMethod.POST && request.path().equals("/orders")) return createOrder(request);
if (request.method() == HttpMethod.GET && request.path().startsWith("/orders/")) return getOrder(request);
return HttpResponse.json(404, "{\"error\":\"not found\"}");
}
private HttpResponse createOrder(HttpRequest request) {
    String key = request.header("Idempotency-Key").orElse(null);
    if (key == null || key.isBlank()) {
        return problem(problems.validation(request.path(), request.correlationId(),
        List.of(new ValidationError("Idempotency-Key", "header is required for POST /orders"))));
    }
String requestHash = sha256(request.body());
return idempotencyStore.executeOnce(key, requestHash, () -> executeCreate(request));
}
private HttpResponse executeCreate(HttpRequest request) {
    CreateOrderRequest dto;
    try {
        dto = json.decodeCreateOrder(request.body());
    }
catch (RuntimeException ex) {
    return problem(problems.validation(request.path(), request.correlationId(),
    List.of(new ValidationError("body", "body cannot be parsed"))));
}
List<ValidationError> errors = validator.validate(dto);
if (!errors.isEmpty()) return problem(problems.validation(request.path(), request.correlationId(), errors));
try {
    OrderResponse response = service.placeOrder(dto);
    return HttpResponse.json(201, json.encode(response))
    .withHeader("Location", "/orders/" + response.id())
    .withHeader("ETag", "W/\"order-" + response.id() + "-v" + response.version() + "\"")
    .withHeader("X-Correlation-Id", request.correlationId());
}
catch (RuntimeException ex) {
    return problem(problems.conflict(request.path(), request.correlationId(), ex.getMessage()));
}
}
private HttpResponse getOrder(HttpRequest request) {
    String id = request.path().substring("/orders/".length());
    try {
        Order domain = service.getDomain(new OrderId(id));
        OrderResponse response = service.get(new OrderId(id));
        return HttpResponse.json(200, json.encode(response))
        .withHeader("ETag", ETag.from(domain).value())
        .withHeader("Cache-Control", "no-store")
        .withHeader("X-Correlation-Id", request.correlationId());
    }
catch (RuntimeException ex) {
    return HttpResponse.json(404, "{\"error\":\"order not found\"}");
}
}
public HttpResponse patchCustomerReference(HttpRequest request, OrderId id, String newCustomerId) {
    try {
        String ifMatch = request.header("If-Match").orElse("");
        OrderResponse response = service.updateCustomerReference(id, newCustomerId, ifMatch);
        return HttpResponse.json(200, json.encode(response));
    }
catch (VersionConflictException ex) {
    return problem(problems.preconditionFailed(request.path(), request.correlationId(), ex.expected(), ex.actual()));
}
}
private HttpResponse problem(ProblemDetail detail) {
    return new HttpResponse(detail.status(), Map.of("Content-Type", "application/problem+json"), json.encode(detail));
}
private String sha256(String value) {
    try {
        return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
    }
catch (Exception e) {
    throw new IllegalStateException(e);
}
}
}

Architekturbewertung

Der Controller kennt Idempotency-Key, X-Api-Version, ProblemDetail und ETag. Der Application Service kennt diese HTTP-Details nicht. Genau diese Trennung ist die entscheidende Architekturqualitaet.

11. Tests: API-Vertrag absichern

Nicht nur Happy Path

API-Tests muessen Vertragsfehler pruefen: Validation Problem, Unsupported Version, Idempotency Replay, Idempotency Conflict und ETag Conflict. Gerade diese Faelle entscheiden, ob Clients robust werden.

Code: Test Runner

JAVA
package com.example.restapi;
import java.util.Map;
public final class Kapitel {
    public static void main(String[] args) {
        createsOrderWithIdempotencyKey();
        replaysSamePostWithoutSecondSideEffect();
        rejectsSameKeyWithDifferentPayload();
        returnsValidationProblem();
        rejectsUnsupportedVersion();
        detectsIfMatchConflict();
        System.out.println("Kapitel");
    }
static void createsOrderWithIdempotencyKey() {
    RestOrderController controller = RestApiLab.newController();
    HttpResponse response = controller.handle(validPost("k-1"));
    assertEquals(201, response.status(), "create status");
    assertTrue(response.headers().containsKey("Location"), "location header");
    assertTrue(response.body().contains("ACCEPTED"), "accepted body");
}
static void replaysSamePostWithoutSecondSideEffect() {
    RestOrderController controller = RestApiLab.newController();
    HttpResponse first = controller.handle(validPost("k-2"));
    HttpResponse second = controller.handle(validPost("k-2"));
    assertEquals(201, second.status(), "replay status");
    assertEquals("true", second.headers().get("Idempotency-Replayed"), "replay header");
    assertEquals(first.body(), second.body(), "same body on replay");
}
static void rejectsSameKeyWithDifferentPayload() {
    RestOrderController controller = RestApiLab.newController();
    controller.handle(validPost("k-3"));
    HttpRequest differentPayload = HttpRequest.post("/orders", Map.of(
    "X-Api-Version", "2026-01",
    "Idempotency-Key", "k-3"), "customer=C-999;lines=SKU-9,1,1.00");
    HttpResponse response = controller.handle(differentPayload);
    assertEquals(409, response.status(), "same key different payload");
}
static void returnsValidationProblem() {
    RestOrderController controller = RestApiLab.newController();
    HttpRequest request = HttpRequest.post("/orders", Map.of(
    "X-Api-Version", "2026-01",
    "Idempotency-Key", "k-4"), "customer=;lines=SKU-1,0,19.99");
    HttpResponse response = controller.handle(request);
    assertEquals(400, response.status(), "validation status");
    assertEquals("application/problem+json", response.headers().get("Content-Type"), "problem content type");
}
static void rejectsUnsupportedVersion() {
    RestOrderController controller = RestApiLab.newController();
    HttpRequest request = HttpRequest.post("/orders", Map.of(
    "X-Api-Version", "2030-01",
    "Idempotency-Key", "k-5"), "customer=C-100;lines=SKU-1,1,9.99");
    assertEquals(406, controller.handle(request).status(), "unsupported version");
}
static void detectsIfMatchConflict() {
    RestOrderController controller = RestApiLab.newController();
    HttpResponse created = controller.handle(validPost("k-6"));
    String location = created.headers().get("Location");
    String id = location.substring("/orders/".length());
    HttpRequest patch = new HttpRequest(HttpMethod.PATCH, location, Map.of(
    "If-Match", "W/\"order-" + id + "-v999\"",
    "X-Correlation-Id", "test-corr"), Map.of(), "");
    HttpResponse response = controller.patchCustomerReference(patch, new OrderId(id), "C-OTHER");
    assertEquals(412, response.status(), "etag conflict");
}
private static HttpRequest validPost(String key) {
    return HttpRequest.post("/orders", Map.of(
    "X-Api-Version", "2026-01",
    "X-Correlation-Id", "corr-" + key,
    "Idempotency-Key", key), "customer=C-100;lines=SKU-1,2,19.99|SKU-2,1,5.00");
}
private static void assertEquals(Object expected, Object actual, String message) {
    if (!java.util.Objects.equals(expected, actual)) {
        throw new AssertionError(message + " expected=" + expected + " actual=" + actual);
    }
}
private static void assertTrue(boolean condition, String message) {
    if (!condition) throw new AssertionError(message);
}
}

Produktionscheckliste

⌂ Cockpit