Close

Spring MVC - Form Validation using Bean Validation and Spring JSP Form Tag

[Last Updated: Apr 15, 2026]

In our last example we saw how to do field validation declaratively using bean validation API.

We can simplify our code a little more on the view side by using Spring Form JSP Tag Library.

Among many things, Spring Tag Library provides JSP tags to render validation errors implicitly via errors tag.


Let's modify our last example one more time to see how that works.


user-registration.jsp

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

<%@taglib uri="http://www.springframework.org/tags/form" prefix="frm" %>
<html>
<head>
    <style>
        span.error {
            color: red;
        }
    </style>
</head>
<body style="padding: 10px">

<h3> Registration Form
    <h3>
        <br/>
        <frm:form action="register" method="post" modelAttribute="user">

        Name
            <frm:input path="name"/>
        <br/>
            <frm:errors path="name" cssClass="error"/>
        <br/><br/>

        Email address
            <frm:input path="emailAddress"/>
        <br/>
            <frm:errors path="emailAddress" cssClass="error"/>
        <br/><br/>
        Password
            <frm:password path="password"/>
        <br/>
            <frm:errors path="password" cssClass="error"/>
        <br/><br/>
        <input type="submit" value="Submit"/>

        </frm:form>
</body>
</html>

In above code, spring form tag is implicitly mapping the backing object fields to the HTML form fields and also taking care of the validation errors via frm:errors.

One more important thing to understand is that, Spring top level form tag must have to specify 'modelAttribute' which is actually the name of the 'model attribute' under which the form object is exposed. That also means we are required to use annotation @ModelAttribute on our backing object using the same name as 'modelAttribute'. Please see our controller next.



The Controller

Comparing with our last example our controller has become simpler, now we don't have to populate error messages in model.

package com.logicbig.example;

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.ModelAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;

import jakarta.validation.Valid;

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

  @Autowired private UserService userService;

  @GetMapping
  public String handleGetRequest(Model model) {
    model.addAttribute("user", new User());
    return "user-registration";
  }

  @PostMapping
  public String handlePostRequest(
      @Valid @ModelAttribute("user") User user, BindingResult bindingResult, Model model) {
    if (bindingResult.hasErrors()) {
      return "user-registration";
    }

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

Using @ModelAttribute to bind with Spring form's modelAttribute

Notice, in above handler method we must use @ModelAttribute with a name matching with 'modelAttribute' in our jsp form.

Running Example

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

mvn jetty:run

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

<html>
<head>
<style>
span.error {
color: red;
}
</style>
</head>
<body style="padding: 10px">

<h3> Registration Form
<h3>
<br/>
<form id="user" action="register" method="post">

Name
<input id="name" name="name" type="text" value="Joe"/>
<br/>
<span id="name.errors" class="error">size must be between 5 and 20</span>
<br/><br/>

Email address
<input id="emailAddress" name="emailAddress" type="text" value="joe@example.com"/>
<br/>

<br/><br/>
Password
<input id="password" name="password" type="password" value=""/>
<br/>
<span id="password.errors" class="error">size must be between 6 and 15</span>
<br/><br/>
<input type="submit" value="Submit"/>

</form>
</body>
</html>

http://localhost:8080/spring-form-tag-lib/register


Integration Tests

package com.logicbig.example;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResult;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.context.WebApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration(classes = MyWebConfig.class)
public class RegistrationControllerTest {

    @Autowired
    private WebApplicationContext wac;

    @Autowired
    private UserService userService;

    private MockMvcTester mockMvc;

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

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

        assertThat(result)
                .hasStatusOk()
                .hasViewName("registration-done");

        assertThat(result).model()
                          .doesNotHaveErrors()
                          .containsKey("user");

        assertThat(result.getMvcResult().getModelAndView().getModel()
                         .get("user"))
                .isInstanceOfSatisfying(User.class, user -> {
                    assertThat(user.getName()).isEqualTo("Joseph");
                    assertThat(user.getEmailAddress()).isEqualTo(
                            "joe@email.address");
                });

        User saved = userService.getUserById(1L);
        assertThat(saved).isNotNull();
        assertThat(saved.getName()).isEqualTo("Joseph");
        assertThat(saved.getEmailAddress()).isEqualTo("joe@email.address");
        assertThat(saved.getPassword()).isEqualTo("abcdef");
    }

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

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

        assertThat(result).model()
                          .hasErrors()
                          .hasAttributeErrors("user");

        assertThat(result).model()
                          .extractingBindingResult("user")
                          .hasErrorsCount(1)
                          .hasOnlyFieldErrors("name");

        assertThat(extractBindingResult(result, "user")
                           .getFieldError("name")
                           .getDefaultMessage())
                .isEqualTo("size must be between 5 and 20");
    }

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

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

        assertThat(result).model()
                          .hasErrors()
                          .hasAttributeErrors("user");

        assertThat(result).model()
                          .extractingBindingResult("user")
                          .hasErrorsCount(1)
                          .hasOnlyFieldErrors("emailAddress");

        assertThat(extractBindingResult(result, "user")
                           .getFieldError("emailAddress")
                           .getDefaultMessage())
                .isEqualTo("Email is not valid");
    }

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

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

        assertThat(result).model()
                          .hasErrors()
                          .hasAttributeErrors("user");

        assertThat(result).model()
                          .extractingBindingResult("user")
                          .hasErrorsCount(2)
                          .hasOnlyFieldErrors("password");

        assertThat(extractBindingResult(result, "user").getFieldErrors(
                "password"))
                .hasSize(2)
                .extracting(FieldError::getDefaultMessage)
                .satisfiesExactlyInAnyOrder(
                        msg -> assertThat(msg).contains("Spaces are not allowed"),
                        msg -> assertThat(msg).contains(
                                "size must be between 6 and 15")
                );
    }

    // --- helper ---
    private BindingResult extractBindingResult(MvcTestResult result,
                                               String attrName) {
        return (BindingResult) result.getMvcResult()
                                     .getModelAndView()
                                     .getModel()
                                     .get(BindingResult.MODEL_KEY_PREFIX + attrName);
    }
}
mvn clean test -Dtest="RegistrationControllerTest"

