00:00

Spring Boot Exception Handling Annotations

Spring Boot gives you a small set of annotations for turning exceptions into clean HTTP responses. Each one has a clear job: one catches an exception, one makes handlers global, one sets the status code, and one decides how the response body is written.

This page is an annotation-by-annotation reference: what each annotation does, its attributes, and how they work together. For the overall strategy, including error formats, logging and testing, see the companion guide Spring Boot Exception Handling.

1. The Annotations at a Glance

AnnotationPlaced onPurpose
@ExceptionHandlerA methodHandles one or more exception types and builds the response
@ControllerAdviceA classMakes its handler methods apply to many controllers
@RestControllerAdviceA class@ControllerAdvice + @ResponseBody, for REST APIs
@ResponseStatusAn exception class or a methodSets the HTTP status code of the response
@ResponseBodyA method or a classWrites the return value as the response body, usually JSON
@OrderAn advice classDecides which advice class is checked first

All of them except @Order, which comes from Spring’s core module, live in Spring’s web packages. They work in both Spring MVC and Spring WebFlux applications.

2. The Example Application

The examples on this page use a small course-enrollment API. Students enroll in courses, and a few things can go wrong:

CourseNotFoundException.java
public class CourseNotFoundException extends RuntimeException {

    public CourseNotFoundException(String courseCode) {
        super("Course " + courseCode + " does not exist");
    }
}
CourseFullException.java
public class CourseFullException extends RuntimeException {

    public CourseFullException(String courseCode, int capacity) {
        super("Course " + courseCode + " is full (" + capacity + " seats)");
    }
}
EnrollmentController.java
@RestController
@RequestMapping("/api/enrollments")
public class EnrollmentController {

    private final EnrollmentService enrollmentService;

    public EnrollmentController(EnrollmentService enrollmentService) {
        this.enrollmentService = enrollmentService;
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Enrollment enroll(@RequestBody EnrollmentRequest request) {
        // may throw CourseNotFoundException or CourseFullException
        return enrollmentService.enroll(request.studentId(), request.courseCode());
    }
}

3. @ExceptionHandler

@ExceptionHandler marks a method that runs when a matching exception is thrown. Placed inside a controller, it handles exceptions from that controller only:

EnrollmentController.java
@RestController
@RequestMapping("/api/enrollments")
public class EnrollmentController {

    // ... endpoints as above ...

    @ExceptionHandler(CourseFullException.class)
    public ResponseEntity<String> handleCourseFull(CourseFullException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage());
    }
}

3.1 Handling several exception types

List several classes in the annotation. The parameter must be a type they all share:

EnrollmentController.java
@ExceptionHandler({CourseNotFoundException.class, StudentNotFoundException.class})
public ResponseEntity<String> handleNotFound(RuntimeException ex) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}

3.2 Letting Spring infer the type

If the annotation has no value, Spring uses the type of the exception parameter:

EnrollmentController.java
@ExceptionHandler // handles CourseFullException, taken from the parameter
public ResponseEntity<String> handleCourseFull(CourseFullException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage());
}
AttributeMeaning
valueThe exception classes to handle; optional when the parameter type is enough
producesMedia types the handler can produce, for choosing between handlers by Accept header (Spring Framework 6.2+)

4. What a Handler Method Can Receive and Return

Handler methods are flexible. Spring supplies any of these parameters automatically, in any order:

Parameter typeWhat you get
The exception (or a supertype)The exception that was thrown
HttpServletRequest / WebRequestDetails of the current request, such as the path
HttpServletResponseDirect access to the response, for example to add headers
LocaleThe client’s language, for translated messages
HttpMethodGET, POST and so on
PrincipalThe logged-in user, if any

And it can return any of these:

Return typeResult
ResponseEntity<T>Full control over status, headers and body
ProblemDetailA standard RFC 9457 error body; the status is taken from the object
Any object + @ResponseBodyThe object is written as JSON with status 200, unless @ResponseStatus sets another
String (without @ResponseBody)A view name, for HTML error pages
voidAn empty body; combine with @ResponseStatus or write to HttpServletResponse yourself

