Skip to content
ExponaBLOG
JAVA, SPRING FRAMEWORK & SPRING BOOT · DAY 028 / 100

Spring Boot validation with Bean Validation: a deep dive

Reject bad input at the edge with clear error messages.

Expona Admin9 min read

Spring Boot validation with Bean Validation: a deep dive

Invalid user input will always find its way into your application—email typos, passwords that are too short, malformed phone numbers. Without proper validation at the request boundary, bad data propagates downstream, corrupting your database and creating support headaches. Spring Boot validation with Bean Validation lets you declare constraints declaratively on your request objects, reject invalid input early, and return clear error messages to the client, all with minimal boilerplate.

Why validate at the edge?

Request validation serves a specific purpose: checking that the shape and format of incoming data match what your application expects. This is a contract between client and server. When a user submits a form or an API caller sends JSON, you need to know immediately whether that input is usable before passing it to your business logic.

We've all seen applications that skip this step. The result is defensive coding throughout the service layer—null checks, length checks, pattern checks scattered everywhere. That's noise that obscures the real business logic. By validating at the HTTP boundary using Bean Validation (JSR 380), you push those checks to the edge and keep your domain clean.

Spring Boot makes this straightforward. Add annotations to your request objects, put @Valid on the method parameter, and the framework handles the rest. Invalid input gets rejected with a standardized error response before your handler method even runs.

Annotate the request—Constraints describe valid input declaratively

Bean Validation provides a set of standard annotations that describe what valid input looks like. You attach these annotations to the fields of your request DTOs or records, and Spring validates every incoming request against those rules.

Here's a practical example—a registration endpoint that requires a valid email and a strong password:

java
public record CreateUserRequest(
    @NotBlank(message = "Email cannot be blank")
    @Email(message = "Email must be a valid email address")
    String email,
    
    @NotBlank(message = "Password cannot be blank")
    @Size(min = 8, message = "Password must be at least 8 characters")
    String password,
    
    @Pattern(regexp = "^[A-Za-z ]+$", message = "Full name must contain only letters and spaces")
    String fullName
) {}

The annotations are self-documenting. Anyone reading this record immediately understands the validation rules without needing to dig into service code. The message attribute lets you provide user-friendly error messages that will be returned in the response.

Common Bean Validation annotations include:

  • @NotNull, @NotBlank, @NotEmpty – for required fields
  • @Email – validates email format
  • @Size – validates string length or collection size
  • @Min, @Max – numeric bounds
  • @Pattern – regex matching
  • @Positive, @PositiveOrZero – numeric sign checks
  • @Future, @Past – date validation

You can combine multiple annotations on a single field. A password field might have both @NotBlank and @Size. Spring validates all of them and returns all violations in the error response.

Trigger with @Valid—Add @Valid to the @RequestBody parameter

Declaring constraints on your DTO does nothing by itself. You need to tell Spring to actually validate the request. You do this by adding the @Valid annotation to the @RequestBody parameter in your controller method:

java
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @PostMapping
    public ResponseEntity<UserResponse> createUser(
            @Valid @RequestBody CreateUserRequest request) {
        User user = userService.createUser(request.email(), request.password(), request.fullName());
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(new UserResponse(user.id(), user.email()));
    }
}

When a POST request arrives, Spring's DispatcherServlet intercepts it. Before the createUser method runs, the framework deserializes the JSON body into a CreateUserRequest instance and validates it against all declared constraints. If validation fails, the request never reaches your handler—instead, a MethodArgumentNotValidException is thrown.

By default, Spring Boot includes a global exception handler in BasicErrorController that catches validation errors and returns a standardized error response. You'll get something like:

java
{
  "timestamp": "2025-01-28T10:15:30.123Z",
  "status": 400,
  "error": "Bad Request",
  "message": "Validation failed for argument [0] in public...",
  "path": "/api/users",
  "errors": [
    {
      "field": "email",
      "message": "Email must be a valid email address",
      "rejectedValue": "not-an-email"
    },
    {
      "field": "password",
      "message": "Password must be at least 8 characters",
      "rejectedValue": "short"
    }
  ]
}

This response format is useful for debugging, but you'll often want to customize it. We'll cover that in a moment.

Custom error responses—Control the validation error shape

The default Spring error response is verbose and may not match your API's style. Create a custom exception handler using @RestControllerAdvice to shape validation errors exactly as you want:

java
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
            errors.put(error.getField(), error.getDefaultMessage())
        );
        
        ErrorResponse response = new ErrorResponse(
            "Validation failed",
            HttpStatus.BAD_REQUEST.value(),
            errors
        );
        return ResponseEntity.badRequest().body(response);
    }
}

public record ErrorResponse(
    String message,
    int status,
    Map<String, String> errors
) {}

Now validation errors are returned in a clean, consistent format that your frontend can easily parse and display to users:

