Skip to content

Exception handling

Mid / Senior

Checked vs unchecked, @ExceptionHandler and @ControllerAdvice, ProblemDetail, validation errors, exceptions vs transactions, and the common mistakes.

Created 5 October 2026 · Updated 23 October 2026

14 concepts · detail hidden

  1. 01Checked vs unchecked exceptions — and why Spring prefers unchecked

    Interview answer: “Checked exceptions extend Exception and must be declared or caught; unchecked ones extend RuntimeException and don’t. Spring deliberately uses unchecked exceptions — DataAccessException, HttpClientErrorException — because most of them can’t be meaningfully handled where they occur, so forcing every layer to declare them just adds noise. They’re handled centrally instead.”

    Throwable
    ├── Error                 — JVM problems (OutOfMemoryError, StackOverflowError); don't catch
    └── Exception             — checked
        └── RuntimeException  — unchecked (NullPointerException, IllegalArgumentException, …)
    

    Rule of thumb: use checked only when the caller can realistically recover and you want to force them to think about it; use unchecked for programming errors and for failures that should bubble up to a central handler. Wrapping a checked exception: always pass the cause — throw new ReportFailed("…", e) — or the original stack trace is lost.

    Spring-specific consequence: checked exceptions don’t roll back @Transactional by default (see below).

    #c-checked-vs-unchecked-exceptions-and-why-spring-prefers-unche
  2. 02The three ways to map an exception to an HTTP response
    1. @ResponseStatus on the exception class — simplest, static:

      @ResponseStatus(HttpStatus.NOT_FOUND)
      public class OrderNotFoundException extends RuntimeException { … }
      
    2. ResponseStatusException — thrown inline, no custom class needed:

      throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Order " + id + " not found");
      

      Handy for prototypes; but it ties service code to HTTP, so keep it in controllers.

    3. @ExceptionHandler in a @ControllerAdvice — the recommended approach for real applications: domain code throws domain exceptions (OrderNotFoundException), and one central class decides status codes and the error body.

    #c-the-three-ways-to-map-an-exception-to-an-http-response
  3. 03@ExceptionHandler and @RestControllerAdvice
    @RestControllerAdvice
    public class ApiExceptionHandler {
    
        @ExceptionHandler(OrderNotFoundException.class)
        public ProblemDetail handleNotFound(OrderNotFoundException e) {
            return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
        }
    
        @ExceptionHandler(OptimisticLockingFailureException.class)
        public ProblemDetail handleConflict(OptimisticLockingFailureException e) {
            return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
                    "The resource was modified by someone else; reload and retry.");
        }
    
        @ExceptionHandler(Exception.class)               // last-resort fallback
        public ProblemDetail handleUnexpected(Exception e) {
            log.error("Unhandled error", e);              // log the details server-side…
            return ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR); // …leak nothing
        }
    }
    

    How Spring picks a handler:

    • An @ExceptionHandler inside a controller applies only to that controller; one in a @ControllerAdvice applies to all controllers. When both match, the controller’s own handler takes precedence (local beats global).
    • Among candidates, the closest match in the exception’s class hierarchy wins — the Exception.class fallback only catches what nothing more specific handled.
    • With several advice classes, order them with @Order; an advice can be narrowed with @ControllerAdvice(basePackages = …) / assignableTypes.
    • @RestControllerAdvice = @ControllerAdvice + @ResponseBody.
    • The handler method can take the exception, HttpServletRequest, WebRequest, etc., and return ProblemDetail, ResponseEntity<…>, or any body object (with @ResponseStatus).
    #c-exceptionhandler-and-restcontrolleradvice
  4. 04ProblemDetail and ResponseEntityExceptionHandler

    ProblemDetail (Spring 6) is the standard RFC 9457 error body, Content-Type: application/problem+json:

    {
      "type": "https://api.acme.com/errors/order-not-found",
      "title": "Order not found",
      "status": 404,
      "detail": "Order 42 does not exist",
      "instance": "/orders/42",
      "orderId": 42
    }
    

    Extra fields are added with problem.setProperty("orderId", id). Using a standard format means clients parse every error the same way.

    Spring MVC’s built-in exceptions (unsupported media type, missing parameter, bad JSON, validation failures) are handled by DefaultHandlerExceptionResolver. To return ProblemDetail for those too and customise them in one place, either set spring.mvc.problemdetails.enabled=true or extend ResponseEntityExceptionHandler in your advice and override the hooks you need (e.g. handleMethodArgumentNotValid). Spring’s own exceptions implement ErrorResponse, so they already carry a status and a ProblemDetail; you can implement ErrorResponse on your own exceptions too.

    #c-problemdetail-and-responseentityexceptionhandler
  5. 05What Spring Boot does when nothing handles an exception

    The exception propagates out of the DispatcherServlet; the servlet container forwards to /error, served by Boot’s BasicErrorController: JSON for API clients, the “Whitelabel Error Page” for browsers. Its content is controlled by server.error.include-message, include-stacktrace, include-binding-errors — all off/never by default for security. Customise by providing your own ErrorAttributes bean, an error/404.html template, or (more commonly) handling everything in @ControllerAdvice so /error is only reached for truly unexpected cases.

    The resolver chain inside the DispatcherServlet, in order: ExceptionHandlerExceptionResolver (your @ExceptionHandlers) → ResponseStatusExceptionResolver (@ResponseStatus, ResponseStatusException) → DefaultHandlerExceptionResolver (Spring’s built-in exceptions).

    #c-what-spring-boot-does-when-nothing-handles-an-exception
  6. 06Exceptions thrown in filters never reach @ControllerAdvice

    @ControllerAdvice only handles exceptions thrown inside the DispatcherServlet (controllers, interceptors, argument resolution). A servlet Filter — JWT parsing, rate limiting, Spring Security — runs before it, so its exceptions go straight to the container’s /error handling. Options:

    • in the filter, catch and write the error response yourself (response.setStatus + body);
    • for Spring Security, use its own hooks: AuthenticationEntryPoint (401 — not authenticated) and AccessDeniedHandler (403 — authenticated but not allowed);
    • inject HandlerExceptionResolver into the filter and delegate to it, which reuses your @ControllerAdvice logic.

    Note that @PreAuthorize on a controller does throw inside the dispatcher, so an AccessDeniedException from it is visible to an advice — if you catch Exception there, you may accidentally turn 403s into 500s.

    #c-exceptions-thrown-in-filters-never-reach-controlleradvice
  7. 07Validation errors — which exception for which case
    Where the constraint is Exception Default status
    @Valid @RequestBody DTO fields MethodArgumentNotValidException 400
    @Valid on a @ModelAttribute / form object BindException (parent of the above) 400
    Constraints directly on controller parameters (@RequestParam @Min(1) int page) — Spring 6.1+ built-in method validation HandlerMethodValidationException 400
    @Validated class, constraints on service method parameters ConstraintViolationException (Jakarta) 500 unless you handle it
    Malformed JSON / wrong type in body HttpMessageNotReadableException 400
    @PathVariable Long id given "abc" MethodArgumentTypeMismatchException 400

    Typical handler that returns field-level errors:

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail invalid(MethodArgumentNotValidException e) {
        var pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
        pd.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(FieldError::getField,
                        f -> String.valueOf(f.getDefaultMessage()), (a, b) -> a)));
        return pd;
    }
    
    #c-validation-errors-which-exception-for-which-case
  8. 08Exceptions and @Transactional rollback

    Interview answer: “By default a @Transactional method rolls back on RuntimeException and Error, and commits on checked exceptions. Use rollbackFor to change that. If I catch the exception inside the method, the proxy never sees it and the transaction commits.”

    The finer points (all detailed in the transactions subtopic):

    • @Transactional(rollbackFor = Exception.class) to roll back on checked exceptions.
    • Catching an exception thrown by an inner @Transactional (REQUIRED) method doesn’t undo the damage — the shared transaction is already rollback-only, and the outer commit throws UnexpectedRollbackException.
    • Need the rollback but want to handle the exception? Rethrow it, or call TransactionAspectSupport.currentTransactionStatus().setRollbackOnly().
    • Database errors often surface at commit, outside your try/catch. If you must handle a duplicate-key error in the method, flush() / saveAndFlush() inside the try.
    • After a database exception inside a transaction, the persistence context is in an undefined state and (on PostgreSQL) the transaction is aborted — you can’t “catch and keep using it”. Handle it outside the transaction, or use a fresh one (REQUIRES_NEW).
    #c-exceptions-and-transactional-rollback
  9. 09DataAccessException — Spring's persistence exception hierarchy

    Spring translates driver- and ORM-specific exceptions (SQLException with vendor error codes, HibernateException, PersistenceException) into one unchecked hierarchy, so code handles what went wrong rather than which library threw it:

    Exception Typical cause Usual HTTP mapping
    DataIntegrityViolationException / DuplicateKeyException Unique / FK / NOT NULL constraint 409 or 400
    OptimisticLockingFailureException @Version conflict 409 (client retries/reloads)
    PessimisticLockingFailureException, CannotAcquireLockException Lock timeout, deadlock victim retry, then 503
    EmptyResultDataAccessException queryForObject found nothing 404
    IncorrectResultSizeDataAccessException Expected one row, got several 500 (bug)
    DataAccessResourceFailureException DB down / connection failure 503

    The parent split worth knowing: TransientDataAccessException (a retry might succeed — deadlocks, lock timeouts, optimistic conflicts) vs NonTransientDataAccessException (retrying won’t help — constraint violations, bad SQL). Translation happens in @Repository proxies, Spring Data repositories, and JdbcTemplate.

    #c-dataaccessexception-springs-persistence-exception-hierarchy
  10. 10Retrying transient failures

    Retry only exceptions where a retry can succeed — optimistic-lock conflicts, deadlocks, lock timeouts, a 503 from a downstream service — never validation errors or constraint violations.

    @Retryable(retryFor = OptimisticLockingFailureException.class,
               maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2))
    @Transactional
    public void reserveStock(long itemId, int qty) { … }
    

    @Retryable comes from Spring Retry (@EnableRetry); Spring Framework 7 also ships a built-in @Retryable (org.springframework.resilience, enabled with @EnableResilientMethods). Key gotchas:

    • the retry must wrap the transaction, not run inside it — each attempt needs a fresh transaction and persistence context, otherwise it retries against stale, rollback-only state;
    • it’s proxy-based → no effect on self-invocation;
    • the operation must be idempotent, or a retry after a timeout can apply it twice;
    • add backoff with jitter so concurrent retries don’t collide again.
    #c-retrying-transient-failures
  11. 11Exceptions from outgoing HTTP calls

    RestClient / RestTemplate throw by default for error responses: HttpClientErrorException (4xx — subclasses like HttpClientErrorException.NotFound), HttpServerErrorException (5xx), and ResourceAccessException for I/O errors and timeouts. RestClient lets you map statuses explicitly:

    restClient.get().uri("/customers/{id}", id)
        .retrieve()
        .onStatus(status -> status.value() == 404,
                  (req, res) -> { throw new CustomerNotFoundException(id); })
        .body(Customer.class);
    

    Translate them into your own domain exceptions at the boundary — don’t let a downstream service’s 404 propagate and become your API’s 500 — and always configure connect/read timeouts (the defaults can wait forever).

    #c-exceptions-from-outgoing-http-calls
  12. 12Exceptions in @Async, @Scheduled and event listeners
    • @Async returning void — the exception can’t reach the caller; it goes to the AsyncUncaughtExceptionHandler (by default just logged). Configure one, or return CompletableFuture<T> so the caller can handle it (exceptionally, join()).
    • @Async returning CompletableFuture — the exception is stored in the future; if nobody ever checks it, it’s silently lost.
    • @Scheduled — an exception is logged and the next run still happens; the failure is easy to miss without alerting.
    • @EventListener (synchronous) — the exception propagates to the publisher and can roll back its transaction. @TransactionalEventListener(AFTER_COMMIT) — the transaction is already committed; the exception can’t undo it.
    #c-exceptions-in-async-scheduled-and-event-listeners
  13. 13Designing an application exception hierarchy

    A small, intentional hierarchy keeps handlers simple:

    public abstract class DomainException extends RuntimeException {
        protected DomainException(String message) { super(message); }
        public abstract String code();             // stable, machine-readable error code
    }
    public class OrderNotFoundException extends DomainException { … }   // → 404
    public class InsufficientStockException extends DomainException { … } // → 409/422
    

    Guidelines:

    • Domain/service code throws domain exceptions, unaware of HTTP; the @ControllerAdvice maps them to status codes. The same service can then be called from a Kafka listener or a batch job.
    • Give errors a stable code (ORDER_NOT_FOUND) the client can branch on — never make clients parse messages.
    • Distinguish client errors (4xx) — expected, logged at WARN/INFO without a stack trace — from server errors (5xx) — logged at ERROR with the stack trace.
    • Don’t use exceptions for normal control flow (e.g. return Optional from a lookup; throw only at the point where absence is genuinely an error). Exceptions are expensive mainly because of the stack trace capture.
    #c-designing-an-application-exception-hierarchy
  14. 14Common exception-handling mistakes
    1. Log and rethrow — the same error appears in the logs several times, once per layer. Either handle it (and log) or rethrow (and don’t log); log once at the boundary.
    2. Swallowing — catch (Exception e) {} or logging only e.getMessage() loses the stack trace. Log log.error("…", e) with the exception as the last argument.
    3. Losing the cause — throw new MyException(e.getMessage()) instead of new MyException("…", e).
    4. Leaking internals — returning stack traces, SQL or class names in API responses (information disclosure). Generic message to the client, details in the logs with a correlation/trace id the client can quote.
    5. Catching Exception broadly in business code — also catches bugs (NullPointerException) and Spring’s signal exceptions, turning 403s or 400s into 500s.
    6. Catching inside @Transactional and continuing — leads to unexpected commits, or to UnexpectedRollbackException.
    7. Catching Throwable / Error — OutOfMemoryError leaves the JVM in an unreliable state; let it crash and restart.
    8. Forgetting resources — use try-with-resources for anything AutoCloseable (streams, JDBC objects when not using Spring templates); exceptions from close() are attached as suppressed exceptions instead of masking the original one.
    9. finally with return — silently discards the exception that was propagating.
    #c-common-exception-handling-mistakes