Here is a handler that uses several parameters at once:

EnrollmentController.java
@ExceptionHandler
public ResponseEntity<ProblemDetail> handleCourseFull(CourseFullException ex,
                                                      HttpServletRequest request,
                                                      Locale locale) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    problem.setInstance(URI.create(request.getRequestURI()));
    problem.setProperty("locale", locale.toLanguageTag());

    return ResponseEntity.status(HttpStatus.CONFLICT)
            .header("X-Waitlist-Available", "true")
            .body(problem);
}

5. How Spring Chooses a Handler

When an exception is thrown, Spring looks for a handler in this order:

  1. The controller that threw it. A local @ExceptionHandler always wins over global ones.
  2. Advice classes, in their @Order, checking only those whose scope includes this controller.
  3. Built-in resolvers, which honour @ResponseStatus on the exception class and Spring’s own exception types.
  4. The /error endpoint, if nothing else handled it.

Within one class, the closest matching type wins. Given these two handlers, a CourseFullException goes to the first one, and any other RuntimeException goes to the second:

EnrollmentController.java
@ExceptionHandler(CourseFullException.class)   // more specific: chosen for CourseFullException
public ResponseEntity<String> handleCourseFull(CourseFullException ex) { ... }

@ExceptionHandler(RuntimeException.class)      // less specific: everything else
public ResponseEntity<String> handleOther(RuntimeException ex) { ... }

Spring also checks the exception’s causes. If a CourseFullException is wrapped inside another exception, the handler for CourseFullException can still match it.

6. @ControllerAdvice

@ControllerAdvice turns a class into a shared helper for controllers. Its @ExceptionHandler methods then apply to every controller, so you write each handler once:

EnrollmentExceptionHandler.java
@ControllerAdvice
public class EnrollmentExceptionHandler {

    @ExceptionHandler
    @ResponseBody
    public ResponseEntity<String> handleNotFound(CourseNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
    }

    @ExceptionHandler
    @ResponseBody
    public ResponseEntity<String> handleCourseFull(CourseFullException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage());
    }
}

Use its attributes to limit which controllers it applies to:

AttributeApplies the advice toExample
basePackagesControllers in these packagesbasePackages = "com.javabytechie.school.api"
basePackageClassesControllers in the packages of these classes (safe when packages are renamed)basePackageClasses = EnrollmentController.class
assignableTypesThese controller classes and their subclassesassignableTypes = {EnrollmentController.class}
annotationsControllers carrying this annotationannotations = RestController.class

Advice classes can also hold @InitBinder and @ModelAttribute methods, which are shared across controllers in the same way.

7. @RestControllerAdvice

Writing @ResponseBody on every handler gets repetitive. @RestControllerAdvice combines @ControllerAdvice and @ResponseBody, so every handler writes its return value as the body:

EnrollmentExceptionHandler.java
@RestControllerAdvice
public class EnrollmentExceptionHandler {

    @ExceptionHandler
    public ProblemDetail handleNotFound(CourseNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler
    public ProblemDetail handleCourseFull(CourseFullException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        problem.setTitle("Course full");
        return problem;
    }
}

This is the usual choice for REST APIs. It relates to @ControllerAdvice exactly as @RestController relates to @Controller.

8. @ResponseStatus on Exception Classes

Put @ResponseStatus on an exception class to give it a fixed HTTP status. Whenever that exception escapes a controller and no @ExceptionHandler catches it, Spring uses that status:

EnrollmentClosedException.java
@ResponseStatus(HttpStatus.FORBIDDEN)
public class EnrollmentClosedException extends RuntimeException {

