Zentrale Exception-Behandlung mit @ControllerAdvice in Spring Boot

1. Überblick

Beim Schreiben von REST APIs kann jeder Controller seine eigenen try-catch-Blöcke haben und verschiedene Error-Response-Formate zurückgeben, was zu dutzenden unterschiedlichen JSON-Error-Formaten im selben System führt. @ControllerAdvice löst dieses Problem, indem es dir erlaubt, die Exception-Behandlungslogik an einer einzigen Stelle zu definieren und auf alle (oder einige) Controller anzuwenden.

Nach dem Lesen dieses Artikels wirst du wissen, wie man einen standardmäßigen GlobalExceptionHandler schreibt, Exceptions auf passende HTTP-Status-Codes mapped, konsistente Error-Bodies zurückgibt und Tests schreibt, um sicherzustellen, dass alles korrekt funktioniert.

2. Problem bei fehlender zentraler Behandlung

Schauen wir uns einen typischen Controller an, wie man ihn oft sieht, wenn man Spring Boot gerade anfängt:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<?> getOrder(@PathVariable Long id) {
        try {
            Order order = orderService.findById(id);
            return ResponseEntity.ok(order);
        } catch (OrderNotFoundException ex) {
            Map<String, String> body = new HashMap<>();
            body.put("error", ex.getMessage());
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
        }
    }
}

Probleme:

  • Die Logik zum Konstruieren des Error-Body (Map<String, String>) wiederholt sich in jeder Methode, in jedem Controller.
  • Leicht zu vergessen, try-catch in einer neuen Methode hinzuzufügen, was zu einem 500-Fehler mit freigegebenem Stack-Trace führt.
  • Es gibt keine zentrale Stelle zur Behandlung „universeller” Exceptions wie MethodArgumentNotValidException (Validierungsfehler von @Valid).

Hinweis: Nicht jeder Fehler sollte im Controller mit try-catch abgefangen werden. Der Controller sollte nur Service-Methoden aufrufen, während die „Umwandlung von Exceptions in Responses” eine separate Verantwortung der Exception-Handling-Ebene ist.

3. Was ist @ControllerAdvice

@ControllerAdvice ist eine Annotation auf Klassenebene, die eine Klasse als zentrale Stelle zur Unterstützung (Advice) mehrerer Controller markiert — die häufigste Anwendung ist die Exception-Behandlung mit @ExceptionHandler.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
        ErrorResponse body = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                Instant.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }
}

Erklärung:

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody, wird für REST APIs verwendet, da der Body immer als JSON serialisiert wird.
  • @ExceptionHandler(OrderNotFoundException.class) registriert diese Methode zur speziellen Behandlung von OrderNotFoundException — kein try-catch mehr im OrderController nötig.
  • ErrorResponse ist ein einfacher Record, der das standardisierte Error-Format für das gesamte System definiert.

3.1. ErrorResponse und benutzerdefinierte Exception definieren

public record ErrorResponse(int status, String message, Instant timestamp) {
}
public class OrderNotFoundException extends RuntimeException {

    public OrderNotFoundException(Long orderId) {
        super("Order mit id = " + orderId + " nicht gefunden");
    }
}

OrderNotFoundException erbt von RuntimeException, daher ist keine throws-Deklaration überall nötig. Im OrderService wirfst du die Exception einfach, wenn nichts gefunden wird:

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order findById(Long id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

Der Controller wird jetzt viel kürzer:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }
}

Kein try-catch, keine manuelle ResponseEntity — der GlobalExceptionHandler kümmert sich um die komplette Umwandlung von Exception zu Response.

4. Behandlung mehrerer Exception-Typen

