1. Controller und Liste: der schnelle Weg zum Kummer
Wenn das Projekt klein ist, greift die Hand von selbst zu etwas wie: „Warum sollte ich nicht List<Task> direkt in den Controller legen?“. Und ehrlich gesagt, die ersten 10 Minuten sieht es sogar genial aus: eine Klasse, alles griffbereit, nichts muss erklärt werden. Das Problem ist, dass ein solcher Code die Schichtdisziplin von Anfang an bricht, und Sie werden dafür bei jedem weiteren Schritt bezahlen – selbst wenn Sie einen zweiten Endpunkt oder eine zweite Ressource hinzufügen.
Schlechtes Beispiel (machen wir nicht, aber anschauen kann man es – wie ein Foto aus der „Kindheit“, wo Sie Sand gegessen haben):
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
// SCHLECHT: Der Controller beginnt, Daten zu besitzen.
// Dies vermischt die Web-Schicht mit der Speicherung und führt zu einem "fat controller".
private final List<Task> tasks = new ArrayList<>();
}
Außen sieht es harmlos aus, aber innen verbergen sich drei Probleme. Erstens wird der Controller zu „Web, Datenspeicherung und Geschäftslogik“ – ein direkter Weg zum fat controller. Zweitens können Sie die Speicherung nicht einfach von einer anderen Stelle (z. B. einem Kommentar-Service) wiederverwenden, weil sie im Controller „versteckt“ ist. Drittens verlieren Sie den Überblick, wo im Projekt die Daten „leben“ und wer dafür verantwortlich ist.
Ein Repository ist genau dafür da, damit Sie ehrlich sagen können: „Die Daten werden hier gespeichert, der Controller greift nicht darauf zu“. Selbst wenn die Daten nur eine Sammlung im Speicher sind.
2. Rolle des Repositories und Verantwortungsgrenzen
Ein Repository in unserem Studienprojekt ist eine Schicht, die für Speicherung und Abruf von Daten zuständig ist – nicht für HTTP-Regeln, nicht für JSON und nicht dafür, „ob eine Aufgabe im Status ARCHIVED geändert werden darf“. Ja, in der realen Welt können die Grenzen feiner sein, aber in einem Studienprojekt sollten die Grenzen dicker und klarer sein, sonst beginnt der Anfänger, in jeder Klasse „ein bisschen von allem“ zu schreiben. Und „ein bisschen von allem“ ist genau das Rezept für ein Chaos.
Es ist sehr nützlich, eine einfache Tabelle im Kopf zu behalten, die die Rollen trennt:
| Schicht | Was sie „weiß“ | Was sie „nicht weiß“ |
|---|---|---|
| Controller | HTTP, @PathVariable, @RequestBody, Antwortstatus | Wie und wo Aufgaben gespeichert werden |
| Service | Anwendungsoperationen („Aufgabe erstellen“, „Aufgabe suchen“) | HTTP, ResponseEntity, Header |
| Repository | Sammlungen, Schlüssel, „speichern/lesen“ | HTTP-Status, URI, @RequestParam |
Wenn das Repository beginnt zu entscheiden, was an den Client zurückgegeben wird, erhalten Sie ein „Repository, das 404 kennt“, und plötzlich tauchen „Repositories auf, die JSON erzeugen können“. Das klingt wie ein Witz, aber meistens fängt es genau so an.
In unseren Beispielen wird das Repository maximal „dumm“ im guten Sinne sein: eine Liste geben, eine Aufgabe nach ID zurückgeben, eine Aufgabe speichern. Aber wie auf null reagiert wird oder was als Fehler gilt – das ist nicht sein Bereich.
3. Wahl der Datenstruktur: Map für IDs
Wenn wir Entitäten „nach Identifikator“ speichern, möchten wir fast immer zwei Dinge: schnelles Finden nach ID und gleichzeitig die Möglichkeit, eine Liste aller Elemente auszugeben. Nimmt man List<Task>, ist „alle ausgeben“ einfach, aber „nach ID suchen“ wird zu einer linearen Suche. Bei kleinen Daten ist die Suche nicht schlimm, aber sie macht den Code weniger transparent: Sie müssen an jeder Stelle Schleifen, Vergleiche, if schreiben – und das verteilt die Verantwortung über das gesamte Projekt.
So sieht normalerweise die Suche nach ID in einer Liste aus (funktionierender Code, der sich aber wie Karnickel vermehrt):
public Task findById(List<Task> tasks, String taskId) {
// Lineare Suche: je mehr Aufgaben, desto länger die Suche.
for (Task task : tasks) {
// Überprüfen auf ID-Übereinstimmung.
if (task.getId().equals(taskId)) {
return task; // Gefunden – sofort raus.
}
}
// Nicht gefunden – null zurückgeben (aber diese Entscheidung muss später in höheren Schichten behandelt werden).
return null;
}
Stellen Sie sich vor, dieser Code taucht in drei Services auf, dann in einem Controller „schnell zum Testen“, dann noch woanders „für ein paar Minuten“. Herzlichen Glückwunsch: Sie haben gerade ein distributed repository geschaffen – ein Repository, das dünn über das gesamte Projekt verteilt ist.
Map<String, Task> löst das Problem natürlich: ID ist der Schlüssel, Aufgabe ist der Wert.
Mini-Tabelle zur Auswahl der Sammlung (einfach, damit das Gehirn einen Anker hat):
| Sammlung | Suche nach ID | Ausgabereihenfolge | Kommentar |
|---|---|---|---|
| ArrayList | langsamer (Durchsuchen) | Einfügereihenfolge | praktisch als Container, aber nicht als Index |
| HashMap | schnell | „wie es sich ergibt“ | kann für unsere Antworten unerwartet sein |
| LinkedHashMap | schnell | stabile Einfügereihenfolge | ausgezeichneter Studienvorschlag |
In unserem Studienprojekt wählen wir oft LinkedHashMap, weil sie eine vorhersagbare Reihenfolge bietet. Das ist nicht als „Sortierung“ wichtig (die machen wir heute nicht), sondern als schlichte Stabilität: Wenn Sie zweimal GET /tasks aufrufen, möchten Sie nicht jedes Mal die Elemente in zufälliger Reihenfolge sehen, nur weil sie innerhalb der HashMap „so gelandet sind“.
4. Vertrag und InMemoryTaskRepository
Jetzt bauen wir ein Repository so, dass es maximal langweilig und nützlich ist. Wir beginnen mit einem Interface. Wichtig ist, dass das Interface Teil der „Domäne“ (also des Projektvertrags) ist und die Implementierung in der Infrastruktur liegt. Dann hängt der Service vom Vertrag ab, nicht von einer konkreten Klasse.
Beispiel eines Interfaces (das Paket kann je nach Struktur gewählt werden; oft ist es praktisch, es in domain zu halten, damit infrastructure von domain abhängt und nicht umgekehrt):
package com.example.tasktracker.domain.repository;
import java.util.List;
import com.example.tasktracker.domain.model.Task;
// Repository-Vertrag: nur Speicherung/Abruf, kein HTTP und keine Geschäftsregeln.
public interface TaskRepository {
// Alle Aufgaben zurückgeben (normalerweise als Kopie/unkveränderliche Sammlung – das entscheidet die Implementierung).
List<Task> findAll();
// Nach ID suchen. In einer In-Memory-Implementierung kann null zurückgegeben werden, wenn die Aufgabe nicht existiert.
Task findById(String taskId);
// Aufgabe nach ID speichern (Erstellung/Aktualisierung wird in höheren Schichten entschieden).
Task save(Task task);
}
Jetzt implementieren wir das In-Memory-Repository im Infrastruktur-Paket. Hier sind zwei Punkte wichtig: Erstens ist das Repository ein Spring-Bean, also markieren wir es mit @Repository. Zweitens speichert es die Sammlung in sich und gibt sie nicht unverändert nach außen.
package com.example.tasktracker.infrastructure.repository.inmemory;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.stereotype.Repository;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.repository.TaskRepository;
@Repository
public class InMemoryTaskRepository implements TaskRepository {
// LinkedHashMap bietet eine stabile Einfügereihenfolge (praktisch für vorhersagbare Antworten und Tests).
private final Map<String, Task> tasks = new LinkedHashMap<>();
}
Als Nächstes kommen die Methoden. Beginnen wir mit findAll. Eine wichtige Angewohnheit: geben Sie eine Kopie zurück, keine „lebende“ View-Sammlung, damit externer Code nicht versehentlich den inneren Zustand des Repositories ändern kann.
import java.util.List;
@Override
public List<Task> findAll() {
// Nicht die "lebende" Sammlung aus der Map nach außen geben – eine unveränderliche Kopie erstellen.
return List.copyOf(tasks.values());
}
findById in der In-Memory-Variante ist einfach get per Schlüssel. Ja, es gibt null zurück, wenn die Aufgabe nicht existiert. Und das ist für diese Schicht in Ordnung: Das Repository muss nicht entscheiden, „was not found für die API bedeutet“.
@Override
public Task findById(String taskId) {
// get gibt null zurück, wenn der Schlüssel nicht existiert – das ist auf Repository-Ebene in Ordnung.
return tasks.get(taskId);
}
save ist ein put. Hier gibt es eine feine Nuance: put speichert und überschreibt, wenn der Schlüssel bereits existiert. Und das ist gut: Das Repository entscheidet nicht, ob es „create“ oder „update“ ist. Es speichert einfach nach ID.
import java.util.Objects;
@Override
public Task save(Task task) {
// Minimaler Schutz vor seltsamen Aufrufen: Besser hier fallen, als später einen NPE zu fangen.
Objects.requireNonNull(task, "task must not be null");
// put speichert oder überschreibt nach dem ID-Schlüssel – das Repository unterscheidet nicht zwischen create/update.
tasks.put(task.getId(), task);
return task;
}
Beachten Sie, dass wir requireNonNull hinzugefügt haben – das ist keine „API-Validierung“, sondern ein minimaler Schutz vor wirklich seltsamen Aufrufen innerhalb der Anwendung. Wenn der Service null übergibt, ist es besser, sofort und ehrlich zu scheitern, als einen mysteriösen NullPointerException irgendwo innerhalb der Map zu erhalten.
5. Schutzkopien und die Gefahr von tasks.values()
Anfänger erstellen oft ein Repository, das „eine Liste von Aufgaben zurückgibt“. Und sie geben tasks.values() oder sogar tasks selbst zurück. Das ist fast immer ein Fehler, nicht wegen „böser Menschen um uns herum“, sondern wegen gewöhnlicher Faulheit: Externer Code kann versehentlich beginnen, die Sammlung zu ändern, und das Repository verliert die Kontrolle über seinen Zustand. Ein Repository ohne Kontrolle über den Zustand ist kein Repository mehr, sondern ein „Raum ohne Türen“.
Hier ist ein Beispiel, wie man sich versehentlich ins Bein schießen kann, wenn man eine „lebende“ Sammlung zurückgibt:
import java.util.Collection;
@Override
public Collection<Task> findAll() {
// SCHLECHT: values() ist eine View über die Map, Operationen darauf können die Map selbst ändern.
return tasks.values();
}
Warum ist das schlecht? Weil tasks.values() keine „neue Liste“ ist, sondern eine Ansicht (view) über die Map. In einigen Fällen können Operationen auf dieser Ansicht die Map selbst verändern. Wenn zum Beispiel jemand findAll().clear() aufruft, kann das gesamte Repository geleert werden. Natürlich sollte der Service so etwas nicht tun … aber den Service schreiben Sie auch. Am Freitagabend. Nach zwei Stunden Debugging. Und Ihr zukünftiges „Ich“ kann ein wenig gefährlicher sein als Ihr heutiges „Ich“.
Die richtige Variante ist, eine Kopie zurückzugeben:
import java.util.ArrayList;
import java.util.List;
@Override
public List<Task> findAll() {
// Kopie: Externer Code kann die Liste ändern, aber die interne Map bleibt sicher.
return new ArrayList<>(tasks.values());
}
Noch besser ist eine unveränderliche Kopie, damit selbst ein versehentliches list.add(...) nicht durchgeht:
import java.util.List;
@Override
public List<Task> findAll() {
// Unveränderliche Kopie: weder add noch clear möglich – gute grundlegende Hygiene.
return List.copyOf(tasks.values());
}
Hier gibt es eine ehrliche Einschränkung: Selbst wenn die Liste unveränderlich ist, können die Elemente (Task) muta bel sein. Das heißt, jemand könnte task.setTitle("...") aufrufen. Aber das ist ein anderes Thema – die Mutabilität des Modells. Die Liste haben wir geschützt; den inneren Container des Repositories auch.
6. In-Memory: Neustart, Singleton, Nebenläufigkeit
Ein In-Memory-Repository klingt nach „Spielzeug“, aber in diesem Kurs erfüllt es eine wichtige Funktion: Es gibt uns eine Grenze der Persistenzschicht, ohne dass wir eine Datenbank anschleppen müssen. Und dennoch ist es wichtig zu verstehen, wie es sich in einer lebenden Spring-Boot-Anwendung verhält, damit Sie keine falschen Erwartungen aufbauen.
Erstens: InMemoryTaskRepository ist ein Bean. Standardmäßig erstellt Spring es als Singleton. Das bedeutet, dass während der Laufzeit der Anwendung eine einzige Instanz des Repositories und eine einzige Map darin existieren. Daher sehen verschiedene HTTP-Anfragen denselben Zustand. Genau das brauchen wir für die Studien-API.
Zweitens: Beim Neustart der Anwendung verschwinden die Daten. Das ist kein Fehler. Es ist buchstäblich das, was „In-Memory“ bedeutet. Wenn Sie erwarten, dass „die Aufgaben nach einem Neustart noch da sind“, haben Sie im Geiste eine Datenbank angeschlossen, die wir nicht haben. Manchmal verursacht das leichte Schmerzen, aber methodisch ist es nützlich: Sie vermischen nicht die API-Schicht mit der Persistenz.
Drittens: Nebenläufigkeit. Spring MVC verarbeitet Anfragen parallel, und unser LinkedHashMap ist nicht threadsicher. In der Produktion würden Sie über Synchronisation, ConcurrentHashMap, Transaktionen usw. nachdenken. In unserem Studienprojekt tun wir das nicht, weil es uns schnell in einen separaten Kurs über Nebenläufigkeit und Datenkonsistenz führen würde. Aber es ist gut, zumindest zu wissen, dass „das kein ewiges Rezept ist“, sondern eine bewusste didaktische Vereinfachung.
Repository im Service und Controller
Es ist sehr verlockend, besonders wenn es „schnell gehen muss“, das Repository direkt in den Controller zu injizieren und fertig. Aber dann brechen Sie genau die Kette, die wir aufgebaut haben: Der Controller wird zu einem Ort, an dem Entscheidungen über Daten getroffen werden. Und der Service verwandelt sich in eine „überflüssige Schicht, die wir nicht nutzen“. Das ist eine typische Situation, in der die Architektur formal vorhanden ist, aber das Projekt faktisch „wie früher“ lebt.
Lassen Sie den Service über das Repository-Interface arbeiten. Beispiel einer „gewöhnlichen“ Service-Operation, bei der der Service einfach delegiert:
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class DefaultTaskService implements TaskService {
private final TaskRepository taskRepository;
public DefaultTaskService(TaskRepository taskRepository) {
// Injizieren genau das Interface (Vertrag), nicht eine konkrete Implementierung.
this.taskRepository = taskRepository;
}
@Override
public List<Task> getAll() {
// Der Service entscheidet "welche Operation wir ausführen", das Repository – "wie die Daten geholt werden".
return taskRepository.findAll();
}
}
Und der Controller ruft einfach den Service auf:
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
// Der Controller hängt vom Service ab, nicht vom Repository.
this.taskService = taskService;
}
@GetMapping("/api/v1/tasks")
public List<Task> getAll() {
// Der Controller kümmert sich um den HTTP-Handler, nicht darum, wo und wie die Aufgaben gespeichert sind.
return taskService.getAll();
}
}
Dieser Code mag „zu einfach“ erscheinen. Aber darin liegt der Sinn: Jede Schicht macht ihre kleine Aufgabe. Das Repository speichert, der Service leitet die Operationen, der Controller kommuniziert mit HTTP. Und wenn Sie in ein paar Tagen eine neue Ressource hinzufügen (z. B. Kommentare), müssen Sie die Welt nicht neu erfinden – Sie fügen einfach ein neues Repository und einen neuen Service hinzu, ohne die aktuelle Mechanik zu brechen.
7. Typische Fehler beim Schreiben eines In-Memory-Repositories
Fehler Nr. 1: Die Sammlung im Controller oder Service speichern, „weil es einfacher ist“.
Es erscheint nur die ersten halbe Stunde einfacher. Danach beginnt der Controller mit Such-, Aktualisierungs-, Löschlogik und „Behandlung von nicht gefunden“ zu wuchern. Selbst wenn Sie jetzt kein Error Handling schreiben, werden Sie trotzdem if und for im Controller schreiben – und damit die Web-Schicht mit der Speicherung vermischen. Ein Repository wird benötigt, damit Controller und Service keine Container und Zugriffsalgorithmen mit sich herumschleppen.
Fehler Nr. 2: Map oder tasks.values() nach außen geben und versehentlich die Kapselung verlieren.
Die hinterhältigste Version dieses Fehlers ist, wenn alles „funktioniert“, bis jemand im Service ein „harmloses“ clear() oder eine Modifikation der Sammlung durchführt. Das Repository wird zu einem globalen Objekt, das von überall her geändert werden kann. Geben Sie Kopien zurück und halten Sie den Container privat – das ist keine Paranoia, sondern elementare Hygiene.
Fehler Nr. 3: HashMap verwenden und sich wundern, warum die Aufgabenliste mal so, mal so aussieht.
HashMap muss keine stabile Reihenfolge liefern. Daher kann es passieren: Sie fügen zwei Aufgaben hinzu, und die Liste gibt mal „A, B“ zurück, mal „B, A“. Für den API-Client ist das unangenehm, und für Sie erschwert es das Testen und den visuellen Vergleich der Antworten. Für ein Studienprojekt ist LinkedHashMap eine ausgezeichnete Wahl, weil die Einfügereihenfolge vorhersagbar wird.
Fehler Nr. 4: Versuchen, das Repository „klug“ zu machen, indem man fachliche Regeln hinzufügt.
Zum Beispiel innerhalb von save prüfen, dass „eine Aufgabe im Status ARCHIVED nicht gespeichert werden darf“ oder dass „der title nicht leer ist“. Das klingt logisch, aber das sind Geschäftsregeln und sie gehören in den Service. Das Repository sollte sich auf die Speicherung konzentrieren. Sonst erhalten Sie ein Projekt, in dem die Regeln verstreut sind: die Hälfte im Service, die Hälfte im Repository, und niemand weiß, wo die Wahrheit liegt.
Fehler Nr. 5: Nicht verstehen, dass die In-Memory-Speicherung nur bis zum Neustart der Anwendung lebt.
Das ist kein Fehler „im Code“, aber ein häufiger Fehler „in den Erwartungen“. Wenn Sie die API testen, die Anwendung neu starten und eine leere Liste sehen – das ist normal. Ein In-Memory-Repository muss sich nicht an Daten „erinnern“. Es soll uns eine stabile Architektur der Web/API-Schicht ohne Datenbank geben, nicht die Persistenz ersetzen.
GO TO FULL VERSION