Jak zacząć naukę Spring Boot od zera i zbudować pierwszą aplikację REST w Javie

1
116
3.7/5 - (3 votes)

Nawigacja:

Scenka startowa: od „czystej” Javy do pierwszego endpointu

Wyobraź sobie juniora, który spokojnie radzi sobie z kolekcjami, klasami i interfejsami w Javie, ale gdy ktoś rzuca hasło „Spring Boot”, widzi tylko gąszcz adnotacji i plików konfiguracyjnych. Odpala przykład z internetu, coś działa, ale nie ma pojęcia, dlaczego. Kiedy trzeba zmienić najmniejszy fragment kodu, wszystko się sypie – a w logach pojawia się ściana komunikatów, z których niewiele wynika.

Tak wygląda typowe wejście w Spring Boot bez zrozumienia podstaw. Z drugiej strony istnieje świat „starej szkoły” – ręczne stawianie Tomcata, serwlety, pliki web.xml, mozolne konfigurowanie wszystkiego samodzielnie. Spring Boot obiecuje: „jedno kliknięcie i masz gotową, działającą aplikację webową”. Działa to zaskakująco dobrze, ale tylko wtedy, gdy wiesz, co jest „pod spodem” i co robi za ciebie framework.

Cel jest prosty: od zera dojść do działającej aplikacji REST w Spring Boot – zrozumieć minimalny zestaw pojęć, zbudować kontroler, warstwę serwisu i prostą „pamięć” danych, a przy tym nie utknąć na typowych pułapkach początkujących. Kluczem jest połączenie podstaw Javy, podstaw HTTP i kilku kluczowych adnotacji Spring Boot w jedną, spójną całość.

Punkt startowy jest jasny: wystarczy solidne fundamenty z Javy, podstawowe zrozumienie, czym jest HTTP i chęć chwilowego zwolnienia tempa, żeby zamiast ślepego kopiowania tutoriali, świadomie przejść przez pierwszą aplikację REST.

Niezbędne fundamenty: co trzeba umieć z Javy, zanim dotkniesz Spring Boot

Absolutne minimum z Javy pod Spring Boot

Spring Boot nie jest frameworkiem do nauki samego języka. On zakłada, że Java jest już w miarę oswojona, przynajmniej na poziomie podstawowym. Zanim wejdziesz w adnotacje i endpointy REST, przyda się swoboda w takich obszarach jak:

  • Klasy i obiekty – pola, konstruktory, metody, modyfikatory dostępu.
  • Interfejsy – definiowanie kontraktów, implementacje, „programowanie do interfejsu”.
  • Kolekcje – List, Set, Map, podstawowe operacje na nich.
  • Wyjątki – różnica między checked i unchecked, try/catch, własne wyjątki.
  • Java 8+ – lambdy, streamy na poziomie podstawowym, Optional, podstawy dat (java.time).

W codziennej pracy z Spring Boot bardzo szybko natkniesz się na listy obiektów, mapy konfiguracji, proste przetwarzanie strumieniowe oraz obsługę wyjątków. Bez tych elementów każda metoda serwisu będzie zagadką, a stacktrace z błędem przy pierwszej próbie wywołania endpointu może skutecznie zniechęcić.

OOP i zależności między klasami a DI w Spring

Spring Boot opiera się na idei wstrzykiwania zależności (Dependency Injection, DI). W czystej Javie zależność tworzysz ręcznie:

public class OrderService {
    private final PaymentService paymentService = new PaymentService();
}

W Springu ten kod jest rzadko spotykany. Zamiast tego deklarujesz, że klasa potrzebuje innego komponentu, a framework sam dostarcza odpowiednią instancję:

@Service
public class OrderService {

    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

To działa tylko wtedy, gdy rozumiesz już koncept zależności między klasami: która klasa czego potrzebuje, gdzie powinna być logika biznesowa, a gdzie tylko delegowanie. Dependency Injection nie zastępuje myślenia o architekturze – ono tylko automatyzuje tworzenie i zarządzanie obiektami.

Konsola vs aplikacja webowa – co się zmienia

W aplikacji konsolowej cały przepływ kontrolujesz sam: od uruchomienia metody main do ostatniej linijki kodu. Program kończy się, gdy kończy się metoda main. W aplikacji webowej jest inaczej – startujesz serwer, który „nasłuchuje” żądań HTTP i reaguje na nie w momencie, gdy nadejdą.

To oznacza, że:

  • nie ma jednego prostego „flow” programu – zamiast tego są requesty i odpowiedzi,
  • część aplikacji odpowiada za przyjmowanie żądań (kontrolery),
  • część za logikę (serwisy),
  • a część za dane (repozytoria, baza danych).

Spring Boot uruchamia w środku wbudowany serwer (np. Tomcat), który nasłuchuje na porcie (np. 8080) i wywołuje odpowiednie metody kontrolerów na podstawie adresu URL i metody HTTP.

HTTP i REST – krótki fundament, bez którego się nie ruszysz

REST to nie jest magia Spring Boot. To po prostu styl budowania API w oparciu o protokół HTTP. W praktyce wystarczy kilka kluczowych pojęć:

