In Spring MVC, a method annotated with @ExceptionHandler catches an exception thrown from a controller and writes the error response for it. When the method sits inside a @Controller class, it handles exceptions from that controller only. When the method sits inside a class annotated with @ControllerAdvice, it handles exceptions from every controller in the app. So in @ExceptionHandler vs @ControllerAdvice, the difference is the scope, one controller or all of them.
We use @ExceptionHandler and @ControllerAdvice to keep the error handling out of the controller methods. The controller throws an exception such as BookingNotFoundException, and one handler method turns it into a 404 response with a JSON body that every client of the API understands.
The following example is a global handler for a bookings API. Spring calls handleNotFound() whenever any controller throws a BookingNotFoundException, and the returned ProblemDetail, the Spring class for a standard JSON error body, becomes the response.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(BookingNotFoundException.class)
public ProblemDetail handleNotFound(BookingNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Not found (global handler)");
return problem; // 404, Content-Type: application/problem+json
}
}
Notice that the handler method never touches HttpServletResponse. It returns a value, and Spring converts the value into the status code and the response body. The base class ResponseEntityExceptionHandler adds handlers for the exceptions that Spring itself throws, and we come back to it in section 6.
In the rest of the post, we compare the local and the global handler, return standard JSON error bodies (RFC 9457), handle validation errors, combine the handlers with @ResponseStatus, and test each case with MockMvc.
1. What Spring Boot Does Without a Handler
Before we add any handler, it helps to see what the client gets today. When a controller method throws an exception and nothing handles it, Spring Boot forwards the request to its own /error mapping. A REST client gets a generic JSON body with the status 500, and a browser gets the “Whitelabel Error Page”.
POST /bookings/1/rebook
HTTP/1.1 500
Content-Type: application/json
{"timestamp":"2026-10-05T20:11:08.760Z","status":500,"error":"Internal Server Error","path":"/bookings/1/rebook"}
The body has no hint about what went wrong, because Spring Boot hides exception messages by default (spring.web.error.include-message=never). The status is also wrong for most business errors. A missing booking is a 404, not a 500, and only our code knows that. The annotations in the next sections let us set the right status and the right body for each exception.
2. @ExceptionHandler Inside a Controller
A local exception handler is a method annotated with @ExceptionHandler inside a @Controller or @RestController class, and it handles the exceptions thrown from the handler methods of that controller only. Spring registers the method for the exception class named in the annotation and for all its subclasses.
The following example is a BookingController for a small hotel bookings API. The get() method asks BookingService for a booking, and the service throws BookingNotFoundException when the id does not exist. The handleNotFound() method in the same class turns that exception into a 404 response. The example runs on Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 25.
@RestController
@RequestMapping("/bookings")
public class BookingController {
@GetMapping("/{id}")
public Booking get(@PathVariable long id) {
return service.find(id); // throws BookingNotFoundException for an unknown id
}
@ExceptionHandler(BookingNotFoundException.class)
public ProblemDetail handleNotFound(BookingNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Booking not found");
return problem;
}
}
A call for an unknown id gets the status and the body from handleNotFound(), and the stack trace never leaves the server.
HTTP/1.1 404
Content-Type: application/problem+json
{"detail":"Booking 5 does not exist","instance":"/bookings/5","status":404,"title":"Booking not found"}
2.1. Method Arguments and Return Types
The @ExceptionHandler annotated methods allow for very flexible signatures. The exception itself is the most common argument, and the other arguments give access to the request or the model. The reference documentation lists all of them, and the following ones cover most handlers.
| Argument | What we get |
|---|---|
| The exception type | The exception that was thrown. When Spring matched a nested cause, we get the outer exception |
| HttpServletRequest, HttpServletResponse | The raw request and response from the Servlet API |
| WebRequest | Request parameters and attributes without the Servlet API |
| HandlerMethod | The controller method that threw the exception |
| Principal | The logged-in user |
| Locale | The locale of the request, for translated messages |
| Model | An empty model for an error view |
The return type decides how Spring writes the response. For a REST API, we return either ProblemDetail or ResponseEntity. For a web page, we return a view name as a String or a ModelAndView.
| Return type | Response |
|---|---|
| ProblemDetail, ErrorResponse | A standard JSON error body (RFC 9457) with the status taken from the object |
| ResponseEntity | Any status and body we build, plus our own headers |
| An object plus @ResponseBody | The object as JSON, with status 200 unless @ResponseStatus is present |
| String | A view name, rendered with the model |
| ModelAndView | A view, a model and optionally a status |
| void | We wrote the response ourselves, or @ResponseStatus sets the status |
2.2. One Method for Several Exceptions
A handler method can name more than one exception class in the annotation. The argument type must be a common supertype of all of them, such as RuntimeException. When we leave the title empty, Spring fills it with the standard text for the status.
@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
public ProblemDetail handleBadInput(RuntimeException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
return problem; // 400 for both exception types
}
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"id must be positive, was 0","instance":"/bookings/0","status":400,"title":"Bad Request"}
When several handler methods in the same class match the thrown exception, Spring picks the method whose exception type is closest to the thrown type. A handler for RuntimeException loses to a handler for BookingNotFoundException, so a catch-all method does not hide the specific ones.
3. @ControllerAdvice for All Controllers
The @ControllerAdvice annotation is used to define a class that will handle exceptions globally across all controllers. Its handler methods are annotated with @ExceptionHandler, the same as inside a controller. By default, the methods in a @ControllerAdvice apply globally to all controllers. The annotation includes @Component, so component scanning picks the class up as a bean.
For a REST API, we use @RestControllerAdvice instead. It is @ControllerAdvice plus @ResponseBody, so every handler method writes its return value to the response body as JSON instead of looking for a view.
The following example is the GlobalExceptionHandler from the intro, extended with a second handler. The GuestController class in the same app also calls service.find() and has no handler of its own, so its BookingNotFoundException reaches the advice.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(BookingNotFoundException.class)
public ProblemDetail handleNotFound(BookingNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Not found (global handler)");
return problem;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleOther(Exception ex) throws Exception {
if (ex.getClass().isAnnotationPresent(ResponseStatus.class)) {
throw ex; // let @ResponseStatus on the exception class decide
}
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong, please retry later");
problem.setTitle("Unexpected error");
return problem;
}
}
The handleOther() method is a catch-all. It hides the real message of unexpected exceptions, which keeps class names and SQL out of the response. The throw ex line matters, and we come back to it in section 8.
GET /guests/5/name
HTTP/1.1 404
{"detail":"Booking 5 does not exist","instance":"/guests/5/name","status":404,"title":"Not found (global handler)"}
POST /bookings/1/rebook
HTTP/1.1 500
{"detail":"Something went wrong, please retry later","instance":"/bookings/1/rebook","status":500,"title":"Unexpected error"}
A @ControllerAdvice can also be limited to some controllers with the attributes of the annotation. Spring checks the attributes on every request, which costs a little time, so we use them only when two groups of controllers need different error formats.
- @ControllerAdvice(annotations = RestController.class) applies to the classes with that annotation.
- @ControllerAdvice(“com.howtodoinjava.bookings”) applies to the controllers in that package.
- @ControllerAdvice(assignableTypes = BookingController.class) applies to that class and its subclasses.
4. @ExceptionHandler vs @ControllerAdvice
The two annotations do different jobs. @ExceptionHandler marks the method that handles an exception, and @ControllerAdvice marks the class whose handler methods apply to every controller. So the real choice is where we put the @ExceptionHandler method.
| @ExceptionHandler in a controller | @ExceptionHandler in a @ControllerAdvice | |
|---|---|---|
| Scope | The handler methods of that one controller | All controllers, or the ones selected by the attributes |
| Handles the exceptions Spring itself throws (wrong HTTP method, unreadable JSON) | No, unless the method names them | Yes, when the class extends ResponseEntityExceptionHandler |
| Typical use | One controller needs a different response for one exception | One error format for the whole API |
| Priority | Checked first | Checked after the local handlers |
| Number of classes | None extra | One extra class, found by component scanning |
When both a local and a global handler match the same exception, the local handler in the controller wins. Spring checks the @ExceptionHandler methods of the controller that threw the exception first, and looks at the @ControllerAdvice beans only when none of them matched. With several advice classes, Spring sorts them by @Order, the lowest value first, and takes the first class with a matching method.

