Close

Spring MVC - Form Validation with Jakarta Bean Validation

[Last Updated: Apr 13, 2026]

Spring supports Jakarta Bean Validation (formally known as Bean Validation, JSR-303/349/380) to validate object fields declaratively.

Check out Bean validation tutorial.

Also check out Spring Core validation tutorial.


In Spring MVC, when a Bean Validation implementation (such as Hibernate Validator) exists on the classpath, LocalValidatorFactoryBean is automatically registered as the global validator. This enables the use of @Valid and @Validated on controller method arguments to trigger validation automatically.

Example

Let's modify our last example to show validation errors in the registration page.


Backing Object with Bean Validation constraints annotations

package com.logicbig.example;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public class User {
    private Long id;

    @NotEmpty(message = "Name is required")
    @Size(min = 5, max = 20)
    private String name;

    @NotEmpty(message = "Password is required")
    @Size(min = 6, message = "Password must be at least 6 characters")
    @Pattern(regexp = "\\S+", message = "Spaces are not allowed")
    private String password;

    @NotEmpty(message = "Email is required")
    @Email(message = "Please provide a valid email address")
    private String emailAddress;
    .............
}

Check out all predefined constraint annotations here.

The Controller

package com.logicbig.example;

import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/register")
public class UserRegistrationController {

    @Autowired
    private UserService userService;

    @GetMapping
    public String handleGetRequest() {
        return "user-registration";
    }

    @PostMapping
    public String handlePostRequest(@Valid User user,
                                    BindingResult bindingResult,
                                    Model model) {

        if (bindingResult.hasErrors()) {
            populateError("name", model, bindingResult);
            populateError("emailAddress", model, bindingResult);
            populateError("password", model, bindingResult);

            return "user-registration";
        }

        userService.saveUser(user);
        return "registration-done";
    }

    private void populateError(String field,
                               Model model,
                               BindingResult bindingResult) {
        if (bindingResult.hasFieldErrors(field)) {
            model.addAttribute(field + "Error",
                               bindingResult.getFieldError(field)
                                            .getDefaultMessage());
        }
    }
}

What is @Valid annotation?

@Valid is a Jakarta Bean Validation annotation (JSR-380) that marks an object for automatic validation. When you annotate a controller method parameter with @Valid, Spring automatically invokes the configured Validator instance on that object before entering the method body. In this tutorial, Spring uses Hibernate Validator (the reference implementation of Bean Validation 3.0/JSR-380) as the validation provider.


What is BindingResult?

org.springframework.validation.BindingResult is an interface that holds the results of data binding and validation. Its primary role is to store validation errors and field-specific error messages. When you use @Valid on a model object, Spring automatically populates a BindingResult parameter with any validation failures.

In the example above, we manually extract field errors from the BindingResult and add them to the Model to display error messages on the registration page. However, this manual approach is not ideal. A better alternative is to use Spring's form tag library (spring-form.tld), which automatically binds validation errors to form fields. We will cover this improved approach in the next tutorial.


Important rule: The BindingResult parameter must appear immediately after the validated object in your controller method signature. Spring uses this positional relationship to associate each BindingResult with its corresponding validated model object.

The handler method signature might have more than one model object and Spring will create a separate BindingResult instance for each of them.

In our example above 'bindingResult' parameter is used immediately after 'user' parameter. If you don't follow this rule you will have 400 error:




user-registration.jsp

src/main/webapp/WEB-INF/views/user-registration.jsp

<%@ page language="java"
         contentType="text/html; charset=ISO-8859-1"
         pageEncoding="ISO-8859-1" %>
<html>
<head>
    <style>
        span.error {
            color: red;
        }
    </style>
</head>
<body>

<h3> Registration Form
    <h3>
        <br/>
        <form action="register" method="post">
  <pre>
                  Name <input type="text" name="name"
                              value="${user.name}"/>
                       <span class="error">${nameError}</span>

         Email address <input type="text" name="emailAddress"
                              value="${user.emailAddress}"/>
                       <span class="error">${emailAddressError}</span>

              Password <input type="password"
                              name="password"
                              value="${user.password}"/>
                       <span class="error">${passwordError}</span>
                                        <input type="submit" value="Submit"/>
  </pre>
        </form>
</body>
</html>


Running Example

To try examples, run embedded Jetty (configured in pom.xml of example project below):

mvn jetty:run

http://localhost:8080/spring-form-validation/register

$ curl -s http://localhost:8080/spring-form-validation/register -d "name=Joe&emailAddress=joe@example.com&password=abc"

<html>
<head>
<style>
span.error {
color: red;
}
</style>
</head>
<body>

<h3> Registration Form
<h3>
<br/>
<form action="register" method="post">
<pre>
Name <input type="text" name="name"
value="Joe"/>
<span class="error">size must be between 5 and 20</span>

