Realny cel: odpalić pierwsze REST API w Spring Boot, które zwraca JSON, przyjmuje JSON, waliduje dane i oddaje sensowne błędy. Przy okazji: podjąć trzy decyzje startowe bez pakowania się w „architekturę jak z NASA”.
Pytania, które zwykle blokują start (i na które padają konkretne odpowiedzi w kolejnych sekcjach):
- Jak najszybciej utworzyć projekt Spring Boot pod API i co wybrać w generatorze?
- Spring Web (MVC) czy WebFlux — kiedy to realnie ma znaczenie?
- Maven czy Gradle — co prostsze na start i jak to wpływa na codzienną pracę?
- Jak wygląda minimalny endpoint REST (GET/POST) i gdzie go umieścić w projekcie?
- Jak uruchomić aplikację lokalnie i jak ją najprościej przetestować (curl / HTTP client w IDE)?
- Jakie startery są naprawdę potrzebne, a jakie to „bloat” na pierwsze podejście?
- Jak dodać walidację DTO i jeden wspólny format błędów bez rozbudowy frameworka?
- Co sprawdzić, gdy aplikacja nie wstaje albo nagle dostajesz pierwsze 500?
Trzy decyzje na starcie (i które możesz odłożyć bez wyrzutów)
Decyzje, które faktycznie wpływają na pierwsze godziny
Na początku liczą się trzy wybory, bo determinują, czy po 15 minutach masz działające API, czy po godzinie nadal walczysz z konfiguracją:
- sposób utworzenia projektu (Spring Initializr vs generator w IDE vs ręczne składanie),
- narzędzie budowania (Maven vs Gradle),
- stos webowy (Spring Web MVC vs WebFlux).
Reszta to dodatki. Przy pierwszym API lepiej wyrobić nawyk „działa, rozumiem, dopiero rozbudowuję” niż „mam 40 zależności i nie wiem, która robi co”.
Decyzje, które kuszą, ale są szumem na start
Na listę „za wcześnie” często wpadają: baza danych (JPA), Spring Security, mikroserwisy, Kafka, Docker, wzorce warstwowe w pięciu pakietach i repozytorium na wszystko. To są ważne tematy — po prostu nie są konieczne, żeby postawić pierwsze sensowne API.
Minimalny, sensowny cel na szybki start API w Spring Boot to:
- aplikacja się uruchamia,
- są dwa endpointy (GET i POST),
- POST przyjmuje JSON i ma walidację,
- błędy walidacji wracają jako 400 w przewidywalnym formacie.
Dlaczego „pierwsze 500” przychodzi szybciej niż kawa
Gdy robisz pierwszy kontroler, bardzo łatwo przypadkiem wywołać wyjątek (np. null, zła walidacja, brak zależności). To normalne. Różnica między przyjemnym startem a frustracją polega na tym, czy umiesz szybko odróżnić: błąd klienta (400) od błędu serwera (500) i czy widzisz w odpowiedzi coś więcej niż „Internal Server Error”. Ten tekst prowadzi do stanu, w którym 500 zdarza się rzadziej i przestaje być tajemniczą magią.
Warianty utworzenia projektu: Initializr vs generator w IDE vs ręczne składanie zależności
Spring Initializr (web) — domyślny wybór dla większości
Spring Initializr to najprostsza droga, bo dostajesz projekt ze spójnymi wersjami zależności (Boot ogarnia BOM) i sensowną strukturą. Największy plus dla początkujących: mniejsza szansa, że wylądujesz z konfliktem wersji zanim napiszesz pierwszą linijkę kodu.
Co wybrać w metryczce, żeby nie strzelić sobie w stopę
- Java: wybierz wersję LTS wspieraną w Twoim środowisku (najczęściej 17 lub 21). Jeśli nie wiesz — 17 to bezpieczny punkt startu.
- Packaging: Jar (na start niemal zawsze).
- Group i Artifact: użyj sensownych nazw, bo to wpłynie na pakiet bazowy i identyfikację projektu.
- Package name: trzymaj porządek od początku (np. pl.twojadomena.demo), bo później będzie to „root” skanowania komponentów.
Minimalne startery pod REST API
Na szybki start API zwykle wystarczą:
- Spring Web (czyli spring-boot-starter-web) — MVC, kontrolery, wbudowany Jackson do JSON.
- Validation (czyli spring-boot-starter-validation) — walidacja adnotacjami + integracja z kontrolerami.
Opcjonalnie (ale nie obowiązkowo):
- Lombok — skraca kod, ale utrudnia zrozumienie, co naprawdę dzieje się w klasie, jeśli dopiero uczysz się Javy + Springa.
- Actuator — super do diagnostyki, ale to raczej „krok 2”, gdy już masz API i chcesz więcej wglądu.
Generator w IDE (IntelliJ / Eclipse) — kiedy ma sens
Jeśli i tak pracujesz w IDE, generator projektu Spring Boot bywa wygodny: mniej przełączania kart, IDE od razu indeksuje projekt, tworzy konfiguracje uruchomieniowe. To jest szczególnie przyjemne, gdy dopiero oswajasz się z ekosystemem Springa.
Pułapka: w generatorach IDE łatwo „doklikać” 10 zależności, bo brzmią znajomo (Security! JPA! Cloud!). Tylko że po tygodniu trudno pamiętać, po co to w ogóle było. Trzymaj się minimalnego zestawu i dodawaj kolejne elementy dopiero, gdy umiesz uzasadnić: co to daje i jak to przetestuję.
Ręczne dopisywanie zależności — dlaczego to antywzorzec dla początkujących
Ręczne budowanie projektu „od zera” ma sens, gdy już wiesz, jak działa zarządzanie wersjami zależności w Spring Boot i co oznaczają konflikty transitive dependencies. Na starcie to zwykle przepis na problemy typu:
- aplikacja nie wstaje, bo biblioteki się gryzą,
- brakuje Jacksona albo walidacji, choć „przecież dodałem coś od weba”,
- pojawia się błąd klasy w runtime, bo wersje nie pasują do Boota.
Jeśli celem jest szybki start REST API w Spring Boot, Initializr wygrywa prostotą i przewidywalnością.
Porównanie wariantów startu projektu
| Wariant | Najlepszy na start, bo… | Pułapki | Kiedy nie wybierać |
|---|---|---|---|
| Spring Initializr (web) | Powtarzalny szkielet, spójne wersje, szybkie dodanie starterów | Można dodać za dużo zależności „bo są” | Gdy masz sztywny firmowy szablon repo lub monorepo z narzuconą strukturą |
| Generator w IDE | Wygoda, IDE od razu konfiguruje uruchamianie i indeksowanie | „Przeklikanie” wielu modułów bez zrozumienia skutków | Gdy IDE jest ograniczone albo zespół bazuje na skryptach i CI, a nie na konfiguracjach IDE |
| Ręczne składanie zależności | Daje kontrolę, gdy wiesz co robisz | Konflikty wersji, dłuższa diagnostyka, łatwe pominięcie kluczowych elementów | Gdy dopiero zaczynasz i chcesz po prostu postawić pierwsze API |
Maven vs Gradle: wybór narzędzia budowania bez „wielkiej wojny build tooli”
Kryteria praktyczne dla początkującego
To nie jest wybór „lepszy/gorszy”, tylko „co szybciej ogarnę i co będzie pasować do kontekstu”. Dobre kryteria na start:
- standard w zespole — jeśli projekt ma trafić do zespołu, ich wybór jest ważniejszy niż Twoje preferencje,
- czytelność konfiguracji — czy łatwo znajdziesz, gdzie zmienić wersję Javy, nazwę artefaktu, zależności,
- wsparcie w IDE i CI — oba narzędzia są wspierane, ale w praktyce liczy się to, co już masz „gotowe” w środowisku,
- krzywa uczenia — ile nowej składni musisz poznać, zanim ruszysz dalej.
Kiedy Maven jest rozsądnym wyborem
Maven bywa bardziej „oczywisty” na start, bo pom.xml jest deklaratywny i łatwo znaleźć przykłady. Jeśli tworzysz projekt rekrutacyjny, uczysz się Spring Boot od zera albo chcesz iść najbardziej standardową ścieżką — Maven jest bezpieczny.
Typowe komendy:
- build: mvn clean package
- test: mvn test
- run: mvn spring-boot:run
Kiedy Gradle ma więcej sensu
Gradle często wygrywa, jeśli zespół już go używa, projekt będzie wielomodułowy albo chcesz większej elastyczności. Tylko pamiętaj: elastyczność oznacza też więcej możliwości zrobienia sobie krzywdy w buildzie, gdy dopiero się uczysz.
Typowe komendy (wrapper):
- build: ./gradlew build
- test: ./gradlew test
- run: ./gradlew bootRun
Najczęstszy błąd: mieszanie porad z Maven i Gradle
Brzmi banalnie, ale to klasyk: ktoś kopiuje fragment z poradnika o Mavenie do projektu w Gradle albo odwrotnie, a potem dziwi się, że „nie działa”. Pierwszy krok diagnostyczny, gdy coś jest nie tak: upewnij się, czy projekt jest Mavenowy (pom.xml) czy Gradlowy (build.gradle/build.gradle.kts) i trzymasz się jednego świata naraz.
Spring Web (MVC) vs WebFlux: sensowne kryteria, nie religia
Spring MVC (spring-boot-starter-web) — domyślnie dla REST i CRUD
Dla większości początkujących Spring Web MVC to najlepszy start: klasyczny model request/response, łatwe debugowanie, proste kontrolery, prosty mental model. Jeśli robisz typowe REST API, integrację z innym systemem, małe API rekrutacyjne albo ćwiczeniowe — MVC jest naturalne.
W MVC bardzo łatwo zrozumieć, co się dzieje:
- przychodzi żądanie HTTP,
- kontroler je mapuje,
- zwracasz obiekt,
- Spring + Jackson zamienia go na JSON.
WebFlux — kiedy naprawdę ma sens
WebFlux jest sensowny, gdy masz konkretne wymagania: streaming danych, SSE/WebSocket, backpressure, duże obciążenie I/O i reaktywne integracje end-to-end (np. cały łańcuch jest reaktywny, a nie tylko kontroler).
Koszt wejścia jest realny: inny styl myślenia, inne typy (Mono/Flux), inne pułapki. Najczęstsza: w aplikacji reaktywnej wstawiasz blokujące wywołania (np. klasyczne JDBC) i nagle tracisz korzyści, a zyskujesz trudniejszą diagnostykę. WebFlux bez potrzeby bywa jak kupno kombajnu do krojenia chleba — robi wrażenie, ale kromka i tak jest jedna.
Proste kryterium wyboru (bez długich debat)
- Jeśli nie masz pewności, wybierz Spring Web MVC.
- Jeśli wiesz, że potrzebujesz streamingu / SSE / WebSocket lub reaktywnego łańcucha I/O — rozważ WebFlux.
- Jeśli Twoje API to klasyczne CRUD — WebFlux raczej skomplikuje start.
Minimalny „Hello API”: 2 endpointy, JSON i kody HTTP bez rozbudowanej architektury
Minimalna struktura pakietów, która nie boli
Na szybki start API nie potrzebujesz dziesięciu warstw. Wystarczy czytelny podział, który rośnie razem z projektem. Przykład:
- pl.twojadomena.api (pakiet bazowy, tu jest klasa z main)
- pl.twojadomena.api.controller
- pl.twojadomena.api.dto
- pl.twojadomena.api.error (opcjonalnie na obsługę błędów)
Warstwy typu service/repository wprowadzaj, gdy pojawia się logika, którą chcesz testować i utrzymywać. Jeśli kontroler ma 20 linijek i robi prostą transformację — nie ma sensu udawać wielkiego systemu.
Endpoint GET: szybki sanity check, że aplikacja żyje
Najprostszy punkt kontrolny to endpoint, który zwraca JSON. Dzięki temu od razu wiesz, że działa routing, kontroler i serializacja.
Najprościej: zwróć mapę albo mały DTO. Spring dorzuci nagłówki i kod 200, a Jackson zrobi z obiektu JSON bez proszenia się o pozwolenie.
@RestController
class HelloController {
@GetMapping("/api/hello")
Map<String, Object> hello() {
return Map.of("message", "Hello API", "status", "ok");
}
}
Jeśli po odpaleniu aplikacji wejdziesz na /api/hello i widzisz JSON, masz potwierdzone trzy rzeczy naraz: działa routing, działa kontroler, działa serializacja. W praktyce to najlepszy „bezpiecznik” przed grzebaniem godzinę w zależnościach, gdy problem był banalny (np. zły port).
Endpoint POST: przyjmij JSON, zwróć 201 i lokalizację zasobu
Drugi endpoint powinien dotknąć tego, co w API boli najbardziej: parsowanie wejścia, walidacja i sensowne kody HTTP. Weźmy prosty przykład tworzenia „notatki” (bez bazy, na skróty — magazyn w pamięci wystarczy do nauki).
record CreateNoteRequest(String title, String content) {}
record NoteResponse(Long id, String title, String content) {}
@RestController
@RequestMapping("/api/notes")
class NotesController {
private final AtomicLong seq = new AtomicLong();
private final Map<Long, NoteResponse> store = new ConcurrentHashMap<>();
@PostMapping
ResponseEntity<NoteResponse> create(@RequestBody CreateNoteRequest req) {
long id = seq.incrementAndGet();
var saved = new NoteResponse(id, req.title(), req.content());
store.put(id, saved);
return ResponseEntity
.created(URI.create("/api/notes/" + id))
.body(saved);
}
@GetMapping("/{id}")
ResponseEntity<NoteResponse> get(@PathVariable long id) {
var note = store.get(id);
return (note != null) ? ResponseEntity.ok(note) : ResponseEntity.notFound().build();
}
}
To jest celowo „prostackie”, ale uczy dobrych odruchów: POST zwraca 201 Created i nagłówek Location, a GET umie oddać 404, zamiast magicznego null. Taki zestaw szybko wychwytuje typowe pomyłki: brak Content-Type: application/json, literówki w polach JSON, mylenie ścieżki (/api/note vs /api/notes) — czyli „klasyka weekendowego API”.
DTO + walidacja wejścia: najmniej kodu, największy zysk
DTO jako kontrakt: nie wystawiaj encji i nie „bierz Mapy”
DTO to prosty filtr między światem HTTP a resztą aplikacji. Dzięki temu nie wiążesz się przypadkiem ze strukturą domeny ani z tym, jak trzymasz dane. W praktyce różnica jest ogromna: możesz zmienić pole w środku aplikacji, a kontrakt API zostaje stabilny. I odwrotnie — możesz dodać pole do odpowiedzi, nie psując zapisu w bazie.
Unikaj dwóch skrajności: przyjmowania Map<String, Object> (bo wtedy walidacja i błędy są jak wróżenie z fusów) oraz wystawiania encji JPA „bo już jest”. DTO w formie record jest lekkie, czytelne i wystarczające do większości endpointów.
Walidacja Bean Validation: jasne błędy zamiast „500 i cisza”
Żeby walidacja zadziałała, dorzuć starter: spring-boot-starter-validation. Potem adnotacje w DTO i jedno @Valid w kontrolerze. Tyle. Żadnych ręcznych ifów na początku metody, które po miesiącu wyglądają jak regulamin osiedla.
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
record CreateNoteRequest(
@NotBlank @Size(max = 100) String title,
@Size(max = 2000) String content
) {}
@PostMapping
ResponseEntity<NoteResponse> create(@Valid @RequestBody CreateNoteRequest req) {
// ...
}
Efekt: jeśli ktoś wyśle pusty title, dostanie 400 Bad Request z opisem błędu walidacji, a nie „Internal Server Error”, po którym jedyną wskazówką jest stacktrace.
Jak wygląda odpowiedź walidacji i jak ją szybko „ucywilizować”
Domyślna odpowiedź przy błędach walidacji potrafi być poprawna technicznie, ale średnio przyjazna dla człowieka (albo dla frontendu, który chce listę pól i komunikatów). Dwa warianty są najczęstsze:
- Wariant A: zostawiasz domyślne zachowanie — szybkie, OK do nauki i prostych projektów.
- Wariant B: dodajesz własny format błędu — minimalnie więcej kodu, ale od razu lepsza ergonomia API.
Obsługa błędów: domyślnie vs własne @RestControllerAdvice
Wariant 1: domyślne błędy Springa (na start: akceptowalne)
Spring Boot zwróci sensowny status (np. 400 dla walidacji, 404 dla brakującego endpointu), a w treści pojawi się opis błędu. Jeśli Twoim celem jest tylko „żeby działało” — możesz na tym poprzestać.
Minus jest praktyczny: format odpowiedzi nie jest „kontraktem”, tylko implementacją. Raz jest bardziej opisowy, raz mniej (zależy od wersji, ustawień, a czasem od tego, czy błąd był z walidacji czy z parsowania JSON).
Wariant 2: jedno miejsce na błędy w API (minimum, które robi różnicę)
Najprostszy sposób, żeby API zachowywało się przewidywalnie: @RestControllerAdvice. Złapiesz typowe wyjątki i zwrócisz jednolity JSON. Bez frameworków do frameworków.
package pl.twojadomena.api.error;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
for (FieldError fe : ex.getBindingResult().getFieldErrors()) {
// przy duplikatach zostawiamy pierwszy komunikat, żeby nie robić śmietnika
fields.putIfAbsent(fe.getField(), fe.getDefaultMessage());
}
var body = Map.<String, Object>of(
"error", "validation_failed",
"fields", fields
);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<Map<String, Object>> handleBadJson(HttpMessageNotReadableException ex) {
var body = Map.<String, Object>of(
"error", "invalid_json",
"message", "Nie da się sparsować JSON-a. Sprawdź składnię i typy pól."
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
@ExceptionHandler(Exception.class)
ResponseEntity<Map<String, Object>> handleUnexpected(Exception ex) {
// Tu nie wylewaj stacktrace do klienta. Logi są dla Ciebie.
var body = Map.<String, Object>of(
"error", "internal_error",
"message", "Coś poszło nie tak po stronie serwera."
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}
Plusy/minusy tego wariantu są bardzo „życiowe”:
- Plus: klient zawsze dostaje przewidywalny JSON; łatwiej to obsłużyć w UI i testach.
- Plus: masz jedno miejsce, żeby dopiąć sensowne komunikaty.
- Minus: da się przesadzić i zrobić z tego mini-platformę do obsługi wszystkich możliwych wyjątków. Na start nie musisz.
Kiedy który wariant ma sens
- Domyślne błędy — nauka, krótkie demo, jednorazowy endpoint do integracji, gdzie klientem jesteś tylko Ty (np. curl).
- @RestControllerAdvice — cokolwiek, co ma żyć dłużej niż weekend, mieć front albo być używane przez kogoś poza Tobą. Tak, nawet w projekcie rekrutacyjnym wygląda to dojrzale i nie wymaga tony kodu.
Szybkie testowanie endpointów: curl vs klient HTTP w IDE
Wariant 1: curl — najszybsze, gdy chcesz „widzieć HTTP”
Curl daje brutalnie prawdziwy obraz: nagłówki, statusy, body. Idealne do sanity checku, gdy coś nie gra z Content-Type albo kodem odpowiedzi.
curl -i http://localhost:8080/api/hellocurl -i -X POST http://localhost:8080/api/notes
-H "Content-Type: application/json"
-d '{"title":"Pierwsza notatka","content":"Test"}'Jeśli dostaniesz 415 Unsupported Media Type, to niemal zawsze brakuje Content-Type: application/json. Jeśli 400 i komunikat o parsowaniu — JSON jest popsuty (czasem winny bywa cudzysłów „inteligentny” skopiowany z komunikatora, bo technologia lubi żarty).
Wariant 2: HTTP Client w IDE — wygodniej do powtarzania i trzymania requestów w repo
Jeśli używasz IntelliJ, plik .http (albo odpowiednik w innym IDE) pozwala trzymać gotowe requesty obok kodu. To praktycznie takie „manualne testy”, które nie znikają po zamknięciu terminala.
### Hello
GET http://localhost:8080/api/hello
### Create note
POST http://localhost:8080/api/notes
Content-Type: application/json
{
"title": "Notatka",
"content": "Treść"
}
### Get note
GET http://localhost:8080/api/notes/1
Wybór jest prosty:
- curl — gdy debugujesz protokół i chcesz pełną kontrolę.
- HTTP Client w IDE — gdy chcesz szybko klikać, iterować i mieć zestaw requestów „pod ręką”.
Najczęstsze powody, że aplikacja nie wstaje (i jak nie spędzić tu wieczoru)
Port zajęty: klasyczne „Address already in use”
Gdy w logach widzisz, że 8080 jest zajęty, masz trzy sensowne wyjścia: ubij proces, zmień port albo odpal na innym profilu/konfiguracji. Najszybciej zmienić port w application.properties:
server.port=8081Nie ta wersja Javy
Objawy są różne: od komunikatów o „unsupported class file major version” po dziwne błędy pluginów buildu. Sprawdź:
- jaką Javą budujesz: mvn -v / ./gradlew -v,
- jaką Javą uruchamiasz w IDE (Project SDK),
- jaką wersję ustawiono w projekcie (np. w Maven/Gradle).
Najczęściej problemem jest miks: kompilujesz jedną Javą, uruchamiasz drugą.
Brak adnotacji / zły pakiet bazowy (czyli Spring „nie widzi” kontrolera)
Jeśli endpoint zwraca 404, a aplikacja działa, to często kontroler nie został zeskanowany. Dwa szybkie testy:
- czy klasa aplikacji z
public static void mainjest w pakiecie nadrzędnym (np.pl.twojadomena.api), - czy kontroler ma
@RestControlleri poprawne mapowania.
Spring skanuje komponenty „w dół” od pakietu klasy głównej. Gdy kontroler wyląduje przypadkiem w innym korzeniu pakietów, będzie jak plakat w piwnicy — istnieje, ale nikt go nie widział.
Startery „na krzyż”: MVC i WebFlux albo brak walidacji
Na start trzymaj zależności prosto:
- dla MVC: spring-boot-starter-web,
- dla WebFlux: spring-boot-starter-webflux (bez web, chyba że wiesz, po co mieszasz),
- dla walidacji: spring-boot-starter-validation.
Najbardziej zdradliwy błąd począ
