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

0
3
Rate this post

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)

Poprzedni artykułSzybki obiad z mrożonek: jak złożyć zdrowy talerz dla cukrzyka bez stania w kuchni
Wiktoria Stępień
Dietetyczka kliniczna specjalizująca się w insulinooporności i cukrzycy typu 2. Wiktoria łączy aktualne wytyczne towarzystw diabetologicznych z praktyką gabinetową, w której na co dzień pracuje z osobami z rozchwianą glikemią i nadmiernym apetytem na słodkie. Zanim poleci przepis lub strategię żywieniową, testuje je w kuchni i konfrontuje z wynikami badań pacjentów. Stawia na prosty język, małe kroki i rozwiązania możliwe do wdrożenia po pracy, a nie tylko „na papierze”. W Bazyliowym Musie odpowiada za treści o diecie, planowaniu posiłków i mądrzejszych zamiennikach cukru.