Skip to main content
  1. Notes/

Spring Boot Exception

·8 mins·

USER
#

in java 21, springboot 3, what’s the best practice to handle exception in projects

Thoughts
#

In Spring Boot 3, I’ll suggest using the ProblemDetail class (RFC7807) for structured error reports. Utilize @ControllerAdvice, @ExceptionHandler, and @RestControllerAdvice with logging (slf4j). Map exceptions to details like type, title, and status. While @ResponseStatus is still valid, consider customizing with WebMvcConfigurer for MVC and WebFlux.

ASSISTANT
#

Great question. In Java 21 + Spring Boot 3 (Spring Framework 6), the “modern” best practice is to use RFC 7807 Problem Details everywhere, with a small, intentional exception hierarchy and a single global handler. Here’s a compact blueprint you can drop into any project.

1) Define a small, meaningful exception set
#

  • Prefer unchecked domain exceptions (extend RuntimeException), e.g. ResourceNotFoundException, BusinessRuleViolationException, ConflictException.
  • Keep messages developer-friendly; expose safe, client-facing text in the response body (not stack traces).
  • Use Spring’s built-ins where appropriate (e.g. MethodArgumentNotValidException, ConstraintViolationException, HttpMessageNotReadableException, DataIntegrityViolationException).
public class ResourceNotFoundException extends RuntimeException {
  public ResourceNotFoundException(String msg) { super(msg); }
}

2) Return RFC 7807 responses via ProblemDetail
#

Spring 6 introduces ProblemDetail and ErrorResponse/ErrorResponseException. Prefer these for consistent JSON errors.

@RestControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(ResourceNotFoundException.class)
  ProblemDetail handleNotFound(ResourceNotFoundException ex, HttpServletRequest req) {
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
    pd.setTitle("Resource Not Found");
    pd.setDetail(ex.getMessage());
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setType(URI.create("https://example.com/problems/resource-not-found"));
    pd.setProperty("errorCode", "RES_404_001");
    return pd;
  }

  @ExceptionHandler(MethodArgumentNotValidException.class)
  ProblemDetail handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    pd.setTitle("Validation Failed");
    pd.setDetail("One or more fields are invalid.");
    pd.setInstance(URI.create(req.getRequestURI()));
    var fieldErrors = ex.getBindingResult().getFieldErrors().stream()
        .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
        .toList();
    pd.setProperty("violations", fieldErrors);
    return pd;
  }

  @ExceptionHandler(ConstraintViolationException.class)
  ProblemDetail handleConstraint(ConstraintViolationException ex, HttpServletRequest req) {
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    pd.setTitle("Constraint Violation");
    pd.setDetail("One or more constraints were violated.");
    pd.setInstance(URI.create(req.getRequestURI()));
    var violations = ex.getConstraintViolations().stream()
        .map(v -> Map.of("path", v.getPropertyPath().toString(), "message", v.getMessage()))
        .toList();
    pd.setProperty("violations", violations);
    return pd;
  }

  // Fallback
  @ExceptionHandler(Exception.class)
  ProblemDetail handleUnknown(Exception ex, HttpServletRequest req) {
    // log full details server-side only
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    pd.setTitle("Internal Server Error");
    pd.setDetail("An unexpected error occurred. If the problem persists, contact support.");
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setType(URI.create("https://example.com/problems/internal"));
    return pd;
  }
}

Typical JSON (client-facing):

{
  "type": "https://example.com/problems/validation-failed",
  "title": "Validation Failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "instance": "/api/orders",
  "violations": [
    { "field": "email", "message": "must be a well-formed email address" }
  ],
  "errorCode": "VAL_400_002"
}

3) Use ErrorResponseException inside controllers/services when needed
#

If you must throw with an HTTP status from deep layers:

throw new ErrorResponseException(HttpStatus.FORBIDDEN,
  ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "Not allowed for this account"), null);

4) Validation: prefer annotations + groups
#

  • DTOs: jakarta.validation annotations + @Valid.
  • For query/path validations: @Validated on controller + ConstraintViolationException handling (shown above).
public record CreateUserRequest(
  @NotBlank String username,
  @Email String email
) {}
@PostMapping("/users")
public UserDto create(@Valid @RequestBody CreateUserRequest req) { ... }

5) Logging & observability
#

  • Never log stack traces at INFO/WARN. Use:
    • ERROR for server bugs (5xx),
    • WARN for client issues worth attention (e.g., repeated 4xx),
    • INFO for expected flows (optional),
    • DEBUG/TRACE during development.
  • Add a correlation/request ID (e.g., from X-Request-Id) via a filter and put it in MDC so logs tie to a request. Return it back in responses.
