00:00

Spring Boot Exception Handling

Every application meets errors: a book that does not exist, an invalid request, a database that is down. Good exception handling turns these moments into clear, consistent responses that clients can understand and act on, instead of confusing stack traces or generic “Something went wrong” pages.

This tutorial shows how Spring Boot handles errors out of the box, and how to take control with custom exceptions, @ExceptionHandler, @RestControllerAdvice and the standard ProblemDetail format, with logging, testing and best practices along the way.

1. Why Exception Handling Matters

Compare two responses to the same problem: a client asks for book 42, which does not exist.

Poor response (HTTP 500)
java.util.NoSuchElementException: No value present
    at java.base/java.util.Optional.orElseThrow(Optional.java:377)
    at com.javabytechie.books.BookService.findById(BookService.java:28)
    ...
Good response (HTTP 404)
{
  "type": "https://api.javabytechie.com/errors/book-not-found",
  "title": "Book not found",
  "status": 404,
  "detail": "No book exists with id 42",
  "instance": "/api/books/42"
}

The first response uses the wrong status code, leaks internal class names and helps nobody. The second tells the client exactly what happened, in a predictable format. Good exception handling gives you:

  • Correct HTTP status codes, so clients and tools can react automatically.
  • A consistent error format across every endpoint.
  • Security: no stack traces or internal details sent to the outside world.
  • Cleaner code: controllers and services focus on the happy path, and errors are handled in one place.

2. What Spring Boot Does by Default

If an exception escapes your controller and nothing handles it, Spring Boot forwards the request to its built-in /error endpoint. For a REST client, the response looks like this:

Default error response
{
  "timestamp": "2026-10-04T10:15:30.123+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "path": "/api/books/42"
}

Browsers see the “Whitelabel Error Page” instead. By default, the exception message and stack trace are hidden, which is the safe choice. You can change this with properties, but only do so in development:

application-dev.properties
server.error.include-message=always
server.error.include-binding-errors=always
server.error.include-stacktrace=on_param

The default is a safety net, not a design: every unhandled error becomes a 500 with a generic message. The rest of this tutorial shows how to return the right status and a helpful message for each situation.

3. Creating Custom Exceptions

Custom exceptions describe what went wrong in business terms, such as “book not found” or “out of stock”, rather than technical ones. A small hierarchy keeps them organised:

ApplicationException.java
package com.javabytechie.books.error;

public abstract class ApplicationException extends RuntimeException {

    protected ApplicationException(String message) {
        super(message);
    }

    protected ApplicationException(String message, Throwable cause) {
        super(message, cause);
    }
}
BookNotFoundException.java
public class BookNotFoundException extends ApplicationException {

    private final Long bookId;

    public BookNotFoundException(Long bookId) {
        super("No book exists with id " + bookId);
        this.bookId = bookId;
    }

    public Long getBookId() {
        return bookId;
    }
}
OutOfStockException.java
public class OutOfStockException extends ApplicationException {

    public OutOfStockException(String isbn, int requested, int available) {
        super("Only " + available + " copies of " + isbn + " are available, but " + requested + " were requested");
    }
}

Throw them from the service layer, where the business rules live:

BookService.java
@Service
public class BookService {

    private final BookRepository bookRepository;

    public BookService(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    public Book findById(Long id) {
        return bookRepository.findById(id)
                .orElseThrow(() -> new BookNotFoundException(id));
    }

    @Transactional
    public void reserve(String isbn, int quantity) {
        Book book = bookRepository.findByIsbn(isbn).orElseThrow();
        if (book.getStock() < quantity) {
            throw new OutOfStockException(isbn, quantity, book.getStock());
        }
        book.setStock(book.getStock() - quantity);
    }
}

Extending RuntimeException keeps method signatures clean, because unchecked exceptions do not need throws clauses. It also triggers a rollback in @Transactional methods by default.

4. Handling Exceptions in One Controller

An @ExceptionHandler method inside a controller handles exceptions thrown by that controller only:

BookController.java
@RestController
@RequestMapping("/api/books")
public class BookController {

    private final BookService bookService;

    public BookController(BookService bookService) {
        this.bookService = bookService;
    }

