Das nimmst du mit
- DTOs bewusst schneiden
- Problem Details nutzen
- Pagination stabil halten
- Kompatibel versionieren
Kapitelkompass
Gute REST-Schnittstellen trennen Transport, Fachmodell und Fehlervertrag und bleiben unter Evolution kompatibel.
Ein Client sendet eine alte Repräsentation, während der Server ein neues optionales Feld einführt.
Additive Änderungen bevorzugen; Versionen nur bei wirklich inkompatiblen Vertragsbrüchen.
Entities direkt auszuliefern koppelt Datenbank, Security und API-Evolution aneinander.
| Signal | Lesart | Nächster Schritt |
|---|---|---|
| Evolution | Optionales Feld oder neuer Link | Additiv ändern und alte Clients weiter akzeptieren |
| Pagination | Große, veränderliche Ergebnismenge | Stabilen Sortierschlüssel und Cursor bevorzugen |
| Idempotenz | Client wiederholt POST nach Timeout | Idempotency Key mit Ergebnis und Ablaufzeit speichern |
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.
// 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);
}
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.
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.
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
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.
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.
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);
}
}
Automapping ist fuer einfache CRUD-Faelle brauchbar. Bei Geld, Status, Berechtigungen, Datenschutz, Aggregates und fachlichen Fehlern sollte Mapping explizit und testbar bleiben.
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.
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;
}
}
| 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 |
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.
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"));
}
}
| 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 |
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.
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);
}
}
| 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 |
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.
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;
}
}
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.
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.
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() + "\"");
}
}
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.
GET /orders ohne Limit ist in Demos bequem und in
Produktion riskant. Daten wachsen, Sortierungen werden teuer, Responses
werden gross, und Timeouts nehmen zu.
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.
size definiert?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.
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);
}
}
}
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.
API-Tests muessen Vertragsfehler pruefen: Validation Problem, Unsupported Version, Idempotency Replay, Idempotency Conflict und ETag Conflict. Gerade diese Faelle entscheiden, ob Clients robust werden.
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);
}
}