    public EnrollmentClosedException(String term) {
        super("Enrollment for " + term + " has closed");
    }
}

No handler is needed. A client that enrolls too late receives 403 from Spring Boot’s standard error response:

Response
{
  "timestamp": "2026-10-04T09:30:00.000+00:00",
  "status": 403,
  "error": "Forbidden",
  "path": "/api/enrollments"
}
AttributeMeaning
value / codeThe HTTP status; the two are aliases, so use either one
reasonA fixed reason text; Spring then sends the error with sendError

The exception message is hidden by default, because Spring Boot does not include messages in error responses unless server.error.include-message=always is set. Use this annotation for simple cases. When you need a custom body, write an @ExceptionHandler instead.

9. @ResponseStatus on Handler Methods

On a handler method, @ResponseStatus sets the status of the response the method returns. It is useful when the handler returns a plain object rather than a ResponseEntity:

EnrollmentExceptionHandler.java
public record ErrorMessage(String code, String message) {
}

@RestControllerAdvice
public class EnrollmentExceptionHandler {

    @ExceptionHandler
    @ResponseStatus(HttpStatus.CONFLICT)
    public ErrorMessage handleCourseFull(CourseFullException ex) {
        return new ErrorMessage("COURSE_FULL", ex.getMessage());
    }
}

The same annotation also works on normal controller methods, such as @ResponseStatus(HttpStatus.CREATED) on the enroll() method in section 2.

10. @ResponseBody

@ResponseBody tells Spring to convert a method’s return value into the response body, using JSON by default, instead of treating it as the name of an HTML template.

Where it comes fromEffect
@ResponseBody on a handler methodThat method’s result becomes the body
@ResponseBody on the advice classEvery handler in the class writes a body
@RestControllerAdvice / @RestControllerAlready include @ResponseBody
Returning ResponseEntity or ProblemDetailAlways written as the body, even without @ResponseBody

In an application that mixes HTML pages and a REST API, a plain @ControllerAdvice can serve both: handlers without @ResponseBody return error page names, and handlers with it return JSON.

MixedExceptionHandler.java
@ControllerAdvice
public class MixedExceptionHandler {

    // For the HTML website: shows templates/error/course-not-found.html
    @ExceptionHandler
    public String handlePageNotFound(CourseNotFoundException ex, Model model) {
        model.addAttribute("message", ex.getMessage());
        return "error/course-not-found";
    }

    // For the REST API
    @ExceptionHandler
    @ResponseBody
    public ResponseEntity<ErrorMessage> handleCourseFull(CourseFullException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(new ErrorMessage("COURSE_FULL", ex.getMessage()));
    }
}

11. @Order for Multiple Advice Classes

With more than one advice class, add @Order so Spring checks them in a predictable order. A lower number means a higher priority:

EnrollmentExceptionHandler.java
@RestControllerAdvice(basePackageClasses = EnrollmentController.class)
@Order(1)
public class EnrollmentExceptionHandler {
    // specific handlers for the enrollment API
}

@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class FallbackExceptionHandler {

    @ExceptionHandler
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorMessage handleAnything(Exception ex) {
        return new ErrorMessage("INTERNAL_ERROR", "An unexpected error occurred.");
    }
}

Spring uses the first advice, in order, that has a handler for the exception. Keep the broadest handlers in the class with the lowest priority.

12. Putting It All Together

This complete example combines every annotation on the page:

SchoolApiExceptionHandler.java
package com.javabytechie.school.api.error;

import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice(annotations = RestController.class)   // REST controllers only
@Order(Ordered.HIGHEST_PRECEDENCE)                           // checked before other advice
public class SchoolApiExceptionHandler {

