Close

Spring MVC - Form Validation using Jakarta Validation API and External Message Source

[Last Updated: Apr 15, 2026]

Following example shows how to configure and map custom validation error codes to external messages while implementing Java Bean Validation in a Spring MVC application. We are going to reuse our last example. We just need to add our custom message codes with Java Bean Validation annotations and include an external message source.

Example

A Java Bean

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;

  @Size(min = 5, max = 20, message = "{user.name.size}")
  private String name;

  @Size(min = 6, max = 15, message = "{user.password.size}")
  @Pattern(regexp = "\\S+", message = "{user.password.pattern}")
  private String password;

  @NotEmpty(message = "{user.email.empty}")
  @Email(message = "{user.email.valid}")
  private String emailAddress;
    .............
}

Message Source

src/main/resources/ValidationMessages_en.properties

user.name.size=User Name must be of more than 5 and less than 20 characters.
user.password.size=User Password length most be between 6 and 15.
user.password.pattern=Password must not have spaces.
user.email.empty=User Email cannot be empty.
user.email.valid=Email is not valid.

Spring Controller

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";
  }
}

JSP Form

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;
display: inline-block;
}
</style>
</head>
<body>

<h3> Registration Form <h3>
<br/>
 <frm:form action="register" method="post" modelAttribute="user">
  <pre>
                  Name <frm:input path="name" />
                       <frm:errors path="name" cssClass="error" />

         Email address <frm:input path="emailAddress" />
                       <frm:errors path="emailAddress" cssClass="error" />

              Password <frm:password path="password" />
                       <frm:errors path="password" cssClass="error" />

                                  <input type="submit" value="Submit" />
  </pre>
 </frm:form>
</body>
</html>

Java Config

@EnableWebMvc
@Configuration
@ComponentScan
public class MyWebConfig implements WebMvcConfigurer {

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasenames("ValidationMessages");
        return messageSource;
    }

    @Override
    public void configureViewResolvers(ViewResolverRegistry registry) {
        registry.jsp("/WEB-INF/views/", ".jsp");
    }
}

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

mvn jetty:run

Output

Submitting invalid values:

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

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

<h3> Registration Form <h3>
<br/>
<form id="user" action="register" method="post">
<pre>
Name <input id="name" name="name" type="text" value="Joe"/>
<span id="name.errors" class="error">User Name must be of more than 5 and less than 20 characters.</span>

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


Password <input id="password" name="password" type="password" value=""/>
<span id="password.errors" class="error">User Password length most be between 6 and 15.</span>

<input type="submit" value="Submit" />
</pre>
</form>
</body>
</html>

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.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;

    private MockMvcTester mockMvc;

    @Autowired
    private UserService userService;

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

    @Test
    public void testRegistrationSuccess() {
        assertThat(
                mockMvc.post().uri("/register")
                       .param("name", "Joseph")
                       .param("emailAddress", "joe@email.address")
                       .param("password", "abcdef")
                       .exchange()
        )
                .hasStatusOk()
                .hasViewName("registration-done")
                .model()
                .containsKey("user")
                .extractingByKey("user")
                .satisfies(raw -> {
                    User u = (User) raw;
                    assertThat(u.getName()).isEqualTo("Joseph");
                    assertThat(u.getEmailAddress()).isEqualTo("joe@email.address");
                });

        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() {
        MvcTestResult result =
                mockMvc.post().uri("/register")
                       .param("name", "")
                       .param("emailAddress", "joe@email.address")
                       .param("password", "abcdef")
                       .exchange();

        assertThat(result)
                .hasStatusOk()
                .hasViewName("user-registration")
                .model()
                .hasErrors()
                .hasAttributeErrors("user")
                .extractingByKey("user")
                .satisfies(raw -> assertThat(((User) raw).getName()).isEmpty());

        assertThat(result)
                .model()
                .extractingBindingResult("user")
                .satisfies(br ->
                                   assertThat(br.getFieldError("name").getDefaultMessage())
                                           .isEqualTo("User Name must be of more than 5 and less than 20 characters.")
                );
    }

    @Test
    public 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")
                .model()
                .hasErrors()
                .hasAttributeErrors("user");

        assertThat(result)
                .model()
                .extractingBindingResult("user")
                .satisfies(br ->
                                   assertThat(br.getFieldError("emailAddress").getDefaultMessage())
                                           .isEqualTo("Email is not valid.")
                );
    }

    @Test
    public 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")
                .model()
                .hasErrors()
                .hasAttributeErrors("user")
                .extractingByKey("user")
                .satisfies(raw -> assertThat(((User) raw).getPassword()).isEmpty());

        assertThat(result)
                .model()
                .extractingBindingResult("user")
                .satisfies(br -> {
                    assertThat(br.getFieldErrors("password"))
                            .hasSize(2)
                            .map(e -> e.getDefaultMessage())
                            .anySatisfy(m -> assertThat(m).contains("Password must not have spaces."))
                            .anySatisfy(m -> assertThat(m).contains("User Password length most be between 6 and 15."));
                });
    }
}
mvn clean test -Dtest="RegistrationControllerTest"

