Validation and Exception Handling in Spring Boot
A staff-engineer guide to Bean Validation, global exception handlers, and RFC 7807 ProblemDetail for consistent Spring Boot error responses.
Validation and Exception Handling
An API's error responses are as much a part of its contract as its success responses. This guide covers Bean Validation for rejecting bad input at the boundary, @ControllerAdvice for centralizing error handling, and RFC 7807 ProblemDetail for producing consistent, machine-parseable error payloads — the difference between an API clients can build reliable retry/error-handling logic around and one they have to special-case endpoint by endpoint.
1. Why Centralize Error Handling
Without centralization, every controller method ends up with its own ad hoc try/catch, producing inconsistent error shapes across the API:
// BAD: every endpoint invents its own error format
@GetMapping("/{orderId}")
public ResponseEntity<?> getOrder(@PathVariable String orderId) {
try {
return ResponseEntity.ok(orderService.findById(orderId));
} catch (OrderNotFoundException e) {
Map<String, String> error = new HashMap<>();
error.put("message", e.getMessage()); // different shape per developer, per endpoint
return ResponseEntity.status(404).body(error);
}
}A single, centralized exception-handling layer is one of the highest-leverage pieces of a Spring Boot service. It's where you enforce that every error response — regardless of which controller or service threw it — has the same shape, the same field names, and the same logging behavior.
2. Bean Validation Fundamentals
Spring Boot includes spring-boot-starter-validation, which brings in Hibernate Validator (the reference implementation of Jakarta Bean Validation).
public record CreateOrderRequest(
@NotBlank(message = "customerId is required")
String customerId,
@NotEmpty(message = "at least one line item is required")
@Size(max = 100, message = "an order cannot exceed 100 line items")
List<@Valid LineItemRequest> items,
@NotNull(message = "shipping address is required")
@Valid
ShippingAddressRequest shippingAddress,
@Email(message = "must be a valid email address")
String notificationEmail,
@Positive(message = "quantity must be positive")
int priority
) {}
public record LineItemRequest(
@NotBlank String productId,
@Min(value = 1, message = "quantity must be at least 1") int quantity,
@DecimalMin(value = "0.0", inclusive = false) BigDecimal unitPrice
) {}@PostMapping
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
// If validation fails, this method body never executes —
// MethodArgumentNotValidException is thrown before the controller runs.
Order created = orderService.create(request.toCommand());
return ResponseEntity.status(HttpStatus.CREATED).body(OrderResponse.from(created));
}Common Bean Validation annotations
| Annotation | Validates | Example |
|---|---|---|
@NotNull | Value is not null | Any required field |
@NotBlank | String is not null, not empty, not whitespace-only | Names, IDs |
@NotEmpty | Collection/array/string is not null or empty | Lists that must have at least one entry |
@Size(min=, max=) | Length/size bounds | String length, collection size |
@Min / @Max | Numeric bounds | Quantities, ages |
@Positive / @PositiveOrZero | Numeric sign | Prices, counts |
@Email | Valid email format | Contact fields |
@Pattern(regexp=) | Regex match | Phone numbers, custom formats |
@Past / @Future | Date/time relative to now | Birthdates, expiry dates |
@Valid | Cascades validation into a nested object/collection | Nested DTOs |
@Valid does not automatically cascade into collection elements unless you also annotate the element type — List<@Valid LineItemRequest>, not just @Valid List<LineItemRequest> alone in older setups. In modern Jakarta Bean Validation (used by Spring Boot 3.x), annotating the type argument as shown above is the reliable way to validate every item in the list.
3. @Valid vs @Validated
// @Valid (jakarta.validation) — triggers cascading validation, works on @RequestBody
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) { }
// @Validated (org.springframework.validation.annotation) — Spring's wrapper,
// required for validation groups and for validating @RequestParam / @PathVariable
@RestController
@RequestMapping("/api/v1/orders")
@Validated
public class OrderController {
@GetMapping
public ResponseEntity<List<OrderResponse>> listOrders(
@RequestParam @Min(0) int page,
@RequestParam @Max(100) int size) {
// @Validated on the class is required for method-parameter constraints
// like @Min/@Max on simple @RequestParam values to be enforced
}
}| Aspect | @Valid | @Validated |
|---|---|---|
| Package | jakarta.validation.Valid | org.springframework.validation.annotation.Validated |
| Cascading into nested objects | Yes | Yes |
| Validation groups | No | Yes |
Method parameter validation (@RequestParam, @PathVariable) | No | Yes (class must also be @Validated) |
| Standard (portable outside Spring) | Yes | No, Spring-specific |
// Validation groups — different rules for create vs update
public interface OnCreate {}
public interface OnUpdate {}
public record OrderRequest(
@Null(groups = OnCreate.class, message = "id must not be set on create")
@NotNull(groups = OnUpdate.class, message = "id is required on update")
String id,
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
String customerId
) {}
@PostMapping
public ResponseEntity<OrderResponse> create(@Validated(OnCreate.class) @RequestBody OrderRequest request) { }
@PutMapping("/{id}")
public ResponseEntity<OrderResponse> update(@Validated(OnUpdate.class) @RequestBody OrderRequest request) { }4. Custom Validators
When declarative annotations aren't expressive enough, write a custom constraint.
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidOrderStatusTransition.Validator.class)
public @interface ValidOrderStatusTransition {
String message() default "invalid order status transition";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
class Validator implements ConstraintValidator<ValidOrderStatusTransition, String> {
private static final Set<String> VALID_STATUSES =
Set.of("PENDING", "CONFIRMED", "SHIPPED", "DELIVERED", "CANCELLED");
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
return value == null || VALID_STATUSES.contains(value);
}
}
}public record UpdateStatusRequest(
@ValidOrderStatusTransition String status
) {}Custom validators are the right tool for format and shape validation (does this string look like a valid SKU, is this enum value one we support). They are the wrong tool for validation that requires a database lookup (does this customerId exist) — that kind of check belongs in the service layer, not a ConstraintValidator, because injecting a repository into a validator creates a tight, hard-to-test coupling between the validation and persistence layers.
5. Centralizing Errors with @RestControllerAdvice
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Request validation failed");
problem.setTitle("Validation Error");
List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
.toList();
problem.setProperty("errors", errors);
return problem;
}
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(DuplicateOrderException.class)
public ProblemDetail handleConflict(DuplicateOrderException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleForbidden(AccessDeniedException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "You do not have permission to perform this action");
}
// Catch-all — the last line of defense. Never leak internal details here.
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
log.error("Unhandled exception", ex); // full stack trace goes to logs, not the client
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
problem.setTitle("Internal Server Error");
return problem;
}
}Never let the catch-all Exception handler leak ex.getMessage() or a stack trace to the client. Internal exception messages routinely contain SQL fragments, file paths, or internal class names — an information disclosure risk. Log the full exception server-side with a correlation ID, and return a generic message to the client, optionally including that correlation ID so support can trace it back to the log entry.
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
String correlationId = UUID.randomUUID().toString();
log.error("Unhandled exception [correlationId={}]", correlationId, ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred. Reference: " + correlationId);
return problem;
}6. RFC 7807 ProblemDetail
Spring 6 / Spring Boot 3 ship native support for RFC 7807 "Problem Details for HTTP APIs" via org.springframework.http.ProblemDetail — a standardized, machine-parseable error format that replaces ad hoc {"error": "..."} payloads.
{
"type": "about:blank",
"title": "Validation Error",
"status": 400,
"detail": "Request validation failed",
"instance": "/api/v1/orders",
"errors": [
{ "field": "customerId", "message": "customerId is required" },
{ "field": "items", "message": "at least one line item is required" }
]
}| Field | Meaning |
|---|---|
type | A URI identifying the problem type (defaults to about:blank) |
title | Short, human-readable summary |
status | HTTP status code, duplicated in the body for convenience |
detail | Human-readable explanation specific to this occurrence |
instance | URI identifying the specific occurrence (often the request path) |
| (extension properties) | Any additional structured data via setProperty() |
@ExceptionHandler(InsufficientInventoryException.class)
public ProblemDetail handleInsufficientInventory(InsufficientInventoryException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
problem.setType(URI.create("https://api.acme.com/problems/insufficient-inventory"));
problem.setProperty("productId", ex.getProductId());
problem.setProperty("requested", ex.getRequestedQuantity());
problem.setProperty("available", ex.getAvailableQuantity());
return problem;
}ProblemDetail is now the default error format Spring Boot 3 uses for its own built-in error responses (404s, 405s, etc.) when spring.mvc.problemdetails.enabled=true is set. Adopting it for your own exceptions means your custom errors and the framework's built-in errors share one consistent shape — a real win for API consumers.
# application.yml
spring:
mvc:
problemdetails:
enabled: true7. Domain Exceptions: Design Guidance
// Base for all domain-level exceptions — carries no HTTP concerns
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) { super(message); }
}
public class OrderNotFoundException extends DomainException {
public OrderNotFoundException(String orderId) {
super("Order not found: " + orderId);
}
}
public class DuplicateOrderException extends DomainException {
public DuplicateOrderException(String idempotencyKey) {
super("Order already exists for idempotency key: " + idempotencyKey);
}
}Keep exceptions in the domain/service layer free of HTTP concerns. OrderNotFoundException should not know it eventually becomes a 404 — that mapping belongs entirely in @RestControllerAdvice. This separation means the same service layer can be reused behind a REST API, a gRPC endpoint, or a message consumer, each mapping domain exceptions to their own transport-specific error format.
Checked vs unchecked in Spring service layers
| Style | Behavior | Recommendation |
|---|---|---|
Unchecked (RuntimeException) | No throws clause needed, doesn't force callers to handle | Preferred for domain exceptions in Spring services |
Checked (Exception) | Forces try/catch or throws at every call site | Avoid for domain logic — adds ceremony without safety benefit in a layered service architecture |
A checked exception thrown from inside a @Transactional method that is not a RuntimeException will not trigger a rollback by default — Spring only rolls back on unchecked exceptions unless you explicitly configure @Transactional(rollbackFor = Exception.class). This is one of the most common causes of "the transaction didn't roll back" bugs in production.
8. Handling Specific Framework Exceptions
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail handleMalformedJson(HttpMessageNotReadableException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Malformed JSON request body");
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ProblemDetail handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
String detail = String.format("Parameter '%s' should be of type %s",
ex.getName(), ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown");
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail);
}
@ExceptionHandler(NoHandlerFoundException.class)
public ProblemDetail handleNotFoundRoute(NoHandlerFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, "No such endpoint");
}
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ProblemDetail handleMethodNotAllowed(HttpRequestMethodNotSupportedException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.METHOD_NOT_ALLOWED,
"Method " + ex.getMethod() + " is not supported for this endpoint");
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail handleDataIntegrity(DataIntegrityViolationException ex) {
// e.g. unique constraint violation from the database
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The request conflicts with existing data");
}
}Exception handler methods are matched by most specific exception type first. If you register a handler for both DataAccessException (broad) and DataIntegrityViolationException (its subtype, specific), Spring dispatches to the more specific one. Order your handlers by specificity in your mind even though Spring resolves this automatically — it avoids confusion when debugging why a particular handler didn't fire.
9. End-to-End Flow
Key takeaways
- Centralize error handling in a single
@RestControllerAdvice— never scattertry/catchblocks with ad hoc error shapes across controllers. - Use
@Validfor standard cascading Bean Validation; reach for@Validatedonly when you need validation groups or method-parameter (@RequestParam/@PathVariable) validation. - Custom
ConstraintValidators are for format/shape checks, not for checks requiring a database lookup — those belong in the service layer. - Adopt
ProblemDetail(RFC 7807) as your standard error shape; it's now Spring Boot's own default for built-in errors, so aligning your custom exceptions with it gives API consumers one consistent contract. - Never leak stack traces, SQL fragments, or internal exception messages to clients through the catch-all handler — log server-side with a correlation ID and return a generic message.
- Keep domain exceptions free of HTTP concerns; the HTTP status mapping belongs exclusively in the exception-handling layer.
- Remember that Spring only rolls back transactions on unchecked exceptions by default — a checked exception in a
@Transactionalmethod silently commits unless you configurerollbackFor. - Prefer unchecked (
RuntimeException-based) domain exceptions in service layers to avoidthrows-clause ceremony without a corresponding safety benefit.
Interview Questions
- Why should error handling be centralized in a
@RestControllerAdviceinstead oftry/catchin each controller? - What is the difference between
@Validand@Validated? When would you need@Validatedspecifically? - How does
List<@Valid LineItemRequest>differ from@Valid List<LineItemRequest>in terms of what gets validated? - What is RFC 7807, and what problem does
ProblemDetailsolve compared to ad hoc error JSON? - Why is it dangerous to return
ex.getMessage()directly from a genericExceptionhandler? - How would you design validation groups to apply different rules on create versus update for the same DTO?
- When should you write a custom
ConstraintValidator, and when does that logic belong in the service layer instead? - Why don't checked exceptions trigger a rollback in a
@Transactionalmethod by default? - Should domain-layer exceptions know about HTTP status codes? Why or why not?
- How does Spring decide which
@ExceptionHandlermethod to invoke when multiple handlers could match an exception's type hierarchy? - What status code would you return for a request that is syntactically valid JSON but violates a business rule, and why?
- How would you correlate a generic 500 error response with the actual stack trace in your logs?
- What's the risk of using
DataIntegrityViolationExceptionhandling as your only defense against duplicate records, instead of an application-level uniqueness check?