Spring Boot
Form Validation
Server-side form validation in Spring MVC is a pipeline: the framework binds request parameters to a form object, runs Bean Validation constraints on it, and collects every problem in a BindingResult so the controller can re-render the form with messages next to the fields. This guide covers the dependency and flow, annotating form objects, the placement rule for BindingResult, the difference between binding errors and validation errors, business-rule errors, custom and cross-field constraints, validation groups, message resolution and internationalisation, trimming input, rendering errors in Thymeleaf, programmatic validation, and testing the whole thing with MockMvc.
Setup and the Validation Flow
Bean Validation is not part of spring-boot-starter-web. Since Spring Boot 2.3 you have to add spring-boot-starter-validation, which brings in Hibernate Validator (the reference implementation) and the Jakarta Validation API. Without it the annotations compile only if some other dependency happens to supply them, and they are silently ignored at runtime. On Spring Boot 3 the annotations live in the jakarta.validation.constraints package; on Boot 2 they are in javax.validation.constraints.
When a request reaches a handler method that has a @ModelAttribute parameter annotated with @Valid, three things happen in order. First, data binding copies request parameters onto the form object and converts types, recording a binding error for anything it cannot convert. Second, Bean Validation runs the constraint annotations on the bound object and records a violation for each failure. Third, both kinds of error are stored in the BindingResult that follows the form parameter, and the handler method is called. The framework never decides what to do about the errors; your controller does, typically by returning the form view again.
Java
<!-- pom.xml -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
// build.gradle
// implementation 'org.springframework.boot:spring-boot-starter-validation'
// Imports on Spring Boot 3 / Jakarta EE 9+:
// import jakarta.validation.Valid;
// import jakarta.validation.constraints.*;
// Imports on Spring Boot 2.x:
// import javax.validation.Valid;
// import javax.validation.constraints.*;
// Request lifecycle for POST /register
//
// request parameters
// -> data binding (String "abc" into int age => typeMismatch error)
// -> Bean Validation (@NotBlank, @Email, ... => constraint errors)
// -> BindingResult (holds every error from both steps)
// -> your controller (hasErrors() ? re-render : process)Annotating the Form Object
A form object is a plain class whose fields mirror the inputs on the page. Put constraints on the fields and keep the messages out of the annotations if you intend to internationalise (see the messages section below); inline messages are fine for a single-language application.
A few type choices matter more than the annotations. Use wrapper types such as Integer for numeric fields that can be empty. An empty text box cannot be converted to a primitive int, so the user sees a type-mismatch error instead of your "required" message, whereas Integer with @NotNull gives you a proper constraint error. For a checkbox that must be ticked, use a primitive boolean with @AssertTrue. Thymeleaf's th:field adds a hidden marker input, so an unticked box binds to false and fails the constraint cleanly. Combining @NotNull with @AssertTrue, as is common, adds nothing and can produce two messages.
Prefer a class with getters and setters over a record when the object backs a Thymeleaf form. th:field reads values through JavaBean getters, which records do not have.
Remember that @NotBlank and @Size(min = 3) both fail on an empty string, so an empty username reports two errors. Either accept both messages, or use a @GroupSequence as shown in the groups section.
Java
public class RegisterForm {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 30, message = "Username must be 3 to 30 characters")
@Pattern(regexp = "^[a-zA-Z0-9_]*$",
message = "Use letters, numbers and underscores only")
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Enter a valid email address")
@Size(max = 254)
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, max = 72, message = "Password must be 8 to 72 characters")
private String password;
private String confirmPassword;
// Integer, not int: an empty field then yields a clean @NotNull error
// instead of a type-mismatch error.
@NotNull(message = "Age is required")
@Min(value = 18, message = "You must be at least 18")
@Max(value = 120, message = "Enter a realistic age")
private Integer age;
// Primitive boolean: an unticked box binds to false, which @AssertTrue rejects.
@AssertTrue(message = "You must accept the terms")
private boolean acceptedTerms;
// getters and setters
}
// Constraint cheat sheet
// @NotNull value is not null (empty string is allowed)
// @NotEmpty not null and length > 0 (strings, collections, arrays)
// @NotBlank not null and contains a non-whitespace character (strings only)
// @Size(min, max) length of string / size of collection (null passes)
// @Min / @Max numeric bounds (null passes)
// @Email basic format check, lenient by design
// @Pattern(regexp) full-string regex match (null passes)
// @Past / @Future dates
// @Positive / @PositiveOrZero / @Digits
//
// Most constraints treat null as valid; pair them with @NotNull / @NotBlank
// when the field is required.The Controller: @Valid and BindingResult
The controller needs two handlers: a GET that puts an empty form object into the model, and a POST that validates the submitted one. The POST method takes the form annotated with @Valid and @ModelAttribute, immediately followed by a BindingResult parameter.
That ordering is a hard rule. Spring pairs a BindingResult with the model attribute declared directly before it. If you put another parameter in between, or leave the BindingResult out entirely, Spring does not hand the errors to your method; it throws an exception and the client receives a 400 Bad Request, which is rarely what a form user should see.
On failure, return the view name of the form without redirecting. The model, including the BindingResult and the submitted values, is still available to the template, so the fields are repopulated and the messages shown. On success, redirect to a different URL (the Post/Redirect/Get pattern) so that refreshing the browser does not resubmit the form.
Java
@Controller
@RequestMapping("/register")
public class RegistrationController {
private final UserService userService;
public RegistrationController(UserService userService) {
this.userService = userService;
}
@GetMapping
public String showForm(Model model) {
model.addAttribute("form", new RegisterForm());
return "register";
}
@PostMapping
public String submit(
@ModelAttribute("form") @Valid RegisterForm form,
BindingResult bindingResult, // must come right after the form
RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
return "register"; // re-render, no redirect
}
userService.register(form);
redirectAttributes.addFlashAttribute("message", "Account created, please sign in");
return "redirect:/login"; // Post/Redirect/Get
}
}
// WRONG: parameter between the form and its BindingResult
// public String submit(@Valid RegisterForm form, Model model, BindingResult result)
// -> the errors are not delivered to the method; Spring throws and responds 400.
//
// WRONG: no BindingResult at all
// public String submit(@Valid RegisterForm form)
// -> validation failure throws MethodArgumentNotValidException (400).
// That is the right behaviour for REST bodies, not for HTML forms.Binding Errors vs Validation Errors
BindingResult holds two different kinds of failure, and they come from different stages. A binding error happens before validation, when Spring cannot convert a parameter to the target type: "abc" into an Integer, "2026-13-45" into a LocalDate. These show up with the error code typeMismatch, and the default message is a long technical string ("Failed to convert property value of type String to required type Integer...") that you do not want users to see. A validation error is a constraint violation, such as @Size or @Email, reported with the constraint name as its code.
If a field fails to bind, the constraint on it does not run on a meaningful value, so the user sees only the binding error. Provide a friendly message for typeMismatch in your message bundle (shown in the messages section) rather than trying to catch it in code.
You can distinguish the two when you need to by inspecting the codes on a FieldError. Most applications never need to; just make sure both are covered with readable messages.
Java
// Example submissions
// age="" with Integer age + @NotNull -> constraint error NotNull.form.age
// age="x" with Integer age -> binding error typeMismatch.form.age
// age="" with int age -> binding error typeMismatch.form.age (avoid)
@PostMapping
public String submit(@ModelAttribute("form") @Valid RegisterForm form,
BindingResult result) {
if (result.hasFieldErrors("age")) {
FieldError error = result.getFieldError("age");
// error.getCode() "typeMismatch" or "NotNull" or "Min" ...
// error.getCodes() the full message-code hierarchy, most specific first
// error.getRejectedValue() the raw value the user typed
// error.isBindingFailure() true for conversion errors, false for constraints
}
// ...
}
// Useful BindingResult methods
// hasErrors(), hasFieldErrors(), hasFieldErrors("name"), hasGlobalErrors()
// getFieldError("name") first error for a field
// getFieldErrors("name") all errors for a field
// getGlobalErrors() errors not tied to a field
// getErrorCount()Business-Rule Errors with rejectValue and reject
Constraints on the form object can only judge the values themselves. Rules that need other data, like "is this username taken?", belong in the controller or service, and you report them through the same BindingResult so they appear in the page exactly like constraint errors. Use rejectValue(field, code, defaultMessage) to attach an error to a field, and reject(code, defaultMessage) for an error that concerns the form as a whole.
Two habits keep this tidy. First, do business checks only if the basic validation passed. If the email is blank, there is no point looking it up, and calling methods on null fields invites NullPointerExceptions. Second, never rely on a check-then-insert as your only protection against duplicates. Two requests can pass the check at the same moment and both insert. The database unique constraint is the real guard; catch the resulting DataIntegrityViolationException and turn it into a field error, as in the example.
Java
@PostMapping
public String submit(@ModelAttribute("form") @Valid RegisterForm form,
BindingResult result) {
// 1. Stop if the basic constraints already failed.
if (result.hasErrors()) {
return "register";
}
// 2. Business rule that needs data from elsewhere.
if (userService.usernameExists(form.getUsername())) {
result.rejectValue("username", "username.taken",
"That username is already taken");
return "register";
}
// 3. The database unique index is the real guarantee; two simultaneous
// requests can both pass step 2.
try {
userService.register(form);
} catch (DataIntegrityViolationException ex) {
result.rejectValue("email", "email.taken",
"That email address is already registered");
return "register";
}
return "redirect:/login?registered";
}
// An error about the whole form, not one field:
result.reject("registration.blocked", "Registration is not available right now");
// Schema-level backstop (Flyway / Liquibase / DDL):
// ALTER TABLE users ADD CONSTRAINT uk_users_email UNIQUE (email);Custom Constraint Annotations
When the same rule is needed on several forms, package it as a constraint. A constraint has two parts: an annotation carrying the three required attributes (message, groups, payload), and a class implementing ConstraintValidator that holds the logic.
Spring creates validator instances through its own ConstraintValidatorFactory, which autowires dependencies, so a validator can take a repository in its constructor without being declared as a bean. You do not need @Component on it, and adding it only creates an extra bean nobody uses.
Return true for null or blank values and leave the "required" decision to @NotBlank. If every constraint reports on empty input, the user sees three messages for one empty field. Remember that a database-backed constraint like uniqueness is only a convenience for the user; as covered above, it does not remove the need for the unique index.
Java
// 1. The annotation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueEmailValidator.class)
@Documented
public @interface UniqueEmail {
String message() default "This email address is already registered";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 2. The validator. No @Component needed: Spring autowires constraint validators.
public class UniqueEmailValidator implements ConstraintValidator<UniqueEmail, String> {
private final UserRepository userRepository;
public UniqueEmailValidator(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null || email.isBlank()) {
return true; // let @NotBlank report missing values
}
return !userRepository.existsByEmailIgnoreCase(email.trim());
}
}
// 3. Use it
public class RegisterForm {
@NotBlank
@Email
@UniqueEmail
private String email;
}
// Constraints with parameters:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = AllowedDomainValidator.class)
public @interface AllowedDomain {
String message() default "Email domain is not allowed";
String[] value(); // @AllowedDomain({"example.com", "acme.io"})
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}Cross-Field Validation
A rule that compares two fields, such as "password and confirmation match" or "end date is after start date", cannot be expressed on either field alone. There are two good options.
The lightest is an @AssertTrue method on the form: any boolean method whose name starts with "is" and is annotated with @AssertTrue is evaluated as a constraint. The drawback is that the error attaches to the property named after the method (for example "passwordMatching"), not to the field you want to highlight, so the page needs a message for that name or you have to display it as a form-level error.
The more flexible option is a class-level constraint whose validator reports the violation on a chosen field using the ConstraintValidatorContext. That keeps the error next to the input where the user needs to see it. For one-off rules the programmatic rejectValue check in the controller is also perfectly reasonable.
Java
// Option 1: @AssertTrue method. Simple, but the error is not on a field.
public class PasswordForm {
@NotBlank @Size(min = 8) private String password;
@NotBlank private String confirmPassword;
@AssertTrue(message = "Passwords do not match")
public boolean isPasswordMatching() {
return password == null || password.equals(confirmPassword);
}
}
// Option 2: class-level constraint that reports on a specific field.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordsMatchValidator.class)
public @interface PasswordsMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordsMatchValidator
implements ConstraintValidator<PasswordsMatch, PasswordForm> {
@Override
public boolean isValid(PasswordForm form, ConstraintValidatorContext ctx) {
if (form.getPassword() == null || form.getConfirmPassword() == null) {
return true; // the field-level constraints report missing values
}
if (form.getPassword().equals(form.getConfirmPassword())) {
return true;
}
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate(ctx.getDefaultConstraintMessageTemplate())
.addPropertyNode("confirmPassword") // error shows on this input
.addConstraintViolation();
return false;
}
}
@PasswordsMatch
public class PasswordForm {
@NotBlank @Size(min = 8) private String password;
@NotBlank private String confirmPassword;
}Nested Objects and Validation Groups
Bean Validation does not look inside nested objects or collection elements unless you ask. Put @Valid on the field that holds the nested object, or on the type argument of a collection, and its constraints are checked too, with the error path reported as address.postalCode or items[0].quantity. Without @Valid the nested constraints are silently skipped, which is a frequent cause of "my validation does nothing" on larger forms.
Groups let one form class carry different rule sets for different situations, such as creating versus editing, or the steps of a wizard. Constraints belong to the Default group unless you name a group in the annotation. In the controller, switch from @Valid to Spring's @Validated, which accepts the groups to apply. Be careful that once you name a group, the Default group no longer runs unless you include it too.
A @GroupSequence, defined on the form, runs groups in order and stops at the first failure. It solves the earlier problem of an empty field reporting "required" and "too short" together: validate presence first, then format.
Java
// Nested objects: @Valid cascades into them.
public class OrderForm {
@NotBlank private String customerName;
@Valid // without this, Address constraints are skipped
@NotNull
private AddressForm address;
@Valid // validates each element
@NotEmpty(message = "Add at least one item")
private List<@Valid ItemForm> items = new ArrayList<>();
}
public class AddressForm {
@NotBlank private String street;
@Pattern(regexp = "\\d{5}") private String postalCode; // error path: address.postalCode
}
// Groups: marker interfaces
public interface OnCreate { }
public interface OnUpdate { }
public class UserForm {
@Null(groups = OnCreate.class)
@NotNull(groups = OnUpdate.class)
private Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
private String name;
@NotBlank(groups = OnCreate.class) // password only required when creating
private String password;
}
// @Validated selects the groups. @Valid cannot.
@PostMapping("/users")
public String create(@ModelAttribute("form") @Validated(OnCreate.class) UserForm form,
BindingResult result) { ... }
@PostMapping("/users/{id}")
public String update(@ModelAttribute("form") @Validated(OnUpdate.class) UserForm form,
BindingResult result) { ... }
// Ordered checks: report "required" first, then the format rules.
public interface Required { }
public interface Format { }
@GroupSequence({Required.class, Format.class})
public interface Ordered { }
public class LoginForm {
@NotBlank(groups = Required.class)
@Size(min = 3, groups = Format.class)
private String username;
}
// controller: @Validated(Ordered.class)Error Messages and Message Codes
For every error, Spring builds a list of message codes and tries each in turn against your MessageSource, from most specific to most general, using the first one it finds. For a violation of @NotBlank on the username field of a form named registerForm, the codes tried are NotBlank.registerForm.username, then NotBlank.username, then NotBlank.java.lang.String (the field type), then NotBlank. A binding failure follows the same pattern with typeMismatch as the constraint name. Two practical consequences follow. A message defined in messages.properties wins over the message attribute written in the annotation, which is only the final fallback. And the form name in the code is the model attribute name with a lowercase first letter, so @ModelAttribute("form") means NotBlank.form.username.
Be careful with placeholders. When a message is found through these codes, the constraint attributes are passed as positional arguments: {0} is the field name, and the constraint's own attributes follow in alphabetical order. For @Size that means {1} is the max and {2} is the min; for @Min, {1} is the value. The named form {min} is a feature of Hibernate Validator's own interpolation, and it works in the message attribute of the annotation or in a ValidationMessages.properties bundle, not in messages.properties code lookups.
Spring Boot reads messages.properties automatically (spring.messages.basename defaults to "messages"), and adding messages_fr.properties and similar files gives you localisation driven by the request's Locale.
application.properties
# src/main/resources/messages.properties
# Most specific: constraint + form name + field
NotBlank.form.username=Choose a username
Size.form.password=Password must be between {2} and {1} characters
# Per field name (any form)
Email.email=Enter a valid email address, for example name@example.com
# Per field type
Min=Must be at least {1}
# Per constraint (general fallback)
NotBlank=This field is required
Size=Must be between {2} and {1} characters
Pattern=Invalid format
# Binding failures: the friendly replacement for "Failed to convert property value..."
typeMismatch=Invalid value
typeMismatch.int=Enter a whole number
typeMismatch.java.lang.Integer=Enter a whole number
typeMismatch.java.time.LocalDate=Enter a date as yyyy-mm-dd
# Programmatic error codes (rejectValue / reject)
username.taken=That username is already taken
email.taken=That email address is already registered
registration.blocked=Registration is not available right now
# Custom constraints (code = annotation name)
UniqueEmail=This email address is already registered
PasswordsMatch=Passwords do not match
# Placeholders in these lookups are POSITIONAL:
# {0} = field name, then attributes alphabetically:
# @Size -> {1}=max, {2}=min
# @Min -> {1}=value
# @Max -> {1}=value
# Named {min}/{max} placeholders belong in the annotation message or in
# ValidationMessages.properties, which uses Hibernate's own interpolation.Configuring the Message Source
Spring Boot configures a MessageSource for you. You only need properties when the defaults do not fit: a different file name, several bundles, or behaviour when the requested locale has no file. Setting fallback-to-system-locale to false means an unsupported language falls back to messages.properties rather than to the server's own locale, which makes the result independent of where the application happens to run.
For localised validation, the active Locale must also follow the user. By default Spring uses the Accept-Language header; add a LocaleResolver (cookie or session based) and a LocaleChangeInterceptor if users should be able to choose a language from the page.
yaml
# application.yml
spring:
messages:
basename: messages,validation # messages.properties, validation.properties
encoding: UTF-8
fallback-to-system-locale: false # unknown locale -> messages.properties
use-code-as-default-message: false # missing code -> error, not the raw code
// Let users switch language with ?lang=fr
@Configuration
public class LocaleConfig implements WebMvcConfigurer {
@Bean
public LocaleResolver localeResolver() {
CookieLocaleResolver resolver = new CookieLocaleResolver("lang");
resolver.setDefaultLocale(Locale.ENGLISH);
return resolver;
}
@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
interceptor.setParamName("lang");
return interceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}
// Files:
// messages.properties default (English)
// messages_fr.properties French
// messages_de.properties GermanTrimming Input with @InitBinder
Users routinely paste values with leading or trailing spaces. @NotBlank handles whitespace-only input, but a value like " alice " will pass @Size and @Pattern checks differently from "alice" and end up stored with the spaces. An @InitBinder method registers a StringTrimmerEditor that trims every string before validation. Passing true converts empty strings to null, which also makes @NotNull behave as users expect for optional text fields.
Put the method in a @ControllerAdvice class to apply it to every controller. Because it runs before validation, the constraints see the cleaned value and the stored value is clean too. Do not apply it to password fields if exact characters matter; either skip passwords with a field-specific rule or register the editor only for the fields you choose.
Java
// Global: every controller trims strings and turns "" into null
@ControllerAdvice
public class GlobalBindingConfig {
@InitBinder
public void initBinder(WebDataBinder binder) {
binder.registerCustomEditor(String.class, new StringTrimmerEditor(true));
}
}
// Per controller, excluding password fields from trimming
@InitBinder("form")
public void initBinder(WebDataBinder binder) {
StringTrimmerEditor trimmer = new StringTrimmerEditor(true);
binder.registerCustomEditor(String.class, "username", trimmer);
binder.registerCustomEditor(String.class, "email", trimmer);
// password / confirmPassword intentionally left untouched
}
// Also useful in the same method:
// binder.setDisallowedFields("id", "role"); // never bind these from the request
// binder.setAllowedFields("username", "email"); // or whitelist instead
// Binding to an entity directly? Disallowing fields prevents mass-assignment attacks.
// Better still, bind to a dedicated form class that has no such fields.Displaying Errors in Thymeleaf
Thymeleaf reads the errors from the BindingResult that Spring put in the model. The form element must use th:object so that the *{field} expressions and the #fields helper know which object to inspect. th:field binds an input to a property and fills in its current value, id and name. th:errorclass adds a CSS class to the input only when it has errors. th:errors writes the message or messages for a field, joined by a line break if there are several.
Wrap th:errors in a th:if on #fields.hasErrors so that you do not render an empty element when the field is fine. For a summary at the top of the page use #fields.hasAnyErrors() with #fields.allErrors(), and use #fields.hasGlobalErrors() for errors you added with reject(). Remember to include the CSRF token if Spring Security is on; th:action on a form adds it automatically.
html
<form th:action="@{/register}" th:object="${form}" method="post" novalidate>
<!-- Summary of every error -->
<div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger" role="alert">
<p>Please fix the following:</p>
<ul>
<li th:each="err : ${#fields.allErrors()}" th:text="${err}"></li>
</ul>
</div>
<!-- Errors not tied to a field (from bindingResult.reject) -->
<div th:if="${#fields.hasGlobalErrors()}" class="alert alert-warning">
<p th:each="err : ${#fields.globalErrors()}" th:text="${err}"></p>
</div>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" id="username" th:field="*{username}"
th:errorclass="is-invalid" class="form-control" />
<div class="invalid-feedback" th:if="${#fields.hasErrors('username')}"
th:errors="*{username}"></div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" id="email" th:field="*{email}"
th:errorclass="is-invalid" class="form-control" />
<div class="invalid-feedback" th:if="${#fields.hasErrors('email')}"
th:errors="*{email}"></div>
</div>
<div class="mb-3">
<label for="age" class="form-label">Age</label>
<input type="number" id="age" th:field="*{age}"
th:errorclass="is-invalid" class="form-control" />
<div class="invalid-feedback" th:if="${#fields.hasErrors('age')}"
th:errors="*{age}"></div>
</div>
<div class="form-check mb-3">
<input type="checkbox" id="terms" th:field="*{acceptedTerms}"
th:errorclass="is-invalid" class="form-check-input" />
<label for="terms" class="form-check-label">I accept the terms</label>
<div class="invalid-feedback" th:if="${#fields.hasErrors('acceptedTerms')}"
th:errors="*{acceptedTerms}"></div>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
<!-- Notes
- novalidate turns off the browser's own popups so users see your messages.
Keep client-side checks as a convenience only; the server is the authority.
- Errors rendered with th:errors are HTML-escaped by default. Keep it that way.
- Password inputs do not echo their value back, which is the desired behaviour. -->Programmatic Validation with the Validator API
Sometimes validation has to happen outside the web layer: in a service that receives an object from a message queue, in a batch import, or on an object built in code. Inject jakarta.validation.Validator, which Spring Boot configures for you, and call validate(). It returns a set of ConstraintViolation objects rather than throwing, so you decide what to do with them.
Alternatively, annotate a Spring bean with @Validated and put @Valid on method parameters to have Spring validate arguments automatically using AOP. The failure surfaces as a ConstraintViolationException, which is not the same as the BindException you get for web forms, so handle it separately. Note that @Validated on a class only affects calls that arrive through the Spring proxy; a call to another method of the same class bypasses it.
To reuse an existing BindingResult-style flow with programmatic validation, Spring's own SmartValidator (or LocalValidatorFactoryBean) can validate an object into an Errors instance.
Java
// 1. Validator injected directly
@Service
public class ImportService {
private final Validator validator;
public ImportService(Validator validator) {
this.validator = validator;
}
public List<String> check(RegisterForm form) {
Set<ConstraintViolation<RegisterForm>> violations = validator.validate(form);
return violations.stream()
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
.sorted()
.toList();
}
}
// 2. Method validation: Spring checks arguments before the method runs.
@Service
@Validated
public class AccountService {
public void changeEmail(@NotNull Long userId, @Email @NotBlank String newEmail) { ... }
public void import(@Valid List<@Valid RegisterForm> forms) { ... }
}
// Failure -> jakarta.validation.ConstraintViolationException
// Only works through the Spring proxy: this.changeEmail(...) from inside the class is NOT checked.
// 3. Validating into a Spring Errors object
@Autowired private SmartValidator smartValidator;
public BindingResult validate(RegisterForm form) {
BindingResult result = new BeanPropertyBindingResult(form, "form");
smartValidator.validate(form, result);
return result;
}Testing Form Validation with MockMvc
Test the controller with @WebMvcTest and MockMvc rather than starting the whole application. Submit the form with param(...) calls, include a CSRF token if Spring Security is on the classpath, and assert on the model: model().attributeHasFieldErrors checks that specific fields failed, model().attributeHasNoErrors checks the happy path. Also assert the view name, to confirm the form is shown again on failure and a redirect happens on success.
Cover each rule once with a representative bad value, and cover the success path. For custom constraints, unit test the ConstraintValidator directly; it is just a class with an isValid method. Mock the service, and assert that it was never called when validation fails, since that is the point of validating at all.
Java
@WebMvcTest(RegistrationController.class)
class RegistrationControllerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private UserService userService; // @MockBean on Spring Boot < 3.4
@Test
void invalidForm_redisplaysFormWithFieldErrors() throws Exception {
mockMvc.perform(post("/register")
.with(csrf())
.param("username", "ab") // too short
.param("email", "not-an-email")
.param("password", "short")
.param("age", "17"))
.andExpect(status().isOk())
.andExpect(view().name("register"))
.andExpect(model().attributeHasFieldErrors(
"form", "username", "email", "password", "age"));
verifyNoInteractions(userService);
}
@Test
void nonNumericAge_isABindingError() throws Exception {
mockMvc.perform(post("/register")
.with(csrf())
.param("username", "alice")
.param("email", "alice@example.com")
.param("password", "Passw0rd!x")
.param("age", "abc"))
.andExpect(model().attributeHasFieldErrorCode("form", "age", "typeMismatch"));
}
@Test
void validForm_redirectsToLogin() throws Exception {
mockMvc.perform(post("/register")
.with(csrf())
.param("username", "alice")
.param("email", "alice@example.com")
.param("password", "Passw0rd!x")
.param("confirmPassword", "Passw0rd!x")
.param("age", "30")
.param("acceptedTerms", "true"))
.andExpect(status().is3xxRedirection())
.andExpect(redirectedUrl("/login"));
verify(userService).register(any(RegisterForm.class));
}
}
// A custom validator is a plain class; test it without Spring.
class PasswordsMatchValidatorTest {
private final PasswordsMatchValidator validator = new PasswordsMatchValidator();
@Test
void matchingPasswords_arePassing() {
PasswordForm form = new PasswordForm();
form.setPassword("secret123");
form.setConfirmPassword("secret123");
assertThat(validator.isValid(form, null)).isTrue();
}
}Troubleshooting and Pitfalls
Most validation problems fall into a few recognisable patterns. If nothing is validated, the starter dependency is probably missing, or @Valid is absent from the parameter. If the application answers 400 instead of showing the form, the BindingResult is missing or not directly after the form. If nested objects are never checked, they need @Valid. If a message does not change, remember that codes in messages.properties override the annotation's message attribute, so look for a more specific or more general code that matches first.
Keep in mind what server-side validation can and cannot do. It guards against bad input, but it does not replace output encoding, parameterised queries or authorisation checks. Do not bind request parameters straight onto JPA entities; a dedicated form class stops attackers from setting fields such as role or id that you never put on the page.
Shell
# Symptom -> likely cause -> fix
# Constraints are ignored entirely
# spring-boot-starter-validation missing, or @Valid missing on the parameter.
# -> add the starter; annotate the @ModelAttribute parameter with @Valid.
# Page returns 400 instead of re-rendering the form
# No BindingResult, or another parameter sits between the form and its BindingResult.
# -> put BindingResult immediately after the @Valid parameter.
# Nested object / list element constraints never fire
# Cascading is opt-in.
# -> add @Valid on the nested field and on collection type arguments.
# Annotation message ignored, a different text appears
# A matching code in messages.properties takes precedence.
# -> check NotBlank.form.username, NotBlank.username, NotBlank.java.lang.String, NotBlank.
# {min} / {max} appear literally, or messages.properties throws on them
# Named placeholders are not used in code lookups.
# -> use {1}, {2} in messages.properties, or put {min} in ValidationMessages.properties.
# Message reads "Failed to convert property value of type String to required type..."
# A typeMismatch binding error.
# -> define typeMismatch.* messages; use Integer instead of int for optional numbers.
# Two messages for one empty field (e.g. "required" and "too short")
# @NotBlank and @Size both fail on "".
# -> @GroupSequence, or accept both, or let @Size skip empties with a custom constraint.
# Checkbox always fails or always passes
# -> use primitive boolean + @AssertTrue; th:field adds the hidden _field marker.
# th:field fails on a record
# th:field reads through getters; records expose name(), not getName().
# -> use a regular class for forms that back Thymeleaf templates.
# Duplicate records despite a @UniqueEmail constraint
# Check-then-insert races.
# -> add the database unique index and handle DataIntegrityViolationException.
# Mass assignment
# Binding request data to an entity lets users set fields you did not render.
# -> bind to a form class, or use setAllowedFields / setDisallowedFields.