@Component
public class CorrelationIdFilter implements Filter {
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
    String cid = Optional.ofNullable(((HttpServletRequest) request).getHeader("X-Request-Id"))
        .orElse(UUID.randomUUID().toString());
    try (MDC.MDCCloseable ignored = MDC.putCloseable("cid", cid)) {
      ((HttpServletResponse) response).setHeader("X-Request-Id", cid);
      chain.doFilter(request, response);
    }
  }
}

6) Transactions & rollback rules
#

  • By default, runtime exceptions cause rollback; checked exceptions do not.
  • If you throw checked exceptions and still want rollback: @Transactional(rollbackFor = Exception.class).
  • Keep business exceptions unchecked to align with default rollback semantics.

7) Don’t leak internals
#

  • Avoid exposing DB keys, stack traces, or class names in detail.
  • Map low-level exceptions to stable, documented error codes. Keep a central ErrorCatalog (enum or constants) and document them in your API spec.

8) Integrate with API docs
#

  • If you use springdoc-openapi, define common error schemas (ProblemDetail with violations, errorCode) and reference them in endpoint responses for 4xx/5xx.

9) Configuration and defaults
#

  • Keep Boot’s default /error path; but turn on helpful bits in non-prod only:
    • server.error.include-message=never (prod), always (local)
    • server.error.include-binding-errors=never (prod), always (local)
    • server.error.include-exception=never
  • Centralize handler in one package and test it with @WebMvcTest or @SpringBootTest.

10) For reactive (WebFlux)
#

Same approach: @RestControllerAdvice + ProblemDetail. Use reactive equivalents (ServerWebExchange if you need the path for instance).


TL;DR template (copy/paste)
#

  1. Create a few custom RuntimeExceptions for domain errors.
  2. One @RestControllerAdvice that returns ProblemDetail for:
    • domain errors (404/409/422, etc.),
    • validation (MethodArgumentNotValidException, ConstraintViolationException),
    • parsing/binding (HttpMessageNotReadableException),
    • persistence (DataIntegrityViolationException),
    • a final catch-all Exception → 500.
  3. Add correlation-ID filter + structured logging (MDC).
  4. Keep messages safe, publish stable errorCodes, document them in OpenAPI.
  5. Lean on default transaction rollback (unchecked exceptions).

If you want, I can turn this into a small starter module (with tests) you can drop into your multi-module repo.


Awesome — here’s a pragmatic, hybrid strategy that uses ProblemDetail, ErrorResponse, and ErrorResponseException together without overengineering.

0) Goals
#

  • One consistent JSON shape (RFC 7807).
  • Minimal boilerplate for common cases.
  • Clear separation: domain errors vs framework/validation/infra.
  • Good logging + traceability.

1) Project layout (suggested)
#

com.example.shared.error
  ├─ ErrorCode.java           // stable catalog of error codes
  ├─ BaseAppException.java    // implements ErrorResponse
  ├─ NotFoundException.java   // extends BaseAppException
  ├─ ConflictException.java   // extends BaseAppException
  ├─ ValidationAdvice.java    // @RestControllerAdvice: validation & parsing
  └─ FallbackAdvice.java      // @RestControllerAdvice: 3rd-party, unknown

2) Prefer custom exceptions for domain errors (implement ErrorResponse)
#

Make reusable domain exceptions self-describing by implementing ErrorResponse, so you don’t need a handler for each.

// ErrorCode is your stable, documented catalog
public enum ErrorCode {
  USER_NOT_FOUND("USR_404_001", "The user does not exist."),
  EMAIL_TAKEN("USR_409_002", "Email already in use.");
  // ...
  public final String code;
  public final String defaultMessage;
  ErrorCode(String code, String defaultMessage) { this.code = code; this.defaultMessage = defaultMessage; }
}
public abstract class BaseAppException extends RuntimeException implements ErrorResponse {
  private final ProblemDetail body;
  private final HttpStatus status;

  protected BaseAppException(HttpStatus status, ErrorCode error, String detail, URI type, URI instance) {
    super(detail);
    this.status = status;
    this.body = ProblemDetail.forStatus(status);
    body.setTitle(error.defaultMessage);          // safe, client-facing summary
    body.setDetail(detail);                       // domain-specific detail (safe)
    body.setType(type != null ? type : URI.create("https://example.com/problems/" + error.code));
    if (instance != null) body.setInstance(instance);
    body.setProperty("errorCode", error.code);    // stable code for clients
  }

  @Override public ProblemDetail getBody()      { return body; }
  @Override public HttpStatusCode getStatusCode(){ return status; }
}
public class NotFoundException extends BaseAppException {
  public NotFoundException(ErrorCode error, String detail, URI instance) {
    super(HttpStatus.NOT_FOUND, error, detail, null, instance);
  }
}

