Skip to main content
  1. Notes/

Spring Boot Exception Best Practice

·10 mins·
Table of Contents

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
Exception
Request 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 executes
Request arrives
Authentication filter            ← AuthenticationException → 401
Authorization filter (URL rules) ← AccessDeniedException → 403 (filter path)
DispatcherServlet
Method security (@PreAuthorize)  ← AccessDeniedException → 403 (controller path)
Controller method executes

And commonly throw these yourself:
#

IllegalArgumentException
IllegalStateException
ResponseStatusException
AccessDeniedException

Templates
#

@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: true

2. 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:

  1. Format consistency — every error response should look the same shape. ✅ ProblemDetail + extending the parent solves this.
  2. Semantic correctness — every error should have the right status code and useful info for the client. ❌ Catching Exception makes 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.