In der Praxis hast du viele Fehlertypen: nicht gefunden (404), ungültige Request (400), Datenkonflikt (409), Systemfehler (500). @ExceptionHandler erlaubt dir mehrere Methoden zu deklarieren, wobei jede eine oder mehrere Exceptions behandelt.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
        return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
        return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
        return buildResponse(HttpStatus.CONFLICT, "Datenkonflikt, bitte versuchen Sie es später erneut");
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
        return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Systemfehler, bitte kontaktieren Sie den Support");
    }

    private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, String message) {
        ErrorResponse body = new ErrorResponse(status.value(), message, Instant.now());
        return ResponseEntity.status(status).body(body);
    }
}

Erklärung:

  • Jeder @ExceptionHandler behandelt genau einen Fehlertyp, Spring wählt die beste Methode basierend auf der Klassenhierarchie der geworfenen Exception.
  • handleUnknown fängt Exception.class als „Sicherheitsnetz” — verhindert, dass der ursprüngliche Stack-Trace dem Client preisgegeben wird. Das sollte in fast jedem GlobalExceptionHandler sein.
  • Die Reihenfolge der Methodendeklaration spielt nicht eine Rolle; Spring wählt den Handler mit dem spezifischsten Exceptiontyp der mit der tatsächlich geworfenen Exception übereinstimmt.

Hinweis: Wenn zwei @ExceptionHandler in derselben @ControllerAdvice den gleichen Exception-Typ deklarieren, wirft Spring beim Starten der App einen Fehler wegen mehrdeutiger Mapping.

5. Validierungsfehler mit @Valid behandeln

Ein sehr häufiger Fall: Request-Body mit Bean Validation validieren. Wenn die Validierung fehlschlägt, wirft Spring automatisch MethodArgumentNotValidException, du musst sie nur fangen und neu formatieren.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
        @NotBlank(message = "customerName darf nicht leer sein") String customerName,
        @Positive(message = "amount muss größer als 0 sein") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
    return orderService.create(request);
}

Füge einen Handler für MethodArgumentNotValidException hinzu:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
    Map<String, String> fieldErrors = new LinkedHashMap<>();
    for (FieldError error : ex.getBindingResult().getFieldErrors()) {
        fieldErrors.put(error.getField(), error.getDefaultMessage());
    }

    Map<String, Object> body = new LinkedHashMap<>();
    body.put("status", HttpStatus.BAD_REQUEST.value());
    body.put("errors", fieldErrors);
    body.put("timestamp", Instant.now());

    return ResponseEntity.badRequest().body(body);
}

Erklärung:

  • ex.getBindingResult().getFieldErrors() gibt eine Liste der ungültigen Felder und zugehörigen Meldungen zurück — genau die Meldungen, die du in @NotBlank, @Positive deklariert hast.
  • LinkedHashMap wird verwendet, um die Feldverzeichnisse zu erhalten, was beim Debuggen hilft.

Eine Request ohne customerName und mit negativem amount gibt folgende Antwort:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "status": 400,
  "errors": {
    "customerName": "customerName darf nicht leer sein",
    "amount": "amount muss größer als 0 sein"
  },
  "timestamp": "2026-10-05T10:15:30.123Z"
}

6. Anwendungsbereich mit basePackages einschränken

Standardmäßig gilt @ControllerAdvice für alle Controller im Anwendungskontext. Wenn du mehrere Module hast (z.B. admin und public-api) mit unterschiedlicher Fehlerbehandlung, kannst du den Umfang begrenzen:

@RestControllerAdvice(basePackages = "com.nonladev.shop.api.admin")
public class AdminExceptionHandler {

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        ErrorResponse body = new ErrorResponse(
                HttpStatus.FORBIDDEN.value(), "Zugriff verweigert", Instant.now());
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
    }
}

basePackages wendet diesen Advice nur auf Controller im Package com.nonladev.shop.api.admin an. Neben basePackages kannst du auch annotations verwenden (nur für Controller mit einer bestimmten Annotation) oder assignableTypes (nur für bestimmte Controller-Klassen).

7. ProblemDetail verwenden (RFC 7807 Standard) — moderner Ansatz

Ab Spring Framework 6 gibt es das vordefinierte ProblemDetail, das den RFC 7807 Standard für Error-Responses implementiert — keine eigene ErrorResponse notwendig:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Order Not Found");
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

