Gestion centralisée des exceptions avec @ControllerAdvice

1. Aperçu

Quand vous écrivez une REST API, si chaque controller effectue son propre try-catch et retourne une réponse d’erreur à sa façon, vous aurez des dizaines de formats JSON d’erreur différents dans le même système. @ControllerAdvice résout ce problème en vous permettant de définir la logique de gestion des exceptions à un seul endroit, applicable à tous (ou à certains) controllers.

Après avoir lu cet article, vous saurez comment écrire un GlobalExceptionHandler standard, mapper les exceptions vers le bon statut HTTP, retourner un corps d’erreur cohérent, et écrire des tests pour vérifier qu’il fonctionne correctement.

2. Le problème sans gestion centralisée

Regardons un controller “typique” quand on découvre Spring Boot :

@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);
        }
    }
}

Les problèmes :

  • La logique de construction du corps d’erreur (Map<String, String>) se répète dans chaque méthode, chaque controller.
  • Il est facile d’oublier try-catch dans une nouvelle méthode, ce qui entraîne une erreur 500 avec la stack trace exposée au client.
  • Il n’y a pas d’endroit unique pour gérer les exceptions “globales” comme MethodArgumentNotValidException (erreur de validation @Valid).

Note : Toute erreur ne doit pas être capturée par try-catch dans le controller. Le controller devrait juste appeler le service, tandis que “transformer l’exception en réponse” est la responsabilité d’une couche dédiée à la gestion des exceptions.

3. Qu’est-ce que @ControllerAdvice ?

@ControllerAdvice est une annotation au niveau de la classe qui marque une classe contenant des méthodes pour conseiller (advise) plusieurs controllers à la fois — le plus courant étant de gérer les exceptions via @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);
    }
}

Explication :

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody, utilisé pour les REST API car le corps retourné est toujours sérialisé en JSON.
  • @ExceptionHandler(OrderNotFoundException.class) enregistre cette méthode pour gérer spécifiquement le type d’exception OrderNotFoundException — plus besoin de try-catch dans OrderController.
  • ErrorResponse est un simple record qui définit le format d’erreur standardisé pour tout le système.

3.1. Définir ErrorResponse et les exceptions personnalisées

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

    public OrderNotFoundException(Long orderId) {
        super("Commande introuvable avec id = " + orderId);
    }
}

OrderNotFoundException hérite de RuntimeException, donc vous n’avez pas besoin de déclarer throws partout. Dans OrderService, vous n’avez qu’à lever l’exception quand vous ne trouvez pas :

@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));
    }
}

Le controller devient maintenant beaucoup plus concis :

@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);
    }
}

Plus de try-catch, plus de ResponseEntity manuel — GlobalExceptionHandler gère toute la conversion d’exception en réponse.

4. Gérer plusieurs types d’exceptions

En réalité, vous aurez plusieurs types d’erreurs : not found (404), validation de requête invalide (400), conflit de données (409), erreur système (500). @ExceptionHandler vous permet de déclarer plusieurs méthodes, chacune gérant une ou plusieurs exceptions.

@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, "Les données sont en conflit, veuillez réessayer");
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
        return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "Erreur système, veuillez contacter le support");
    }

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

Explication :

  • Chaque @ExceptionHandler gère un seul type d’erreur, Spring choisit automatiquement la méthode la plus appropriée en fonction de la hiérarchie de classe de l’exception levée.
  • handleUnknown capture Exception.class comme “filet de sécurité” final — évite d’exposer la stack trace originale au client. Ceci devrait toujours être présent dans tout GlobalExceptionHandler.
  • L’ordre de déclaration des méthodes ne détermine pas la priorité ; Spring choisit le handler avec le type d’exception le plus spécifique correspondant à l’exception réelle levée.

Note : Si deux @ExceptionHandler déclarent tous les deux le même type d’exception dans le même @ControllerAdvice, Spring signalera une erreur de mapping ambigu au démarrage.

5. Gérer les erreurs de validation avec @Valid

Une situation très courante : valider le corps de la requête avec Bean Validation. Quand la validation échoue, Spring lève automatiquement MethodArgumentNotValidException, vous n’avez qu’à la capturer et la reformater pour la lisibilité.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
        @NotBlank(message = "customerName ne doit pas être vide") String customerName,
        @Positive(message = "amount doit être supérieur à 0") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
    return orderService.create(request);
}

Ajouter un handler pour MethodArgumentNotValidException :

@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);
}