  • Metody HTTP – GET (pobieranie), POST (tworzenie), PUT/PATCH (aktualizacja), DELETE (usuwanie).
  • URL – ścieżki, np. /api/users, /api/users/1.
  • Statusy – 200 (OK), 201 (Created), 400 (Bad Request), 404 (Not Found), 500 (Internal Server Error).
  • Body – dane przesyłane najczęściej w JSON.

Znając te podstawy, kontrolery REST w Spring Boot stają się dużo bardziej intuicyjne: widzisz @GetMapping i od razu rozumiesz, że ktoś będzie coś pobierał, @PostMapping – że tworzył, a kod statusu 404 nie jest już tajemniczym błędem, tylko jasnym sygnałem: „nie znaleziono zasobu”.

Zbliżenie ekranu z kolorowym kodem programistycznym
Źródło: Pexels | Autor: Pixabay

Pierwsze spotkanie ze Spring Boot: o co chodzi z tą „magią”

Spring i Spring Boot – jak to się łączy

Spring jako framework to ogromny ekosystem: DI, AOP, obsługa danych, bezpieczeństwo, integracje – można się w tym zgubić. Spring Boot powstał, żeby uprościć start nowej aplikacji. Zamiast ręcznego ustawiania dziesiątek zależności i konfiguracji, dostajesz:

  • domyślne ustawienia dla typowych przypadków,
  • zestawy zależności pogrupowane w tzw. Spring Boot Starters,
  • wbudowany serwer aplikacji (np. Tomcat),
  • mechanizm automatycznej konfiguracji (auto-configuration).

Dzięki temu pierwszy projekt Spring Boot można postawić w kilka minut, zamiast walczyć z plikami XML czy konfiguracją serwera.

Spring Boot Starters – koniec „piekła konfiguracji”

Startery to specyficzne zależności Maven/Gradle, które zawierają w sobie zestawy innych bibliotek dobranych pod konkretny cel. Na przykład:

  • spring-boot-starter-web – wszystko, czego potrzeba do REST API (Spring MVC, Jackson, wbudowany Tomcat),
  • spring-boot-starter-data-jpa – Spring Data JPA i Hibernate do pracy z bazą danych,
  • spring-boot-starter-test – biblioteki do testów jednostkowych i integracyjnych.

Zamiast samodzielnie dobierać wersje kilkunastu zależności, dodajesz jeden starter i Spring Boot dobiera restę za ciebie. To eliminuje wiele problemów z niekompatybilnymi wersjami bibliotek i przyspiesza start projektu.

Kluczowe adnotacje na start

Początkujący często widzą całą ścianę adnotacji i przestają rozumieć, co robi kod. Na start wystarczy kilka z nich:

  • @SpringBootApplication – na klasie głównej; informuje Spring Boot, że tu jest punkt startowy aplikacji, włącza skanowanie komponentów i auto-konfigurację.
  • @RestController – mówi, że dana klasa wystawia endpointy HTTP i każda metoda zwraca dane bezpośrednio w odpowiedzi (domyślnie JSON).
  • @GetMapping, @PostMapping, @PutMapping, @DeleteMapping – mapowanie konkretnej metody HTTP i ścieżki URL na metodę w kontrolerze.
  • @Service – oznaczenie klasy jako serwisu z logiką biznesową.
  • @Repository – komponent odpowiedzialny za dostęp do danych (np. baza, pamięć).

Znając tylko te adnotacje, można już zbudować pełnoprawne REST API: kontroler, serwis i warstwę dostępu do danych.

Kontener IoC – co dzieje się przy starcie aplikacji

Kiedy uruchamiasz metodę main w klasie oznaczonej @SpringBootApplication, Spring Boot tworzy tzw. kontener IoC (Inversion of Control). W praktyce oznacza to, że:

  • skanuje pakiety w poszukiwaniu klas z adnotacjami typu @RestController, @Service, @Repository,
  • tworzy ich instancje (tzw. beany),
  • łączy je ze sobą według deklarowanych zależności (np. przez konstruktor),
  • uruchamia wbudowany serwer HTTP.

Od tej chwili nie ty tworzysz obiekty ręcznie – robisz to tylko pośrednio, deklarując je jako komponenty i zależności. To duża zmiana myślenia, ale w zamian zyskujesz mniej kodu „klejącego” i lepsze możliwości testowania.

Mini-wniosek: mniej kopiuj-wklej, więcej zrozumienia

Zrozumienie, że Spring Boot to przede wszystkim kontener zarządzający obiektami i ich konfiguracją, odcina dużą część frustracji. Każdy starter, każda adnotacja ma konkretne zadanie. Zamiast ślepo powielać przykład z tutoriala, lepiej zadać jedno pytanie: „co ta adnotacja mówi Springowi o tej klasie/metodzie?”. To podejście procentuje przy każdym kolejnym projekcie.

Zakładamy warsztat: narzędzia i konfiguracja środowiska krok po kroku

Wybór IDE: IntelliJ, Eclipse czy VS Code

Narzędzie pracy ma ogromny wpływ na tempo nauki. Do Spring Boot nada się kilka popularnych opcji:

  • IntelliJ IDEA Community – darmowa, świetne wsparcie dla Javy, wygodny refactoring, dobre wsparcie Mavena/Gradle; do większości nauki Spring Boot wystarczy.
  • IntelliJ IDEA Ultimate – płatna, lepsze wsparcie dla Springa (nawigacja po beanach, widoczność kontekstu), przyspiesza pracę w większych projektach.
  • Eclipse – darmowy, mocno rozbudowany, ale czasem cięższy w konfiguracji; wielu programistów z niego wychodziło, ale część przesiada się później na IntelliJ.
  • VS Code – lekki edytor z rozszerzeniami dla Javy i Spring Boot; dobry na szybki start, ale wymagający doinstalowania kilku pluginów.

Na pierwszy projekt Spring Boot zwykle najprościej zacząć od IntelliJ IDEA Community. Ma wygodną integrację ze Spring Initializr i dobrze współpracuje z Mavenem i Gradle.

Instalacja JDK i konfiguracja JAVA_HOME

Spring Boot 3+ wymaga Javy 17 lub nowszej (LTS). Dobrym wyborem jest OpenJDK 17 – wersja stabilna i długo wspierana. Po instalacji koniecznie sprawdź:

java -version
javac -version

W systemie warto ustawić zmienną środowiskową JAVA_HOME wskazującą na katalog z JDK. Wiele narzędzi (Maven, Gradle, IDE) korzysta właśnie z niej. Jeśli po wpisaniu w terminalu java -version pojawi się wersja 17 lub wyższa, jesteś na dobrej drodze.

Maven vs Gradle – co wybrać na początek

Spring Boot wspiera dwa główne narzędzia do budowania projektów: Maven i Gradle. Oba robią podobne rzeczy, ale różnią się filozofią i składnią.

NarzędzieGłówny plik konfiguracyjnySkładniaNa start
Mavenpom.xmlXML, bardziej deklaratywnaŁatwiejszy do czytania dla początkujących
Gradlebuild.gradleGroovy/Kotlin, bardziej „skryptowy”Większa elastyczność, ale trudniejszy na start

Dla pierwszego projektu Spring Boot najczęściej wygodniejszy będzie Maven – czytelny pom.xml pomaga zrozumieć, jakie dokładnie zależności znajdują się w projekcie. Gdy poczujesz się pewniej, bez problemu przerzucisz się na Gradle, jeśli będziesz tego potrzebować.

Lombok – wygoda, ale i potencjalne źródło frustracji

Lombok to biblioteka generująca automatycznie gettery, settery, konstruktory czy buildery na podstawie adnotacji. Brzmi świetnie, ale ma haczyk: wymaga dobrze skonfigurowanego IDE. Typowa sytuacja początkującego:

  • kod się kompiluje,
  • ale IDE pokazuje błędy w klasach z @Getter/@Setter,
  • podpowiedzi nie działają, a wszystko wygląda „na czerwono”.

Żeby uniknąć takich problemów:

  • zainstaluj plugin Lombok w IDE (np. w IntelliJ: Settings → Plugins → Lombok),
  • Konfiguracja projektu w IDE i pierwsze uruchomienie

    Przychodzi ten moment: projekt utworzony przez Spring Initializr jest już w katalogu, IDE wszystko wczytało, ale po wciśnięciu „Run” pojawia się czerwony stos błędów. Zamiast pierwszego sukcesu – rozczarowanie i pytanie, co poszło nie tak. Kilka prostych kroków na starcie oszczędza takich niespodzianek.

    Po zaimportowaniu projektu do IDE dobrze jest przejść krótką checklistę:

  • czy projekt buduje się z poziomu Mavena/Gradle (mvn clean install lub ./mvnw spring-boot:run),
  • czy w drzewie katalogów widać standardową strukturę src/main/java i src/test/java,
  • czy klasa z metodą main jest oznaczona @SpringBootApplication i znajduje się w „głównym” pakiecie projektu.

Jeśli wszystko wygląda sensownie, można spróbować uruchomić aplikację z IDE. W IntelliJ wystarczy otworzyć klasę główną (np. DemoApplication) i kliknąć zieloną strzałkę obok metody main. W konsoli powinna pojawić się seria logów, zakończona komunikatem w stylu:

Started DemoApplication in 2.345 seconds (JVM running for 2.789)

To sygnał, że kontener Springa wstał, wbudowany serwer (zwykle Tomcat) nasłuchuje, a aplikacja jest dostępna pod adresem http://localhost:8080. Jeśli port jest zajęty (częsty przypadek, gdy działa inne API), logi pokażą błąd z informacją o konflikcie portu – wtedy można zmienić port w konfiguracji.

Dobry nawyk od pierwszego projektu: gdy coś nie działa, najpierw sprawdź logi Spring Boot w konsoli. Często jasno wskazują przyczynę: brakująca zależność, konflikt portu, błąd w konfiguracji bazy.

Plik application.properties / application.yml – pierwsze ustawienia

Prędzej czy później pojawia się potrzeba zmiany portu, konfiguracji logowania czy podpięcia bazy danych. Tym wszystkim zarządza plik konfiguracyjny Spring Boot – najczęściej application.properties lub application.yml w katalogu src/main/resources.

Na start wygodniejszy jest application.properties, bo ma prostą składnię klucz=wartość. Przykładowe minimalne ustawienia:

# zmiana portu HTTP
server.port=8081

# poziom logów
logging.level.root=INFO
logging.level.org.springframework.web=DEBUG

Po zapisaniu pliku i ponownym uruchomieniu aplikacji Spring Boot automatycznie zastosuje nowe wartości. Nie trzeba kompilować projektu ręcznie ani niczego „odświeżać” – konfiguracja jest wczytywana przy starcie.

Mini-wniosek: zanim dodasz kolejną adnotację lub bibliotekę, często wystarczy pojedyncza linijka w application.properties, żeby osiągnąć efekt, o który chodzi.

Laptop z edytorem kodu Java Spring Boot na biurku obok kubka kawy
Źródło: Pexels | Autor: Daniil Komov

Tworzenie projektu Spring Boot od zera: Initializr i struktura katalogów

Spring Initializr – szybki start bez ręcznego klejenia pom.xml

Wyobraź sobie, że musisz ręcznie pisać pom.xml, ustawiać wersję Javy, dodawać wszystkie zależności, tworzyć strukturę katalogów i klasę główną. Da się, ale cała energia idzie w konfigurację, a nie w pisanie logiki. Spring Initializr ten etap skraca do kilku kliknięć.

Na koniec warto zerknąć również na: Jak używać zadań domowych z deadline’ami, żeby motywować, a nie stresować — to dobre domknięcie tematu.

Klasyczny scenariusz wygląda tak:

