1. Różnica między GET /tasks/{id} a GET /tasks
Patrząc na projekt okiem nowicjusza, można pomyśleć: „No lista to po prostu dużo TaskDetailsResponse, tylko w tablicy". To bardzo zrozumiała myśl, bo mózg lubi proste uogólnienia. Ale kolekcja i pojedynczy zasób mają różne pytania klienta, a co za tym idzie – różne reguły kontraktu, różne pułapki i różną odpowiedzialność po stronie serwera. Teraz ostrożnie rozróżnimy te „zwierzęta", żeby się potem nie mylić.
Wyobraź sobie, że GET /api/v1/tasks/{taskId} to pytanie „daj mi to konkretne zadanie". Klient zna już identyfikator, a serwerowi pozostaje tylko znaleźć zasób albo uczciwie powiedzieć „nie znaleziono". A GET /api/v1/tasks to pytanie „daj mi jakieś zadania", i od razu pojawia się pytanie: które dokładnie, w jakiej kolejności, ile sztuk, jaki fragment listy dostałem. Dla jednego zasobu wystarczy adres, dla kolekcji potrzebujemy kontraktu wyboru.
Zobaczmy to w małej tabeli – bardzo pomaga mózgowi przestać mylić listy ze szczegółami:
| Endpoint | O co pyta klient | Główna trudność | Typ odpowiedzi |
|---|---|---|---|
| GET /api/v1/tasks/{taskId} | „Gdzie jest konkretne zadanie?" | Znaleźć/nie znaleźć jednego zasobu | TaskDetailsResponse |
| GET /api/v1/tasks | „Jaki zestaw zadań pokazać?" | Wybór, kolejność, wielkość, powtarzalność | nie zwykły List, a odpowiedź stronicowana |
Właśnie dlatego w prawdziwych systemach lista prawie zawsze ewoluuje szybciej niż endpoint szczegółowy. GET by id może żyć latami bez zmian (dopóki nie dodajesz nowych pól), a lista dosłownie po tygodniu ktoś poprosi o „sortowanie, paginację, wyszukiwanie i żeby nie cięło". To normalne: lista to witryna. A witryna zawsze dostaje więcej wymagań niż magazyn.
Mini-przykład, jak wygląda endpoint szczegółowy w naszym projekcie (i dlaczego jest stosunkowo prosty):
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController // Kontroler: przyjmuje żądanie HTTP i zwraca DTO na zewnątrz
public class TaskController {
@GetMapping("/api/v1/tasks/{taskId}") // Endpoint szczegółowy: jeden konkretny zasób
public TaskDetailsResponse getById(@PathVariable String taskId) {
// Ważne: taskId ustalił już klient, tu nie ma logiki paging/sorting – bo element jest jeden
// (taskService zakładamy wstrzyknięty przez DI, pole/konstruktor tu pominięte)
return taskService.getById(taskId);
}
}
Główna idea jest tu taka, że klient już „przybił gwoździem" cel żądania: taskId. Nie kłócimy się z nim o rozmiar strony i kolejność elementów, bo elementów jest… jeden.
2. Surowy List jako kontrakt listy
Na starcie projektu List<TaskSummaryResponse> wygląda uroczo: mało kodu, szybko sprawdza się w Postmanie, świetnie czuje się na demo. Problem w tym, że to jak taboret z IKEA: jak raz się na nim usiadłeś – jest ok, ale jeśli spróbujesz na nim żyć, pracować i trzymać rzeczy, nagle okazuje się, że to nie mebel, a pamiątka. Lista bez metadanych to „pamiątkowy" kontrakt, który źle znosi wzrost wymagań.
Dlaczego? Bo gołe lista odpowiada tylko na jedno pytanie: „jakie elementy mi przysłałeś". Ale nie odpowiada na następne naturalne pytanie klienta: „a to wszystko czy fragment?" Jeśli w odpowiedzi jest tylko tablica, klient nie może zrozumieć, ile jest łącznie zadań, ile stron jest dostępnych, jaki rozmiar strony zastosował serwer, jakie sortowanie zadziałało domyślnie. A jeśli później zechcesz dodać te informacje – wpadniesz w problem zgodności.
Tak wygląda „surowy" kontrakt listy:
[
{ "id": "…", "title": "Buy milk", "status": "TODO" },
{ "id": "…", "title": "Fix build", "status": "IN_PROGRESS" }
]
Z perspektywy człowieka jest czytelny. Ale z perspektywy długożyjącego API jest słaby: nie możesz dodać obok page, size i totalElements, nie zmieniając formy odpowiedzi z tablicy na obiekt. A zmiana formy odpowiedzi to prawie zawsze breaking change dla klientów. Widzieliśmy już, że „wydaje się drobiazgiem po stronie serwera" często oznacza „zepsuli aplikację" po stronie klienta.
Jest też drugi, bardziej praktyczny niuans. Gdy lista rośnie, serwer zaczyna płacić cenę za „oddawaj wszystko": pamięcią, czasem serializacji, czasem sieci i czasem klienta na parsowanie JSON. Nawet jeśli teraz pracujesz na repozytorium in-memory, kontrakt „zawsze oddajemy wszystko" to zły nawyk. Tworzy u klienta oczekiwanie, że w dowolnym momencie może wyciągnąć cały świat jednym żądaniem. A potem, gdy pojawi się prawdziwe magazyn lub po prostu więcej danych, znajdziesz się w sytuacji „teraz trzeba pilnie naprawiać kontrakt".
I wreszcie, lista bez jawnych reguł bardzo łatwo zamienia się w zagadkę. Jeśli nie ustalasz kolejności, niemal na pewno dostaniesz „skaczącą listę": dziś elementy w jednej kolejności, jutro w innej. Klient będzie cierpiał, QA będzie cierpiał, ty będziesz cierpiał – i ktoś na pewno zapyta: „dlaczego czasem zmienia kolejność, chociaż nic nie robiłem?" Najbardziej smutna odpowiedź w życiu backend developera: „no… bo tak wyszło".
3. Kontrakt collection endpoint: wejście i wyjście
Gdy tylko odpowiedź dostała formę envelope, od razu pojawia się drugie pytanie: czym klient w ogóle zarządza tym wyborem na wejściu. Żeby lista stała się dojrzała, musimy przestać postrzegać ją jako „endpoint, który zwraca dużo rzeczy", a zacząć jako endpoint, który zwraca stronicowaną reprezentację kolekcji. Czyli ma część wejściową kontraktu opisującą „co dokładnie chcesz zobaczyć" i część wyjściową opisującą „co dokładnie dostałeś". I to nie biurokracja, a uczciwy opis tego, jak klient i serwer się dogadują.
Na wejściu list-endpointu prawie nieuchronnie pojawiają się parametry sterujące. W naszym kursie trzymamy minimalnie rozsądny zestaw: numer strony, rozmiar strony i sortowanie. Teraz ważne tylko, że te parametry w ogóle należą do żądania. Konkretne zakresy i defaults na razie zostawiamy poza kadrem, żeby nie mieszać formy żądania ze szczegółami walidacji.
Na wyjściu pojawia się odpowiedź envelope: obiekt, w którym są i elementy (items), i metadane. Metadane należą do odpowiedzi, bo to informacja „co realnie wyszło" po zastosowaniu reguł serwera. I tu pojawia się ważny wniosek projektowy: dla dojrzałej listy potrzebujemy nie List<T>, a PagedResponse<T>.
Jeśli wyobrazić sobie przetwarzanie list-endpointu jako przenośnik taśmowy, wygląda to mniej więcej tak (i zauważ: kontroler pozostaje cienki, a większość „mózgu" żyje w serwisie):
flowchart TD
A["HTTP GET /api/v1/tasks ?page&size&sort"] --> B["TaskController"]
B --> C["TaskService"]
C --> D["TaskRepository"]
D --> C
C --> E["TaskMapper"]
E --> C
C --> B
B --> F["PagedResponse⟨TaskSummaryResponse⟩ JSON"]
To ta sama idea, co mieliśmy wcześniej: „kontroler przyjmuje kontrakt i deleguje". Tylko teraz kontrakt listy to nie „zwróć listę", a „zwróć stronę listy".
I jeszcze jedna ważna dyscyplina: wejście do list-endpointu wygodnie trzymać jako criteria DTO. Nawet jeśli dziś w criteria są tylko trzy pola, jutro będzie ich więcej (i to jest normalne). Criteria DTO daje nam stabilną sygnaturę kontrolera i zmniejsza ryzyko, że TaskController zacznie puchnąć jak kula śniegowa.
4. PagedResponse<T> jako kontrakt envelope
PagedResponse<T> to nie „owijanie dla owijania". To sposób, żeby lista była rozszerzalna bez psucia formy odpowiedzi i bez przyszłych „a może szybko nową wersję API, bo musimy dodać totalElements". W naszym kursie ten envelope już się pojawiał, i teraz po prostu zaczynamy naprawdę używać go w GET /api/v1/tasks. Ważne, żeby rozumieć, że to część publicznego kontraktu, więc należy do niego podchodzić ostrożnie.
Minimalny model PagedResponse<T> w naszym projekcie wygląda tak:
import java.util.List;
public record PagedResponse<T>(
List<T> items, // Elementy bieżącej strony (same DTO, nie model domenowy)
int page, // Numer strony (zazwyczaj 0-based, jeśli tak ustalono w kontrakcie)
int size, // Rozmiar strony, który zastosował serwer
long totalElements, // Ile łącznie elementów jest w kolekcji
int totalPages, // Ile łącznie stron wyszło przy danym size
String sort // Faktycznie zastosowane sortowanie (ważne dla powtarzalności żądań)
) {}
Zwróć uwagę na interesujący szczegół: items to nadal twoje zwykłe DTO elementów listy. W naszym przypadku będzie to TaskSummaryResponse, bo lista prawie zawsze chce być kompaktowa i szybka. A pozostałe pola to kontekst strony. Klient może używać ich do paginacji UI, do wyświetlania „pokazano 20 z 153", do przycisków „następna/poprzednia", do stabilnych powtarzanych żądań.
Jak to wygląda w JSON (przykład, żeby poczuć formę oczami klienta):
{
"items": [
{ "id": "a1…", "title": "Fix build", "status": "IN_PROGRESS" },
{ "id": "b2…", "title": "Buy milk", "status": "TODO" }
],
"page": 0,
"size": 20,
"totalElements": 57,
"totalPages": 3,
"sort": "updatedAt,desc"
}
Dlaczego to wygodne właśnie jako kontrakt? Bo obiekt można ewoluować ostrożnie: dodanie nowego pola do odpowiedzi jest zazwyczaj bezpieczniejsze niż zmiana całej formy odpowiedzi. Czyli z góry wybieramy format, który wytrzyma „dojrzewanie" listy. I tak, to właśnie ten moment, gdy backend developer po raz pierwszy zaczyna myśleć „żeby nie zepsuć klientowi", nawet jeśli klienta jeszcze nie ma. To nie paranoja – to doświadczenie.
Jeszcze jedna zasadnicza kwestia naszego kursu: nie wciągamy do publicznego kontraktu typów Spring Data. Nawet jeśli znasz Page, Pageable i Sort, nie używamy ich jako publicznych DTO. Przyczyna jest prosta: to typy z sąsiedniego świata (świata abstrakcji persistence), a nasz kurs trzyma się warstwy web/API. Dlatego PagedResponse<T> to „nasz" kontrakt, jednakowo uczciwy zarówno przy implementacji in-memory, jak i po późniejszej zmianie magazynu.
5. Refaktoryzacja TaskController pod paging
Teraz trzeba to uczciwie wyrazić w sygnaturze kontrolera. Dobra wiadomość: żeby lista stała się dojrzała, kontroler nie musi stawać się „mądrzejszy". Kontroler musi stawać się uczciwszy: jawnie przyjąć criteria, jawnie zwrócić PagedResponse, i delegować dalej. To ten przypadek, gdy nie „komplikujemy kodu", a „komplikujemy kontrakt". I tak, kontrakt jest bardziej złożony, bo prawdziwe życie jest bardziej złożone – tyle że wcześniej ukrywaliśmy tę złożoność pod dywanem.
Najpierw przypomnijmy, jak zazwyczaj wygląda najwcześniejsza wersja list-endpointu:
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Kontroler pozostaje cienki: żadnych obliczeń stron wewnątrz
public class TaskController {
@GetMapping("/api/v1/tasks") // Naiwny wariant: bez paging, bez sortowania, bez metadanych
public List<TaskSummaryResponse> list() {
// Problem: ten kontrakt prawie na pewno będzie trzeba psuć, żeby dodać totalElements itp.
return taskService.findAllSummaries();
}
}
Działa? Działa. Ale ta metoda nie ma miejsca, gdzie „wmontować" page/size/sort, nie zamieniając jej w długą sygnaturę. I co najważniejsze – odpowiedź nie niesie metadanych.
Teraz kanoniczny dla nas wariant: list-endpoint zwraca stronę i przyjmuje criteria DTO. Zauważ, że kontroler przy tym nie zaczyna liczyć stron i sortować – po prostu przyjmuje kontraktowe parametry.
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks") // Bazowa ścieżka kontrolera: dalej można pisać po prostu @GetMapping
public class TaskController {
@GetMapping
public PagedResponse<TaskSummaryResponse> list(@Valid @ModelAttribute TaskSearchCriteria criteria) {
// @ModelAttribute: pola criteria są wypełniane z query string (?page=..&size=..&sort=..)
// @Valid: włączamy walidację wejścia
return taskService.findPage(criteria);
}
}
I tu bardzo ważna dla nowicjusza myśl: @ModelAttribute oznacza, że criteria jest wypełniane z parametrów query. Czyli nasz list-endpoint pozostaje GET, pozostaje „czytający" i bezstanowy, a klient zarządza żądaniem przez query string. Nie wysyłamy JSON body w GET i nie próbujemy robić „wyszukiwania przez POST", dopóki nie jest to uzasadnione. Trzymamy prosty, czytelny kontrakt REST.
Samo criteria DTO na tym etapie może być minimalne. Teraz ważne tylko, żeby istniało i ustalało pola kontraktu. Zakresy, defaults i reguły parsowania sort na razie tu nie wbudowujemy, żeby najpierw zobaczyć samą formę żądania.
public record TaskSearchCriteria(
Integer page, // Numer strony
Integer size, // Rozmiar strony
String sort // Tekstowy opis sortowania, np. "updatedAt,desc"
) {}
A warstwa serwisowa dostaje czytelne żądanie: „daj stronę zadań według tych reguł" i zwraca PagedResponse<TaskSummaryResponse>. Implementację będziemy rozszerzać dalej, ale kontrakt serwisu staje się już oczywisty.
public interface TaskService {
// Serwis pracuje z naszymi DTO/criteria, nie z typami MVC ani Spring Data Page jako publicznym kontraktem
PagedResponse<TaskSummaryResponse> findPage(TaskSearchCriteria criteria);
}
Zauważ: serwis nadal nic nie wie o ResponseEntity, HttpServletRequest i innych detalach web. Pracuje z naszymi DTO i logiką domenową, a MVC pozostaje na zewnątrz.
6. Weryfikacja kontraktu przez .http
Bardzo przydatny nawyk programisty API – okresowo wychodzić z roli „piszę kod Java" i wchodzić w rolę „jestem klientem, który czyta kontrakt". Najszybszy sposób – żądania .http w IDE lub Postman. I tu od razu poczujesz, dlaczego odpowiedź envelope jest lepsza: widzisz nie tylko elementy, ale i kontekst. A poza tym rozumiesz, że dodanie parametrów żądania nie wymaga zmiany formy odpowiedzi.
Przykład prostego żądania (nawet bez jawnych parametrów query, żeby zobaczyć domyślne zachowanie serwera):
### List tasks (paged contract)
# Sprawdzamy, że korzeń odpowiedzi to obiekt (PagedResponse), a elementy leżą w items
GET http://localhost:8080/api/v1/tasks
Accept: application/json
I oczekujesz zobaczyć obiekt, a nie tablicę. Nawet jeśli items jest pusty (tak też bywa), forma odpowiedzi pozostaje ta sama: klient nie musi pisać „jeśli tablica – to jeden kontrakt, jeśli obiekt – inny". Zawsze dostaje PagedResponse.
W ramach „wizualnej weryfikacji" można na tym etapie zrobić tymczasową zaślepkę w serwisie, żeby szybko zobaczyć formę odpowiedzi, nawet gdy implementacja paging/sorting jest jeszcze nieukończona. Najważniejsze – ustalić kontrakt i upewnić się, że jest naprawdę zwracany na zewnątrz.
W Swagger UI taki kontrakt też będzie wyglądał uczciwie: lista to nie „array of TaskSummaryResponse", a „PagedResponse of TaskSummaryResponse". Klient od razu rozumie: „aha, pracuję ze stronami".
7. Typowe błędy przy pracy z pagingiem
Gdy po raz pierwszy robisz list-endpoint „dorosłym", błędy najczęściej zdarzają się nie przez składnię Javy, a przez mieszanie znaczeń i przez chęć „zrobić szybko, a potem ładnie". I właśnie „potem ładnie" zazwyczaj jest bardzo drogie: klient już napisał kod pod twój pierwszy kontrakt, a ty nagle chcesz zmienić formę odpowiedzi. Lepiej zauważyć te błędy teraz, gdy projekt jest edukacyjny i bezpieczny.
Błąd nr 1: zostawić List<TaskSummaryResponse> i obiecać sobie „później dodam paginację".
Klasyczny scenariusz, który wygląda niewinnie, ale prawie zawsze kończy się tym, że albo psuje się klientów, albo trzeba utrzymywać dwa warianty odpowiedzi jednocześnie. Znacznie taniej od razu ustalić PagedResponse<T>, nawet jeśli na początku zwracasz jedną stronę bez prawdziwej logiki.
Błąd nr 2: używać TaskDetailsResponse na liście „żeby nie mnożyć DTO".
Lista powinna być szybka i kompaktowa. Model szczegółowy prawie zawsze zawiera zbędne pola, co zwiększa rozmiar odpowiedzi i komplikuje klienta. Poza tym zaczyna się wciągać do list-endpointu dane, które logicznie powinny być pobierane tylko w detail-view. Pogarsza to i wydajność, i czytelność kontraktu.
Błąd nr 3: traktowanie sortowania i paginacji jako „wewnętrznej implementacji", a nie części publicznej umowy.
Jeśli klient nie może zrozumieć, dlaczego widzi akurat taką kolejność i akurat tyle elementów, znaczy że masz „niedopowiedziany" kontrakt. Nawet jeśli implementacja jest teraz in-memory, parametry wejściowe i metadane wyjściowe powinny być jasne i stabilne. Inaczej budujesz API, z którego można korzystać tylko „na szczęście".
Błąd nr 4: wyciekanie wewnętrznego modelu na zewnątrz, bo „no to przecież tylko lista".
Na liście jest dokładnie taka sama odpowiedzialność kontraktowa jak w endpoint szczegółowym. Jeśli zwrócisz na zewnątrz Task z domain.model, przywiążesz klienta do swojej wewnętrznej struktury, a potem będziesz się bać zmienić jakiekolwiek pole. Dyscyplina DTO jest potrzebna wszędzie, szczególnie tam, gdzie „wydaje się, że nic złego".
Błąd nr 5: robienie kontrolera „mądrego": sortowanie, przycinanie listy, liczenie totalPages bezpośrednio w warstwie web.
Kontroler powinien przyjmować wejście, wywoływać serwis i zwracać odpowiedź. Gdy tylko zaczynasz „programować algorytm listy" w kontrolerze, przestaje być cienki i zamienia się w miejsce, gdzie trudno testować, trudno ponownie używać i łatwo zepsuć jednolity styl projektu. Lepiej niech mózg mieszka w serwisie, a kontroler pozostaje uczciwym przewodnikiem kontraktu.
GO TO FULL VERSION