Email address <input type="text" name="emailAddress"
value="joe@example.com"/>
<span class="error"></span>

Password <input type="password"
name="password"
value="abc"/>
<span class="error">Password must be at least 6 characters</span>
<input type="submit" value="Submit"/>
</pre>
</form>
</body>
</html>

Integration Test

package com.logicbig.example;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.junit.jupiter.web.SpringJUnitWebConfig;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;

@SpringJUnitWebConfig(MyWebConfig.class)
public class RegistrationControllerTest {

    @Autowired
    private WebApplicationContext wac;

    private MockMvcTester mockMvcTester;

    @Autowired
    private UserService userService;

    @BeforeEach
    public void setup() {
        this.mockMvcTester = MockMvcTester.from(this.wac);
    }

    @Test
    public void testRegistrationSuccess() {
        var result = mockMvcTester.post().uri("/register")
                                  .param("name", "Joseph")
                                  .param("emailAddress", "joe@email.address")
                                  .param("password", "abcdef")
                                  .exchange();

        // AssertJ style assertions on the result
        assertThat(result).hasStatusOk()
                          .hasViewName("registration-done")
                          .model()
                          .satisfies(model -> {
                              User user = (User) model.get("user");
                              assertThat(user.getName()).isEqualTo("Joseph");
                              assertThat(user.getEmailAddress()).isEqualTo(
                                      "joe@email.address");
                          });

        // Assertions on the service/DB remain standard AssertJ
        User user = userService.getUserById(1L);
        assertThat(user).isNotNull();
        assertThat(user.getName()).isEqualTo("Joseph");
        assertThat(user.getEmailAddress()).isEqualTo("joe@email.address");
        assertThat(user.getPassword()).isEqualTo("abcdef");
    }

    @Test
    public void testRegistrationFailure_EmptyName() {
        var result = mockMvcTester.post().uri("/register")
                                  .param("name", "")
                                  .param("emailAddress", "joe@email.address")
                                  .param("password", "abcdef")
                                  .exchange();

        assertThat(result).hasStatusOk()
                          .hasViewName("user-registration")
                          .model().containsKey("nameError");

        assertThat(result).model().extractingByKey("nameError")
                          .isIn("Name is required", "size must be between 5 and 20");
    }

    @Test
    public void testRegistrationFailure_InvalidEmail() {
        var result = mockMvcTester.post().uri("/register")
                                  .param("name", "Joseph")
                                  .param("emailAddress", "invalid-email")
                                  .param("password", "abcdef")
                                  .exchange();

        assertThat(result).hasStatusOk()
                          .hasViewName("user-registration")
                          .model().containsEntry("emailAddressError",
                                                 "Please provide a valid email address");
    }

    @Test
    public void testRegistrationFailure_EmptyPassword() {
        var result = mockMvcTester.post().uri("/register")
                                  .param("name", "Joseph")
                                  .param("emailAddress", "joe@email.address")
                                  .param("password", "")
                                  .exchange();

        assertThat(result).hasStatusOk()
                          .hasViewName("user-registration")
                          .model().containsKey("passwordError");
    }

    @Test
    public void testRegistrationFailure_AllFieldsEmpty() {
        var result = mockMvcTester.post().uri("/register")
                                  .param("name", "")
                                  .param("emailAddress", "")
                                  .param("password", "")
                                  .exchange();

        assertThat(result).hasStatusOk()
                          .hasViewName("user-registration")
                          .model().containsKeys("nameError",
                                                "emailAddressError",
                                                "passwordError");
    }
}
mvn clean test -Dtest="RegistrationControllerTest"

Output

$ mvn clean test -Dtest="RegistrationControllerTest"
[INFO] Scanning for projects...
[INFO]
[INFO] ------------< com.logicbig.example:spring-form-validation >-------------
[INFO] Building spring-form-validation 1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ war ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ spring-form-validation ---
[INFO] Deleting D:\example-projects\spring-mvc\spring-form-validation\target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ spring-form-validation ---
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] skip non existing resourceDirectory D:\example-projects\spring-mvc\spring-form-validation\src\main\resources
[INFO]
[INFO] --- compiler:3.15.0:compile (default-compile) @ spring-form-validation ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 6 source files with javac [debug target 25] to target\classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ spring-form-validation ---
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] skip non existing resourceDirectory D:\example-projects\spring-mvc\spring-form-validation\src\test\resources
[INFO]
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ spring-form-validation ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 2 source files with javac [debug target 25] to target\test-classes
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ spring-form-validation ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[WARNING] file.encoding cannot be set as system property, use <argLine>-Dfile.encoding=...</argLine> instead
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.logicbig.example.RegistrationControllerTest
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.845 s -- in com.logicbig.example.RegistrationControllerTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 3.433 s
[INFO] Finished at: 2026-04-11T23:40:18+08:00
[INFO] ------------------------------------------------------------------------
Apr 11, 2026 11:40:18 PM org.hibernate.validator.internal.util.Version <clinit>
INFO: HV000001: Hibernate Validator 9.0.1.Final
INFO: Completed initialization in 1 ms
INFO: Completed initialization in 1 ms
INFO: Completed initialization in 1 ms
INFO: Completed initialization in 1 ms
INFO: Completed initialization in 1 ms