Our app shows both cases with the same exception. The BookingController has its own handler, so GET /bookings/5 answers with the title “Booking not found”. The GuestController has none, so GET /guests/5/name answers with “Not found (global handler)”.
5. Returning ProblemDetail (RFC 9457)
A ProblemDetail is the Java class for the error body defined in RFC 9457, the “Problem Details for HTTP APIs” standard. The body has five standard fields, type, title, status, detail and instance, and the response is sent as application/problem+json. The format is a public standard, so client libraries in many languages can parse it, and we do not need to invent our own error class.
We create the object with ProblemDetail.forStatusAndDetail(), and Spring takes the HTTP status from it. Spring fills instance with the request path when we leave it empty. Extra fields go in with setProperty() and show up as top-level JSON fields.
@ExceptionHandler(RoomUnavailableException.class)
public ProblemDetail handleRoomUnavailable(RoomUnavailableException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Room unavailable");
problem.setProperty("supportEmail", "desk@example.com");
return problem;
}
HTTP/1.1 409
Content-Type: application/problem+json
{"detail":"No room is free for Ravi","instance":"/bookings","status":409,"title":"Room unavailable","supportEmail":"desk@example.com"}
If we need headers on the error response, we wrap the ProblemDetail in a ResponseEntity. The body and the content type stay the same.
6. ResponseEntityExceptionHandler
Spring MVC throws its own exceptions before our controller code runs, for example HttpRequestMethodNotSupportedException for a wrong HTTP method or MethodArgumentNotValidException for a failed validation. Our @ExceptionHandler methods do not see them unless we name them, so these errors would still get the default Spring Boot body.
ResponseEntityExceptionHandler provides exception handlers for internal Spring exceptions. It is a base class for a @ControllerAdvice, and it has one protected method per Spring MVC exception, each building a ProblemDetail body with the right status. We extend it, as GlobalExceptionHandler does, and override only the methods whose response we want to change.
HTTP/1.1 405
Content-Type: application/problem+json
{"detail":"Method 'DELETE' is not supported.","instance":"/bookings/1","status":405,"title":"Method Not Allowed"}
Spring Boot offers the same result without a class of our own. The property spring.mvc.problemdetails.enabled=true registers a ResponseEntityExceptionHandler bean for us. The property is the quicker option when we have nothing to override, and the subclass is the option when we add our own handlers or change the validation response.
7. Handling Validation Errors
When a request body annotated with @Valid fails validation, Spring throws MethodArgumentNotValidException before the controller method runs. Spring answers with status 400 on its own, but the default body does not say which field was wrong, and the client cannot show a message next to the field.
The following example is a BookingRequest record with two constraints, and the override in GlobalExceptionHandler that lists the failed fields. We override handleMethodArgumentNotValid() from ResponseEntityExceptionHandler instead of writing a new @ExceptionHandler method, because the base class already claims that exception.
public record BookingRequest(
@NotBlank(message = "guest name is required") String guest,
@Min(value = 1, message = "nights must be at least 1")
@Max(value = 30, message = "nights must be at most 30") int nights) {
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request has invalid fields");
problem.setTitle("Validation failed");
problem.setProperty("errors", errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem);
}
The getBindingResult() call returns one FieldError per failed constraint, with the field name and the message from the annotation.
HTTP/1.1 400
Content-Type: application/problem+json
{"detail":"Request has invalid fields","instance":"/bookings","status":400,"title":"Validation failed","errors":{"guest":"guest name is required","nights":"nights must be at least 1"}}
8. @ResponseStatus on an Exception Class
The @ResponseStatus annotation sets the HTTP status without a handler method. When we put it on an exception class, Spring answers every uncaught instance of that exception with that status and forwards the request to the Spring Boot error page for the body.
@ResponseStatus(value = HttpStatus.GONE, reason = "The booking was cancelled")
public class BookingCancelledException extends RuntimeException {
public BookingCancelledException(long id) {
super("Booking " + id + " was cancelled");
}
}
HTTP/1.1 410
Content-Type: application/json
{"timestamp":"2026-10-05T20:10:36.218Z","status":410,"error":"Gone","path":"/bookings/99"}
The body comes from Spring Boot, not from us, so the reason text does not appear unless we set spring.web.error.include-message=always. The annotation is enough for a quick status mapping, and a handler method is the option when the body matters.
A catch-all @ExceptionHandler(Exception.class) also catches the exceptions annotated with @ResponseStatus, because Spring checks the handler methods before the annotation. The 410 above would become our 500 “Unexpected error”. So handleOther() in section 3 rethrows any exception whose class carries @ResponseStatus. A rethrown exception continues down the chain as if the handler had never matched.
The annotation also works on a handler method. A method that returns void with @ResponseStatus(HttpStatus.NOT_FOUND) sends an empty 404.
9. Testing the Handlers With MockMvc
A handler is part of the API contract, so we test it like a controller method. @WebMvcTest starts the web layer only, and it includes the @ControllerAdvice beans by default. We mock BookingService with @MockitoBean, make it throw, and check the status and the JSON body.
The following example uses MockMvcTester, the newer form of MockMvc with fluent assertThat() checks, which Spring Boot 4 configures for us. The first test checks that the local handler wins, and the second checks the field errors.
@WebMvcTest({BookingController.class, GuestController.class})
class BookingControllerTest {
@Autowired
MockMvcTester mvc;
@MockitoBean
BookingService service;
@Test
void localHandlerWinsOverGlobalHandler() {
given(service.find(5L)).willThrow(new BookingNotFoundException(5));
mvc.get().uri("/bookings/5")
.assertThat()
.hasStatus(HttpStatus.NOT_FOUND)
.hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
.bodyJson()
.hasPathSatisfying("$.title", title -> title.assertThat().isEqualTo("Booking not found"));
}
@Test
void validationErrorsAreListedPerField() {
mvc.post().uri("/bookings")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"guest\":\"\",\"nights\":0}")
.assertThat()
.hasStatus(HttpStatus.BAD_REQUEST)
.bodyJson()
.hasPathSatisfying("$.errors.guest", g -> g.assertThat().isEqualTo("guest name is required"))
.hasPathSatisfying("$.errors.nights", n -> n.assertThat().isEqualTo("nights must be at least 1"));
}
}
The full test class in the repository has one test per case from this post, for example the 409 with the extra property and the 410 from @ResponseStatus.
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
Notice that MockMvc does not forward to the Spring Boot /error page. The @ResponseStatus test can check the 410 status, but the JSON body with timestamp and path appears only in a running app.
10. Spring Exception Handling FAQs
10.1. Can We Use @ExceptionHandler Without @ControllerAdvice?
Yes. A method annotated with @ExceptionHandler inside a controller works on its own, as in section 2. We add @ControllerAdvice only when the same handler should serve more than one controller.
10.2. What Is the Difference Between @ControllerAdvice and @RestControllerAdvice?
The @RestControllerAdvice annotation is @ControllerAdvice plus @ResponseBody. In a @RestControllerAdvice, a handler that returns an object writes it as JSON. In a plain @ControllerAdvice, the same return value is treated as a view name or a model attribute, unless the method has @ResponseBody or returns ResponseEntity or ProblemDetail.
10.3. Does @ExceptionHandler Catch Exceptions From a Service or a Filter?
It catches the exception when it is thrown from, or passes through, a controller handler method. A service exception that the controller does not catch reaches the handler, because the controller method threw it. An exception from a servlet filter or from Spring Security is thrown before the controller runs, so no @ExceptionHandler sees it. Spring Security answers those with its own 401 or 403 response.
10.4. Which Handler Runs When Two Methods Match the Same Exception?
Inside one class, the method whose exception type is closest to the thrown exception runs. Between classes, the controller’s own methods run before any @ControllerAdvice, and the advice beans are checked in @Order. Two methods in the same class for the same exception type are an error, and Spring reports “Ambiguous @ExceptionHandler method mapped for” when it registers them.
11. Conclusion
The @ExceptionHandler annotation turns an exception into an HTTP response, and its place decides its scope. In a controller it serves that controller, and in a @ControllerAdvice it serves them all, with the local method taking priority when both match.
For a REST API, a @RestControllerAdvice that extends ResponseEntityExceptionHandler and returns ProblemDetail covers the most ground. We get RFC 9457 bodies for our own exceptions and for the Spring MVC ones in one class, and we override handleMethodArgumentNotValid() to list the failed fields. The @ResponseStatus annotation remains the shortest way to map an exception to a status when the body does not matter.
Each handler deserves a @WebMvcTest, because the status code and the error body are part of the API that our clients depend on.
12. References
- Exceptions in Spring MVC
- Controller Advice
- Error Responses (ProblemDetail)
- Spring Boot Error Handling
- ResponseEntityExceptionHandler Javadoc
- MockMvcTester
- RFC 9457, Problem Details for HTTP APIs
Happy Learning !!
Hi Lokesh, I have a little confussion in this exception handling… i have a controller which have 5-8 @RequestMapping annotations(ModelAndView methods). if i use 1 @ExceptionHandler(Exception.class) for this controller, does it handles any exception which comes in this controller?
Yes. Please read again “register the method as exception handler for given exception and all of its subclasses.”
Hi Lokesh,
I appreciate your work on Spring 3 . Good work keep it up.
past 7 years I have been practicing java -j2ee working with reputed organization
I have few questions on Spring Annotation , would like to discuss with you ,please provide me your valuable reply on those .
1)@Configuration is introduced in Spring3 , which is the primary class reading that annotation and doing all necessary steps like instantiation , wiring and disposing bean .
2)pls observe below code snippet which does creation of bean and disposing bean
/*
* To change this license header, choose License Headers in Project Properties.
* To change this template file, choose Tools | Templates
* and open the template in the editor.
*/
package annotationparser;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
/**
*
* @author chinni
*/
public class AnnotationParser {
/**
* @param args the command line arguments
*/
public static void main(String[] args) throws ClassNotFoundException {
// TODO code application logic here
Class aimpl= (Class) Configuration.class.getClassLoader().loadClass(Configuration.class.getName());
Annotation a[]= aimpl.getDeclaredAnnotations();
for(Annotation m :a){
System.out.println(m.getDeclaredAnnotations()[0]);
}
}
}
using bove code we can get list of all configuration classes and we can instantiate object and return back
3)is it possible to invoke super class constructor using java.lang.reflect , if yes please send me the code
snippet
4) same question on this keyword
5) can we write annotations to inject Author and date time stamp on the fly ?
I am working on big project with Annotations , hence I have got all these questions . pls reply me back
Thanks
Sitaram Venkata
Hyderabad