    @GetMapping("/{id}")
    public Book getBook(@PathVariable Long id) {
        return bookService.findById(id); // may throw BookNotFoundException
    }

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

Notice that getBook() contains no try/catch. It simply lets the exception travel up, and the handler turns it into a response. This is useful for errors that only one controller can produce. For errors shared by many controllers, use a global handler instead.

5. Global Handling with @RestControllerAdvice

A class marked with @RestControllerAdvice holds exception handlers that apply to every controller in the application:

GlobalExceptionHandler.java
package com.javabytechie.books.error;

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

@RestControllerAdvice
public class GlobalExceptionHandler {

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

    @ExceptionHandler(OutOfStockException.class)
    public ResponseEntity<String> handleOutOfStock(OutOfStockException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(ex.getMessage());
    }
}
@ControllerAdvice@RestControllerAdvice
Return value of handlersA view name, unless the method has @ResponseBody or returns ResponseEntityWritten to the response body, usually as JSON
Best forWeb apps that render HTML pagesREST APIs
RelationshipThe base annotation@ControllerAdvice + @ResponseBody

When several handlers could match, Spring picks the most specific exception type. A handler for BookNotFoundException wins over one for ApplicationException, which wins over one for Exception.

6. A Consistent Error Response

Plain strings are hard for clients to process. Return the same structured object for every error instead. A Java record keeps it short:

ApiError.java
public record ApiError(
        Instant timestamp,
        int status,
        String error,
        String message,
        String path
) {
    public static ApiError of(HttpStatus status, String message, String path) {
        return new ApiError(Instant.now(), status.value(), status.getReasonPhrase(), message, path);
    }
}
GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<ApiError> handleNotFound(BookNotFoundException ex, HttpServletRequest request) {
        return build(HttpStatus.NOT_FOUND, ex.getMessage(), request);
    }

    @ExceptionHandler(OutOfStockException.class)
    public ResponseEntity<ApiError> handleOutOfStock(OutOfStockException ex, HttpServletRequest request) {
        return build(HttpStatus.CONFLICT, ex.getMessage(), request);
    }

    private ResponseEntity<ApiError> build(HttpStatus status, String message, HttpServletRequest request) {
        return ResponseEntity.status(status).body(ApiError.of(status, message, request.getRequestURI()));
    }
}
Response
{
  "timestamp": "2026-10-04T10:15:30.123Z",
  "status": 409,
  "error": "Conflict",
  "message": "Only 2 copies of 9780134685991 are available, but 5 were requested",
  "path": "/api/books/reservations"
}

Handler methods can receive useful extra parameters, such as HttpServletRequest, WebRequest or Locale. Spring supplies them automatically.

7. Using ProblemDetail (RFC 9457)

Instead of inventing your own format, you can use the internet standard for API errors: Problem Details, defined in RFC 9457. Spring Framework 6 supports it with the ProblemDetail class:

GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ProblemDetail handleNotFound(BookNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Book not found");
        problem.setType(URI.create("https://api.javabytechie.com/errors/book-not-found"));
        return problem;
    }

    @ExceptionHandler(OutOfStockException.class)
    public ProblemDetail handleOutOfStock(OutOfStockException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        problem.setTitle("Not enough stock");
        problem.setProperty("retryable", false); // extra fields are allowed
        return problem;
    }
}
FieldMeaning
typeA URI that identifies the kind of problem; ideally a page that documents it
titleA short, human-readable summary that is the same for every occurrence
statusThe HTTP status code
detailAn explanation of this specific occurrence
instanceThe request path where the problem happened (filled in automatically)

The response is sent with the content type application/problem+json. To make Spring’s own built-in exceptions use this format too, add one property:

application.properties
spring.mvc.problemdetails.enabled=true

8. Extending ResponseEntityExceptionHandler

Spring MVC throws its own exceptions for problems such as malformed JSON or unsupported HTTP methods. ResponseEntityExceptionHandler is a base class that already handles all of them with the right status codes. Extend it, and override only what you want to change:

GlobalExceptionHandler.java
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    // Add field-level details to validation errors
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {

        Map<String, String> fieldErrors = new LinkedHashMap<>();
        ex.getBindingResult().getFieldErrors()
          .forEach(error -> fieldErrors.putIfAbsent(error.getField(), error.getDefaultMessage()));

        ProblemDetail problem = ex.getBody();
        problem.setTitle("Validation failed");
        problem.setProperty("errors", fieldErrors);

        return handleExceptionInternal(ex, problem, headers, status, request);
    }

    // Your own exceptions sit alongside the inherited handlers
    @ExceptionHandler(BookNotFoundException.class)
    public ProblemDetail handleNotFound(BookNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
}