  1. Wchodzisz na https://start.spring.io.
  2. Wybierasz:
    • Project: Maven Project,
    • Language: Java,
    • Spring Boot: aktualną stabilną wersję (np. 3.x),
    • Project Metadata: Group (np. pl.twoja.firma), Artifact (np. task-api).
  3. Ustawiasz Packaging na jar, a Java na 17 (lub wyżej, jeśli środowisko na to pozwala).
  4. W sekcji Dependencies dodajesz:
    • Spring Web – do budowy REST API,
    • opcjonalnie Spring Data JPA i H2 Database – jeśli planujesz CRUD z bazą.
  5. Klikasz „Generate” i pobierasz archiwum ZIP z gotowym szkieletem projektu.

Po rozpakowaniu paczki otrzymujesz minimalnie działający projekt, który wymaga tylko zaimportowania do IDE i uruchomienia. Reszta to już tworzenie własnych klas.

Struktura katalogów – co gdzie leży

Na pierwszy rzut oka w nowo utworzonym projekcie jest kilka katalogów i plików, które nic nie mówią. W praktyce ten układ jest spójny praktycznie we wszystkich projektach Spring Boot i dobrze go oswoić od razu.

projekt/
 ├─ src/
 │   ├─ main/
 │   │   ├─ java/
 │   │   │   └─ pl/twoja/firma/taskapi/
 │   │   │        └─ TaskApiApplication.java
 │   │   └─ resources/
 │   │        ├─ application.properties
 │   │        └─ static/ (opcjonalnie)
 │   └─ test/
 │       └─ java/
 │            └─ pl/twoja/firma/taskapi/
 ├─ pom.xml
 └─ mvnw / mvnw.cmd (skrypty Mavena)

Najważniejsze elementy na start:

  • TaskApiApplication.java – klasa z metodą main, oznaczona @SpringBootApplication, punkt wejścia do aplikacji.
  • katalog pl/twoja/firma/taskapi/ – miejsce na twoje klasy: kontrolery, serwisy, encje.
  • application.properties – wspomniany już plik konfiguracyjny.
  • pom.xml – definicja zależności i ustawień Mavena.

Jedna praktyczna zasada: nie twórz klas poza pakietem głównym (tym, w którym leży klasa z @SpringBootApplication). Spring domyślnie skanuje pakiety potomne od tej klasy w dół. Jeśli stworzysz kontroler w innym drzewie pakietów, kontener może go po prostu nie znaleźć.

Rozsądny podział na pakiety w małej aplikacji

Nawet w małym projekcie lepiej od początku wprowadzić minimalny porządek. Jedna klasa „wszystko robiąca” kusi, ale bardzo szybko robi się z niej chaos. Spokojny, prosty podział wygląda często tak:

pl.twoja.firma.taskapi
 ├─ controller
 ├─ service
 ├─ repository
 ├─ model
 └─ dto (opcjonalnie na później)

W praktyce oznacza to:

  • controller – klasy z @RestController, obsługa żądań HTTP, zwracanie odpowiedzi,
  • service – logika biznesowa, metody operujące na danych (np. „utwórz zadanie”, „zakończ zadanie”),
  • repository – warstwa dostępu do danych (dla JPA: interfejsy rozszerzające JpaRepository),
  • model – klasy reprezentujące dane domenowe (np. encje JPA).

Mini-wniosek: im wcześniej rozdzielisz odpowiedzialności między pakietami, tym mniej refaktoryzacji czeka cię później, gdy aplikacja urośnie.

Pierwszy działający kontroler REST: od pustego projektu do odpowiedzi JSON

Najprostszy możliwy endpoint – „Hello REST”

W pewnym momencie pojawia się pytanie: „OK, aplikacja działa, ale gdzie jest ta część, która odpowiada na HTTP?”. Odpowiedzią jest kontroler REST. Na początek wystarczy jedna klasa z jedną metodą.

Utwórz pakiet controller i w nim klasę GreetingController:

package pl.twoja.firma.taskapi.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {

    @GetMapping("/hello")
    public String hello() {
        return "Witaj w swoim pierwszym API REST!";
    }
}

Po ponownym uruchomieniu aplikacji otwórz przeglądarkę lub narzędzie typu Postman/Insomnia i wywołaj:

GET http://localhost:8080/hello

Odpowiedź powinna zawierać zwrócony tekst. To jeszcze nie jest JSON, ale pokazuje cały przepływ: żądanie trafia na ścieżkę /hello, Spring znajduje pasującą metodę w kontrolerze i zwraca rezultat do klienta.

Prosty endpoint zwracający JSON

Tekst jest dobry na start, ale w praktyce w API operujesz na strukturach danych – najczęściej w JSON. Spring Boot, dzięki Jacksonowi (dołączonemu przez spring-boot-starter-web), automatycznie serializuje obiekty Javy do JSON.

Dodaj prostą klasę modelu, np. Greeting w pakiecie model:

package pl.twoja.firma.taskapi.model;

public class Greeting {

