Spring Boot Exception
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.validationannotations +@Valid. - For query/path validations:
@Validatedon controller +ConstraintViolationExceptionhandling (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:
ERRORfor server bugs (5xx),WARNfor client issues worth attention (e.g., repeated 4xx),INFOfor expected flows (optional),DEBUG/TRACEduring 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
/errorpath; 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
@WebMvcTestor@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)
- Create a few custom
RuntimeExceptions for domain errors. - One
@RestControllerAdvicethat returns ProblemDetail for:- domain errors (404/409/422, etc.),
- validation (
MethodArgumentNotValidException,ConstraintViolationException), - parsing/binding (
HttpMessageNotReadableException), - persistence (
DataIntegrityViolationException), - a final catch-all
Exception→ 500.
- Add correlation-ID filter + structured logging (MDC).
- Keep messages safe, publish stable
errorCodes, document them in OpenAPI. - 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, unknown2) 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(implementsErrorResponse). - Is it a framework/validation/infra exception?
➜ Let a
@RestControllerAdvicereturnProblemDetail. - Is it a one-off HTTP error from deep code?
➜ Throw
ErrorResponseExceptionwith aProblemDetail.
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) orINFO(expected), and unknown/5xx atERROR. - Keep
detailsafe for clients; never expose stack traces, SQL, class names.
server.error.include-message=never
server.error.include-binding-errors=never
server.error.include-exception=never7) 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
@WebMvcTestfor 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 abstractBaseAppException). - Framework/validation/infra: handle in global
@RestControllerAdviceand returnProblemDetail. - Edge cases/one-offs: throw
ErrorResponseExceptionwith a preparedProblemDetail.
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.