In Spring Boot 3 and later, the third parameter is HttpStatusCode. Older tutorials show HttpStatus, which no longer compiles. For more on validation errors, see the Spring Boot Validation tutorial.

9. Common Spring Exceptions and Their Status Codes

These are the exceptions you will meet most often, and the status each should produce. The ones from Spring MVC are already mapped by ResponseEntityExceptionHandler:

ExceptionCauseStatus
MethodArgumentNotValidException@Valid request body failed validation400
HttpMessageNotReadableExceptionMalformed or unreadable JSON400
MethodArgumentTypeMismatchExceptionWrong type, e.g. /books/abc for a Long id400
MissingServletRequestParameterExceptionA required @RequestParam is missing400
HttpRequestMethodNotSupportedExceptione.g. DELETE on a GET-only URL405
HttpMediaTypeNotSupportedExceptionWrong Content-Type, e.g. XML sent to a JSON endpoint415
NoResourceFoundExceptionNo controller or file matches the URL (Spring 6.1+)404
DataIntegrityViolationExceptionDatabase constraint broken, e.g. a duplicate ISBNMap it to 409 yourself
OptimisticLockingFailureExceptionSomeone else updated the same record firstMap it to 409 yourself

Database exceptions are not mapped automatically, so add handlers for them:

GlobalExceptionHandler.java
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleConflict(DataIntegrityViolationException ex) {
    log.warn("Data integrity violation: {}", ex.getMostSpecificCause().getMessage());
    return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
            "The request conflicts with existing data.");
}

The log keeps the technical detail, such as which constraint failed, while the client gets a safe, general message.

10. The Catch-All Handler

Some exceptions are truly unexpected: a bug, a null pointer, a lost connection. A final handler for Exception catches everything the other handlers missed, so the client always gets your error format:

GlobalExceptionHandler.java
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
    String errorId = UUID.randomUUID().toString();
    log.error("Unexpected error {} on {} {}", errorId, request.getMethod(), request.getRequestURI(), ex);

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred. Please contact support with error id " + errorId + ".");
    problem.setTitle("Internal server error");
    problem.setProperty("errorId", errorId);
    return problem;
}

Three rules for this handler:

  • Return 500, never 400. An unexpected error is the server’s fault, not the client’s.
  • Never send ex.getMessage() to the client. It may contain SQL, file paths or other internal details.
  • Log the full exception with an error ID, and give the same ID to the client. Support staff can then find the exact log entry in seconds.

Important: put this handler in a class that extends ResponseEntityExceptionHandler (section 8). Otherwise, the Exception handler also catches Spring’s own client errors, such as malformed JSON or a wrong parameter type, and turns these 400 responses into 500s.

11. @ResponseStatus and ResponseStatusException

For simple cases, Spring offers two shortcuts that do not need a handler method at all.

11.1 @ResponseStatus on an exception class

InvalidIsbnException.java
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "The ISBN must have 13 digits")
public class InvalidIsbnException extends RuntimeException {
}

Whenever this exception escapes a controller, Spring returns 400 with that reason. It is quick, but the message is fixed, so it cannot include details like the actual ISBN.

11.2 ResponseStatusException

BookController.java
@GetMapping("/isbn/{isbn}")
public Book getByIsbn(@PathVariable String isbn) {
    if (!isbn.matches("\\d{13}")) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "ISBN must have 13 digits, got: " + isbn);
    }
    return bookService.findByIsbn(isbn);
}
ApproachBest for
@ResponseStatus on the classSimple exceptions that always mean the same status and message
ResponseStatusExceptionOne-off errors in a controller, without creating a new class
Custom exception + @RestControllerAdviceMost applications: full control, consistent format, reusable

12. Logging Exceptions the Right Way

Not every exception deserves the same log level. Logging a “book not found” as an ERROR with a full stack trace floods your logs and hides real problems.

Kind of errorLog levelStack trace?
Client mistakes (400, 404, 409)WARN or INFONo, the message is enough
Expected technical problems, e.g. a dependency timing outWARNUsually no
Unexpected errors (500)ERRORYes, always
GlobalExceptionHandler.java
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex, HttpServletRequest request) {
    log.info("Book not found: {} ({})", ex.getMessage(), request.getRequestURI()); // no stack trace
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}

Log each exception once, in the global handler. If services also log before rethrowing, the same error appears several times and is harder to follow.

13. Testing Exception Handling

Error responses are part of your API contract, so test them like any other response. A web slice test with MockMvc checks the status and body:

