Jak napisać własny wyjątek w Springu i obsłużyć go globalnie?

You are currently viewing Jak napisać własny wyjątek w Springu i obsłużyć go globalnie?

Wstęp

Jeśli budujesz API w Spring Boot, na pewno widziałeś domyślną odpowiedź przy błędzie. Wygląda mniej więcej tak:

{
  "timestamp": "2025-09-15T16:30:00.123+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/api/users/99"
}

Działa, ale w praktyce rzadko pomaga klientowi API. Nie mówi, co poszło nie tak, jak temu zapobiec i czy błąd dotyczy walidacji, konfliktu stanu czy po prostu braku zasobu. W realnych projektach szybko okazuje się, że potrzebujemy spójnego formatu odpowiedzi, prawidłowych kodów HTTP, jasnego komunikatu i – najlepiej – identyfikatora zdarzenia, żeby dało się łatwo dojść do logów.

W tym „Szybkim Strzale” przejmiemy nad tym kontrolę. Zdefiniujemy własny wyjątek domenowy i obsłużymy go globalnie, tak aby API zwracało czytelny JSON z właściwym statusem i niezbędnym kontekstem.

Tworzenie własnego wyjątku

Zacznijmy od podstaw – stworzenia dedykowanego wyjątku. Dzięki niemu nasz kod będzie bardziej semantyczny. Zamiast rzucać ogólnym IllegalStateException, rzucimy czymś, co dokładnie opisuje problem, np. ResourceNotFoundException.

Najczęściej własne wyjątki rozszerzają RuntimeException. Dlaczego? Ponieważ są to wyjątki niekontrolowane (unchecked), co oznacza, że nie musimy ich deklarować w sygnaturze każdej metody. To znacznie upraszcza kod.

Stwórzmy prosty wyjątek, który będzie rzucany, gdy nie znajdziemy w bazie danych szukanego zasobu:

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(value = HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

Adnotacja @ResponseStatus(HttpStatus.NOT_FOUND) to już pierwszy krok – informuje Springa, że gdy ten wyjątek dotrze do warstwy webowej, domyślnie powinien zwrócić status 404 Not Found.

Globalna obsługa błędów

Mamy już wyjątek, ale teraz chcemy zapanować nad tym, jak będzie wyglądała odpowiedź JSON. Zamiast pchać logikę obsługi błędów do każdego kontrolera z osobna (co jest bardzo złą praktyką), stworzymy jedno centralne miejsce do zarządzania wyjątkami.

Do tego celu służy adnotacja @RestControllerAdvice. Tworzymy klasę, którą oznaczamy właśnie tą adnotacją. Wtedy Spring automatycznie będzie jej używał do przechwytywania wyjątków rzuconych z dowolnego kontrolera (@RestController).

import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponseDto> handleResourceNotFoundException(ResourceNotFoundException ex) {
        // Logika tworzenia odpowiedzi
        // ...
    }
}

Wewnątrz tej klasy tworzymy metody oznaczone adnotacją @ExceptionHandler. Każda taka metoda może przyjmować jako argument konkretny typ wyjątku. W naszym przykładzie metoda handleResourceNotFoundException zostanie wywołana za każdym razem, gdy w aplikacji zostanie rzucony ResourceNotFoundException.

Spersonalizowana odpowiedź błędu

Aby nasze odpowiedzi o błędach były spójne, stwórzmy dedykowaną klasę DTO (Data Transfer Object). Będzie ona reprezentować strukturę naszego JSON-a. Użycie klasy record z nowszych wersji Javy jest tutaj idealne ze względu na zwięzłość.

import java.time.LocalDateTime;

public record ErrorResponseDto(
        LocalDateTime timestamp,
        int status,
        String error,
        String message,
        String path
) {
}

Teraz możemy uzupełnić nasz globalny handler, aby zwracał odpowiedź w tej właśnie strukturze.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import java.time.LocalDateTime;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponseDto> handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
        ErrorResponseDto errorResponse = new ErrorResponseDto(
                LocalDateTime.now(),
                HttpStatus.NOT_FOUND.value(),
                HttpStatus.NOT_FOUND.getReasonPhrase(),
                ex.getMessage(),
                request.getDescription(false).replace("uri=", "")
        );
        return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND);
    }
}

Używamy obiektu WebRequest, aby dynamicznie pobrać ścieżkę (path), pod którą wystąpił błąd.

Spring Boot 3 – klasa ProblemDetail

Warto wiedzieć, że od Spring Boot 3 (i Spring Framework 6) istnieje nowocześniejsze, standardowe podejście do obsługi błędów, zgodne z RFC 9457. Służy do tego klasa ProblemDetail. Zamiast tworzyć własne DTO, możemy wykorzystać gotowe rozwiązanie, które dostarcza ustandaryzowany format odpowiedzi.

W tym przypadku nasz handler będzie wyglądał nieco inaczej – zwraca bezpośrednio obiekt ProblemDetail:

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;
import java.time.Instant;