    private String message;
    private String author;

    public Greeting(String message, String author) {
        this.message = message;
        this.author = author;
    }

    public String getMessage() {
        return message;
    }

    public String getAuthor() {
        return author;
    }
}

Następnie zmodyfikuj kontroler tak, by zwracał obiekt:

package pl.twoja.firma.taskapi.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import pl.twoja.firma.taskapi.model.Greeting;

@RestController
public class GreetingController {

    @GetMapping("/greeting")
    public Greeting greeting() {
        return new Greeting("Pierwszy JSON z Spring Boot", "System");
    }
}

Wywołanie:

GET http://localhost:8080/greeting

zwróci coś w tym stylu:

{
  "message": "Pierwszy JSON z Spring Boot",
  "author": "System"
}

Żadnej ręcznej konwersji, żadnej zabawy w parsowanie – Spring i Jackson robią to za kulisami.

Parametry w ścieżce i query string – dynamiczne odpowiedzi

Statyczna odpowiedź szybko przestaje wystarczać. Częściej potrzebujesz endpointów przyjmujących parametry, np. identyfikator zasobu lub filtr wyszukiwania. W Spring Boot realizuje się to dzięki adnotacjom @PathVariable i @RequestParam.

Dobrym uzupełnieniem będzie też materiał: Jak projektować aplikacje cloud-native w Javie? — warto go przejrzeć w kontekście powyższych wskazówek.

Prosty przykład z parametrem ścieżki:

@GetMapping("/greeting/{name}")
public Greeting greetingByName(@PathVariable String name) {
    return new Greeting("Cześć, " + name, "System");
}

Wywołanie:

GET http://localhost:8080/greeting/Ania

zwróci JSON z komunikatem powitalnym dla konkretnego imienia.

Parametr zapytania (query) wygląda podobnie:

@GetMapping("/greeting-query")
public Greeting greetingWithQuery(@RequestParam(defaultValue = "Gość") String name) {
    return new Greeting("Cześć, " + name, "System");
}

Wywołania:

GET http://localhost:8080/greeting-query
GET http://localhost:8080/greeting-query?name=Kasia

zwracają odpowiednio powitanie z domyślnym imieniem i imieniem przekazanym w URL. Ten sam mechanizm później wykorzystasz przy filtrach, paginacji czy sortowaniu.

Prosty POST – przyjmowanie danych w JSON

Scenka z życia: front-endowiec pisze UI, które ma wysyłać formularz z danymi nowego obiektu (np. zadania). Potrzebuje endpointu, który przyjmie JSON, przetworzy go i zwróci potwierdzenie. Tu wkracza @PostMapping i @RequestBody.

Załóżmy prosty model zadania w pamięci. Klasa Task:

package pl.twoja.firma.taskapi.model;

public class Task {

    private Long id;
    private String title;
    private boolean done;

    public Task() {
    }