BookControllerErrorTest.java
@WebMvcTest(BookController.class)
@Import(GlobalExceptionHandler.class)
class BookControllerErrorTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private BookService bookService;

    @Test
    void returns404WhenBookDoesNotExist() throws Exception {
        when(bookService.findById(42L)).thenThrow(new BookNotFoundException(42L));

        mockMvc.perform(get("/api/books/42"))
                .andExpect(status().isNotFound())
                .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.detail").value("No book exists with id 42"));
    }

    @Test
    void returns400ForNonNumericId() throws Exception {
        mockMvc.perform(get("/api/books/abc"))
                .andExpect(status().isBadRequest());
    }
}

@MockitoBean is available from Spring Boot 3.4; on older versions, use @MockBean.

14. Best Practices

  • Handle errors in one place with a single @RestControllerAdvice, instead of try/catch blocks in every controller.
  • Create meaningful custom exceptions that describe business problems, and throw them from the service layer.
  • Use the correct status code: 4xx when the client must change the request, 5xx when the server failed.
  • Use one error format everywhere; ProblemDetail is the standard choice.
  • Never expose internals: no stack traces, SQL, class names or file paths in responses.
  • Always have a catch-all handler that returns 500 with an error ID.
  • Log once, at the right level, with enough context to investigate.
  • Test your error responses, because clients depend on them.

15. Common Mistakes to Avoid

MistakeWhat goes wrongFix
Catching exceptions in every controllerDuplicated code and inconsistent responsesOne global @RestControllerAdvice
Returning 400 for unexpected errorsClients think they sent a bad request and stop retryingReturn 500 for server-side failures
Sending ex.getMessage() from any exceptionInternal details leak to the outside worldOnly return messages from your own exceptions
Returning 200 OK with an error inside the bodyClients and monitoring tools think the call succeededUse real HTTP status codes
Swallowing exceptions with an empty catchErrors disappear without a traceHandle, rethrow or at least log them
Logging the same exception at every layerOne error appears many times in the logsLog once, in the global handler
Using HttpStatus in ResponseEntityExceptionHandler overridesDoes not compile on Spring Boot 3Use HttpStatusCode

16. Summary

Spring Boot gives you a safe default for errors, and simple tools to do much better. Describe business problems with custom exceptions, throw them from your services, and turn them into correct status codes and a consistent ProblemDetail response in one @RestControllerAdvice. Extend ResponseEntityExceptionHandler to cover Spring’s own exceptions, add a catch-all that returns 500 with an error ID, log at the right level, and test your error responses like any other part of your API.

17. Frequently Asked Questions (FAQ)

Q1. Why is my @RestControllerAdvice not handling an exception?

Check these common causes:

  1. The advice class is not scanned. It must be in the package of your @SpringBootApplication class or a sub-package.
  2. The exception is caught earlier, for example by a try/catch in the service that returns null instead.
  3. It was thrown in a filter, such as Spring Security, before the request reached a controller.
  4. Another handler matched first: a more specific handler, a local @ExceptionHandler in the controller, or another advice with higher priority.
  5. The advice is limited with basePackages or assignableTypes that do not include this controller.
Q2. Why are Spring Security 401 and 403 errors not handled by my advice?

Most security checks happen in filters, before any controller runs, so controller advice never sees them. Customise them in the security configuration instead:

http.exceptionHandling(ex -> ex
        .authenticationEntryPoint((request, response, e) ->            // 401
                response.sendError(HttpServletResponse.SC_UNAUTHORIZED))
        .accessDeniedHandler((request, response, e) ->                 // 403
                response.sendError(HttpServletResponse.SC_FORBIDDEN)));

One exception: AccessDeniedException from @PreAuthorize on a controller method does reach your advice. Make sure your catch-all Exception handler does not turn it into a 500. Add a specific handler that returns 403.

Q3. Should my custom exceptions be checked or unchecked?

For most Spring applications, use unchecked exceptions that extend RuntimeException:

  • Methods do not need throws clauses all the way up the call chain.
  • @Transactional rolls back automatically on unchecked exceptions, but not on checked ones by default.
  • A global handler deals with them anyway, so forcing every caller to catch them adds no value.

Checked exceptions still make sense in libraries, when the caller can genuinely recover, for example by choosing another file when one is missing.

Q4. Which status code fits a business rule violation: 400, 409 or 422?
StatusUse it whenExample
400 Bad RequestThe request itself is malformed or invalidMissing field, wrong date format
409 ConflictThe request clashes with the current state of the dataDuplicate ISBN, book already reserved, version conflict
422 Unprocessable ContentThe request is well-formed, but breaks a business ruleOrdering 0 copies, a return date before the loan date