Die Response wird dann in dieser Form zurückgegeben:

{
  "type": "about:blank",
  "title": "Order Not Found",
  "status": 404,
  "detail": "Order mit id = 99 nicht gefunden",
  "instance": null,
  "timestamp": "2026-10-05T10:20:00Z"
}

ProblemDetail ist eine gute Wahl, wenn dein Team einen gemeinsamen Standard nutzen möchte und RFC 7807 folgt (z.B. einige API-Gateways und OpenAPI-Tools verstehen diesen Standard).

8. Tests für GlobalExceptionHandler schreiben

Tests sollten den richtigen Status-Code und den richtigen Body sicherstellen — verhindert, dass der Handler unbemerkt falsch geändert wird.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(GlobalExceptionHandler.class)
class OrderControllerExceptionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L))
                .willThrow(new OrderNotFoundException(99L));

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.message").value("Order mit id = 99 nicht gefunden"));
    }
}

Oder teste den Handler direkt als Unit-Test ohne Spring-Kontext:

class GlobalExceptionHandlerTest {

    private final GlobalExceptionHandler handler = new GlobalExceptionHandler();

    @Test
    void shouldMapOrderNotFoundTo404() {
        ResponseEntity<ErrorResponse> response =
                handler.handleNotFound(new OrderNotFoundException(42L));

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(response.getBody().message())
                .isEqualTo("Order mit id = 42 nicht gefunden");
    }
}

Die zweite Variante ist schneller, da MockMvc nicht geladen wird, und ist ideal, wenn du nur die Logik „Exception → Response” testen willst, nicht das Routing.

9. Häufig gemachte Fehler

  • Handler für Exception.class vergessen: Wenn unerwartete Fehler auftreten (NullPointerException, Datenbankverbindungsfehler…), erhält der Client die Standard-500-Response von Spring Boot mit Stack-Trace — das gibt interne Informationen preis. Immer einen „Catch-All”-Handler am Ende haben.
  • Sensitive Exception-Messages dem Client preisgeben: Z.B. kann JPA-Exceptions Tabellen- und Spaltennamen enthalten. Gib ex.getMessage() nicht direkt für DataIntegrityViolationException an den Client zurück, verwende stattdessen generische Meldungen.
  • Mehrere @ControllerAdvice behandeln dieselbe Exception: Es wird unklar, welcher Handler ausgeführt wird. Wenn du mehrere Advice verwendest, nutze @Order um die Reihenfolge zu definieren, oder besser: Zentralisiere gemeinsame Logik in einer Klasse.
  • @ControllerAdvice für Business-Logik missbrauchen: Advice sollte nur „Exceptions zu Responses übersetzen”, nicht Business-Logik enthalten (z.B. E-Mails versenden bei Fehler) — das gehört in Service oder Event-Listener.

Hinweis: @ControllerAdvice fängt nur Exceptions, die innerhalb von Controller-Methoden (oder aufgerufenen Services) im selben Request-Thread geworfen werden. Exceptions in anderen Threads (z.B. @Async) oder in Filtern, die vor DispatcherServlet laufen, werden nicht von @ControllerAdvice abgefangen.

10. Fazit

@ControllerAdvice in Kombination mit @ExceptionHandler trennt die Fehlerbehandlungslogik komplett vom Controller, stellt sicher, dass alle Error-Responses das gleiche Format haben, und verhindert, dass sensitive Informationen dem Client preisgegeben werden. Immer einen Handler für Exception.class als Sicherheitsnetz am Ende haben, @Valid + MethodArgumentNotValidException für Request-Validierung nutzen, und ProblemDetail in Betracht ziehen, wenn du RFC 7807 befolgen möchtest. Tests für Handler sind genauso wichtig wie Tests für Services — sie bestimmen, was der Client bei Fehlern zu sehen bekommt.

spring-bootexception-handlingcontroller-advicejavarest-api