Exception handling
Mid / SeniorChecked 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
01Checked vs unchecked exceptions — and why Spring prefers unchecked
#c-checked-vs-unchecked-exceptions-and-why-spring-prefers-uncheInterview answer: “Checked exceptions extend
Exceptionand must be declared or caught; unchecked ones extendRuntimeExceptionand 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
@Transactionalby default (see below).02The three ways to map an exception to an HTTP response
#c-the-three-ways-to-map-an-exception-to-an-http-response-
@ResponseStatuson the exception class — simplest, static:@ResponseStatus(HttpStatus.NOT_FOUND) public class OrderNotFoundException extends RuntimeException { … } -
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.
-
@ExceptionHandlerin 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.
-
03@ExceptionHandler and @RestControllerAdvice
#c-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
@ExceptionHandlerinside a controller applies only to that controller; one in a@ControllerAdviceapplies 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.classfallback 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 returnProblemDetail,ResponseEntity<…>, or any body object (with@ResponseStatus).
- An
04ProblemDetail and ResponseEntityExceptionHandler
#c-problemdetail-and-responseentityexceptionhandlerProblemDetail(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 returnProblemDetailfor those too and customise them in one place, either setspring.mvc.problemdetails.enabled=trueor extendResponseEntityExceptionHandlerin your advice and override the hooks you need (e.g.handleMethodArgumentNotValid). Spring’s own exceptions implementErrorResponse, so they already carry a status and aProblemDetail; you can implementErrorResponseon your own exceptions too.05What Spring Boot does when nothing handles an exception
#c-what-spring-boot-does-when-nothing-handles-an-exceptionThe exception propagates out of the
DispatcherServlet; the servlet container forwards to/error, served by Boot’sBasicErrorController: JSON for API clients, the “Whitelabel Error Page” for browsers. Its content is controlled byserver.error.include-message,include-stacktrace,include-binding-errors— all off/neverby default for security. Customise by providing your ownErrorAttributesbean, anerror/404.htmltemplate, or (more commonly) handling everything in@ControllerAdviceso/erroris 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).06Exceptions thrown in filters never reach @ControllerAdvice
#c-exceptions-thrown-in-filters-never-reach-controlleradvice@ControllerAdviceonly handles exceptions thrown inside theDispatcherServlet(controllers, interceptors, argument resolution). A servletFilter— JWT parsing, rate limiting, Spring Security — runs before it, so its exceptions go straight to the container’s/errorhandling. 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) andAccessDeniedHandler(403 — authenticated but not allowed); - inject
HandlerExceptionResolverinto the filter and delegate to it, which reuses your@ControllerAdvicelogic.
Note that
@PreAuthorizeon a controller does throw inside the dispatcher, so anAccessDeniedExceptionfrom it is visible to an advice — if you catchExceptionthere, you may accidentally turn 403s into 500s.- in the filter, catch and write the error response yourself (
07Validation errors — which exception for which case
#c-validation-errors-which-exception-for-which-caseWhere the constraint is Exception Default status @Valid @RequestBodyDTO fieldsMethodArgumentNotValidException400 @Validon a@ModelAttribute/ form objectBindException(parent of the above)400 Constraints directly on controller parameters ( @RequestParam @Min(1) int page) — Spring 6.1+ built-in method validationHandlerMethodValidationException400 @Validatedclass, constraints on service method parametersConstraintViolationException(Jakarta)500 unless you handle it Malformed JSON / wrong type in body HttpMessageNotReadableException400 @PathVariable Long idgiven"abc"MethodArgumentTypeMismatchException400 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; }08Exceptions and @Transactional rollback
#c-exceptions-and-transactional-rollbackInterview answer: “By default a
@Transactionalmethod rolls back onRuntimeExceptionandError, and commits on checked exceptions. UserollbackForto 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 throwsUnexpectedRollbackException. - 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 thetry. - 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).
09DataAccessException — Spring's persistence exception hierarchy
#c-dataaccessexception-springs-persistence-exception-hierarchySpring translates driver- and ORM-specific exceptions (
SQLExceptionwith 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/DuplicateKeyExceptionUnique / FK / NOT NULL constraint 409 or 400 OptimisticLockingFailureException@Versionconflict409 (client retries/reloads) PessimisticLockingFailureException,CannotAcquireLockExceptionLock timeout, deadlock victim retry, then 503 EmptyResultDataAccessExceptionqueryForObjectfound nothing404 IncorrectResultSizeDataAccessExceptionExpected one row, got several 500 (bug) DataAccessResourceFailureExceptionDB down / connection failure 503 The parent split worth knowing:
TransientDataAccessException(a retry might succeed — deadlocks, lock timeouts, optimistic conflicts) vsNonTransientDataAccessException(retrying won’t help — constraint violations, bad SQL). Translation happens in@Repositoryproxies, Spring Data repositories, andJdbcTemplate.10Retrying transient failures
#c-retrying-transient-failuresRetry 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) { … }@Retryablecomes 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.
11Exceptions from outgoing HTTP calls
#c-exceptions-from-outgoing-http-callsRestClient/RestTemplatethrow by default for error responses:HttpClientErrorException(4xx — subclasses likeHttpClientErrorException.NotFound),HttpServerErrorException(5xx), andResourceAccessExceptionfor I/O errors and timeouts.RestClientlets 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).
12Exceptions in @Async, @Scheduled and event listeners
#c-exceptions-in-async-scheduled-and-event-listeners@Asyncreturningvoid— the exception can’t reach the caller; it goes to theAsyncUncaughtExceptionHandler(by default just logged). Configure one, or returnCompletableFuture<T>so the caller can handle it (exceptionally,join()).@AsyncreturningCompletableFuture— 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.
13Designing an application exception hierarchy
#c-designing-an-application-exception-hierarchyA 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/422Guidelines:
- Domain/service code throws domain exceptions, unaware of HTTP; the
@ControllerAdvicemaps 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/INFOwithout a stack trace — from server errors (5xx) — logged atERRORwith the stack trace. - Don’t use exceptions for normal control flow (e.g. return
Optionalfrom a lookup; throw only at the point where absence is genuinely an error). Exceptions are expensive mainly because of the stack trace capture.
- Domain/service code throws domain exceptions, unaware of HTTP; the
14Common exception-handling mistakes
#c-common-exception-handling-mistakes- 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.
- Swallowing —
catch (Exception e) {}or logging onlye.getMessage()loses the stack trace. Loglog.error("…", e)with the exception as the last argument. - Losing the cause —
throw new MyException(e.getMessage())instead ofnew MyException("…", e). - 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.
- Catching
Exceptionbroadly in business code — also catches bugs (NullPointerException) and Spring’s signal exceptions, turning 403s or 400s into 500s. - Catching inside
@Transactionaland continuing — leads to unexpected commits, or toUnexpectedRollbackException. - Catching
Throwable/Error—OutOfMemoryErrorleaves the JVM in an unreliable state; let it crash and restart. - Forgetting resources — use try-with-resources for anything
AutoCloseable(streams, JDBC objects when not using Spring templates); exceptions fromclose()are attached as suppressed exceptions instead of masking the original one. finallywithreturn— silently discards the exception that was propagating.