Many teams simply use 400 for all input problems and 409 for conflicts with existing data. Whatever you choose, be consistent and document it.

Q5. How do I return a JSON 404 for URLs that do not exist?

Since Spring Framework 6.1 (Spring Boot 3.2), an unknown URL raises NoResourceFoundException, which you can handle like any other exception:

@ExceptionHandler(NoResourceFoundException.class)
public ProblemDetail handleUnknownUrl(NoResourceFoundException ex) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
            "The requested resource does not exist.");
}

If you extend ResponseEntityExceptionHandler, this is already handled for you. On older versions, set spring.mvc.throw-exception-if-no-handler-found=true and spring.web.resources.add-mappings=false, then handle NoHandlerFoundException.

Q6. I have several @RestControllerAdvice classes. Which one runs first?

Without configuration, the order is not guaranteed. Set it with @Order; a lower number means higher priority:

@RestControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
public class PaymentExceptionHandler { ... }

@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class GlobalExceptionHandler { ... }   // contains the catch-all

Spring checks the advice classes in order and uses the first one that has a matching handler. Keep the catch-all Exception handler in the lowest-priority class, or it will hide the specific handlers in other classes.

Q7. Can I apply an advice to only some controllers?

Yes. Limit it by package, by class, or by annotation:

@RestControllerAdvice(basePackages = "com.javabytechie.books.api.admin")
public class AdminExceptionHandler { ... }

@RestControllerAdvice(assignableTypes = {BookController.class, AuthorController.class})
public class CatalogExceptionHandler { ... }

@RestControllerAdvice(annotations = PublicApi.class)
public class PublicApiExceptionHandler { ... }

This helps when one application serves two kinds of clients, for example a public API that must use ProblemDetail and an internal admin API with its own format.

Q8. Does @Transactional still roll back if my advice handles the exception?

Yes. The exception passes through the transactional service method first, and the transaction is rolled back at that moment. Only afterwards does it reach the controller advice, which turns it into a response.

The opposite case is the trap: if you catch the exception inside the @Transactional method and do not rethrow it, the transaction commits as if nothing went wrong.

Q9. Are exceptions from @Async or @Scheduled methods handled by my advice?

No. Controller advice only handles exceptions from web requests. Background methods run on other threads, outside any request:

  • @Async methods returning CompletableFuture store the exception in the future; handle it with exceptionally().
  • @Async methods returning void need an AsyncUncaughtExceptionHandler.
  • @Scheduled methods need their own try/catch, or an error handler on the task scheduler.
Q10. How should I handle errors returned by other services I call?

Translate them into your own exceptions at the edge of your service. Do not simply pass another service’s status codes through, because they may make no sense to your clients:

public AuthorDto getAuthor(Long id) {
    return restClient.get()
            .uri("/api/authors/{id}", id)
            .retrieve()
            .onStatus(status -> status.value() == 404,
                    (request, response) -> { throw new AuthorNotFoundException(id); })
            .onStatus(HttpStatusCode::is5xxServerError,
                    (request, response) -> { throw new AuthorServiceUnavailableException(); })
            .body(AuthorDto.class);
}

A 404 from the author service means “author not found” to your client. A 500 from it should become a 503 from you, because your service is fine, but a dependency is not.

Q11. How can clients identify an error without parsing the message text?

Messages are for humans and may change. Give each error a stable, machine-readable code that clients can rely on:

ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setType(URI.create("https://api.javabytechie.com/errors/out-of-stock"));
problem.setProperty("code", "BOOK_OUT_OF_STOCK");
return problem;

A mobile app can then check code == "BOOK_OUT_OF_STOCK" and show its own message, in the user’s language, without breaking when the English text is reworded.

Q12. How do I return error messages in the user’s language?

Store messages in messages.properties files and look them up with MessageSource. Spring passes the client’s Locale to the handler, based on the Accept-Language header:

# messages.properties
error.book.notFound=No book exists with id {0}

# messages_hi.properties
error.book.notFound=आईडी {0} वाली कोई किताब नहीं है
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex, Locale locale) {
    String detail = messageSource.getMessage("error.book.notFound", new Object[]{ex.getBookId()}, locale);
    return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, detail);
}
Q13. Is it bad to use exceptions for normal control flow?

