The problem: three endpoints, three error formats
One endpoint returns a plain string on invalid input, another returns a map with a message field, and a third returns the default Spring error page with a stack trace in the log. The mobile team writes three different parsers. When a field fails validation, the user sees "Bad Request" instead of "email is not valid". Worse, an unexpected exception leaks a class name or SQL fragment in the response body.
Consistent error handling has two halves: reject bad input early with clear rules, and turn every failure into one predictable response shape.
Bean Validation on DTOs
Bean Validation (Jakarta Validation in Spring Boot 3) lets you declare constraints on the request type. Add the spring-boot-starter-validation dependency, then annotate the DTO:
public record RegisterRequest( @NotBlank @Email @Size(max = 254) String email, @NotBlank @Size(min = 12, max = 72) String password, @NotNull @Past LocalDate birthDate, @Valid @NotNull AddressRequest address) {}Activate validation with @Valid on the controller parameter:
@PostMapping("/users")ResponseEntity<Void> register(@Valid @RequestBody RegisterRequest request) { userService.register(request); return ResponseEntity.status(HttpStatus.CREATED).build();}When a constraint fails, Spring throws MethodArgumentNotValidException before your method body runs. Note @Valid on the nested address field: validation does not descend into nested objects unless you ask it to.
For path variables and request parameters you annotate the controller class with @Validated and put constraints directly on the parameters. In Spring 6.1 and later, a failure there is reported as HandlerMethodValidationException, which you should handle as well.
Bean Validation checks shape: required, length, format, range. It should not run database queries. "Email is already registered" is a business rule and belongs in the service.
A custom constraint
When the same rule appears in several DTOs, make it a constraint. Example: an Azerbaijani mobile number in a defined format.
@Documented@Constraint(validatedBy = PhoneValidator.class)@Target({FIELD, PARAMETER, RECORD_COMPONENT})@Retention(RUNTIME)public @interface ValidPhone { String message() default "invalid phone number"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};}public class PhoneValidator implements ConstraintValidator<ValidPhone, String> { private static final Pattern PHONE = Pattern.compile("^\\+994\\d{9}$"); @Override public boolean isValid(String value, ConstraintValidatorContext context) { return value == null || PHONE.matcher(value).matches(); // null is @NotNull's job }}Returning true for null is a convention: combine @NotNull with your constraint when the value is required, so each annotation has one job.
One place for errors: @RestControllerAdvice
A controller advice catches exceptions from all controllers. Spring 6 provides ProblemDetail, a class that models the RFC 9457 "Problem Details for HTTP APIs" format (it replaces RFC 7807). The media type is application/problem+json.
@RestControllerAdviceclass ApiExceptionHandler extends ResponseEntityExceptionHandler { @Override protected ResponseEntity<Object> handleMethodArgumentNotValid( MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) { ProblemDetail problem = ProblemDetail.forStatusAndDetail( HttpStatus.BAD_REQUEST, "One or more fields are invalid."); problem.setTitle("Validation failed"); problem.setProperty("code", "VALIDATION_FAILED"); problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream() .map(e -> Map.of("field", e.getField(), "message", String.valueOf(e.getDefaultMessage()))) .toList()); return ResponseEntity.badRequest().body(problem); } @ExceptionHandler(EmailAlreadyUsedException.class) ProblemDetail handleConflict(EmailAlreadyUsedException ex) { ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); problem.setProperty("code", "EMAIL_ALREADY_USED"); return problem; } @ExceptionHandler(Exception.class) ProblemDetail handleUnexpected(Exception ex) { log.error("Unhandled exception", ex); ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR); problem.setProperty("code", "INTERNAL_ERROR"); return problem; // no exception message, no stack trace }}ResponseEntityExceptionHandler already maps the standard Spring MVC exceptions to problem details; you override the methods where you want to enrich the output. You can also enable the built-in behaviour with spring.mvc.problemdetails.enabled=true, which makes the default handler produce application/problem+json for those exceptions.
The response a client receives looks like this:
{ "type": "about:blank", "title": "Validation failed", "status": 400, "detail": "One or more fields are invalid.", "instance": "/users", "code": "VALIDATION_FAILED", "errors": [{ "field": "email", "message": "must be a well-formed email address" }]}Stable error codes for clients
The human-readable detail can change with wording or language. Clients should not parse it. A machine-readable code is the contract: EMAIL_ALREADY_USED, VALIDATION_FAILED, ORDER_NOT_FOUND. Keep codes in one enum or constants class, document them, and never reuse a code for a different meaning. The type member can be a URI that identifies the problem category if you publish documentation for it; about:blank is acceptable when you have none.
Choose status codes deliberately: 400 for malformed or invalid input, 404 for a missing resource, 409 for a conflict with current state, 422 if your team prefers it for semantic validation failures (be consistent), 401 and 403 for authentication and authorisation, and 500 only for genuine server faults.
Common mistakes
- Returning exception messages from unexpected errors. They can reveal table names, class names or file paths.
- Validating in the controller with manual
ifstatements, so every endpoint does it a little differently. - Forgetting
@Validon nested objects and on elements of collections (List<@Valid LineRequest>). - Running database lookups inside a validator. The extra query hides in a place nobody expects, and the check is still racy; enforce uniqueness with a database constraint and translate the violation.
- Echoing user input into error messages without thinking about log injection or markup in clients.
- Different error shapes for security errors (401/403) than for application errors. Configure the security entry points to write the same format.
- Using translated text as the contract. Translate on the client from the code, or use
Accept-Languagefordetailonly.
Trade-offs
Problem details are a standard with tooling support, but they leave room for extension members, so a team still has to agree on code and errors. A very detailed validation message helps users but can help attackers too (for example, revealing which of the e-mail or password was wrong on a login). Be generous in registration forms and vague in authentication flows.
Validation groups and update scenarios
The same DTO is sometimes used for both creation and update, but the requirements differ: email is mandatory on creation but not on a partial update. Bean Validation groups can solve this, but the DTO becomes unreadable as groups multiply. The simpler way is separate records: CreateUserRequest and UpdateUserRequest. A little duplication is better than confusing conditions later. If a partial update (PATCH) must distinguish a field that was not sent from one explicitly sent as null, treat that as a separate design decision.
Testing the error contract
Error responses are part of your API, so they deserve tests. A web slice test is enough, because it needs no database (@MockitoBean requires Spring Framework 6.2 / Spring Boot 3.4 or newer; on older Boot 3.x versions use @MockBean):
@WebMvcTest(UserController.class)class UserControllerValidationTest { @Autowired MockMvc mvc; @MockitoBean UserService userService; @Test void invalidEmailReturnsProblemDetail() throws Exception { mvc.perform(post("/users").contentType(APPLICATION_JSON) .content("{\"email\":\"nope\",\"password\":\"long-enough-pass\"}")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value("VALIDATION_FAILED")) .andExpect(jsonPath("$.errors[0].field").value("email")); }}Assert on code and field, never on the exact wording of the message. Add one test for the fallback handler as well, so a refactoring cannot silently start leaking exception messages again.
These tests also act as documentation: a new team member can read from the test how an error looks to the client. And when this contract changes, for example when a field name changes or a new code is added, treat it as a change agreed with the mobile and front-end teams, because their code parses this response.
Messages and languages
Constraint messages can be externalised into ValidationMessages.properties and resolved against the request locale. That is useful for forms rendered by the server, but for a JSON API many teams prefer to send only the field name and a code such as EMAIL_INVALID, and let each client display a message in its own language. Whichever way you go, decide it once and apply it everywhere.
Checklist
- Every request body is annotated and the controller parameter has
@Valid. - Nested objects and collection elements are validated.
- One
@RestControllerAdviceproducesapplication/problem+json. - Each error has a stable
code; field errors list the field and a message. - Unexpected exceptions return a generic body and are logged with a stack trace on the server.
- Tests cover at least one validation failure, one business error and the fallback handler.
Where to go next
- How the layers fit together: layering a Spring Boot application.
- Design the exceptions that feed the handler: exception design in Java.
- Input validation is also a security control: API security with the OWASP list.
- Assess your Spring skills with the skills assessment.