    @ExceptionHandler({CourseNotFoundException.class, StudentNotFoundException.class})
    public ProblemDetail handleNotFound(RuntimeException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler
    public ProblemDetail handleCourseFull(CourseFullException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        problem.setTitle("Course full");
        problem.setProperty("code", "COURSE_FULL");
        return problem;
    }

    @ExceptionHandler
    @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
    public ErrorMessage handlePrerequisite(PrerequisiteMissingException ex) {
        return new ErrorMessage("PREREQUISITE_MISSING", ex.getMessage());
    }
}

Meanwhile, EnrollmentClosedException still needs no handler at all, because its class carries @ResponseStatus(HttpStatus.FORBIDDEN).

13. Which Annotation Should You Use?

You want to...Use
Give an exception a fixed status, with no custom body@ResponseStatus on the exception class
Handle an exception that only one controller throws@ExceptionHandler inside that controller
Handle exceptions the same way across a REST API@RestControllerAdvice with @ExceptionHandler methods
Mix HTML error pages and JSON errors@ControllerAdvice, adding @ResponseBody to the JSON handlers
Return a plain object with a non-200 status@ResponseStatus on the handler method
Control which of several advice classes runs first@Order

14. Common Mistakes to Avoid

MistakeWhat goes wrongFix
Using @ControllerAdvice without @ResponseBody in a REST APISpring treats the returned object or string as a view nameUse @RestControllerAdvice
Handler parameter type does not fit all listed exceptionsThe handler itself fails with an error instead of producing a responseUse a common supertype as the parameter
Mapping NullPointerException to 400A server bug looks like the client’s faultLet unexpected errors become 500
Expecting @ResponseStatus(reason = ...) to return your own JSONSpring sends a standard error response insteadWrite an @ExceptionHandler for a custom body
Two advice classes without @OrderWhich handler runs may change between buildsAdd @Order
Expecting a handler in one controller to catch another controller’s exceptionsIt never runsMove it to an advice class

15. Summary