@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleResourceNotFoundException(ResourceNotFoundException ex) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problemDetail.setTitle("Zasób nie został znaleziony");
        problemDetail.setType(URI.create("/errors/not-found")); // Link do dokumentacji błędu
        problemDetail.setProperty("timestamp", Instant.now());
        return problemDetail;
    }
}

Użycie ProblemDetail sprawia, że Twoje API jest zgodne z powszechnie akceptowanym standardem, co ułatwia integrację z wieloma narzędziami i klientami, które potrafią automatycznie interpretować ten format.

Jak to wszystko działa w praktyce?

Niezależnie od wybranego podejścia (własne DTO czy ProblemDetail), kod w kontrolerze pozostaje tak samo czysty.

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    // ... konstruktor

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUserById(@PathVariable Long id) {
        UserDto user = userService.findUserById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Użytkownik o ID " + id + " nie został znaleziony."));
        return ResponseEntity.ok(user);
    }
}

Jeśli użytkownik nie istnieje, handler przechwyci wyjątek, a klient API otrzyma elegancką, ustandaryzowaną odpowiedź (w tym przypadku dla ProblemDetail):

{
    "type": "/errors/not-found",
    "title": "Zasób nie został znaleziony",
    "status": 404,
    "detail": "Użytkownik o ID 10 nie został znaleziony.",
    "instance": "/api/users/10",
    "timestamp": "2025-09-15T12:01:15.452Z"
}

Jak testować własny wyjątek?

Stworzyliśmy logikę, ale jak się upewnić, że działa ona poprawnie? Pisząc testy! W przypadku kontrolerów Springa idealnie nadaje się do tego MockMvc. Nasz test sprawdzi, czy wołając endpoint dla nieistniejącego użytkownika, faktycznie otrzymamy status 404 i odpowiednio sformatowaną odpowiedź JSON.

Do tego celu użyjemy adnotacji @WebMvcTest, która testuje warstwę webową w izolacji, oraz @MockBean do zamockowania zależności (naszego serwisu).

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.Optional;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void shouldReturn404WhenUserNotFound() throws Exception {
        // Given
        Long userId = 10L;
        String expectedMessage = "Użytkownik o ID " + userId + " nie został znaleziony.";
        
        when(userService.findUserById(userId)).thenReturn(Optional.empty());

        // When & Then
        mockMvc.perform(get("/api/users/{id}", userId))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.status").value(404))
                .andExpect(jsonPath("$.message").value(expectedMessage));
    }
}

W teście symulujemy sytuację, w której UserService zwraca pusty Optional, a następnie weryfikujemy, czy odpowiedź HTTP ma status 404 Not Found i czy ciało odpowiedzi zawiera oczekiwane przez nas pola (status i message).

Wskazówki

Z praktyki mogę podrzucić kilka cennych rad, które oszczędzą Ci czasu i frustracji:

Nie przesadzaj z liczbą wyjątków. Często wystarczy jeden BusinessException z errorCode (i ewentualnie HttpStatus). Osobne klasy tylko wtedy, gdy realnie różni się semantyka lub wymagany status.

Używaj sensownych nazw i komunikatów. UserNotFoundException mówi wszystko – Exception1 nie mówi nic. Komunikat krótki i techniczny, zrozumiały dla devów, bez wrażliwych danych.

Loguj błędy we właściwym miejscu. Rób to w globalnym handlerze: przewidywalne błędy domenowe loguj jako INFO/WARN (bez stacktrace), niespodzianki jako ERROR z pełnym stacktrace. Dodaj kontekst (np. userId, resourceId).

Testuj obsługę błędów. Sprawdź w testach serwisów, że odpowiednie wyjątki są rzucane, a w @WebMvcTest/MockMvc – że handler zwraca właściwy status i format JSON. To trzyma spójność na dłużej.

Uważaj na szczegóły w odpowiedziach. Nie wypychaj stacktrace, nazw tabel ani zapytań SQL do klienta. Takie rzeczy zostają w logach. W odpowiedzi trzymaj się errorCode + zwięzły message (i ewentualnie timestamp/path).

Podsumowanie

Własne wyjątki + globalny handler w @RestControllerAdvice to prosty fundament, który porządkuje całe API. Zyskujesz czyste kontrolery, jeden punkt prawdy dla błędów i przewidywalny JSON (idealnie w formacie ProblemDetail). Semantyczne typy, takie jak ResourceNotFoundException, mówią wprost, co się stało, a Ty masz pełną kontrolę nad statusem, treścią i logowaniem.

Wdrożenie własnych wyjątków i ich obsługa jest bardzo prosta, a efekt jaki dają jest naprawdę duży. Mniej try–catch w kodzie, czysta komunikacja z klientem API i prostsze utrzymanie. Jeśli jeszcze tego nie masz u siebie, polecam spróbować – zajmie Ci chwilę, a kod od razu stanie się czystszy.

Subskrybuj
Powiadom o
guest
0 komentarzy
Najstarsze
Najnowsze Najwięcej głosów
Opinie w linii
Zobacz wszystkie komentarze