java
{
  "message": "Validation failed",
  "status": 400,
  "errors": {
    "email": "Email must be a valid email address",
    "password": "Password must be at least 8 characters"
  }
}

Custom constraints—Write a ConstraintValidator for rules like unique slugs or valid currency codes

Standard Bean Validation annotations cover common cases, but real-world applications have unique rules. When you need validation that depends on the database or custom business logic—like checking that a username is unique, or that a currency code is supported—you write a custom constraint and validator.

Here's how to enforce a unique username during registration:

java
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
@NotBlank
public @interface UniqueUsername {
    String message() default "This username is already taken";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

This is the constraint annotation itself. It marks which validator to use and provides a default error message. Now implement the validator:

java
public class UniqueUsernameValidator implements ConstraintValidator<UniqueUsername, String> {
    
    private final UserRepository userRepository;
    
    public UniqueUsernameValidator(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
    
    @Override
    public void initialize(UniqueUsername constraintAnnotation) {
        // Initialization logic if needed
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true; // Let @NotNull handle null values
        }
        return !userRepository.existsByUsername(value);
    }
}

The validator implements ConstraintValidator<UniqueUsername, String>, specifying the constraint annotation and the type it validates. Spring injects dependencies—here, the UserRepository—automatically. The isValid method returns true if validation passes, false otherwise.

Now use the constraint in your request DTO:

java
public record RegisterRequest(
    @UniqueUsername
    String username,
    
    @Email
    String email
) {}

Custom constraints are powerful. You can validate that a date falls within a business window, that a discount code is valid, that a product SKU belongs to an allowed category—any logic that needs database access or complex computation. The constraint gets checked at the HTTP boundary before your handler runs, keeping validation concerns separate from business logic.

Validate in the domain too—Request validation checks shape; domain rules still belong in the service

Request validation is not a replacement for domain validation. Bean Validation checks the contract at the API boundary. Domain validation enforces business rules inside your application.

Consider this scenario: you've validated that the password field is not blank and meets the minimum length. But your business rule says that users cannot create an account with a password that has ever been in a data breach. That check belongs in the UserService, not in a Bean Validation constraint.

java
@Service
public class UserService {
    
    private final UserRepository userRepository;
    private final BreachedPasswordService breachedPasswordService;
    
    public UserService(UserRepository userRepository, BreachedPasswordService breachedPasswordService) {
        this.userRepository = userRepository;
        this.breachedPasswordService = breachedPasswordService;
    }
    
    public User createUser(String email, String password, String fullName) {
        // Request validation has already passed at this point
        // Now enforce domain rules
        
        if (breachedPasswordService.isBreached(password)) {
            throw new DomainException("This password has been compromised. Please choose a different one.");
        }
        
        if (userRepository.existsByEmail(email)) {
            throw new DomainException("An account with this email already exists.");
        }
        
        User user = new User(email, password, fullName);
        return userRepository.save(user);
    }
}

The distinction is important. Request validation answers: "Does this request have the right shape?" Domain validation answers: "Is this request allowed by our business rules?" The first uses Bean Validation at the controller boundary. The second lives in your service layer and throws domain exceptions when rules are violated.

This separation keeps your code clean and testable. Your business logic is not tangled with HTTP concerns. You can test domain rules without setting up a Spring context. And you can change validation rules without changing your controller.

Common mistakes

  • Forgetting @Valid on @RequestBody. You annotate the DTO with constraints, but forget to add @Valid to the parameter. The request passes through without validation. Always pair them.
  • Putting all validation logic into custom constraints. Constraints should handle straightforward, reusable checks. Complex business logic with multiple dependencies belongs in the service, not in a validator.
  • Ignoring @NotNull versus @NotBlank. @NotNull allows empty strings. @NotBlank does not. If you want required strings, use @NotBlank.
  • Not catching domain exceptions. If your service throws a checked exception or a domain exception, make sure your global exception handler catches it and returns an appropriate response.
  • Validating collections incorrectly. To validate items inside a collection, use @Valid List<@NotNull Item> items. Without the nested @Valid on the item type, individual items are not validated.
  • Exposing internal validation error details to clients. Be careful not to leak database column names or internal field names in error messages. Use the message attribute to provide user-friendly text.

Key takeaways

  • Bean Validation annotations on your request DTOs declare valid input shape declaratively, making contracts explicit.
  • @Valid on @RequestBody triggers validation before your handler runs, rejecting invalid input at the HTTP boundary.
  • Customize the error response format with a global @RestControllerAdvice to match your API style.
  • Write custom constraint validators for business-specific rules like uniqueness checks, but keep them focused and testable.
  • Request validation checks shape; domain validation enforces business rules. Use both, in their proper places.
  • Validation errors provide feedback to clients immediately, reducing downstream debugging and data corruption.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles