Spring Boot
Form Validation
Form validation in Spring MVC combines Bean Validation (JSR-380) annotations on the form object with BindingResult to capture errors and re-render the form with field-level feedback. Spring Boot auto-configures the Hibernate Validator implementation. Custom validators, cross-field constraints, and programmatic validation with the Validator API are all first-class citizens of the framework.
Bean Validation with @Valid
Spring MVC integrates with Bean Validation (JSR-380) automatically. Annotate the form object's fields with constraint annotations and add @Valid before the @ModelAttribute parameter. Spring validates the bound object and puts any constraint violations into the BindingResult. Check bindingResult.hasErrors() before processing — if true, re-render the form.
Java
// ── Form object with Bean Validation constraints: ────────────────────
public class RegisterForm {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 30, message = "Username must be 3-30 characters")
@Pattern(regexp = "^[a-zA-Z0-9_]+$",
message = "Username may only contain letters, numbers, and underscores")
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Must be a valid email address")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password;
@Min(value = 18, message = "Must be at least 18 years old")
@Max(value = 120, message = "Please enter a valid age")
private int age;
@NotNull(message = "Please accept the terms and conditions")
@AssertTrue(message = "You must accept the terms and conditions")
private Boolean acceptedTerms;
// getters + setters
}
// ── Controller — @Valid triggers validation, BindingResult captures errors:
@PostMapping("/register")
public String register(
@ModelAttribute("form") @Valid RegisterForm form,
BindingResult bindingResult) { // MUST be the parameter immediately after
if (bindingResult.hasErrors()) {
return "register"; // re-render — BindingResult keeps errors in model
}
userService.register(form);
return "redirect:/login?registered";
}BindingResult — Inspecting and Adding Errors
BindingResult is the container for both binding errors (type mismatches during form binding) and validation errors (constraint violations). It also provides programmatic methods to add errors for custom cross-field or business-rule validation.
Java
@PostMapping
public String create(
@ModelAttribute("form") @Valid CreateUserForm form,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
// ── Inspect binding + validation errors: ─────────────────────────
boolean hasErrors = bindingResult.hasErrors();
int errorCount = bindingResult.getErrorCount();
List<ObjectError> allErrors = bindingResult.getAllErrors();
List<FieldError> fieldErrors = bindingResult.getFieldErrors();
FieldError nameError = bindingResult.getFieldError("name");
// ── Programmatic error for cross-field validation: ────────────────
if (!form.getPassword().equals(form.getConfirmPassword())) {
bindingResult.rejectValue(
"confirmPassword", // field name
"passwords.mismatch", // error code (for message resolution)
"Passwords do not match" // default message
);
}
// ── Programmatic error for a business rule: ───────────────────────
if (userService.existsByUsername(form.getUsername())) {
bindingResult.rejectValue(
"username",
"username.taken",
"This username is already taken"
);
}
// ── Global (object-level) error — not tied to a specific field: ───
if (isSpamRegistration(form)) {
bindingResult.reject(
"registration.spam",
"Registration could not be completed"
);
}
if (bindingResult.hasErrors()) {
// bindingResult is automatically added to the model — template
// can access errors via #fields.hasErrors('fieldName') and *{field}:
model.addAttribute("roles", User.Role.values());
return "users/form";
}
userService.create(form);
redirectAttributes.addFlashAttribute("success", "Account created");
return "redirect:/login";
}Custom Constraint Annotation
Custom constraints package reusable validation logic into an annotation. A constraint is two parts: the annotation (@interface) and a ConstraintValidator implementation. Spring picks up validators in the application context automatically, allowing @Autowired dependencies inside the validator.
Java
// ── Step 1: Define the annotation ────────────────────────────────────
@Target({ElementType.FIELD, ElementType.METHOD})
@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 {};
}
// ── Step 2: Implement ConstraintValidator ─────────────────────────────
@Component // Spring-managed — allows @Autowired
public class UniqueEmailValidator
implements ConstraintValidator<UniqueEmail, String> {
@Autowired
private UserRepository userRepository;
@Override
public boolean isValid(String email, ConstraintValidatorContext context) {
if (email == null || email.isBlank()) {
return true; // defer to @NotBlank — don't double-report
}
return !userRepository.existsByEmail(email);
}
}
// ── Step 3: Apply the annotation ──────────────────────────────────────
public class RegisterForm {
@NotBlank
@Email
@UniqueEmail // custom constraint
private String email;
// ...
}
// ── Class-level constraint — cross-field validation: ──────────────────
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordsMatch {
String message() default "Passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class PasswordMatchValidator
implements ConstraintValidator<PasswordsMatch, PasswordForm> {
@Override
public boolean isValid(PasswordForm form, ConstraintValidatorContext ctx) {
if (form.getPassword() == null) return true;
boolean match = form.getPassword().equals(form.getConfirmPassword());
if (!match) {
// Report on confirmPassword field, not the class:
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate("Passwords do not match")
.addPropertyNode("confirmPassword")
.addConstraintViolation();
}
return match;
}
}
@PasswordsMatch // applied at the class level
public class PasswordForm {
@NotBlank @Size(min = 8) private String password;
@NotBlank private String confirmPassword;
// getters + setters
}Validation Error Messages
Spring resolves validation error messages from a messages.properties file in src/main/resources. This externalises all user-facing strings and supports internationalisation. Error codes follow a hierarchy — Spring tries the most specific code first and falls back to more general ones.
application.properties
# ── src/main/resources/messages.properties ───────────────────────────
# Field-specific messages (most specific — checked first):
# {constraint}.{objectName}.{fieldName}
NotBlank.registerForm.username=Please enter a username
Email.registerForm.email=Please enter a valid email address
Size.registerForm.password=Password must be at least {min} characters
# Constraint-level messages (fallback):
# {constraint}.{fieldName}
NotBlank.email=Email is required
# Constraint-type messages (most general fallback):
NotBlank=This field is required
Email=Must be a valid email address
Size=Must be between {min} and {max} characters
Min=Must be at least {value}
Max=Must be no more than {value}
Pattern=Invalid format
# Custom constraint:
UniqueEmail=This email address is already registered
PasswordsMatch=Passwords do not match
# ── application.yml — configure message source: ───────────────────────
spring:
messages:
basename: messages # loads messages.properties (and messages_fr.properties etc.)
encoding: UTF-8
fallback-to-system-locale: false
# ── Interpolation — use {attribute} syntax for constraint attributes: ──
Size.password=Password must be {min}-{max} characters # {min}, {max} from @Size
Min.age=Must be at least {value} years old # {value} from @MinDisplaying Errors in Thymeleaf
Thymeleaf's #fields utility provides methods to check for and display validation errors. th:errorclass adds a CSS class to an input when it has an error. th:errors renders all error messages for a field.
html
<!-- ── Field-level errors: ──────────────────────────────────────────── -->
<div class="form-group">
<label for="username">Username</label>
<!-- th:errorclass adds "is-invalid" when the field has errors: -->
<input type="text"
th:field="*{username}"
th:errorclass="is-invalid"
class="form-control" />
<!-- th:errors renders the error message(s) for this field: -->
<div th:if="${#fields.hasErrors('username')}"
th:errors="*{username}"
class="invalid-feedback"></div>
</div>
<!-- ── Email field with custom styling: ─────────────────────────────── -->
<div th:classappend="${#fields.hasErrors('email')} ? 'has-error' : ''">
<input type="email" th:field="*{email}" />
<small th:if="${#fields.hasErrors('email')}"
th:errors="*{email}"
class="text-danger"></small>
</div>
<!-- ── All errors summary at top of form: ───────────────────────────── -->
<div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger">
<ul>
<li th:each="error : ${#fields.allErrors()}"
th:text="${error}"></li>
</ul>
</div>
<!-- ── Global (non-field) errors only: ──────────────────────────────── -->
<div th:if="${#fields.hasGlobalErrors()}" class="alert alert-danger">
<p th:each="err : ${#fields.globalErrors()}" th:text="${err}"></p>
</div>
<!-- ── Check a specific field in a conditional: ─────────────────────── -->
<span th:text="${#fields.hasErrors('email')} ? 'Invalid' : 'Valid'"></span>