1. Unvermeidliches Mapping nach DTO
Sobald create, list, detail und update ihre eigenen DTOs haben, entsteht ein neues ehrliches Problem: Die Daten können nicht mehr „von selbst“ aus JSON in die Domäne und zurück fließen. Sie müssen von einer Form in eine andere übersetzt werden. Das ist keine „überflüssige Schicht“, sondern der Preis für die Kontrolle über den Vertrag. Wie am Flughafen: Man kann mit Handgepäck fliegen (ein Modell für alles), oder mit Koffer und Dokumenten (DTO + Mapping) – etwas aufwändiger, dafür vorhersehbar.
Das Wort Mapping klingt oft nach etwas aus der Welt der Kartografie und Magie: „wir mappen, es wird gemappt“. In der Praxis ist es gewöhnliche Ingenieursarbeit: Felder aus TaskCreateRequest nehmen, in die Domänen-Task legen (oder zumindest in ein Domänenobjekt, mit dem der Service arbeitet), und dann umgekehrt – eine Task nehmen und ein TaskSummaryResponse oder TaskDetailsResponse zusammenbauen.
Wichtig festzuhalten: Mapping ist keine Geschäftslogik. Es ist die „Übersetzung der Datenform“. Wenn der Service entscheidet, was zu tun ist, dann entscheidet der Mapper, wie dies in die richtige Form gepackt wird.
Um das Bild zu vervollständigen, ist es hilfreich, einen einfachen Ablauf im Kopf zu haben:
flowchart TD A[JSON request] --> B[TaskCreateRequest] B --> C[TaskMapper] C --> D["Task (internal)"] D --> E[TaskService] E --> F["Task (internal)"] F --> G[TaskMapper] G --> H[TaskDetailsResponse] H --> I[JSON response]
Das ist die übliche Arbeitsroute der API-Grenze: Ein Request-DTO kommt von außen, wird in ein internes Objekt umgewandelt, durchläuft den Service und wird dann in ein Response-DTO verpackt.
Hier gibt es keinen „geheimen Spring-Mechanismus“. Jackson wandelt JSON in Request-DTO und zurück um, und alles, was zwischen DTO und internem Modell liegt, liegt in unserer Verantwortung.
2. Zwei Richtungen des Mappings: Eingabe und Ausgabe
Wenn man beginnt, Mapping zu schreiben, besteht die Versuchung, „eine universelle Methode zu machen“ und glücklich zu leben. Aber die Realität schlägt einem schnell auf die Finger: Eingabe-Mapping und Ausgabe-Mapping sind verschiedene Wesenheiten, und sie zu vermischen ist unbequem schon im Kopf, geschweige denn im Code.
Eingabe-Mapping beantwortet die Frage: „Wie erhält man aus dem, was der Client gesendet hat, ein internes Objekt, mit dem man innerhalb der Anwendung bequem arbeiten kann?“. Es ist fast immer „schmaler“, weil der Client nicht alles Mögliche senden soll.
Ausgabe-Mapping beantwortet die Frage: „Wie baut man aus dem internen Objekt die Darstellung, die ein bestimmter Endpunkt verspricht?“. Und hier taucht ein wichtiges Detail auf: für list und für detail sind es verschiedene Darstellungen, also wird auch das Mapping unterschiedlich sein.
Es ist praktisch, dies in einer kleinen Tabelle festzuhalten, damit das Gehirn nicht versucht, zwei verschiedene Aufgaben in eine zu kleben:
| Richtung | Von → nach | Beispiel in Task Tracker API | Was wichtig ist |
|---|---|---|---|
| Eingabe-Mapping | request DTO → internal model | TaskCreateRequest → Task (Entwurf) | übertragen nur das, was der Client tatsächlich sendet |
| Ausgabe-Mapping (Zusammenfassung) | internal model → response DTO | Task → TaskSummaryResponse | kurz, praktisch für die Liste |
| Ausgabe-Mapping (Details) | internal model → response DTO | Task → TaskDetailsResponse | detaillierter, praktisch für eine einzelne Ressource |
Dieses „dreiteilige“ Schema vereinfacht das Leben erheblich: Sie suchen nicht nach dem „idealen DTO“, sondern machen mehrere einfache Transformationen, jede zuständig für eine verständliche Form.
3. Wo der Mapper leben sollte
Bei kleinen Beispielen möchte man es so machen: „Im Controller habe ich Task erhalten, gleich von Hand TaskDetailsResponse zusammengebaut und zurückgegeben.“ Das funktioniert, aber es ähnelt der Situation „Ich lebe in der Küche, während die Renovierung läuft“. Die Renovierung, wie wir wissen, dauert manchmal Jahre.
Wenn das Mapping über die Controller verteilt ist, erhält man zwei unangenehme Effekte. Erstens hört der Controller auf, dünn zu sein: Er beginnt mit routinemäßigem Umschichten von Daten, und dann kommt „ein bisschen Logik“, „ein bisschen Bedingungen“ hinzu, und schon hat man unmerklich einen fetten Kuchen statt einer dünnen Schicht. Zweitens beginnt sich dasselbe Mapping zu duplizieren: Denselben Task wandeln Sie an drei Stellen in TaskSummaryResponse um, und überall ist es etwas anders (und dann wundern Sie sich, warum das API „schwimmt“).
In unserem Projekt wird dies ordentlich und vorhersehbar gelöst: Wir legen den Mapper in das Paket com.example.tasktracker.api.mapper. Das unterstreicht die Idee: Mapping gehört zur API-Grenze, also zu dem Ort, an dem die innere Welt auf den äußeren Vertrag trifft.
Das minimale Grundgerüst eines Mappers sieht so aus:
package com.example.tasktracker.api.mapper;
import org.springframework.stereotype.Component;
@Component // Mapper als Spring-Bean, um ihn ohne manuelles new einzufügen
public class TaskMapper {
// Mapper ist ein „Übersetzer“ zwischen DTO und internem Modell an der API-Grenze
// hier werden die Mapping-Methoden stehen
}
Die Annotation @Component ist nicht nur der Schönheit wegen: So wird der Mapper zu einem Spring-Bean, und wir können ihn ordentlich in die Controller (oder andere Komponenten der API-Schicht) injizieren, ohne in jeder Klasse manuell new TaskMapper() zu erzeugen.
4. Eingabe-Mapping: TaskCreateRequest → Entwurf Task
Jetzt machen wir eine wichtige Sache: Wir lernen, das interne Modell aus request DTO so zusammenzubauen, dass das Mapping einfach und „ohne Überraschungen“ ist. In diesem Schritt müssen wir keine kniffligen Regeln erfinden. Unser Ziel ist es einfach, die Felder, die vom Client kamen, ehrlich zu kopieren und ein Objekt zu erhalten, mit dem der Service arbeiten wird.
Um nicht in den Feldern zu ertrinken, lassen wir unten nur den Ausschnitt von Task, an dem man die Übersetzung selbst sieht.
Beginnen wir mit request DTO. Es lebt in api.dto.request und enthält nur die Felder, die der Client tatsächlich beim Erstellen einer Aufgabe sendet:
package com.example.tasktracker.api.dto.request;
public class TaskCreateRequest {
// Felder, die der Client beim Erstellen einer Aufgabe sendet
private String title;
private String description;
private String assigneeName;
public String getTitle() { return title; }
public String getDescription() { return description; }
public String getAssigneeName() { return assigneeName; }
}
Jetzt das interne Modell Task (die Domänenfelder zeigen wir hier minimal, um nicht in Details zu ertrinken):
package com.example.tasktracker.domain.model;
public class Task {
// Interne Felder des Domänenmodells: nach außen geben wir sie nicht direkt
private String id;
private String title;
private String description;
private String status;
private String assigneeName;
// Im Beispiel zeigen wir nur, was für das Eingabe-Mapping nötig ist
public void setTitle(String title) { this.title = title; }
public void setDescription(String description) { this.description = description; }
public void setAssigneeName(String assigneeName) { this.assigneeName = assigneeName; }
}
Und hier ist das Mapping in TaskMapper. Wir erstellen bewusst eine Methode, die einen Entwurf von Task erzeugt: Sie überträgt die client-provided Felder, versucht aber nicht zu entscheiden, „welche id“ und „welcher Status“ sein werden. Mapper ist ein Übersetzer, kein Drehbuchautor.
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.domain.model.Task;
import org.springframework.stereotype.Component;
@Component
public class TaskMapper {
public Task toDraftTask(TaskCreateRequest request) {
// Erstellen eines „Entwurfs“ des Domänenobjekts aus request DTO
Task task = new Task();
// Übertragen nur das, was tatsächlich vom Client kam
task.setTitle(request.getTitle());
task.setDescription(request.getDescription());
task.setAssigneeName(request.getAssigneeName());
// Wichtig: id/Status setzen wir hier nicht – das ist die Verantwortung des Services/Domäne
return task;
}
}
Beachten Sie die angenehme Sache: Diese Methode liest sich wie eine kurze Geschichte. Auch wenn Sie Java nicht mögen, versteht das Gehirn trotzdem, was hier passiert. Keine Magie, kein Reflection, kein „es hat die gleichen Felder selbst kopiert“.
5. Ausgabe-Mapping: Task → Summary und Details
Jetzt kümmern wir uns um die umgekehrte Richtung: Aus dem internen Modell muss eine Antwort zusammengebaut werden. Und hier machen wir sofort zwei verschiedene Methoden, weil „summary“ und „details“ zwei verschiedene Verträge sind. Ja, selbst wenn sie heute nur um ein Feld unterschiedlich sind. Denn morgen werden sie sich mit ziemlicher Sicherheit stärker unterscheiden.
Die DTOs selbst halten wir kurz: Summary ist für die Liste zuständig, Details für eine vollständigere Darstellung einer einzelnen Aufgabe.
Zuerst das Response-DTO für die Liste. Üblicherweise lässt man im Summary das „Wichtigste und Kürzeste“: id, title, status.
package com.example.tasktracker.api.dto.response;
public class TaskSummaryResponse {
// DTO der Antwort: fixiert den öffentlichen Vertrag (besser solche DTOs unveränderlich machen)
private final String id;
private final String title;
private final String status;
public TaskSummaryResponse(String id, String title, String status) {
this.id = id;
this.title = title;
this.status = status;
}
}
Und für Details – eine vollständigere Read-Darstellung. Hier fügen wir die Beschreibung hinzu.
package com.example.tasktracker.api.dto.response;
public class TaskDetailsResponse {
// Der detaillierte Vertrag enthält normalerweise mehr Felder als das Summary
private final String id;
private final String title;
private final String description;
private final String status;
public TaskDetailsResponse(String id, String title, String description, String status) {
this.id = id;
this.title = title;
this.description = description;
this.status = status;
}
}
Jetzt ergänzen wir TaskMapper um zwei Methoden:
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.model.Task;
import org.springframework.stereotype.Component;
@Component
public class TaskMapper {
public TaskSummaryResponse toSummaryResponse(Task task) {
// Summary-Antwort: kurze Darstellung (z. B. für die Liste)
return new TaskSummaryResponse(
task.getId(),
task.getTitle(),
task.getStatus()
);
}
public TaskDetailsResponse toDetailsResponse(Task task) {
// Details-Antwort: vollständigere Darstellung (z. B. für die Aufgabenkarte)
return new TaskDetailsResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.getStatus()
);
}
}
Wenn Sie jetzt denken „warum gibt es hier so viel Gleiches?“, dann herzlichen Glückwunsch: Sie denken wie ein Entwickler. Aber dies ist der Fall, wo Gleichheit gut ist. Wir wollen, dass Mapping langweilig ist. Langweiliges Mapping bedeutet in der Regel einen vorhersehbaren Vertrag, und ein vorhersehbarer Vertrag ist Glück für den Client und weniger Schmerz für uns.
6. Controller und Sammlungen
Mapping von Sammlungen ohne Duplizierung
Wenn Sie den list-Endpunkt implementieren (GET /api/v1/tasks), geraten Sie fast sofort in eine Situation: Der Service gibt eine Liste von internen Task zurück, und nach außen müssen Sie eine Liste von TaskSummaryResponse ausgeben. Und technisch kann man direkt im Controller tasks.stream().map(taskMapper::toSummaryResponse).toList() schreiben. Aber wenn Sie das in jedem Controller tun, beginnt es sich zu duplizieren – und schon wieder haben wir die „Renovierung in der Küche“.
Es gibt einen einfachen Kompromiss: Im Mapper eine Methode für Sammlungen hinzufügen. Sie bleibt sehr kurz und verbirgt keine Magie, beseitigt aber die Wiederholung im Projekt.
package com.example.tasktracker.api.mapper;
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.domain.model.Task;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class TaskMapper {
public List<TaskSummaryResponse> toSummaryResponseList(List<Task> tasks) {
// Einheitlicher Punkt zur Umwandlung einer Sammlung von Domänenobjekten in DTO für die Liste
return tasks.stream()
.map(this::toSummaryResponse) // Wiederverwenden des „einzelnen“ Mappings
.toList();
}
}
Ja, das sind drei Zeilen. Aber diese drei Zeilen ersparen Ihnen zukünftige „Aber wo hatten wir noch so ein gleiches Mapping?“. Und, was noch wichtiger ist: Sie schaffen einen einzigen Ort, an dem der Vertrag der Summary-Darstellung gebildet wird.
Der Controller bleibt dünn
Der schönste Moment beim manuellen Mapping ist, wenn Sie sehen, wie der Controller aufhört, ein „Kombinat“ zu sein, und sich in einen sauberen Einstiegspunkt verwandelt. Er nimmt request DTO entgegen, gibt es an den Mapper weiter, ruft den Service und ruft erneut den Mapper – diesmal für die Antwort.
Unten ein Entwurf von TaskController, der drei Szenarien zeigt: list, detail und create. Er ist demonstrativ, daher ohne feine Nuancen und ohne den Versuch, „alles auf der Welt zu behandeln“.
package com.example.tasktracker.api.controller;
import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.api.dto.response.TaskDetailsResponse;
import com.example.tasktracker.api.dto.response.TaskSummaryResponse;
import com.example.tasktracker.api.mapper.TaskMapper;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.service.TaskService;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
private final TaskService taskService;
private final TaskMapper taskMapper;
public TaskController(TaskService taskService, TaskMapper taskMapper) {
// Controller halten wir „dünn“: Abhängigkeiten injizieren, Geschäftslogik nicht hierher ziehen
this.taskService = taskService;
this.taskMapper = taskMapper;
}
@GetMapping
public List<TaskSummaryResponse> getTasks() {
// Controller erhält Domänenobjekte vom Service...
List<Task> tasks = taskService.findAll();
// ...und gibt DTO nach außen über den Mapper
return taskMapper.toSummaryResponseList(tasks);
}
@GetMapping("/{taskId}")
public TaskDetailsResponse getTask(@PathVariable String taskId) {
// Suche/Existenzprüfungen – Verantwortungsbereich des Services
Task task = taskService.findById(taskId);
return taskMapper.toDetailsResponse(task);
}
@PostMapping
public TaskDetailsResponse create(@RequestBody TaskCreateRequest request) {
// Aus dem Eingabe-DTO erstellen wir einen „Entwurf“ des Domänenmodells
Task draft = taskMapper.toDraftTask(request);
// Der Service entscheidet bereits über alles Fachliche: id, Status, Speicherung usw.
Task created = taskService.create(draft);
// Nach außen geben wir DTO zurück, nicht das Domänenobjekt
return taskMapper.toDetailsResponse(created);
}
}
In diesem Code ist gut zu sehen, dass der Controller „Routing“ betreibt: wer was aufgerufen hat, wohin übergeben, was zurückgeben. Und der Mapper – „Übersetzung“. Das ist eine sehr gesunde Gewohnheit: Der Controller muss nicht wissen, wie viele Felder wir in TaskDetailsResponse haben und wie sie gefüllt werden. Er muss nur wissen, dass es DTO und Mapper gibt.
Genau hier taucht die nächste praktische Frage auf: Wenn der draft aus request DTO stammt, welche Felder sind überhaupt vom Client zulässig und welche muss der Server selbst setzen?
7. Grenzen der Verantwortung des Mappers
Beim manuellen Mapping gibt es ein lustiges Risiko: Sie beginnen mit ehrlichem „drei Felder kopiert“, und eine Woche später wird im Mapper hinzugefügt „wenn der Status so ist, füge ein Flag hinzu“, dann „geh ins Repository“, dann „berechnete etwas“, und schon haben Sie keinen Mapper mehr, sondern einen Mini-Service mit einem Geheimgang zur Geschäftslogik.
Um dies zu vermeiden, ist es nützlich, eine einfache Disziplin einzuhalten. Der Mapper hat das Recht, ein Objekt in ein anderes umzuwandeln, Felder umzubenennen, Überflüssiges wegzuwerfen, eine zusammengesetzte Antwort aus Feldern eines Objekts zusammenzubauen. Aber der Mapper darf nicht entscheiden, ob die Aufgabe existiert, ob sie geändert werden kann, welche Status erlaubt sind, und schon gar nicht darf er ins Repository gehen.
Unten eine kurze „Merkhilfe“ in Tabellenform. Man kann sie gedanklich neben den Monitor kleben (oder zumindest neben den Kaffee).
| Aktion | Im Mapper erlaubt? | Warum |
|---|---|---|
| Feld title in Antwort kopieren | Ja | das ist reine Änderung der Datenform |
| Internes Feld aus Antwort entfernen | Ja | der Mapper ist genau dafür da, dass Internes nicht nach außen gelangt |
| Enum in String konvertieren (falls nötig) | Ja | das ist immer noch über die Datenform |
| id generieren | Normalerweise nein | das ist bereits Verantwortung des Services/Domäne |
| Entscheiden „welcher Status beim Erstellen gesetzt wird“ | Normalerweise nein | das ist bereits eine fachliche Entscheidung, keine Übersetzung |
| Ins Repository/Service innerhalb des Mappers gehen | Nein | der Mapper wird „verborgene Magie“ und erschwert das Debuggen |
Diese Disziplin erscheint genau so lange langweilig, bis Sie einen Bug debuggen, wo „irgendwie das Feld status in der Antwort so geworden ist“. Wenn die Logik im Mapper versteckt und mit Geschäftsregeln vermischt ist, werden Sie lange und traurig nach der Ursache suchen. Wenn der Mapper einfach ist, ist die Ursache immer irgendwo in der Nähe.
8. Typische Fehler beim manuellen Mapping
Fehler Nr. 1: Das Mapping über die Controller verteilen, „weil es so schneller geht“.
Das ist wirklich schneller in den ersten zwei Stunden, danach – sehr langsam in den folgenden zwei Wochen. Sie erhalten Duplizierung und Abweichungen: Ein Endpunkt gibt status zurück, ein anderer vergisst es, ein dritter gibt description im Summary zurück, und der Client weiß schon nicht mehr, was er erwarten soll. Eine Mapper-Klasse – das ist langweilig, aber sehr zuverlässig.
Fehler Nr. 2: Eine universelle Methode „toResponse“ machen und versuchen, damit list und detail abzudecken.
Anfangs scheint es: „Was ist schon dabei, es ist derselbe Task.“ Aber list und detail sind verschiedene Verträge. Und selbst wenn sie jetzt gleich sind, werden sie auseinandergehen, sobald Sie ein paar Felder hinzufügen oder die Liste leichter machen wollen. Zwei Methoden (toSummaryResponse, toDetailsResponse) sind nicht „überflüssig“, sondern eine Versicherung gegen zukünftiges Chaos.
Fehler Nr. 3: Wichtige Umwandlungen in „intelligente“ Feldkopierer verstecken.
Es besteht die Versuchung, Hilfsprogramme zu verwenden wie „alle gleichen Felder automatisch kopieren“. In den ersten Schritten wird das Mapping zu einer Blackbox: Ein Neuling versteht nicht, welche Felder tatsächlich nach außen gelangen. Und es verschlechtert die Kontrolle über den Vertrag: Ein Feld im internen Modell hinzugefügt – es taucht plötzlich in der API auf. Wir wählen bewusst den manuellen, expliziten Weg.
Fehler Nr. 4: Geschäftsentscheidungen im Mapper hinzufügen, „weil es bequem ist“.
Der Mapper ist ein schlechter Ort für Entscheidungen wie „wenn die Aufgabe überfällig ist, setze den Status“. Das ist bereits Verhalten der Fachdomäne und sollte im Service sein. Sonst erhalten Sie eine Vermischung der Schichten und dann unerwartete Abweichungen: Ein Endpunkt gibt das Objekt durch den Mapper aus und sieht „intelligente“ Regeln, ein anderer Endpunkt formt die Antwort anders und die Regeln werden nicht angewendet.
Fehler Nr. 5: Vergessen, dass der Mapper Teil des öffentlichen Vertrags ist und daher Sorgfalt erfordert.
Wenn Sie das Mapping ändern, ändern Sie das, was der Client sehen wird. Manchmal scheint es harmlos: „Ich benenne ein Feld um“, „Ich entferne ein Feld, es wird nicht benötigt“. Aber für den Client sieht es aus wie eine brechende Vertragsänderung. Daher ist es auch in einem Studienprojekt nützlich, den Mapper als den Ort zu betrachten, an dem der Vertrag Wirklichkeit wird: Wir ändern vorsichtig, bewusst und nur dann, wenn wir die Konsequenzen verstehen.
GO TO FULL VERSION