Yes. Exceptions are for exceptional situations. Creating them is relatively costly because Java records the stack trace, and using them for expected outcomes makes code harder to follow.

// Avoid: an exception for a normal "no" answer
try {
    userService.findByEmail(email);
    return true;
} catch (UserNotFoundException ex) {
    return false;
}

// Prefer: ask the question directly
return userRepository.existsByEmail(email);

Throwing BookNotFoundException when a client requests a missing book is fine: it ends the request with an error. Using exceptions to drive loops or ordinary decisions is not.

Q14. How do I keep the original error when wrapping an exception?

Always pass the original exception as the cause. Otherwise, the real reason for the failure disappears from your logs:

try {
    return pdfRenderer.render(invoice);
} catch (IOException ex) {
    throw new InvoiceGenerationException("Could not render invoice " + invoice.getId(), ex); // keep ex
}

The log then shows both your message and the original IOException under “Caused by”. This is why the ApplicationException base class in this tutorial has a constructor that accepts a cause.

Q15. What happens if an @ExceptionHandler method itself throws an exception?

Spring cannot use your handler, so it falls back to its default error handling. The client usually gets a generic 500 response from the /error endpoint, and the new exception is logged.

Keep handlers simple and safe: no database calls, no remote calls, and careful null handling, for example when ex.getMessage() is null. A handler should never be a new source of failures.

Q16. Does exception handling work the same way in Spring WebFlux?

Mostly, yes. @RestControllerAdvice, @ExceptionHandler and ProblemDetail all work in WebFlux, and handlers may return Mono values.

Two differences to know:

  • There is no HttpServletRequest; use ServerWebExchange or ServerHttpRequest as a handler parameter instead.
  • For errors outside controllers, such as in a WebFilter, implement a WebExceptionHandler.

The reactive base class equivalent to the Spring MVC one is also called ResponseEntityExceptionHandler, but it lives in the org.springframework.web.reactive package.

Q17. How do I customize errors that happen outside controllers, such as in filters?

These errors go straight to Spring Boot’s /error endpoint. Change its JSON by providing your own ErrorAttributes bean:

@Component
public class CustomErrorAttributes extends DefaultErrorAttributes {

    @Override
    public Map<String, Object> getErrorAttributes(WebRequest request, ErrorAttributeOptions options) {
        Map<String, Object> attributes = super.getErrorAttributes(request, options);
        attributes.put("support", "support@javabytechie.com");
        attributes.remove("error");
        return attributes;
    }
}

Combined with a controller advice, every error, inside or outside controllers, then follows a format you control.

Q18. How do I replace the Whitelabel Error Page with my own HTML pages?

Add pages named after the status code. Spring Boot finds them automatically:

FileShown for
src/main/resources/templates/error/404.html404, rendered by a template engine such as Thymeleaf
src/main/resources/static/error/500.html500, as a static page
src/main/resources/templates/error/4xx.htmlAny 4xx status without its own page
src/main/resources/templates/error/5xx.htmlAny 5xx status without its own page

These pages are only used for browser requests that accept HTML. API clients asking for JSON still receive JSON.

Q19. Should I return ResponseEntity, ProblemDetail, or use @ResponseStatus on the handler?

All three work. Choose based on what you need to control:

Handler returnsUse it when
ProblemDetailYou use the standard error format; the status comes from the object itself
ResponseEntity<T>You also need to set headers, such as Retry-After
A plain object + @ResponseStatus on the methodA custom body type with a fixed status and no headers
@ExceptionHandler(RateLimitExceededException.class)
public ResponseEntity<ProblemDetail> handleRateLimit(RateLimitExceededException ex) {
    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.TOO_MANY_REQUESTS, ex.getMessage());
    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
            .header(HttpHeaders.RETRY_AFTER, "60")
            .body(problem);
}
Q20. Should error responses include a trace or request ID?

Yes, it is very helpful. When a user reports a problem, the ID leads you straight to the matching log lines across all your services. With Micrometer Tracing on the classpath, the current trace ID is easy to add:

@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
    String traceId = Optional.ofNullable(tracer.currentSpan())
            .map(span -> span.context().traceId())
            .orElse("unknown");
    log.error("Unexpected error, traceId={}", traceId, ex);

    ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    problem.setDetail("An unexpected error occurred.");
    problem.setProperty("traceId", traceId);
    return problem;
}

A trace ID reveals nothing sensitive, so it is safe to share with clients. Without tracing, a random error ID works just as well, as shown in section 10.