Explication :

  • ex.getBindingResult().getFieldErrors() retourne la liste des champs invalides et leur message correspondant — exactement le message que vous avez déclaré dans @NotBlank, @Positive.
  • Utilisez LinkedHashMap pour préserver l’ordre des champs, plus facile à déboguer.

Envoyer une requête sans customerName et avec un amount négatif retournera :

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

{
  "status": 400,
  "errors": {
    "customerName": "customerName ne doit pas être vide",
    "amount": "amount doit être supérieur à 0"
  },
  "timestamp": "2026-10-05T10:15:30.123Z"
}

6. Limiter la portée avec basePackages

Par défaut, @ControllerAdvice s’applique à tous les controllers dans le contexte de l’application. Si vous avez plusieurs modules (par exemple admin et public-api) nécessitant une gestion d’erreur différente, vous pouvez limiter la portée :

@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(), "Accès non autorisé", Instant.now());
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
    }
}

basePackages applique ce conseil uniquement aux controllers du package com.nonladev.shop.api.admin. En plus de basePackages, vous pouvez aussi utiliser annotations (s’appliquer uniquement aux controllers ayant une annotation spécifique) ou assignableTypes (s’appliquer uniquement à certaines classes de controller).

7. Utiliser ProblemDetail (norme RFC 7807) — l’approche moderne

À partir de Spring Framework 6, ProblemDetail implémente la norme RFC 7807 pour les réponses d’erreur — pas besoin de définir vous-même ErrorResponse :

@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;
    }
}

La réponse aura la forme :

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

ProblemDetail est un bon choix si votre équipe veut suivre une norme commune, facile à intégrer avec les clients/outils qui comprennent RFC 7807 (par exemple certaines API gateway, outils OpenAPI).

8. Écrire des tests pour GlobalExceptionHandler

Les tests doivent vérifier le bon code de statut et le bon corps — éviter que le handler soit modifié incorrectement sans que personne ne le remarque.

<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("Commande introuvable avec id = 99"));
    }
}

Ou tester directement le handler au niveau unitaire, sans besoin du contexte Spring :

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("Commande introuvable avec id = 42");
    }
}

La deuxième approche est plus rapide car vous n’avez pas besoin de charger MockMvc, appropriée si vous voulez juste vérifier la logique de mapping exception → réponse, indépendamment du routage.

9. Erreurs courantes

  • Oublier le handler pour Exception.class : quand une erreur imprévisible se produit (NullPointerException, erreur de connexion DB…), le client reçoit une réponse 500 avec la stack trace par défaut de Spring Boot — exposant des informations internes. Ayez toujours un handler “catch-all” à la fin.
  • Exposer des messages d’exception contenant des détails sensibles : par exemple, une exception de JPA peut contenir des noms de tables, des noms de colonnes. Ne retournez pas ex.getMessage() directement de DataIntegrityViolationException au client, utilisez un message générique.
  • Plusieurs @ControllerAdvice gérant la même exception : facile de comprendre confus car il n’est pas clair lequel s’exécute. Si vous utilisez plusieurs advisors, utilisez @Order pour définir l’ordre, ou mieux encore, rassemblez la logique commune dans une seule classe.
  • Abuser de @ControllerAdvice pour la logique métier : l’advisor devrait juste “traduire” les exceptions en réponses, ne pas contenir de logique métier (par exemple envoyer un email de notification d’erreur) — cela devrait être dans le service ou un event listener dédié.

Note : @ControllerAdvice ne capture que les exceptions levées dans une méthode du controller (ou les couches appelées à partir de là) dans le même thread de requête. Les exceptions levées dans un thread différent (par exemple @Async) ou dans un filtre s’exécutant avant DispatcherServlet ne seront pas capturées par @ControllerAdvice.

10. Conclusion

@ControllerAdvice associé à @ExceptionHandler vous aide à séparer complètement la logique de gestion des erreurs du controller, en vous assurant que toutes les réponses d’erreur ont le même format, tout en évitant d’exposer les informations sensibles au client. Ayez toujours un handler qui capture Exception.class comme filet de sécurité final, utilisez @Valid + MethodArgumentNotValidException pour valider les requêtes, et envisagez ProblemDetail si vous voulez suivre la norme RFC 7807. Écrire des tests pour le handler est aussi important que d’écrire des tests pour le service — car c’est là que se décide ce que le client verra en cas d’erreur.

spring-bootgestion-d-exceptioncontroller-advicejavarest-api