00:00

Spring Boot Validation

Every application receives data it cannot trust: request bodies, query parameters, form fields and configuration files. Spring Boot validates this data with the Jakarta Bean Validation API (formerly JSR 380), using Hibernate Validator as the implementation. You describe the rules once, as annotations on your classes, and Spring checks them automatically before your code runs.

1. Adding the Dependency

Since Spring Boot 2.3, validation is not included in spring-boot-starter-web. Add the validation starter explicitly:

pom.xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

If you use Gradle:

build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'

In Spring Boot 3 and later, all validation annotations live in the jakarta.validation package. Imports from javax.validation compile, but they are ignored at runtime.

2. Built-in Constraint Annotations

These are the annotations you will use most often. All of them accept a message attribute to customize the error text.

AnnotationWhat it checksExample
@NotNullThe value is not null@NotNull Integer age
@NotEmptyNot null and not empty (strings, collections, maps, arrays)@NotEmpty List<String> roles
@NotBlankNot null and contains at least one non-whitespace character@NotBlank String name
@SizeLength of a string or size of a collection is within a range@Size(min = 2, max = 30)
@Min / @MaxA number is at least or at most a value@Min(18) @Max(120)
@Positive / @PositiveOrZeroA number is greater than zero, or zero and above@Positive BigDecimal price
@DecimalMin / @DigitsLower bound and digit precision for decimal values@Digits(integer = 6, fraction = 2)
@EmailThe string is a well-formed email address@Email String email
@PatternThe string matches a regular expression@Pattern(regexp = "[A-Z]{3}")
@Past / @FutureA date or time is before or after now@Past LocalDate dateOfBirth
@AssertTrueA boolean is true@AssertTrue boolean termsAccepted

Most constraints treat null as valid. For example, @Email alone accepts a missing email. Combine it with @NotBlank or @NotNull when the field is required.

3. Example Request Model

Put constraints on the class that receives the incoming data, usually a request DTO rather than your JPA entity. A Java record keeps it short:

UserRequest.java
package com.javabytechie.user;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public record UserRequest(

        @NotBlank(message = "Name is required")
        @Size(min = 2, max = 30, message = "Name must be between 2 and 30 characters")
        String name,

        @NotBlank(message = "Email is required")
        @Email(message = "Email should be valid")
        String email,

        @NotNull(message = "Age is required")
        @Min(value = 18, message = "Age must be at least 18")
        @Max(value = 120, message = "Age must be at most 120")
        Integer age,

        @Pattern(regexp = "^\\+?[0-9]{10,15}$", message = "Phone must contain 10 to 15 digits")
        String phone
) {
}

A regular class with fields, getters and setters works the same way. Place the annotations on the fields.

4. Validating Request Bodies in a Controller

Add @Valid before @RequestBody. Spring validates the object after reading the JSON and before calling your method:

UserController.java
package com.javabytechie.user;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<UserResponse> create(@Valid @RequestBody UserRequest request) {
        UserResponse created = userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}

If validation fails, the method is never called. Spring throws MethodArgumentNotValidException and responds with HTTP 400 Bad Request.

If you need to handle errors inside a single method, add a BindingResult parameter right after the validated object. Spring then passes the errors to you instead of throwing:

UserController.java
@PostMapping("/draft")
public ResponseEntity<?> saveDraft(@Valid @RequestBody UserRequest request, BindingResult result) {
    if (result.hasErrors()) {
        Map<String, String> errors = new LinkedHashMap<>();
        result.getFieldErrors()
              .forEach(error -> errors.putIfAbsent(error.getField(), error.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }
    return ResponseEntity.ok(userService.saveDraft(request));
}

Repeating this check in every method gets tedious. The next section shows how to handle errors once for the whole application.

5. Returning Clear Error Responses

Spring Boot 3 supports Problem Details (RFC 9457), a standard JSON format for API errors. Extend ResponseEntityExceptionHandler and add the field errors to the response:

ApiExceptionHandler.java
package com.javabytechie.common;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {

        Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(
                        FieldError::getField,
                        error -> Objects.requireNonNullElse(error.getDefaultMessage(), "Invalid value"),
                        (first, second) -> first,
                        LinkedHashMap::new));

        ProblemDetail problem = ex.getBody();
        problem.setTitle("Validation failed");
        problem.setProperty("errors", errors);

        return handleExceptionInternal(ex, problem, headers, status, request);
    }
}

Now send an invalid request:

Terminal
curl -X POST http://localhost:8080/api/users \
     -H "Content-Type: application/json" \
     -d '{"name": "A", "email": "not-an-email", "age": 15}'

The client receives HTTP 400 with every problem listed by field:

Response
{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "detail": "Invalid request content.",
  "instance": "/api/users",
  "errors": {
    "name": "Name must be between 2 and 30 characters",
    "email": "Email should be valid",
    "age": "Age must be at least 18"
  }
}

6. Validating Service Methods

Controllers are not the only entry point. Messages from Kafka, scheduled jobs and other services call your service layer directly. Add @Validated to the class, and Spring validates method parameters and return values on every call:

UserService.java
package com.javabytechie.user;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;