  • @ExceptionHandler turns a matching exception into a response; inside a controller it is local, inside an advice class it is shared.
  • @ControllerAdvice shares handlers across controllers, and its attributes limit where it applies.
  • @RestControllerAdvice is the same, plus @ResponseBody; it is the usual choice for REST APIs.
  • @ResponseStatus sets a status on an exception class or a method.
  • @ResponseBody writes the return value as the body instead of choosing a view.
  • @Order makes the order of several advice classes predictable.

Used together, they keep controllers free of try/catch blocks and give clients clear, consistent error responses. Continue with the Spring Boot Exception Handling guide for error formats, logging and testing.

16. Frequently Asked Questions (FAQ)

Q1. Why does my @ExceptionHandler return status 200 even though it handles an error?

If a handler returns a plain object, such as a Map or a record, Spring has no status to use, so it defaults to 200 OK. The client then believes the request succeeded.

Set the status in one of three ways:

// 1. @ResponseStatus on the method
@ExceptionHandler
@ResponseStatus(HttpStatus.CONFLICT)
public ErrorMessage handle(CourseFullException ex) { ... }

// 2. ResponseEntity
@ExceptionHandler
public ResponseEntity<ErrorMessage> handle(CourseFullException ex) {
    return ResponseEntity.status(HttpStatus.CONFLICT).body(...);
}

// 3. ProblemDetail, which carries its own status
@ExceptionHandler
public ProblemDetail handle(CourseFullException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
Q2. Why does an @ExceptionHandler in one controller not catch exceptions from another controller?

Because a handler inside a controller is local: it only sees exceptions thrown by that controller’s own methods. This is by design, so one controller can treat an error differently from the rest of the application.

To share a handler, move it into a class annotated with @ControllerAdvice or @RestControllerAdvice.

Q3. If a controller and an advice class both handle the same exception, which one runs?

The controller’s own handler always wins, even if the advice has a more specific match. Spring looks inside the controller first and only consults advice classes when the controller has no matching handler.

This lets you override the global behavior for one controller, for example returning a friendlier message for a public endpoint, without changing the shared advice.

Q4. Why does @ResponseStatus with a reason ignore my exception message?

When reason is set, Spring calls response.sendError(status, reason). The request then goes to Spring Boot’s standard error handling, and your exception message is not used:

@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "Course not found")
public class CourseNotFoundException extends RuntimeException { ... }
  • The reason text is fixed, so it cannot include details such as the course code.
  • Even the reason is hidden from the JSON response unless server.error.include-message=always is set.

For dynamic messages, leave out reason and handle the exception with an @ExceptionHandler instead.

Q5. What is the difference between value and code in @ResponseStatus?

None. They are aliases for the same setting, so these two lines mean exactly the same:

@ResponseStatus(HttpStatus.NOT_FOUND)
@ResponseStatus(code = HttpStatus.NOT_FOUND)

value lets you write the shorter form when the status is the only attribute. Use code when you also set reason, because it reads more clearly. Setting both to different values causes an error.

Q6. Do subclasses inherit @ResponseStatus from their parent exception?

Yes. Spring searches the class hierarchy for the annotation, so a subclass gets its parent’s status unless it declares its own:

@ResponseStatus(HttpStatus.NOT_FOUND)
public abstract class SchoolResourceNotFoundException extends RuntimeException { ... }

public class CourseNotFoundException extends SchoolResourceNotFoundException { ... }   // 404
public class StudentNotFoundException extends SchoolResourceNotFoundException { ... }  // 404

This is a tidy way to give a whole family of exceptions the same status code.

Q7. If an exception has @ResponseStatus and also an @ExceptionHandler, which applies?

The @ExceptionHandler wins. Handlers are checked before the resolver that reads @ResponseStatus on exception classes, so the annotation on the class is ignored for that exception.

The response status then comes only from the handler. If the handler returns a plain object without its own @ResponseStatus, the result is 200, not the status written on the exception class.

Q8. Does the order of @ExceptionHandler methods inside a class matter?

No. Spring does not read handlers from top to bottom. It always picks the handler whose exception type is the closest match, wherever the method appears in the class.

What you must avoid is mapping the same exception type in two methods of one class. Spring cannot choose between them and fails with an “Ambiguous @ExceptionHandler method” error:

@ExceptionHandler(CourseFullException.class)
public ProblemDetail first(CourseFullException ex) { ... }

@ExceptionHandler(CourseFullException.class)   // error: same type mapped twice
public ProblemDetail second(CourseFullException ex) { ... }
Q9. Can an @ExceptionHandler match an exception that is wrapped inside another one?

Yes. Since Spring Framework 5.3, Spring checks the exception and then each of its causes, however deeply nested:

// Somewhere deep in the code:
throw new EnrollmentFailedException("Could not enroll", new CourseFullException("JAVA101", 30));

// This handler still matches, through the cause:
@ExceptionHandler
public ProblemDetail handleCourseFull(CourseFullException ex) { ... }

A handler for the outer exception type is still preferred if one exists. The cause is only checked when nothing matches the outer exception directly.

Q10. Should I add @Component to my @ControllerAdvice class?

No. @ControllerAdvice and @RestControllerAdvice already include @Component, so component scanning picks the class up automatically. Adding @Component again does no harm, but it adds nothing.

What matters is that the class is inside a scanned package, usually the package of your @SpringBootApplication class or below it.

Q11. Can I put @ExceptionHandler methods in a @Service class?

No. @ExceptionHandler only works in controllers and advice classes, because it is part of the web layer. In a service, the annotation is simply ignored.

Services should throw meaningful exceptions; the web layer decides how they become HTTP responses. This keeps services free of HTTP details, so the same service can be reused by a scheduled job or a message listener.

Q12. Can @ControllerAdvice be used for things other than exceptions?

Yes. It can also hold @InitBinder and @ModelAttribute methods that apply to many controllers:

@ControllerAdvice
public class WebDefaults {

    // Trim whitespace from all incoming form fields
    @InitBinder
    public void trimStrings(WebDataBinder binder) {
        binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
    }

