1. Kontroler i lista: szybka droga do smutku
Gdy projekt jest mały, ręka sama ciągnie do czegoś w stylu: „a czemu nie wrzucić List<Task> bezpośrednio do kontrolera?". I szczerze, pierwsze 10 minut wygląda to nawet genialnie: jedna klasa, wszystko pod ręką, nie trzeba nic tłumaczyć. Problem w tym, że taki kod psuje dyscyplinę warstw na samym starcie, i potem będziesz za to płacić na każdym kolejnym kroku – nawet gdy dodasz drugi endpoint lub drugi zasób.
Zły przykład (tak nie robimy, ale można popatrzeć – jak na zdjęcie z „dzieciństwa", gdzie jadło się piasek):
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
// ŹLE: kontroler zaczyna posiadać dane.
// Miesza to warstwę web z przechowywaniem i prowadzi do "fat controllera".
private final List<Task> tasks = new ArrayList<>();
}
Z zewnątrz wygląda niewinnie, ale w środku ukrywają się trzy problemy. Po pierwsze, kontroler zamienia się w „i web, i przechowywanie danych, i logika biznesowa" – to prosta droga do fat controllera. Po drugie, trudno będzie ponownie użyć przechowywania z innego miejsca (np. z serwisu komentarzy), bo jest „ukryte" w kontrolerze. Po trzecie, przestajesz rozumieć, gdzie w projekcie „mieszkają dane" i kto za nie odpowiada.
Repozytorium jest właśnie po to, żebyś mógł uczciwie powiedzieć: „dane są przechowywane tu, kontroler tu nie sięga". Nawet jeśli dane to tylko kolekcja w pamięci.
2. Rola repozytorium i granice odpowiedzialności
Repozytorium w naszym projekcie edukacyjnym to warstwa odpowiadająca za przechowywanie i pobieranie danych, a nie za reguły HTTP, nie za JSON i nie za to, „czy można zmieniać zadanie w statusie ARCHIVED". Tak, w prawdziwym świecie granice bywają cieńsze, ale w projekcie edukacyjnym granice powinny być grubsze i bardziej czytelne, inaczej początkujący zaczyna pisać „trochę wszystkiego" w każdej klasie. A „trochę wszystkiego" to przepis na kaszę.
Bardzo pomocne jest trzymanie w głowie prostej tabelki, która rozdziela role:
| Warstwa | Co „wie" | Czego „nie wie" |
|---|---|---|
| Controller | HTTP, @PathVariable, @RequestBody, statusy odpowiedzi | Jak i gdzie przechowujemy zadania |
| Service | Operacje aplikacyjne („utwórz zadanie", „znajdź zadanie") | HTTP, ResponseEntity, nagłówki |
| Repository | Kolekcje, klucze, „zapisz/odczytaj" | Statusy HTTP, URI, @RequestParam |
Jeśli repozytorium zaczyna decydować, co zwrócić klientowi, dostaniesz „repozytorium, które wie o 404", a potem nagle pojawi się „repozytorium umiejące robić JSON". Brzmi jak żart, ale tak to zazwyczaj się zaczyna.
W naszych przykładach repozytorium będzie maksymalnie „głupie" w dobrym sensie: dać listę, oddać zadanie po id, zapisać zadanie. A jak reagować na null i co uważać za błąd – to nie jego strefa.
3. Wybór struktury: Map dla id
Gdy przechowujemy encje „według identyfikatora", prawie zawsze chcemy dwóch rzeczy: szybko znaleźć po id i jednocześnie móc oddać listę wszystkich elementów. Jeśli weźmiemy List<Task>, to „oddaj wszystkie" jest łatwe, a „znajdź po id" zamienia się w przeglądanie po kolei. Na małych danych przeglądanie nie jest straszne, ale czyni kod mniej przejrzystym: w każdym miejscu musisz pisać pętle, porównania, if, co rozsmarowuje odpowiedzialność po projekcie.
Tak zazwyczaj wygląda szukanie po id na liście (kod działa, ale zaczyna się mnożyć jak króliki):
public Task findById(List<Task> tasks, String taskId) {
// Przeglądanie liniowe: im więcej zadań, tym dłużej.
for (Task task : tasks) {
// Sprawdzamy zgodność id.
if (task.getId().equals(taskId)) {
return task; // Znaleziono – wychodzimy od razu.
}
}
// Nie znaleziono – zwracamy null (ale to rozwiązanie trzeba obsłużyć wyżej w warstwach).
return null;
}
Teraz wyobraź sobie, że ten fragment kodu pojawił się w trzech serwisach, potem w jednym z kontrolerów „szybko do sprawdzenia", potem gdzieś jeszcze „na dwie minuty". Gratulacje: właśnie stworzyłeś distributed repository – repozytorium rozsmarowane po całym projekcie cienką warstwą.
Map<String, Task> rozwiązuje zadanie naturalnie: id to klucz, zadanie to wartość.
Mini-tabelka do wyboru kolekcji (żeby mózg miał punkt oparcia):
| Kolekcja | Szukanie po id | Kolejność oddawania | Komentarz |
|---|---|---|---|
| ArrayList | wolniejsze (przeglądanie) | kolejność wstawiania | wygodny jako kontener, ale nie jako indeks |
| HashMap | szybko | kolejność „jak wyjdzie" | dla naszych odpowiedzi może być niespodziewane |
| LinkedHashMap | szybko | stabilna kolejność wstawiania | świetny wariant edukacyjny |
W projekcie edukacyjnym często wybieramy LinkedHashMap, bo daje przewidywalną kolejność. To ważne nie jako „sortowanie" (tego dziś nie robimy), lecz jako podstawowa stabilność: jeśli dwa razy wywołałeś GET /tasks, nie chcesz za każdym razem widzieć elementów w losowej kolejności tylko dlatego, że w HashMap „tak wyszły".
4. Kontrakt i InMemoryTaskRepository
Teraz złożymy repozytorium tak, żeby było maksymalnie nudne i użyteczne. Zacznijmy od interfejsu. Ważne, żeby interfejs był częścią „domeny" (czyli kontraktu projektu), a implementacja – w infrastrukturze. Wtedy serwis zależy od kontraktu, a nie od konkretnej klasy.
Przykład interfejsu (pakiet możesz wybrać według swojej struktury; często wygodnie trzymać go w domain, żeby infrastructure zależała od domain, a nie odwrotnie):
package com.example.tasktracker.domain.repository;
import java.util.List;
import com.example.tasktracker.domain.model.Task;
// Kontrakt repozytorium: tylko przechowywanie/pobieranie, bez HTTP i bez reguł biznesowych.
public interface TaskRepository {
// Zwróć wszystkie zadania (zazwyczaj – kopią/niemutowalną kolekcją, to decyduje implementacja).
List<Task> findAll();
// Znajdź po id. W implementacji in-memory może zwrócić null, jeśli zadania nie ma.
Task findById(String taskId);
// Zapisz zadanie po id (tworzenie/aktualizacja rozstrzygane wyżej w warstwach).
Task save(Task task);
}
Teraz zaimplementujmy repozytorium in-memory w pakiecie infrastrukturalnym. Tu ważne są dwie kwestie: po pierwsze, repozytorium to Spring bean, więc oznaczamy je @Repository. Po drugie, przechowuje kolekcję wewnątrz siebie i nie oddaje jej na zewnątrz wprost.
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 daje stabilną kolejność wstawiania (wygodne dla przewidywalnych odpowiedzi i testów).
private final Map<String, Task> tasks = new LinkedHashMap<>();
}
Dalej – metody. Zacznijmy od findAll. Ważny nawyk: zwracaj kopię, a nie „żywą" kolekcję widokową, żeby zewnętrzny kod nie mógł przypadkowo zmienić wewnętrznego stanu repozytorium.
import java.util.List;
@Override
public List<Task> findAll() {
// Nie oddajemy na zewnątrz „żywej" kolekcji z Map — robimy niemutowalną kopię.
return List.copyOf(tasks.values());
}
findById w wariancie in-memory to po prostu get po kluczu. Tak, może zwrócić null, jeśli zadania nie ma. I to normalne dla tej warstwy: repozytorium nie musi rozstrzygać, „czym jest not found dla API".
@Override
public Task findById(String taskId) {
// get zwróci null, jeśli klucza nie ma — to normalne na poziomie Repository.
return tasks.get(taskId);
}
save to put. Jest tu subtelna kwestia: put zarówno zapisze, jak i nadpisze, jeśli klucz już był. I to dobrze: repozytorium nie rozstrzyga, „czy to tworzenie czy aktualizacja". Po prostu zapisuje po id.
import java.util.Objects;
@Override
public Task save(Task task) {
// Minimalna ochrona przed dziwnymi wywołaniami: lepiej palnąć tu, niż łapać NPE „gdzieś potem".
Objects.requireNonNull(task, "task must not be null");
// put zapisuje lub nadpisuje po kluczu id — repozytorium nie rozróżnia create/update.
tasks.put(task.getId(), task);
return task;
}
Dodaliśmy requireNonNull – to nie jest „walidacja API", lecz minimalna ochrona przed naprawdę dziwnymi wywołaniami wewnątrz aplikacji. Jeśli serwis przekazał null, lepiej od razu i uczciwie paść, niż dostać zagadkowy NullPointerException gdzieś wewnątrz Map.
5. Kopie ochronne i niebezpieczeństwo tasks.values()
Początkujący często robią repozytorium, które „zwraca listę zadań". I zwracają tasks.values() albo nawet sam tasks. To prawie zawsze błąd nie z powodu „złych ludzi wokół", lecz zwykłego lenistwa: zewnętrzny kod może przypadkowo zacząć modyfikować kolekcję, i repozytorium straci kontrolę nad swoim stanem. A repozytorium bez kontroli nad stanem to nie repozytorium, lecz „pokój bez drzwi".
Przykład, jak można się przypadkowo postrzelić, zwracając „żywą" kolekcję:
import java.util.Collection;
@Override
public Collection<Task> findAll() {
// ŹLE: values() to widok na Map, operacje na nim mogą zmieniać samą Map.
return tasks.values();
}
Dlaczego to złe? Bo tasks.values() to nie „nowa lista", lecz widok (view) na Map. W niektórych przypadkach operacje na tym widoku mogą zmienić samą mapę. Na przykład jeśli ktoś zrobi findAll().clear(), może całkowicie wyczyścić repozytorium. Serwis oczywiście nie powinien tak robić… ale serwis też piszesz ty. W piątek wieczorem. Po dwóch godzinach debugowania. I twoje przyszłe „ty" może być nieco groźniejsze od twojego dzisiejszego „ty".
Prawidłowy wariant – oddawać kopię:
import java.util.ArrayList;
import java.util.List;
@Override
public List<Task> findAll() {
// Kopia: zewnętrzny kod może modyfikować listę, ale wewnętrzna Map pozostanie bezpieczna.
return new ArrayList<>(tasks.values());
}
A jeszcze lepiej – niemutowalną kopię, żeby nawet przypadkowe list.add(...) nie przeszło:
import java.util.List;
@Override
public List<Task> findAll() {
// Niemutowalna kopia: nie można ani add, ani clear — dobra podstawowa higiena.
return List.copyOf(tasks.values());
}
Jest tu uczciwe ograniczenie: nawet jeśli lista jest niemutowalna, elementy (Task) mogą być mutowalne. Czyli ktoś może zrobić task.setTitle("..."). To jednak inny temat – mutowalność modelu. Listę zabezpieczyliśmy; wewnętrzny kontener repozytorium też.
6. In-memory: restart, singleton, współbieżność
Repozytorium in-memory brzmi jak „zabawka", ale w ramach tego kursu spełnia ważną funkcję: daje nam granicę warstwy persistence, bez konieczności podciągania bazy danych. Mimo to warto rozumieć, jak zachowuje się w żywej aplikacji Spring Boot, żeby nie budować błędnych oczekiwań.
Po pierwsze, InMemoryTaskRepository to bean. Domyślnie Spring tworzy go jako singleton. Oznacza to, że podczas pracy aplikacji istnieje jedna instancja repozytorium i jedna Map w środku. Dlatego różne żądania HTTP widzą ten sam stan. Tego właśnie potrzebujemy do edukacyjnego API.
Po drugie, po restarcie aplikacji dane znikają. To nie bug. To dosłownie to, co oznacza „in-memory". Jeśli oczekujesz, że „po restarcie zadania zostaną", oznacza to, że mentalnie podłączyłeś bazę danych, której u nas nie ma. Czasem wywołuje to lekki ból, ale metodycznie jest to użyteczne: nie mylisz warstwy API z persistence.
Po trzecie, jest współbieżność. Spring MVC przetwarza żądania równolegle, a nasz LinkedHashMap nie jest bezpieczny wątkowo. Na produkcji myślałbyś o synchronizacji, ConcurrentHashMap, transakcjach i tak dalej. W projekcie edukacyjnym tego nie robimy, bo szybko zaprowadzi to nas do osobnego kursu o wielowątkowości i spójności danych. Ale warto przynajmniej wiedzieć, że „to nie jest wieczny przepis", lecz świadome edukacyjne uproszczenie.
Repozytorium w serwisie i kontrolerze
Bardzo chce się, szczególnie gdy „trzeba szybko", wstrzyknąć repozytorium bezpośrednio do kontrolera i skończyć. Ale wtedy psuje się ten właśnie łańcuch, który budowaliśmy: kontroler staje się miejscem, gdzie podejmowane są decyzje o danych. A serwis zamienia się w „zbędną warstwę, której nie używamy". To typowa sytuacja, gdy architektura formalnie istnieje, ale projekt żyje „jak wcześniej".
Niech serwis pracuje przez interfejs repozytorium. Przykład „zwykłej" operacji serwisowej, gdzie serwis po prostu deleguje:
import java.util.List;
import org.springframework.stereotype.Service;
@Service
public class DefaultTaskService implements TaskService {
private final TaskRepository taskRepository;
public DefaultTaskService(TaskRepository taskRepository) {
// Wstrzykujemy właśnie interfejs (kontrakt), a nie konkretną implementację.
this.taskRepository = taskRepository;
}
@Override
public List<Task> getAll() {
// Serwis decyduje „jaką operację robimy", repozytorium — „jak pobrać dane".
return taskRepository.findAll();
}
}
I kontroler – po prostu wywołuje serwis:
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) {
// Kontroler zależy od serwisu, a nie od repozytorium.
this.taskService = taskService;
}
@GetMapping("/api/v1/tasks")
public List<Task> getAll() {
// Kontroler zajmuje się „uchwytem HTTP", a nie tym, gdzie i jak leżą zadania.
return taskService.getAll();
}
}
Ten kod może wydawać się „zbyt prosty". Ale właśnie o to chodzi: każda warstwa robi swoją małą robotę. Repozytorium przechowuje, serwis kieruje operacjami, kontroler komunikuje się z HTTP. I gdy za kilka dni dodasz nowy zasób (np. komentarze), nie będziesz wymyślać świata od nowa – po prostu dodasz nowe repozytorium i nowy serwis, nie psując obecnej mechaniki.
7. Typowe błędy przy pisaniu repozytorium in-memory
Błąd nr 1: przechowywanie kolekcji w kontrolerze lub serwisie „bo prościej".
Wygląda prościej tylko przez pierwsze pół godziny. Potem kontroler zaczyna rozrastać się logiką szukania, aktualizowania, usuwania i „obsługi nie znaleziono". Nawet jeśli teraz nie piszesz obsługi błędów, i tak zaczniesz pisać if i for w kontrolerze, czyli mieszać warstwę web z przechowywaniem. Repozytorium jest po to, żeby kontroler i serwis nie ciągnęły w sobie kontenerów i algorytmów dostępu do nich.
Błąd nr 2: zwrócenie na zewnątrz Map lub tasks.values() i przypadkowa utrata enkapsulacji.
Najbardziej podstępna wersja tego błędu – gdy wszystko „działa", dopóki ktoś nie zrobi w serwisie „niewinnego" clear() lub nie zacznie modyfikować kolekcji. Repozytorium zamienia się w globalny obiekt, który można zmieniać skąd bądź. Zwracaj kopie i trzymaj kontener prywatnym – to nie paranoja, to elementarna higiena.
Błąd nr 3: używanie HashMap i dziwienie się, dlaczego lista zadań raz taka, raz siaka.
HashMap nie musi oddawać elementów w stabilnej kolejności. Możesz więc dostać sytuację: dodałeś dwa zadania, a lista raz zwraca „A, B", raz „B, A". Klientowi API to nieprzyjemne, a tobie utrudnia testowanie i porównywanie odpowiedzi wzrokiem. Dla projektu edukacyjnego LinkedHashMap to świetny wybór, bo kolejność wstawiania staje się przewidywalna.
Błąd nr 4: próba zrobienia repozytorium „inteligentnym" przez dodawanie do niego reguł dziedzinowych.
Na przykład sprawdzanie wewnątrz save, że „nie można zapisać zadania w statusie ARCHIVED" albo że „tytuł nie jest pusty". Wygląda to logicznie, ale to reguły biznesowe i ich miejsce jest w serwisie. Repozytorium powinno pozostać przy przechowywaniu. Inaczej dostaniesz projekt, gdzie reguły się rozchodzą: połowa w serwisie, połowa w repozytorium, i nikt nie wie, gdzie jest prawda.
Błąd nr 5: niezrozumienie, że przechowywanie in-memory żyje dokładnie do restartu aplikacji.
To nie błąd „w kodzie", ale częsty błąd „w oczekiwaniach". Jeśli testujesz API, restartujesz aplikację i widzisz pustą listę – to normalne. Repozytorium in-memory nie jest zobowiązane „pamiętać" danych. Jest zobowiązane dać nam stabilną architekturę warstwy web/API bez bazy danych, a nie zastępować persistence.
GO TO FULL VERSION