Output

$ mvn clean test -Dtest="RegistrationControllerTest"
[INFO] Scanning for projects...
[INFO]
[INFO] --------------< com.logicbig.example:spring-form-tag-lib >--------------
[INFO] Building spring-form-tag-lib 1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ war ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ spring-form-tag-lib ---
[INFO] Deleting D:\example-projects\spring-mvc\spring-form-tag-lib\target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ spring-form-tag-lib ---
[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-tag-lib\src\main\resources
[INFO]
[INFO] --- compiler:3.15.0:compile (default-compile) @ spring-form-tag-lib ---
[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-tag-lib ---
[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-tag-lib\src\test\resources
[INFO]
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ spring-form-tag-lib ---
[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-tag-lib ---
[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: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.294 s -- in com.logicbig.example.RegistrationControllerTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.698 s
[INFO] Finished at: 2026-04-15T11:39:13+08:00
[INFO] ------------------------------------------------------------------------
Apr 15, 2026 11:39:12 AM org.hibernate.validator.internal.util.Version <clinit>
INFO: HV000001: Hibernate Validator 9.0.1.Final
INFO: Completed initialization in 2 ms
INFO: Completed initialization in 1 ms
INFO: Completed initialization in 1 ms
INFO: Completed initialization in 0 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)
  • jakarta.el 4.0.0 (Jakarta Expression Language Implementation)
  • junit-jupiter-engine 6.0.3 (Module "junit-jupiter-engine" of JUnit)
  • hamcrest 3.0 (Core API and libraries of hamcrest matcher framework)
  • assertj-core 3.26.3 (Rich and fluent assertions for testing in Java)
  • JDK 25
  • Maven 3.9.11

Spring MVC - Select All Download
  • spring-form-tag-lib
    • src
      • main
        • java
          • com
            • logicbig
              • example
                • UserRegistrationController.java
          • webapp
            • WEB-INF
              • views
        • test
          • java
            • com
              • logicbig
                • example

    See Also

    Join