public class ConflictException extends BaseAppException {
  public ConflictException(ErrorCode error, String detail, URI instance) {
    super(HttpStatus.CONFLICT, error, detail, null, instance);
  }
}

Usage (service or controller):

// Service layer
userRepo.findById(id).orElseThrow(() ->
    new NotFoundException(ErrorCode.USER_NOT_FOUND, "User %s not found".formatted(id), URI.create("/users/" + id)));

➡️ Why: The exception already knows how to serialize to a ProblemDetail. No per-type handler needed.

3) Use ProblemDetail in advice for framework/validation/infra
#

Keep two small advices to normalize validation, parsing, DB, security, etc.

@RestControllerAdvice
class ValidationAdvice {

  @ExceptionHandler(MethodArgumentNotValidException.class)
  ProblemDetail handleInvalid(MethodArgumentNotValidException ex, HttpServletRequest req) {
    var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    pd.setTitle("Validation Failed");
    pd.setDetail("One or more fields are invalid.");
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setType(URI.create("https://example.com/problems/validation"));
    pd.setProperty("violations", ex.getBindingResult().getFieldErrors().stream()
        .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
        .toList());
    return pd;
  }

  @ExceptionHandler(ConstraintViolationException.class)
  ProblemDetail handleConstraint(ConstraintViolationException ex, HttpServletRequest req) {
    var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
    pd.setTitle("Constraint Violation");
    pd.setDetail("One or more constraints were violated.");
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setType(URI.create("https://example.com/problems/validation"));
    pd.setProperty("violations", ex.getConstraintViolations().stream()
        .map(v -> Map.of("path", v.getPropertyPath().toString(), "message", v.getMessage()))
        .toList());
    return pd;
  }
}
@RestControllerAdvice
class FallbackAdvice {

  @ExceptionHandler(DataIntegrityViolationException.class)
  ProblemDetail handleDataIntegrity(DataIntegrityViolationException ex, HttpServletRequest req) {
    var pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
    pd.setTitle("Data Integrity Violation");
    pd.setDetail("A database constraint was violated.");
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setType(URI.create("https://example.com/problems/data-integrity"));
    return pd;
  }

  @ExceptionHandler(Exception.class)
  ProblemDetail handleUnknown(Exception ex, HttpServletRequest req) {
    // log with correlation id in MDC (see §6)
    var pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
    pd.setTitle("Internal Server Error");
    pd.setDetail("An unexpected error occurred. If the problem persists, contact support.");
    pd.setInstance(URI.create(req.getRequestURI()));
    pd.setType(URI.create("https://example.com/problems/internal");
    return pd;
  }
}

➡️ Why: Centralize all “generic” exceptions via ProblemDetail without custom types.

4) Use ErrorResponseException for quick one-offs
#

If you don’t need a custom class and want to bubble up an HTTP error from deep code:

throw new ErrorResponseException(
  HttpStatus.FORBIDDEN,
  ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "Not allowed for this account"),
  null
);

Good for rare cases or adapter layers where you don’t want new exception types.

5) Simple decision checklist
#

  • Is it a recurring domain error? ➜ Create/throw a custom exception that extends BaseAppException (implements ErrorResponse).
  • Is it a framework/validation/infra exception? ➜ Let a @RestControllerAdvice return ProblemDetail.
  • Is it a one-off HTTP error from deep code?Throw ErrorResponseException with a ProblemDetail.

6) Cross-cutting: logging, request ID, and safety
#

  • Put a correlation/request ID (e.g., X-Request-Id) in MDC via a servlet filter and echo it in responses.
  • Log domain exceptions at WARN (client-caused) or INFO (expected), and unknown/5xx at ERROR.
  • Keep detail safe for clients; never expose stack traces, SQL, class names.
server.error.include-message=never
server.error.include-binding-errors=never
server.error.include-exception=never

7) OpenAPI & clients
#

With springdoc-openapi, define a Problem schema once (ProblemDetail + errorCode + violations) and reference it for 4xx/5xx. This guarantees a predictable contract for frontend/other services.

8) Testing pattern
#

  • @WebMvcTest for each advice class to snapshot JSON structure for typical exceptions.
  • Service-layer tests that assert thrown exception type (NotFoundException, ConflictException, etc.).
  • One integration test ensuring MDC/request-id is present on error responses.

TL;DR
#

  • Domain errors: custom exceptions that implement ErrorResponse (via an abstract BaseAppException).
  • Framework/validation/infra: handle in global @RestControllerAdvice and return ProblemDetail.
  • Edge cases/one-offs: throw ErrorResponseException with a prepared ProblemDetail.

If you want, tell me your package names and I’ll drop in ready-to-compile classes (with JUnit tests and a minimal sample controller) tailored to your repo.