Output

$ mvn clean test -Dtest="RegistrationControllerTest"
[INFO] Scanning for projects...
[WARNING]
[WARNING] Some problems were encountered while building the effective model for com.logicbig.example:spring-form-message-code:war:1.0-SNAPSHOT
[WARNING] 'build.plugins.plugin.version' for org.apache.maven.plugins:maven-war-plugin is missing. @ line 48, column 21
[WARNING]
[WARNING] It is highly recommended to fix these problems because they threaten the stability of your build.
[WARNING]
[WARNING] For this reason, future Maven versions might no longer support building such malformed projects.
[WARNING]
[INFO]
[INFO] -----------< com.logicbig.example:spring-form-message-code >------------
[INFO] Building spring-form-message-code 1.0-SNAPSHOT
[INFO] from pom.xml
[INFO] --------------------------------[ war ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ spring-form-message-code ---
[INFO] Deleting D:\example-projects\spring-mvc\spring-form-message-code\target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ spring-form-message-code ---
[WARNING] Using platform encoding (UTF-8 actually) to copy filtered resources, i.e. build is platform dependent!
[INFO] Copying 1 resource from src\main\resources to target\classes
[INFO]
[INFO] --- compiler:3.5.1:compile (default-compile) @ spring-form-message-code ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 7 source files to D:\example-projects\spring-mvc\spring-form-message-code\target\classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ spring-form-message-code ---
[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-message-code\src\test\resources
[INFO]
[INFO] --- compiler:3.5.1:testCompile (default-testCompile) @ spring-form-message-code ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 1 source file to D:\example-projects\spring-mvc\spring-form-message-code\target\test-classes
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ spring-form-message-code ---
[INFO] Using auto detected provider org.apache.maven.surefire.junit4.JUnit4Provider
[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: 0.719 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: 2.938 s
[INFO] Finished at: 2026-04-15T20:00:19+08:00
[INFO] ------------------------------------------------------------------------
Apr 15, 2026 8:00:19 PM org.hibernate.validator.internal.util.Version <clinit>
INFO: HV000001: Hibernate Validator 5.2.4.Final

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)
  • assertj-core 3.26.3 (Rich and fluent assertions for testing in Java)
  • jakarta.el 4.0.2 (Jakarta Expression Language Implementation)
  • JDK 25
  • Maven 3.9.11

Spring MVC - Jakarta Validation API and External Message Source Select All Download
  • spring-form-message-code
    • src
      • main
        • java
          • com
            • logicbig
              • example
                • User.java
          • resources
          • webapp
            • WEB-INF
              • views
        • test
          • java
            • com
              • logicbig
                • example

    See Also

    Join