@Service
@Validated
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public UserResponse register(@Valid UserRequest request) {
        User saved = userRepository.save(User.from(request));
        return UserResponse.from(saved);
    }

    @NotNull
    public UserResponse findById(@Positive(message = "Id must be positive") Long id) {
        return userRepository.findById(id)
                .map(UserResponse::from)
                .orElseThrow(() -> new UserNotFoundException(id));
    }
}

A failure here throws ConstraintViolationException. Two points to remember:

  • Method validation works through a Spring proxy, so it does not run when a method calls another method in the same class.
  • Add an @ExceptionHandler(ConstraintViolationException.class) to your @RestControllerAdvice if these errors can reach an HTTP client. Otherwise they return HTTP 500.

7. Writing a Custom Validation Annotation

If the built-in annotations do not fully meet your business requirements, you can create a custom annotation tailored to your specific needs.

In this example, we will create a custom annotation to validate password strength, with a configurable minimum password length.

First, let’s define the custom annotation:

StrongPassword.java
package com.javabytechie.validation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
public @interface StrongPassword {

    String message() default
            "Password must be at least {min} characters and contain an uppercase letter, "
            + "a lowercase letter, a digit and a special character";

    int min() default 8;

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}

Then the validator that holds the logic. initialize() reads the annotation’s attributes once:

StrongPasswordValidator.java
package com.javabytechie.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {

    private int min;

    @Override
    public void initialize(StrongPassword annotation) {
        this.min = annotation.min();
    }

    @Override
    public boolean isValid(String password, ConstraintValidatorContext context) {
        if (password == null) {
            return true; // leave null checks to @NotBlank
        }
        return password.length() >= min
                && password.chars().anyMatch(Character::isUpperCase)
                && password.chars().anyMatch(Character::isLowerCase)
                && password.chars().anyMatch(Character::isDigit)
                && password.chars().anyMatch(ch -> !Character.isLetterOrDigit(ch));
    }
}

Use it like any built-in constraint:

RegistrationRequest.java
public record RegistrationRequest(

        @NotBlank
        @Email
        String email,

        @NotBlank
        @StrongPassword(min = 10)
        String password
) {
}

Returning true for null is a convention: each constraint checks one thing, and @NotBlank decides whether the value is required.

8. Validating Configuration Properties

Validation also protects your application settings. Add @Validated to a @ConfigurationProperties class, and the application refuses to start with invalid values instead of failing later in production:

MailProperties.java
package com.javabytechie.config;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(

        @NotBlank
        String host,

        @Min(1)
        @Max(65535)
        int port,

        @NotBlank
        @Email
        String from
) {
}

Register it with @ConfigurationPropertiesScan on your main class. With app.mail.port=0 in application.properties, startup stops with a clear message:

Console
***************************
APPLICATION FAILED TO START
***************************

Description:

Binding to target com.javabytechie.config.MailProperties failed:

    Property: app.mail.port
    Value: "0"
    Origin: class path resource [application.properties] - 2:15
    Reason: must be greater than or equal to 1

9. Testing Your Validation Rules

Validation rules are business rules, so test them. A plain unit test needs no Spring context and runs in milliseconds:

UserRequestValidationTest.java
package com.javabytechie.user;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.Set;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

class UserRequestValidationTest {

    private static Validator validator;

    @BeforeAll
    static void setUp() {
        validator = Validation.buildDefaultValidatorFactory().getValidator();
    }

    @Test
    void acceptsValidRequest() {
        UserRequest request = new UserRequest("John", "john@example.com", 30, "+919876543210");

        assertThat(validator.validate(request)).isEmpty();
    }

    @Test
    void rejectsShortNameAndUnderageUser() {
        UserRequest request = new UserRequest("J", "john@example.com", 16, null);

        Set<ConstraintViolation<UserRequest>> violations = validator.validate(request);

        assertThat(violations)
                .extracting(violation -> violation.getPropertyPath().toString())
                .containsExactlyInAnyOrder("name", "age");
    }
}

To check the full HTTP behavior, including the error format, use a web slice test with MockMvc:

UserControllerTest.java
@WebMvcTest(UserController.class)
@Import(ApiExceptionHandler.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

    @Test
    void returns400WithFieldErrorsForInvalidEmail() throws Exception {
        mockMvc.perform(post("/api/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"name": "John", "email": "not-an-email", "age": 30}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.title").value("Validation failed"))
                .andExpect(jsonPath("$.errors.email").value("Email should be valid"));
    }
}

@MockitoBean is available from Spring Boot 3.4. On older versions, use @MockBean.

10. Summary and Best Practices

Spring Boot validation lets you declare rules once and enforce them everywhere data enters your application. Keep these practices in mind:

  • Validate at the edges. Put constraints on request DTOs and configuration classes, not deep inside your business logic.
  • Handle errors in one place. A single @RestControllerAdvice gives every endpoint the same error format.
  • Write messages for humans. Say what is wrong and what is allowed, like “Age must be at least 18”.
  • Keep custom validators focused. One rule per constraint, and return true for null.
  • Do not rely on validation alone for data integrity. Keep database constraints such as NOT NULL and UNIQUE as the final safety net.
  • Test your rules with fast unit tests, plus one web test for the error format.