    // Make the current term available to every HTML view
    @ModelAttribute("currentTerm")
    public String currentTerm() {
        return "Autumn 2026";
    }
}

Many teams keep these in a separate class from the exception handlers, so each advice class has one clear job.

Q13. Does @RestControllerAdvice also handle exceptions from @Controller classes that return HTML?

Yes, by default. It applies to every controller, so an HTML page controller would suddenly return JSON errors, which browsers display as raw text.

In applications with both kinds of controllers, limit the REST advice to REST controllers:

@RestControllerAdvice(annotations = RestController.class)
public class ApiExceptionHandler { ... }

Page controllers then fall back to HTML error pages, while API controllers get JSON.

Q14. How can I add headers to an error response?

Return a ResponseEntity, which lets you set the status, headers and body together:

@ExceptionHandler
public ResponseEntity<ProblemDetail> handleCourseFull(CourseFullException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
    return ResponseEntity.status(HttpStatus.CONFLICT)
            .header("X-Waitlist-Url", "/api/waitlist")
            .body(problem);
}

Alternatively, add an HttpServletResponse parameter and call response.setHeader(...), which works with any return type.

Q15. Can an @ExceptionHandler method return void?

Yes. Combine it with @ResponseStatus to send a status with an empty body:

@ExceptionHandler
@ResponseStatus(HttpStatus.NO_CONTENT)
public void handleAlreadyEnrolled(AlreadyEnrolledException ex) {
    // nothing to return: the student is already enrolled, so there is no change
}

This fits cases where the status alone says everything. If you need a body, return an object instead.

Q16. When should I use ResponseStatusException instead of @ResponseStatus?
@ResponseStatus on a classResponseStatusException
New exception class neededYesNo
Status chosenFixed, when you write the classAt the moment you throw it
MessageFixed reason, or hiddenCan include details, such as an ID
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Course " + code + " does not exist");

Use ResponseStatusException for quick, one-off errors in a controller. For errors that appear in many places, a named exception class is clearer and easier to search for.

Q17. Can I return JSON or XML from a handler depending on what the client asks for?

Yes. If the handler returns an object or ResponseEntity, Spring picks the format from the client’s Accept header automatically, as long as a converter for that format is available, for example jackson-dataformat-xml for XML.

Since Spring Framework 6.2, you can also write separate handlers per format with the produces attribute:

@ExceptionHandler(produces = "application/json")
public ResponseEntity<ErrorMessage> handleJson(CourseFullException ex) { ... }

@ExceptionHandler(produces = "text/html")
public String handleHtml(CourseFullException ex, Model model) {
    model.addAttribute("message", ex.getMessage());
    return "error/course-full";
}
Q18. How do I unit test an advice class without starting the whole application?

Use MockMvc in standalone mode. You pass in the controller and the advice yourself, and no Spring context is started, so the test runs in milliseconds:

class EnrollmentExceptionHandlerTest {

    private final EnrollmentService service = mock(EnrollmentService.class);

    private final MockMvc mockMvc = MockMvcBuilders
            .standaloneSetup(new EnrollmentController(service))
            .setControllerAdvice(new EnrollmentExceptionHandler())
            .build();

    @Test
    void returns409WhenCourseIsFull() throws Exception {
        when(service.enroll(7L, "JAVA101")).thenThrow(new CourseFullException("JAVA101", 30));

        mockMvc.perform(post("/api/enrollments")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"studentId\": 7, \"courseCode\": \"JAVA101\"}"))
                .andExpect(status().isConflict());
    }
}
Q19. Do these annotations work in Spring WebFlux?

Yes. @ExceptionHandler, @ControllerAdvice, @RestControllerAdvice, @ResponseStatus and @ResponseBody are the same annotations, from the same packages, in both Spring MVC and WebFlux.

The differences are in the handler parameters and return types. Use ServerWebExchange instead of servlet types, and you may return Mono<ResponseEntity<T>>:

@ExceptionHandler
public Mono<ResponseEntity<ErrorMessage>> handleCourseFull(CourseFullException ex) {
    return Mono.just(ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ErrorMessage("COURSE_FULL", ex.getMessage())));
}
Q20. Is it safe to use @ExceptionHandler(Exception.class) inside a single controller?

It is legal, but usually a poor idea. It catches every error from that controller, including Spring’s own exceptions for bad JSON or missing parameters, and replaces their correct 400 responses with whatever your handler returns.

Prefer handling specific exception types in controllers, and keep one carefully written catch-all in a global advice class with the lowest priority. That way, all controllers share one well-tested fallback instead of each having its own.