For most Spring Boot REST APIs, you commonly handle these globally: #
MethodArgumentNotValidException // @Valid on @RequestBody (validation)
ConstraintViolationException // @Validated on params
HttpMessageNotReadableException // Body can't be parsed/deserialized
MethodArgumentTypeMismatchException // Wrong type in @PathVariable/@RequestParam (e.g. /users/abc where id is Long)
MissingServletRequestParameterException // Required @RequestParam not present
ResponseStatusException // Ad-hoc errors with arbitrary status
DataIntegrityViolationException
AccessDeniedException
ExceptionRequest arrives
↓
Required param/header/cookie check ← MissingServletRequestParameterException
↓
Path/query param conversion ← MethodArgumentTypeMismatchException
↓
Body deserialization ← HttpMessageNotReadableException
↓
Bean validation (@Valid) ← MethodArgumentNotValidException
↓
Method-level validation ← ConstraintViolationException
↓
Controller method executesRequest arrives
↓
Authentication filter ← AuthenticationException → 401
↓
Authorization filter (URL rules) ← AccessDeniedException → 403 (filter path)
↓
DispatcherServlet
↓
Method security (@PreAuthorize) ← AccessDeniedException → 403 (controller path)
↓
Controller method executesAnd commonly throw these yourself: #
IllegalArgumentException
IllegalStateException
ResponseStatusException
AccessDeniedExceptionTemplates #
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handle(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> Optional.ofNullable(fe.getDefaultMessage()).orElse("invalid"),
(a, b) -> a
));
return ResponseEntity.badRequest().body(Map.of(
"status", 400,
"error", "Validation Failed",
"fieldErrors", fieldErrors
));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Map<String, Object>> handle(ConstraintViolationException ex) {
Map<String, String> violations = ex.getConstraintViolations()
.stream()
.collect(Collectors.toMap(
this::extractFieldName,
ConstraintViolation::getMessage,
(a, b) -> a
));
return ResponseEntity.badRequest().body(Map.of(
"status", 400,
"error", "Validation Failed",
"violations", violations
));
}
private String extractFieldName(ConstraintViolation<?> v) {
return StreamSupport.stream(v.getPropertyPath().spliterator(), false)
.reduce((first, second) -> second)
.map(Path.Node::getName)
.orElse("unknown");
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<Map<String, Object>> handle(HttpMessageNotReadableException ex) {
Throwable cause = ex.getCause();
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 400);
body.put("error", "Malformed Request");
if (cause instanceof InvalidFormatException ife) {
// Type mismatch — e.g. "twenty" for an int field
String field = ife.getPath().stream()
.map(JsonMappingException.Reference::getFieldName)
.filter(Objects::nonNull)
.reduce((a, b) -> a + "." + b)
.orElse("unknown");
body.put("message", "Invalid value for field '" + field + "'");
body.put("expectedType", ife.getTargetType().getSimpleName());
} else if (cause instanceof MismatchedInputException mie) {
// Missing required field, wrong shape, etc.
String field = mie.getPath().stream()
.map(JsonMappingException.Reference::getFieldName)
.filter(Objects::nonNull)
.reduce((a, b) -> a + "." + b)
.orElse("unknown");
body.put("message", "Invalid input for field '" + field + "'");
} else if (cause instanceof JsonParseException) {
// Malformed JSON syntax
body.put("message", "Malformed JSON");
} else {
body.put("message", "Request body could not be read");
}
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<Map<String, Object>> handle(MethodArgumentTypeMismatchException ex) {
String paramName = ex.getName();
Object value = ex.getValue();
Class<?> requiredType = ex.getRequiredType();
String typeName = requiredType != null ? requiredType.getSimpleName() : "unknown";
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 400);
body.put("error", "Type Mismatch");
body.put("message", String.format(
"Parameter '%s' should be of type %s but received '%s'",
paramName, typeName, value
));
body.put("parameter", paramName);
body.put("expectedType", typeName);
// Special handling for enums — show valid values
if (requiredType != null && requiredType.isEnum()) {
body.put("allowedValues", requiredType.getEnumConstants());
}
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(ServletRequestBindingException.class)
public ResponseEntity<Map<String, Object>> handleBindingErrors(ServletRequestBindingException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 400);
body.put("error", "Bad Request");
body.put("message", ex.getMessage());
// Add specifics based on subtype
if (ex instanceof MissingServletRequestParameterException p) {
body.put("missingParameter", p.getParameterName());
body.put("expectedType", p.getParameterType());
} else if (ex instanceof MissingRequestHeaderException h) {
body.put("missingHeader", h.getHeaderName());
} else if (ex instanceof MissingRequestCookieException c) {
body.put("missingCookie", c.getCookieName());
}
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<Map<String, Object>> handle(ResponseStatusException ex) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", ex.getStatusCode().value());
body.put("error", HttpStatus.valueOf(ex.getStatusCode().value()).getReasonPhrase());
body.put("message", ex.getReason());
return ResponseEntity.status(ex.getStatusCode())
.headers(ex.getHeaders())
.body(body);
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<Map<String, Object>> handle(DataIntegrityViolationException ex) {
Map<String, Object> body = new LinkedHashMap<>();
HttpStatus status = HttpStatus.CONFLICT; // 409 default
// Drill into the cause for specifics
Throwable root = ex.getMostSpecificCause();
String rootMessage = root.getMessage() != null ? root.getMessage().toLowerCase() : "";
if (root instanceof org.hibernate.exception.ConstraintViolationException hibernateEx) {
String constraintName = hibernateEx.getConstraintName();
body.put("constraint", constraintName);
// SQL state codes (PostgreSQL):
// 23505 = unique_violation
// 23503 = foreign_key_violation
// 23502 = not_null_violation
// 23514 = check_violation
String sqlState = hibernateEx.getSQLState();
switch (sqlState) {
case "23505" -> {
body.put("error", "Duplicate Entry");
body.put("message", "A record with these values already exists");
status = HttpStatus.CONFLICT;
}
case "23503" -> {
body.put("error", "Invalid Reference");
body.put("message", "Referenced resource does not exist or is in use");
status = HttpStatus.CONFLICT;
}
case "23502" -> {
body.put("error", "Missing Required Field");
body.put("message", "A required field was not provided");
status = HttpStatus.BAD_REQUEST;
}
case "23514" -> {
body.put("error", "Constraint Violation");
body.put("message", "Provided values violate a database constraint");
status = HttpStatus.BAD_REQUEST;
}
default -> {
body.put("error", "Data Integrity Violation");
body.put("message", "The operation could not be completed");
}
}
} else {
body.put("error", "Data Integrity Violation");
body.put("message", "The operation could not be completed");
}
body.put("status", status.value());
return ResponseEntity.status(status).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handle(Exception ex, HttpServletRequest request) {
// Generate a unique ID to correlate the client response with server logs
String errorId = UUID.randomUUID().toString();
// Log the full exception server-side — this is where engineers will look
log.error("Unhandled exception [errorId={}, path={}, method={}]",
errorId, request.getRequestURI(), request.getMethod(), ex);
// Return a sanitized response — never leak internals
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 500);
body.put("error", "Internal Server Error");
body.put("message", "An unexpected error occurred. Please contact support if the issue persists.");
body.put("errorId", errorId);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}// Status only — generic message
throw new ResponseStatusException(HttpStatus.FORBIDDEN);
// Status + reason — most common
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found");
// Status + reason + cause — when wrapping another exception
throw new ResponseStatusException(
HttpStatus.BAD_GATEWAY,
"Upstream service unavailable",
originalException
);Domain exception alternative #
// Define once
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(Long id) {
super("User " + id + " not found");
}
}
// Throw anywhere
throw new UserNotFoundException(id);
// Handle once
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<?> handle(UserNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User Not Found", "message", ex.getMessage()));
}SQL state codes by vendor (the same logical violation has different codes): #
| Violation | PostgreSQL | MySQL | Oracle | SQL Server |
|---|---|---|---|---|
| Unique | 23505 |
23000 (errno 1062) |
1 |
2627 / 2601 |
| Foreign key | 23503 |
23000 (errno 1452) |
2291 / 2292 |
547 |
| NOT NULL | 23502 |
23000 (errno 1048) |
1400 |
515 |
| Check | 23514 |
23000 (errno 3819) |
2290 |
547 |
TL;DR #
Short answer: enable ProblemDetail, extend ResponseEntityExceptionHandler, but don’t only handle Exception. That last part would throw away most of the value.
Let me explain the reasoning, because there’s a subtle trap in the question.
What “only handle Exception” gets you
#
If your handler is just:
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleAll(Exception ex) {
// generic 500 response
}
}Here’s what actually happens for each exception type:
| Exception | Caught by | Result |
|---|---|---|
MethodArgumentNotValidException |
Parent class (built-in) | ✅ Default ProblemDetail with no field errors |
HttpMessageNotReadableException |
Parent class | ✅ Default ProblemDetail, no specifics |
HttpMediaTypeNotSupportedException |
Parent class | ✅ Default ProblemDetail |
MissingServletRequestParameterException |
Parent class | ✅ Default ProblemDetail |
ConstraintViolationException |
Your Exception.class handler |
❌ Generic 500 — wrong status! |
AccessDeniedException |
Spring Security filters | ❌ Bypasses your handler entirely |
DataIntegrityViolationException |
Your Exception.class handler |
❌ Generic 500 — should be 409 |
UserNotFoundException (custom) |
Your Exception.class handler |
❌ Generic 500 — should be 404 |
The problem: ProblemDetail handles the format, not the semantics. Spring knows that a MethodArgumentNotValidException should be 400, but it doesn’t know your business rules — that UserNotFoundException should be 404, that duplicate-email DB errors should be 409, etc. You still need handlers for those.
Also, Spring doesn’t include field-level details in the default ProblemDetail for validation errors. You’d return {"detail": "Invalid request content"} with no info about which fields are wrong, which is unhelpful for clients.
The actual best-practice setup #
Here’s the structure I’d recommend for a new project:
1. Enable ProblemDetail globally
#
spring:
mvc:
problemdetails:
enabled: true2. Extend ResponseEntityExceptionHandler
#
This handles ~15 Spring MVC exceptions with sensible defaults. You override only the ones where the default is insufficient.
3. Override built-ins where you need to enrich them #
The two that need enrichment in almost every API:
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
Map<String, String> fieldErrors = ex.getBindingResult()
.getFieldErrors()
.stream()
.collect(Collectors.toMap(
FieldError::getField,
fe -> Optional.ofNullable(fe.getDefaultMessage()).orElse("invalid"),
(a, b) -> a
));
ProblemDetail problem = ex.getBody();
problem.setTitle("Validation Failed");
problem.setProperty("fieldErrors", fieldErrors);
return ResponseEntity.status(status).headers(headers).body(problem);
}
@Override
protected ResponseEntity<Object> handleHttpMessageNotReadable(
HttpMessageNotReadableException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
ProblemDetail problem = ex.getBody();
Throwable cause = ex.getCause();
if (cause instanceof InvalidFormatException ife) {
problem.setProperty("field", extractJsonPath(ife.getPath()));
problem.setProperty("expectedType", ife.getTargetType().getSimpleName());
} else if (cause instanceof JsonParseException) {
problem.setDetail("Malformed JSON");
}
return ResponseEntity.status(status).headers(headers).body(problem);
}4. Add @ExceptionHandler for what the parent doesn’t cover
#
Non-MVC exceptions and your domain exceptions:
// Bean Validation on @RequestParam/@PathVariable (NOT covered by parent)
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handle(ConstraintViolationException ex) {
Map<String, String> violations = ex.getConstraintViolations()
.stream()
.collect(Collectors.toMap(
this::extractFieldName,
ConstraintViolation::getMessage,
(a, b) -> a
));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Validation failed");
problem.setTitle("Validation Failed");
problem.setProperty("violations", violations);
return problem;
}
// Persistence layer (NOT covered by parent)
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handle(DataIntegrityViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, "Resource conflict");
problem.setTitle("Conflict");
return problem;
}
// Your domain exceptions
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handle(UserNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("User Not Found");
return problem;
}5. Keep Exception.class as the last-resort catch-all
#
Not the only handler, the floor:
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnknown(Exception ex, HttpServletRequest request) {
String errorId = UUID.randomUUID().toString();
log.error("Unhandled exception [errorId={}, path={}]",
errorId, request.getRequestURI(), ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred. Please contact support if the issue persists."
);
problem.setTitle("Internal Server Error");
problem.setProperty("errorId", errorId);
return problem;
}6. Wire up Spring Security’s two paths #
Filter-level auth failures bypass @RestControllerAdvice entirely. You need a separate AccessDeniedHandler and AuthenticationEntryPoint that produce ProblemDetail responses, so Spring Security errors look the same as everything else.
The minimal-but-correct skeleton #
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
// ── Override built-ins to add field-level details ──
@Override protected ResponseEntity<Object> handleMethodArgumentNotValid(...) { ... }
@Override protected ResponseEntity<Object> handleHttpMessageNotReadable(...) { ... }
// ── Non-MVC exceptions Spring doesn't handle ──
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handle(ConstraintViolationException ex) { ... }
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handle(DataIntegrityViolationException ex) { ... }
// ── Your domain exceptions ──
@ExceptionHandler(BusinessException.class)
public ProblemDetail handle(BusinessException ex) { ... }
// ── Last-resort safety net ──
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnknown(Exception ex, HttpServletRequest request) { ... }
}That’s typically 5–8 handlers total for a real API. Not “only Exception,” but also not 20 handlers covering every Spring MVC exception manually.
Why “only Exception” doesn’t work — the deeper reason #
There’s a tempting logic to “just catch Exception and return a generic error.” It feels DRY and consistent. But it conflates two different problems:
- Format consistency — every error response should look the same shape. ✅ ProblemDetail + extending the parent solves this.
- Semantic correctness — every error should have the right status code and useful info for the client. ❌ Catching
Exceptionmakes everything 500, which is wrong for 90% of errors.
A 400 (your fault, client) and a 500 (my fault, server) communicate completely different things to the client and to your monitoring. Conflating them means:
- Clients can’t tell whether to retry (500 → maybe; 400 → no, fix the request)
- Your error rate dashboards become useless because legit client mistakes pollute the “server error” graph
- Alerts fire at 3am because of someone’s bad input, not because your service is broken
- Postman / Insomnia / OpenAPI tooling treats your API as broken
So the principle is: let exception types map to status codes, and use the catch-all only for the genuinely unexpected.
The decision tree #
For each new exception that arises in your codebase, ask:
Is it a Spring MVC built-in?
→ YES: parent class handles it. Override only if you need extra fields.
→ NO: continue.
Is it a known business/domain error?
→ YES: write @ExceptionHandler with the right status code.
→ NO: continue.
Is it from a third-party library?
→ YES: write @ExceptionHandler that maps it to the appropriate status.
→ NO: continue.
It's truly unexpected.
→ The Exception.class catch-all handles it. Log with errorId, return 500.Summary recommendation for your new project #
✅ Enable ProblemDetail
✅ Extend ResponseEntityExceptionHandler
✅ Override 2–3 built-ins to add structured field info (validation, message-not-readable)
✅ Add @ExceptionHandler for: ConstraintViolationException, DataIntegrityViolationException, AccessDeniedException, your domain exceptions
✅ Add a final @ExceptionHandler(Exception.class) as the safety net with error-ID logging
✅ Configure AccessDeniedHandler and AuthenticationEntryPoint in Spring Security to also return ProblemDetail
❌ Don’t only handle Exception — you’d lose semantic correctness in exchange for code brevity that isn’t actually needed.
The setup is a one-time effort (maybe 100 lines total), and it pays off every time a new exception type emerges in the codebase — you usually only need to add one handler, and it slots into a consistent shape.