Example Project

Dependencies and Technologies Used:

  • spring-webmvc 7.0.6 (Spring Web MVC)
     Version Compatibility: 3.2.9.RELEASE - 7.0.6Version List
    ×

    Version compatibilities of spring-webmvc with this example:

      javax.servlet-api:3.x
    • 3.2.9.RELEASE
    • 3.2.10.RELEASE
    • 3.2.11.RELEASE
    • 3.2.12.RELEASE
    • 3.2.13.RELEASE
    • 3.2.14.RELEASE
    • 3.2.15.RELEASE
    • 3.2.16.RELEASE
    • 3.2.17.RELEASE
    • 3.2.18.RELEASE
    • 4.0.0.RELEASE
    • 4.0.1.RELEASE
    • 4.0.2.RELEASE
    • 4.0.3.RELEASE
    • 4.0.4.RELEASE
    • 4.0.5.RELEASE
    • 4.0.6.RELEASE
    • 4.0.7.RELEASE
    • 4.0.8.RELEASE
    • 4.0.9.RELEASE
    • 4.1.0.RELEASE
    • 4.1.1.RELEASE
    • 4.1.2.RELEASE
    • 4.1.3.RELEASE
    • 4.1.4.RELEASE
    • 4.1.5.RELEASE
    • 4.1.6.RELEASE
    • 4.1.7.RELEASE
    • 4.1.8.RELEASE
    • 4.1.9.RELEASE
    • 4.2.0.RELEASE
    • 4.2.1.RELEASE
    • 4.2.2.RELEASE
    • 4.2.3.RELEASE
    • 4.2.4.RELEASE
    • 4.2.5.RELEASE
    • 4.2.6.RELEASE
    • 4.2.7.RELEASE
    • 4.2.8.RELEASE
    • 4.2.9.RELEASE
    • 4.3.0.RELEASE
    • 4.3.1.RELEASE
    • 4.3.2.RELEASE
    • 4.3.3.RELEASE
    • 4.3.4.RELEASE
    • 4.3.5.RELEASE
    • 4.3.6.RELEASE
    • 4.3.7.RELEASE
    • 4.3.8.RELEASE
    • 4.3.9.RELEASE
    • 4.3.10.RELEASE
    • 4.3.11.RELEASE
    • 4.3.12.RELEASE
    • 4.3.13.RELEASE
    • 4.3.14.RELEASE
    • 4.3.15.RELEASE
    • 4.3.16.RELEASE
    • 4.3.17.RELEASE
    • 4.3.18.RELEASE
    • 4.3.19.RELEASE
    • 4.3.20.RELEASE
    • 4.3.21.RELEASE
    • 4.3.22.RELEASE
    • 4.3.23.RELEASE
    • 4.3.24.RELEASE
    • 4.3.25.RELEASE
    • 4.3.26.RELEASE
    • 4.3.27.RELEASE
    • 4.3.28.RELEASE
    • 4.3.29.RELEASE
    • 4.3.30.RELEASE
    • 5.0.0.RELEASE
    • 5.0.1.RELEASE
    • 5.0.2.RELEASE
    • 5.0.3.RELEASE
    • 5.0.4.RELEASE
    • 5.0.5.RELEASE
    • 5.0.6.RELEASE
    • 5.0.7.RELEASE
    • 5.0.8.RELEASE
    • 5.0.9.RELEASE
    • 5.0.10.RELEASE
    • 5.0.11.RELEASE
    • 5.0.12.RELEASE
    • 5.0.13.RELEASE
    • 5.0.14.RELEASE
    • 5.0.15.RELEASE
    • 5.0.16.RELEASE
    • 5.0.17.RELEASE
    • 5.0.18.RELEASE
    • 5.0.19.RELEASE
    • 5.0.20.RELEASE
    • 5.1.0.RELEASE
    • 5.1.1.RELEASE
    • 5.1.2.RELEASE
    • 5.1.3.RELEASE
    • 5.1.4.RELEASE
    • 5.1.5.RELEASE
    • 5.1.6.RELEASE
    • 5.1.7.RELEASE
    • 5.1.8.RELEASE
    • 5.1.9.RELEASE
    • 5.1.10.RELEASE
    • 5.1.11.RELEASE
    • 5.1.12.RELEASE
    • 5.1.13.RELEASE
    • 5.1.14.RELEASE
    • 5.1.15.RELEASE
    • 5.1.16.RELEASE
    • 5.1.17.RELEASE
    • 5.1.18.RELEASE
    • 5.1.19.RELEASE
    • 5.1.20.RELEASE
    • 5.2.0.RELEASE
    • 5.2.1.RELEASE
    • 5.2.2.RELEASE
    • 5.2.3.RELEASE
    • 5.2.4.RELEASE
    • 5.2.5.RELEASE
    • 5.2.6.RELEASE
    • 5.2.7.RELEASE
    • 5.2.8.RELEASE
    • 5.2.9.RELEASE
    • 5.2.10.RELEASE
    • 5.2.11.RELEASE
    • 5.2.12.RELEASE
    • 5.2.13.RELEASE
    • 5.2.14.RELEASE
    • 5.2.15.RELEASE
    • 5.2.16.RELEASE
    • 5.2.17.RELEASE
    • 5.2.18.RELEASE
    • 5.2.19.RELEASE
    • 5.2.20.RELEASE
    • 5.2.21.RELEASE
    • 5.2.22.RELEASE
    • 5.2.23.RELEASE
    • 5.2.24.RELEASE
    • 5.2.25.RELEASE
    • 5.3.0
    • 5.3.1
    • 5.3.2
    • 5.3.3
    • 5.3.4
    • javax.servlet-api:4.x
    • 5.3.5
    • 5.3.6
    • 5.3.7
    • 5.3.8
    • 5.3.9
    • 5.3.10
    • 5.3.11
    • 5.3.12
    • 5.3.13
    • 5.3.14
    • 5.3.15
    • 5.3.16
    • 5.3.17
    • 5.3.18
    • 5.3.19
    • 5.3.20
    • 5.3.21
    • 5.3.22
    • 5.3.23
    • 5.3.24
    • 5.3.25
    • 5.3.26
    • 5.3.27
    • 5.3.28
    • 5.3.29
    • 5.3.30
    • 5.3.31
    • 5.3.32
    • 5.3.33
    • 5.3.34
    • 5.3.35
    • 5.3.36
    • 5.3.37
    • 5.3.38
    • 5.3.39
    • javax.* -> jakarta.*
      jakarta.servlet-api:6.x
      Java 17 min
    • 6.0.0
    • 6.0.1
    • 6.0.2
    • 6.0.3
    • 6.0.4
    • 6.0.5
    • 6.0.6
    • 6.0.7
    • 6.0.8
    • 6.0.9
    • 6.0.10
    • 6.0.11
    • 6.0.12
    • 6.0.13
    • 6.0.14
    • 6.0.15
    • 6.0.16
    • 6.0.17
    • 6.0.18
    • 6.0.19
    • 6.0.20
    • 6.0.21
    • 6.0.22
    • 6.0.23
    • 6.1.0
    • 6.1.1
    • 6.1.2
    • 6.1.3
    • 6.1.4
    • 6.1.5
    • 6.1.6
    • 6.1.7
    • 6.1.8
    • 6.1.9
    • 6.1.10
    • 6.1.11
    • 6.1.12
    • 6.1.13
    • 6.1.14
    • 6.1.15
    • 6.1.16
    • 6.1.17
    • 6.1.18
    • 6.1.19
    • 6.1.20
    • 6.1.21
    • 6.2.0
    • 6.2.1
    • 6.2.2
    • 6.2.3
    • 6.2.4
    • 6.2.5
    • 6.2.6
    • 6.2.7
    • 6.2.8
    • 6.2.9
    • 6.2.10
    • 6.2.11
    • 6.2.12
    • 6.2.13
    • 6.2.14
    • 6.2.15
    • 6.2.16
    • 6.2.17
    • 7.0.0
    • 7.0.1
    • 7.0.2
    • 7.0.3
    • 7.0.4
    • 7.0.5
    • 7.0.6

    Versions in green have been tested.

  • spring-test 7.0.6 (Spring TestContext Framework)
  • jakarta.servlet-api 6.1.0 (Jakarta Servlet API documentation)
  • hibernate-validator 9.0.1.Final (Hibernate's Jakarta Validation reference implementation)
  • junit-jupiter-engine 6.0.3 (Module "junit-jupiter-engine" of JUnit)
  • hamcrest 3.0 (Core API and libraries of hamcrest matcher framework)
  • jakarta.el 4.0.2 (Jakarta Expression Language Implementation)
  • assertj-core 3.26.3 (Rich and fluent assertions for testing in Java)
  • JDK 25
  • Maven 3.9.11

Spring MVC - Jakarta Bean Validation Select All Download
  • spring-form-validation
    • src
      • main
        • java
          • com
            • logicbig
              • example
                • UserRegistrationController.java
          • webapp
            • WEB-INF
              • views
        • test
          • java
            • com
              • logicbig
                • example

    See Also

    Join