    public Task(Long id, String title, boolean done) {
        this.id = id;
        this.title = title;
        this.done = done;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public boolean isDone() {
        return done;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public void setDone(boolean done) {
        this.done = done;
    }
}

W kontrolerze dodaj endpoint POST:

package pl.twoja.firma.taskapi.controller;

import org.springframework.web.bind.annotation.*;
import pl.twoja.firma.taskapi.model.Task;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;

@RestController
@RequestMapping("/tasks")
public class TaskController {

    private final List<Task> tasks = new ArrayList<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    @PostMapping
    public Task createTask(@RequestBody Task task) {
        task.setId(idGenerator.getAndIncrement());
        tasks.add(task);
        return task;
    }

    @GetMapping
    public List<Task> getAllTasks() {
        return tasks;
    }
}

Przykładowe wywołanie POST (np. z Postmana):

POST http://localhost:8080/tasks
Content-Type: application/json

{
  "title": "Pierwsze zadanie",
  "done": false
}

Odpowiedź:

{
  "id": 1,
  "title": "Pierwsze zadanie",
  "done": false
}

Wywołanie:

GET http://localhost:8080/tasks

zwróci listę dotychczas utworzonych zadań. Na razie przechowywane są tylko w pamięci, czyli znikną po restarcie aplikacji, ale to wystarczy, aby zrozumieć przepływ danych i strukturę prostego API.

Laptop z kodem w języku Java w ciemnym pokoju obok kubka kawy
Źródło: Pexels | Autor: Daniil Komov

Projekt przykładowy: proste API CRUD krok po kroku

Wprowadzenie operacji CRUD – pełny cykl życia zadania

W pewnym momencie lista zadań w pamięci zaczyna żyć własnym życiem: jedno zadanie trzeba oznaczyć jako wykonane, inne poprawić, jeszcze inne usunąć. Pada pytanie: „to gdzie te wszystkie operacje mają mieszkać i jak je wystawić na REST?”. Tu wjeżdża klasyczny zestaw CRUD – Create, Read, Update, Delete.

Obecny kontroler ma już CREATE (POST) i prosty READ (GET wszystkich). Brakuje odczytu pojedynczego zadania, aktualizacji oraz usuwania.

Odczyt jednego zadania po ID (READ)

Zaczyna się niewinnie: front-end potrzebuje „szczegółów zadania” po kliknięciu w listę. Endpoint musi przyjąć identyfikator i zwrócić jedno dopasowane zadanie albo błąd, jeśli go nie ma.

Rozszerz istniejący TaskController o metodę GET po ID:

@GetMapping("/{id}")
public Task getTaskById(@PathVariable Long id) {
    return tasks.stream()
            .filter(task -> task.getId().equals(id))
            .findFirst()
            .orElseThrow(() -> new RuntimeException("Task not found"));
}

Wywołanie:

GET http://localhost:8080/tasks/1

zwróci zadanie o ID = 1, jeśli takie istnieje. Błąd jest jeszcze dość „surowy” (goły RuntimeException), ale to dobry punkt wyjścia do późniejszej obsługi wyjątków.

Mini-wniosek: już na wczesnym etapie warto odróżniać operacje na wielu zasobach (/tasks) od operacji na jednym, konkretnym zasobie (/tasks/{id}).

Aktualizacja zadania (UPDATE) – pełne zastąpienie obiektu

Typowa scena z aplikacji: użytkownik pomylił się w tytule zadania albo chce je oznaczyć jako ukończone. Potrzebny jest endpoint, który przyjmie nowe dane zadania i nadpisze stare.

Do aktualizacji często używa się metody HTTP PUT. Dodaj do TaskController:

@PutMapping("/{id}")
public Task updateTask(@PathVariable Long id, @RequestBody Task updatedTask) {
    Task existingTask = tasks.stream()
            .filter(task -> task.getId().equals(id))
            .findFirst()
            .orElseThrow(() -> new RuntimeException("Task not found"));

    existingTask.setTitle(updatedTask.getTitle());
    existingTask.setDone(updatedTask.isDone());

    return existingTask;
}

Wywołanie:

PUT http://localhost:8080/tasks/1
Content-Type: application/json

{
  "title": "Poprawione zadanie",
  "done": true
}

nadpisze tytuł i status done istniejącego zadania o ID = 1.

Mini-wniosek: PUT to operacja „idempotentna” – wielokrotne wywołanie z tymi samymi danymi powinno dać ten sam efekt. Tak opłaca się projektować endpointy aktualizujące całe zasoby.

Częściowa aktualizacja (UPDATE) – PATCH dla zmiany pojedynczego pola

W praktyce bardzo często zmienia się tylko jedno pole – np. „oznacz zadanie jako ukończone”. Przesyłanie całego obiektu za każdym razem jest nieco nadmiarowe. Z pomocą przychodzi PATCH.

Przykładowy, prosty endpoint do zmiany statusu zadania:

@PatchMapping("/{id}/done")
public Task markTaskAsDone(@PathVariable Long id) {
    Task existingTask = tasks.stream()
            .filter(task -> task.getId().equals(id))
            .findFirst()
            .orElseThrow(() -> new RuntimeException("Task not found"));

    existingTask.setDone(true);
    return existingTask;
}

Wywołanie:

PATCH http://localhost:8080/tasks/5/done

ustawi done = true dla zadania o ID = 5. W wersji produkcyjnej często przyjmuje się ciało żądania z polami do zmiany, ale na start minimalistyczny wariant jest czytelniejszy.

Usuwanie zadania (DELETE) – koniec cyklu

Lista zadań bez usuwania szybko zamienia się w śmietnik. Endpoint HTTP DELETE porządkuje temat.

Dodaj do kontrolera:

@DeleteMapping("/{id}")
public void deleteTask(@PathVariable Long id) {
    tasks.removeIf(task -> task.getId().equals(id));
}

Wywołanie:

DELETE http://localhost:8080/tasks/1

usunie zadanie o ID = 1 z listy w pamięci. Spring domyślnie zwróci kod statusu 200 lub 204 (w zależności od konfiguracji i typu zwracanej wartości).

Mini-wniosek: nazwy ścieżek pozostają spójne (/tasks/{id}), a to metoda HTTP (GET, PUT, PATCH, DELETE) mówi, co dzieje się z zasobem.

Rozdzielenie logiki – wprowadzenie serwisu TaskService

Po dodaniu kilku metod w kontrolerze zaczyna się pojawiać zgrzyt: obsługa HTTP miesza się z operacjami na liście, wyszukiwaniem i prostą „logiką biznesową”. Pierwszy krok do porządku to wydzielenie serwisu.

Warto też podejrzeć, jak ten temat rozwija Programista Java – kursy online, blog i praktyczne projekty — znajdziesz tam więcej inspiracji i praktycznych wskazówek.

Utwórz pakiet service i w nim klasę TaskService:

package pl.twoja.firma.taskapi.service;

import org.springframework.stereotype.Service;
import pl.twoja.firma.taskapi.model.Task;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;

@Service
public class TaskService {

    private final List<Task> tasks = new ArrayList<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    public Task createTask(Task task) {
        task.setId(idGenerator.getAndIncrement());
        tasks.add(task);
        return task;
    }

    public List<Task> getAllTasks() {
        return tasks;
    }

    public Task getTaskById(Long id) {
        return tasks.stream()
                .filter(task -> task.getId().equals(id))
                .findFirst()
                .orElseThrow(() -> new RuntimeException("Task not found"));
    }

    public Task updateTask(Long id, Task updatedTask) {
        Task existingTask = getTaskById(id);
        existingTask.setTitle(updatedTask.getTitle());
        existingTask.setDone(updatedTask.isDone());
        return existingTask;
    }

    public Task markTaskAsDone(Long id) {
        Task existingTask = getTaskById(id);
        existingTask.setDone(true);
        return existingTask;
    }

    public void deleteTask(Long id) {
        tasks.removeIf(task -> task.getId().equals(id));
    }
}

Następnie „odchudź” kontroler, wstrzykując serwis przez konstruktor:

package pl.twoja.firma.taskapi.controller;

import org.springframework.web.bind.annotation.*;
import pl.twoja.firma.taskapi.model.Task;
import pl.twoja.firma.taskapi.service.TaskService;

import java.util.List;

@RestController
@RequestMapping("/tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @PostMapping
    public Task createTask(@RequestBody Task task) {
        return taskService.createTask(task);
    }

    @GetMapping
    public List<Task> getAllTasks() {
        return taskService.getAllTasks();
    }

    @GetMapping("/{id}")
    public Task getTaskById(@PathVariable Long id) {
        return taskService.getTaskById(id);
    }

    @PutMapping("/{id}")
    public Task updateTask(@PathVariable Long id, @RequestBody Task updatedTask) {
        return taskService.updateTask(id, updatedTask);
    }

    @PatchMapping("/{id}/done")
    public Task markTaskAsDone(@PathVariable Long id) {
        return taskService.markTaskAsDone(id);
    }

    @DeleteMapping("/{id}")
    public void deleteTask(@PathVariable Long id) {
        taskService.deleteTask(id);
    }
}

Mini-wniosek: kontroler odpowiada za HTTP (ścieżki, statusy, typy odpowiedzi), a serwis za „co” ma się wydarzyć z danymi. Takie rozdzielenie procentuje przy każdej większej zmianie.

Przejście z pamięci na bazę danych – pierwsze kroki z Spring Data JPA

Prosta lista w pamięci działa zaskakująco długo… do momentu, kiedy trzeba zachować dane po restarcie albo udostępnić API innym ludziom w zespole. Naturalnym kolejnym krokiem jest podpięcie bazy danych, najczęściej relacyjnej (np. PostgreSQL, MySQL, lokalnie H2).

Do integracji z bazą w Springu idealnie nadaje się Spring Data JPA. W projekcie z Mavenem upewnij się, że masz zależność:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

Do nauki wygodny jest wbudowany H2 – lekka baza w pamięci:

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Przekształcenie Task w encję JPA

Model Task trzeba teraz „nauczyć” współpracy z bazą – robi się to za pomocą adnotacji JPA.

Zmień klasę Task w pakiecie model:

package pl.twoja.firma.taskapi.model;

import jakarta.persistence.*;

@Entity
@Table(name = "tasks")
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private boolean done;

    public Task() {
    }

    public Task(String title, boolean done) {
        this.title = title;
        this.done = done;
    }

    public Long getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public boolean isDone() {
        return done;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public void setDone(boolean done) {
        this.done = done;
    }
}

Najważniejsze elementy:

  • @Entity – informacja, że klasa jest encją JPA i ma być mapowana na tabelę,
  • @Table(name = "tasks") – nazwa tabeli w bazie (jeśli jej nie podasz, JPA samo ją wygeneruje),
  • @Id + @GeneratedValue – klucz główny i strategia generowania ID.

Mini-wniosek: encja to klasa, którą JPA potrafi zapisać i odczytać z tabeli; dzięki adnotacjom nie piszesz ręcznie SQL-a przy każdej operacji CRUD.

Repository – interfejs zamiast ręcznego DAO

Najprzyjemniejszy moment w Spring Data JPA to ten, w którym odkrywasz, że większości metod nie musisz pisać samodzielnie. Wystarczy interfejs rozszerzający JpaRepository.

Utwórz pakiet repository i interfejs TaskRepository:

package pl.twoja.firma.taskapi.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import pl.twoja.firma.taskapi.model.Task;

public interface TaskRepository extends JpaRepository<Task, Long> {
}

Ten jeden interfejs daje od razu gotowe metody m.in.:

  • save(Task entity)
  • findById(Long id)
  • findAll()
  • deleteById(Long id)

Bez ani jednego zapytania SQL. Później można dodawać metody typu findByDone(boolean done), a Spring wygeneruje im implementację na podstawie nazwy.

Przepisanie TaskService na repozytorium JPA

Serwis nadal używa listy w pamięci. Czas, by operacje CRUD „spadły” na bazę danych za pośrednictwem repozytorium.

Zmień TaskService tak, aby wstrzykiwał TaskRepository:

package pl.twoja.firma.taskapi.service;

import org.springframework.stereotype.Service;
import pl.twoja.firma.taskapi.model.Task;
import pl.twoja.firma.taskapi.repository.TaskRepository;

import java.util.List;

@Service
public class TaskService {

    private final TaskRepository taskRepository;

    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    public Task createTask(Task task) {
        return taskRepository.save(task);
    }

    public List<Task> getAllTasks() {
        return taskRepository.findAll();
    }

    public Task getTaskById(Long id) {
        return taskRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Task not found"));
    }

    public Task updateTask(Long id, Task updatedTask) {
        Task existingTask = getTaskById(id);
        existingTask.setTitle(updatedTask.getTitle());
        existingTask.setDone(updatedTask.isDone());
        return taskRepository.save(existingTask);
    }

    public Task markTaskAsDone(Long id) {
        Task existingTask = getTaskById(id);
        existingTask.setDone(true);
        return taskRepository.save(existingTask);
    }

    public void deleteTask(Long id) {
        taskRepository.deleteById(id);
    }
}

Kontroler nie wymaga żadnych zmian – jego kontrakt REST pozostaje ten sam. Zmieniło się wyłącznie „zaplecze”, czyli implementacja serwisu.

Mini-wniosek: kiedy kontroler i serwis są dobrze odseparowane, wymiana warstwy danych (pamięć → baza) może odbyć się bez dotykania endpointów widocznych dla świata.

Konfiguracja bazy danych H2 w application.properties

Żeby JPA wiedziało, z jaką bazą ma pracować, trzeba dodać prostą konfigurację. Dla H2 (baza w pamięci) wystarczy kilka wpisów w src/main/resources/application.properties:

spring.datasource.url=jdbc:h2:mem:taskdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

Co tutaj się dzieje:

  • jdbc:h2:mem:taskdb – baza w pamięci o nazwie taskdb,
  • ddl-auto=update – Hibernate sam tworzy/aktualizuje strukturę tabel na podstawie encji,
  • show-sql=true – zapytania SQL będą widoczne w logach (świetne do nauki),
  • Najważniejsze punkty

  • Pewne opanowanie Javy (klasy, interfejsy, kolekcje, wyjątki, podstawy Java 8) jest warunkiem wejścia w Spring Boot – bez tego nawet prosta metoda serwisu czy stacktrace z błędem stają się nieczytelną zagadką.
  • Spring Boot nie zastępuje myślenia o architekturze: Dependency Injection tylko automatyzuje tworzenie obiektów, więc trzeba świadomie projektować zależności między klasami i wiedzieć, gdzie trzymać logikę, a gdzie samo delegowanie.
  • Przeskok z aplikacji konsolowej na webową polega na zmianie sposobu myślenia: zamiast jednego ciągłego „flow” pojawia się cykl request–response, rozdział na kontrolery, serwisy i warstwę danych oraz działający w tle serwer HTTP.
  • Znajomość podstaw HTTP i REST (metody, statusy, ścieżki URL, body w JSON) sprawia, że adnotacje typu @GetMapping czy @PostMapping przestają być „magicznymi słowami” i od razu wiadomo, jaki kontrakt API się właśnie definiuje.
  • Spring to ogromny ekosystem, a Spring Boot jest jego „skrótem startowym” – dostarcza automatyczną konfigurację, wbudowany serwer i gotowe zestawy zależności, dzięki czemu można skupić się na kodzie biznesowym zamiast na plikach XML i ręcznym stawianiu środowiska.
  • Świadome budowanie pierwszej aplikacji REST w Spring Boot polega na połączeniu kilku fundamentów: Javy, HTTP, podziału na warstwy (kontroler–serwis–repozytorium) i zrozumienia, co dokładnie Spring robi „pod spodem”.
  • Bibliografia

  • Spring Boot Reference Documentation. VMware – Oficjalna dokumentacja Spring Boot: auto-konfiguracja, startery, adnotacje
  • Spring Framework Reference Documentation. VMware – Podstawy Spring: DI, kontener IoC, komponenty, architektura aplikacji
  • RESTful Web Services. O’Reilly Media (2007) – Wprowadzenie do stylu REST, zasoby, metody HTTP, statusy
  • Java Platform, Standard Edition 8 Language Specification. Oracle (2015) – Podstawy Javy 8: klasy, interfejsy, wyjątki, lambdy, strumienie
  • Jakarta Servlet Specification. Eclipse Foundation – Serwlety, model żądań/odpowiedzi HTTP, rola kontenera (Tomcat)

1 KOMENTARZ

  1. Bardzo przydatny artykuł dla osób, które chcą rozpocząć naukę Spring Boot od podstaw. Krok po kroku wyjaśnia, jak zbudować pierwszą aplikację REST w Javie, co bardzo ułatwia zrozumienie poszczególnych kroków. Cieszę się, że natrafiłem na ten artykuł, bo teraz mam jasny punkt wyjścia do dalszego zgłębiania tej tematyki. Dzięki autorowi za klarowne wyjaśnienia!

Zaloguj się, aby zostawić komentarz.