Bean Validation Plugin

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Struts 7.4.0 (released 2026-10-02) with Hibernate Validator 9.1.4.Final as the Bean Validation provider, running on Apache Tomcat 11.0.26 and Java 21; the facts were checked on 2026-10-08 against the official plugin page, the source of the plugin in the 7.4.0 sources jar, the contents of the plugin’s struts-plugin.xml and the Hibernate Validator reference guide, and every example was built into a Maven WAR, deployed to the scratch Tomcat and requested with curl. Struts has had its own validation framework since version 2.0 (the validation interceptor, -validation.xml files, validate() and the field validators described in Validation). The Bean Validation plugin, available since Struts 2.5, offers an alternative: instead of Struts validators, the rules are Jakarta Validation annotations (@NotBlank, @Size, @Min, …​) written on the fields of the action or of its model, evaluated by a provider such as Hibernate Validator, with the violations turned into the same field errors and action errors that the workflow interceptor and the s:fielderror tag already understand. The running scenario is the Bookshelf, a catalogue of books; this page validates the "add a book" form with annotations, moves the rules to a model object with a nested publisher, adds a validation group that applies only when a book is published and a class-level constraint that compares two e-mail fields, shows how the messages are resolved and why they do not follow the Struts locale, and ends with the mistakes that fail either the start-up or the validation without a clear message. The pages on type conversion, parameter injection and localization cover the neighbouring topics and are linked where they come up.

What the Plugin Is

The plugin is a thin bridge, a single small jar with about ten classes. The official page says it "implements a bridge to use Bean Validation in struts actions" and lists four features it integrates with: i18n, model driven actions, AJAX validation and the workflow interceptor. Everything else (the constraint annotations, the validation engine, the message interpolation, the groups) belongs to Jakarta Validation and to the provider, which is why the page that defines the constraints in detail is the Hibernate Validator reference, not the Struts documentation. The table lists the parts that the jar contains in 7.4.0.

Part Role

struts2-bean-validation-plugin

Maven artifact org.apache.struts:struts2-bean-validation-plugin; the version is managed by the struts2-bom, so it is declared without a version when the BOM is imported. It brings jakarta.validation:jakarta.validation-api as its only dependency but no provider

struts-bean-validation

A package declared by the plugin’s struts-plugin.xml that extends struts-default. An application package extends it to switch Bean Validation on

BeanValidationInterceptor

The interceptor registered under the name beanValidation: it obtains a jakarta.validation.Validator, validates the action (or its model) and writes the violations as field errors or action errors

BeanValidationManager and DefaultBeanValidationManager

A singleton bean that reads the constants, builds the ValidatorFactory once at start-up and hands out validators

@ValidationGroup

Method annotation org.apache.struts.beanvalidation.constraints.ValidationGroup that selects the validation groups for one action method

@FieldMatch

Class-level constraint org.apache.struts.beanvalidation.constraints.FieldMatch that checks that two properties have equal values (a confirmation field)

@SkipValidation

The annotation of core, org.apache.struts2.interceptor.validation.SkipValidation, that the interceptor honours on an action method

beanValidationDefaultStack

An interceptor stack that is the default stack of the struts-bean-validation package

The plugin is a Struts plugin in the usual sense (a jar with a struts-plugin.xml at its root that the framework loads from the class path, see Configuration: struts.xml & Constants), so adding the jar to the application is enough to make the package and the interceptor known. Nothing is validated until an action is configured in a package that extends struts-bean-validation (or in a package with its own stack that names the beanValidation interceptor).

Setup: The Plugin, a Provider and the Servlet Container

Two dependencies are needed. The plugin is the Struts half; the Jakarta Validation provider is the half that does the work, and the plugin does not bring one. The Maven Central coordinates used in the scenario are the following; with struts2-bom 7.4.0 imported, the Struts artifacts need no version.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.struts</groupId>
            <artifactId>struts2-bom</artifactId>
            <version>7.4.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.apache.struts</groupId>
        <artifactId>struts2-core</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.struts</groupId>
        <artifactId>struts2-bean-validation-plugin</artifactId>
    </dependency>
    <dependency>
        <groupId>org.hibernate.validator</groupId>
        <artifactId>hibernate-validator</artifactId>
        <version>9.1.4.Final</version>
    </dependency>
</dependencies>

A few facts about this set of artifacts, all observed in the scratch WAR (mvn dependency:tree and the contents of WEB-INF/lib):

  • Hibernate Validator 9.1.4.Final brings jboss-logging and classmate and needs an Expression Language implementation to interpolate messages such as ${validatedValue}. Tomcat 11 provides jakarta.el in its own class path, so a WAR on Tomcat needs nothing more; a servlet container without EL (Jetty, for instance) needs a jakarta.el implementation declared by the application.

  • The plugin’s only dependency is jakarta.validation-api 3.0.2, and the struts2-bom manages that version, while Hibernate Validator 9.x implements Jakarta Validation 3.1. Maven resolves the managed 3.0.2 jar, and the scenario of this page (annotations from jakarta.validation.constraints, groups, @Valid, a class-level constraint) ran without a problem on that combination. Application code that uses a 3.1 feature would need the 3.1 API jar declared explicitly.

  • The namespace is jakarta.validation, never javax.validation: Struts 7 is a Jakarta EE 10/11 framework, and Hibernate Validator 9 only knows the Jakarta namespace. The official plugin page still shows javax.validation imports; see the section on outdated material below.

  • The provider is found through the Java service loader (META-INF/services/jakarta.validation.spi.ValidationProvider, which Hibernate Validator’s jar contains), unless a constant names the class.

Configuration: the Package, the Constants and the Stack

The configuration has two parts: the package that the application’s actions live in, and four constants. The package declaration is the part that is always needed.

<struts>
    <constant name="struts.beanValidation.providerClass" value="org.hibernate.validator.HibernateValidator"/>
    <constant name="struts.beanValidation.ignoreXMLConfiguration" value="true"/>
    <constant name="struts.allowlist.packageNames" value="org.example.bookshelf.model"/>

    <package name="bv" extends="struts-bean-validation" namespace="/bv">
        <global-results>
            <result name="403">/WEB-INF/views/forbidden.jsp</result>
        </global-results>
        <action name="add" class="org.example.bookshelf.actions.AddBookAction">
            <result name="input">/WEB-INF/views/form.jsp</result>
            <result>/WEB-INF/views/ok.jsp</result>
        </action>
    </package>
</struts>

The package element is the one that matters: extends="struts-bean-validation" makes the package inherit the beanValidation interceptor and, more importantly, the beanValidationDefaultStack that replaces defaultStack as the default interceptor reference. The global-results entry maps the result name 403, which the fetchMetadata interceptor returns for rejected cross-site requests (explained in its own section below). The struts.allowlist... constant is a 7.x requirement of the OGNL allowlist that the model classes of the scenario need; the validation itself does not use OGNL. The four constants of the plugin, whose defaults are declared in the plugin’s struts-plugin.xml, are the following.

Constant Default Effect

struts.beanValidation.providerClass

empty

The class name of the provider (org.hibernate.validator.HibernateValidator). Empty means "use the first provider that the service loader finds". When the name is set and the class cannot be loaded, an error is logged ("Unable to find any bean validator implementation for class …​") and the plugin falls back to the default provider

struts.beanValidation.ignoreXMLConfiguration

false

When true, the factory is built with ignoreXmlConfiguration() and the provider does not read META-INF/validation.xml or the XML constraint mappings it points at. The Jakarta Validation XML configuration is therefore honoured by default

struts.beanValidation.convertMessageToUtf

false

When true, a message resolved from a Struts resource bundle is re-decoded: its bytes in convertMessageFromEncoding are read as UTF-8. A workaround for bundles that were saved in the wrong encoding

struts.beanValidation.convertMessageFromEncoding

ISO-8859-1

The encoding of the first decoding step of the previous constant

The stack that the package makes the default is, in the order of struts-plugin.xml 7.4.0 (the classic validation interceptor does not appear):

# Interceptor Remark

1

exception, alias, servletConfig, i18n

As in defaultStack

2

prepare, chain, scopedModelDriven, modelDriven

Bring the model of a ModelDriven action into place before the parameters arrive

3

actionFileUpload, checkbox, datetime, multiselect

Request preprocessing

4

staticParams, actionMappingParams, params, conversionError

Parameter injection and type conversion (see Parameter Injection and @StrutsParameter and Type Conversion); after these the action holds the request values

5

coep, coop, fetchMetadata

Security headers and resource isolation, added in 7.4.0 (WW-5718)

6

beanValidation with excludeMethods=input,back,cancel,browse

The plugin’s interceptor, run after the values are in the action

7

workflow with the same excludeMethods

Returns input when field errors or action errors exist

8

debugging

Developer tool

flowchart LR R[HTTP request] --> P[params and conversionError] P --> F[coep coop fetchMetadata] F --> B[beanValidation] B --> V{violations?} V -- yes --> E[field errors and action errors added] V -- no --> W[workflow] E --> W W --> D{errors?} D -- yes --> I[result input - the form is shown again] D -- no --> A[action method executes] A --> S[result success]

The diagram shows why the order of the stack matters. The beanValidation interceptor does not stop the request: it only adds errors to the action, and the workflow interceptor right after it decides, exactly as it does for classic validation, whether the action method runs or the input result is returned. Because Bean Validation reads the values that are already in the action, it must come after params and conversionError; the next section shows what happens when it does not.

What the beanValidation Interceptor Does

The behaviour below is read from the 7.4.0 source of BeanValidationInterceptor and confirmed with the scratch WAR.

  1. It asks the BeanValidationManager for a Validator. If there is none, validation is skipped with a debug message.

  2. It resolves the action method that is going to run (ActionProxy.getMethod()). When that method carries @SkipValidation, the interceptor does nothing. Otherwise it looks for @ValidationGroup on the same method; without it, the group list is Default only.

  3. It validates the action or, when the action implements ModelDriven, the model returned by getModel(), with those groups.

  4. For every ConstraintViolation it takes the interpolated message, looks it up as a Struts text key (next section) and stores the result. A violation whose leaf bean is the invalid value itself (what a class-level constraint produces) becomes an action error; any other violation becomes a field error on the property path (title, publisher.name). For a ModelDriven action, a leading model. is removed from the field name, so the field error is named title, matching the form field.

  5. It always lets the invocation continue (invocation.invoke()); the workflow interceptor that follows acts on the errors.

Because the interceptor extends MethodFilterInterceptor, the includeMethods and excludeMethods parameters work as for other interceptors (see Interceptors); the plugin’s stack sets excludeMethods to input,back,cancel,browse, which means that an action method with one of those four names is not validated even without @SkipValidation. The two filters are different: excludeMethods matches the method name configured for the action, @SkipValidation is an annotation on the method itself.

The Bookshelf Form with Annotations on the Action

The first example puts the constraints on the action, the same way the field validators of the classic framework would be declared for the action. The action extends ActionSupport, the setters carry @StrutsParameter (the 7 requirement for fields that the request may set), and the method that only shows the form is marked @SkipValidation.

package org.example.bookshelf.actions;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
import org.apache.struts2.interceptor.validation.SkipValidation;

public class AddBookAction extends ActionSupport {
    @NotBlank(message = "book.title.required")
    @Size(max = 40)
    private String title;

    @NotBlank
    private String author;

    @NotNull
    @Min(1450)
    @Max(2100)
    private Integer year;

    public String getTitle() { return title; }
    @StrutsParameter public void setTitle(String title) { this.title = title; }
    public String getAuthor() { return author; }
    @StrutsParameter public void setAuthor(String author) { this.author = author; }
    public Integer getYear() { return year; }
    @StrutsParameter public void setYear(Integer year) { this.year = year; }

    @SkipValidation
    @Override
    public String input() { return INPUT; }

    @Override
    public String execute() { return SUCCESS; }
}

The result page lists the action errors and the field errors with plain s:iterator, so that the curl output below shows the keys and messages exactly. Requested with an empty form, a form with long title and out-of-range year, a form with a non-numeric year and a valid form, the scenario answered (HTTP 200 for each; the lists are shown in the order returned, which is the iteration order of a set and not guaranteed):

Request body Messages on the page

title=&author=&year=

author: must not be blank, year: must not be null, title: Please give the book a title

45-character title, author=A, year=3000

title: size must be between 0 and 40, year: must be less than or equal to 2100

title=Dune&author=A&year=abc

year: Invalid field value for field "year". must not be null

title=Dune&author=Herbert&year=1965

No errors: the success result is rendered

Four things are visible in this output. The English texts (must not be blank, size must be between 0 and 40) are the provider’s own messages: nothing needed to be written for them. The title message is the Struts text, because the annotation names a key (explained in the next section). The empty year field became null (Struts converts an empty string for a wrapper type to null), so @NotNull produced a violation, not a conversion error. And the non-numeric year shows two messages on one field: the conversionError interceptor added Invalid field value for field "year". first, then @NotNull added its message because the property stayed null. The second message is true but confusing; the usual remedy is to define a text for the conversion error in the action’s bundle (invalid.fieldvalue.year) and to accept the second message, or to use a wrapper-friendly rule such as @Min instead of @NotNull where only the range matters.

The action that only displays the form is the second use of @SkipValidation. The scratch app maps it with method="input":

<action name="addform" class="org.example.bookshelf.actions.AddBookAction" method="input">
    <result name="input">/WEB-INF/views/form.jsp</result>
</action>

A POST of an empty form to addform.action rendered the form with no errors, while the same request to add.action listed three. In this particular case the stack’s excludeMethods=input,... would have skipped validation anyway; @SkipValidation is needed for a method with another name (a prepareForm() or list(), for example).

Messages: Struts Keys, Provider Messages and Locales

Every violation has a message, and the plugin decides what text the user sees in two steps. Understanding them is the most important part of using the plugin in an application that has more than one language.

  1. The provider interpolates the message of the annotation. The default messages come from the provider’s bundles (must not be blank). A message in braces, {book.isbn.pattern}, is looked up by the provider in ValidationMessages.properties on the class path (and in the locale variants of that bundle). Text that the provider cannot resolve is left as written.

  2. In 7.4.0 the plugin takes the result of step 1 and calls getText(...) on it through the action’s text provider, that is, it treats the interpolated string as a Struts message key. If a key of that name exists in the bundles that the action sees (AddBookAction.properties, the package and global bundles; see the resource bundle search order in Localization and i18n), the Struts text is used; if not, the string itself is used.

The consequence is that there are two ways to write a message, and they differ in who looks the text up. The scenario uses both, side by side, on the model class Book of the next section:

@NotBlank(message = "book.title.required")
@Size(max = 40, message = "book.title.tooLong")
private String title;

@NotBlank(groups = Publish.class, message = "book.isbn.required")
@Pattern(regexp = "\\d{10}|\\d{13}", message = "{book.isbn.pattern}")
private String isbn;

With BookModelAction.properties (the bundle of the action) holding

book.title.required=Title is required (from BookModelAction.properties)
book.title.tooLong=Title is longer than 40 characters
book.isbn.required=ISBN is required to publish
publisher.name.required=Publisher name is required

and ValidationMessages.properties at the root of the class path holding book.isbn.pattern=An ISBN has 10 or 13 digits (ValidationMessages.properties), a submission with an empty title and the ISBN 12ab showed title: Title is required (from BookModelAction.properties) and isbn: An ISBN has 10 or 13 digits (ValidationMessages.properties). The first message is a plain key that the provider left alone and Struts resolved; the second is a brace expression that the provider resolved from its own bundle before Struts saw it. Writing the brace key without the provider bundle does not work: the same {book.isbn.pattern} with the text only in the Struts bundle displayed the literal {book.isbn.pattern} (observed with the text only in a Struts bundle on the model class, Book.properties), because the provider leaves an unknown brace key as written and a key with braces is not a name in a Struts bundle.

The locale is where the two paths part. The Struts locale comes from the i18n interceptor (the request_locale parameter, the session, Accept-Language); the provider’s locale is chosen by Hibernate Validator’s locale resolver, which by default is the JVM’s default locale. The same bad submission, sent with request_locale=es after adding BookModelAction_es.properties with book.title.required=El titulo es obligatorio (...), produced the Spanish text for the title (resolved by Struts) and the English must not be blank and An ISBN has 10 or 13 digits (ValidationMessages.properties) (resolved by the provider, in spite of a ValidationMessages_es.properties on the class path). An Accept-Language: es header changed nothing either. For an application that is localized, the choices are: use Struts keys for every message (so that all texts follow the Struts locale and live in the bundles the rest of the application uses), or configure the provider’s locale resolver with the application’s locale source through Configuration code in a custom BeanValidationManager (the manager is an injectable bean, so it can be replaced in struts.xml with a bean element of type="org.apache.struts.beanvalidation.validation.interceptor.BeanValidationManager"). The first choice needs no code.

The two convertMessage... constants belong to this step. They apply to the message after getText returned it from a Struts bundle, so they matter only for a project whose .properties files were saved in ISO-8859-1 while holding UTF-8 bytes; a project with correctly encoded bundles leaves both constants at their defaults.

ModelDriven Actions, Nested Beans and Groups

Most Struts applications put the data in a model object rather than in the action, with ModelDriven. The plugin supports it directly: the model, not the action, is validated. The nested @Valid and the group in the example are ordinary Jakarta Validation. The model of the scenario is a Book with a nested Publisher.

public class Book {
    @NotBlank(message = "book.title.required")
    @Size(max = 40, message = "book.title.tooLong")
    private String title;

    @NotBlank
    private String author;

    @NotNull
    @Min(1450)
    @Max(2100)
    private Integer year;

    @NotBlank(groups = Publish.class, message = "book.isbn.required")
    @Pattern(regexp = "\\d{10}|\\d{13}", message = "{book.isbn.pattern}")
    private String isbn;

    @Valid
    private Publisher publisher = new Publisher();

    public String getTitle() { return title; }
    @StrutsParameter public void setTitle(String title) { this.title = title; }
    public String getAuthor() { return author; }
    @StrutsParameter public void setAuthor(String author) { this.author = author; }
    public Integer getYear() { return year; }
    @StrutsParameter public void setYear(Integer year) { this.year = year; }
    public String getIsbn() { return isbn; }
    @StrutsParameter public void setIsbn(String isbn) { this.isbn = isbn; }
    @StrutsParameter(depth = 1) public Publisher getPublisher() { return publisher; }
    public void setPublisher(Publisher publisher) { this.publisher = publisher; }
}

public class Publisher {
    @NotBlank(message = "publisher.name.required")
    private String name;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

/** Validation group: extra rules that apply only when a book is published. */
public interface Publish { }

The action implements ModelDriven<Book> and has two methods, one validated with the default group and one with the default group plus Publish:

public class BookModelAction extends ActionSupport implements ModelDriven<Book> {
    private final Book book = new Book();

    @Override
    public Book getModel() { return book; }

    @Override
    public String execute() { return SUCCESS; }

    @ValidationGroup({Default.class, Publish.class})
    public String publish() { return SUCCESS; }
}

with the mappings model (method execute) and publish (method="publish") in the bv package. The observed behaviour:

Request Result

model.action, title=&author=&year= (nothing else)

author, year, title (Struts text from BookModelAction.properties) and publisher.name (the nested bean) all reported; the field name is title, not model.title

model.action, valid book, isbn=12ab, publisher.name=

publisher.name: Publisher name is required and isbn with the ValidationMessages.properties text

model.action, valid book without isbn

OK: the @NotBlank of isbn belongs to the group Publish and the default group does not include it (the @Pattern has no value to check)

publish.action, the same book without isbn

isbn: ISBN is required to publish

model.action, isbn=1234567890, publisher.name=X

OK

Four rules follow. Constraints without a groups attribute belong to Default, and only the groups listed by @ValidationGroup on the running method are validated, so a group’s constraints are silent until a method asks for the group; the annotation on the method replaces Default, which is why the publish() method lists Default.class explicitly (without it only the Publish constraints would run). Cascading into the publisher needs @Valid; without it the nested bean is not validated. The path of a nested violation is the field name that goes into the form (publisher.name), the same name the s:textfield name="publisher.name" tag uses, so field errors appear next to the right control. And the @StrutsParameter(depth = 1) annotation on getPublisher() is the Struts 7 rule that lets the request set publisher.name; it is unrelated to validation, but forgetting it leaves the nested name empty and produces a confusing Publisher name is required for a form that did submit the field (see Parameter Injection and @StrutsParameter).

A Class-Level Constraint: @FieldMatch

Some rules compare two properties, and a single-field annotation cannot express them. The plugin ships one such constraint, @FieldMatch, for confirmation fields. It is applied to the class and names the two properties.

@FieldMatch(first = "email", second = "confirmEmail", message = "The two e-mail addresses must match")
public class ReaderAction extends ActionSupport {
    @NotBlank @Email
    private String email;
    private String confirmEmail;

    public String getEmail() { return email; }
    @StrutsParameter public void setEmail(String email) { this.email = email; }
    public String getConfirmEmail() { return confirmEmail; }
    @StrutsParameter public void setConfirmEmail(String confirmEmail) { this.confirmEmail = confirmEmail; }

    @Override
    public String execute() { return SUCCESS; }
}

The scratch results:

Request body Messages

email=a@b.com&confirmEmail=c@d.com

The action error The two e-mail addresses must match (no field name)

email=a@b.com&confirmEmail=a@b.com

OK

email=nope&confirmEmail=nope

email: must be a well-formed email address

email=&confirmEmail=

email: must not be blank

The mismatch shows up as an action error because a violation raised on the class has the bean itself as its invalid value; the interceptor files such a violation under actionErrors, so a page must show the action errors (the s:actionerror tag) as well as the field errors, or the user sees a form that is rejected without a reason. Any custom class-level constraint written with the Jakarta Validation @Constraint(validatedBy = ...) mechanism behaves the same way; Hibernate Validator’s own class-level @ScriptAssert does too. A custom constraint that must be tied to a field instead builds the violation with context.buildConstraintViolationWithTemplate(...).addPropertyNode("confirmEmail"), which makes the path point at the field and the message a field error.

The Security Interceptors of the Stack (7.4.0)

The plugin’s page for 7.4.0 and 6.12.0 states that beanValidationDefaultStack now contains coep, coop and fetchMetadata, configured as in the core defaultStack (WW-5718), and that earlier versions left them out. The consequence is visible in the scenario: the response of a normal request to a plugin package carries the headers Cross-Origin-Embedder-Policy-Report-Only: require-corp and Cross-Origin-Opener-Policy: same-origin, and fetchMetadata inspects the Sec-Fetch-* request headers. A cross-site request that is neither a GET nor a top-level navigation is rejected: the action does not run and the interceptor returns the result code 403, which needs a result named 403 to display a page. In the scratch app the global result of the bv package maps it:

Request headers (POST of a valid book to bv/add.action) Outcome

Sec-Fetch-Site: cross-site, Sec-Fetch-Mode: cors

HTTP 200 with forbidden.jsp ("Forbidden by fetchMetadata"); the action did not run

Sec-Fetch-Site: same-origin, Sec-Fetch-Mode: cors

OK

Sec-Fetch-Site: cross-site, Sec-Fetch-Mode: navigate, Sec-Fetch-Dest: document

OK (a cross-site form post that is a top-level navigation is allowed)

Sec-Fetch-Site: cross-site, Sec-Fetch-Mode: cors, to a package with no 403 result

HTTP 404: the unmapped result is reported as "no result defined", not as 403

The plugin page says to set fetchMetadata.disabled to true on the stack for actions that must accept cross-site posts (a public endpoint called from other origins); the rule and its consequences are the topic of the security pages of this section. The practical point for this page is that a team that migrates a package from struts-default to struts-bean-validation while updating to 7.4.0 gets these three interceptors without asking for them, so the 403 result must exist in the package or in the parent package.

Combining with Classic Validation, and Writing Your Own Stack

The plugin’s stack deliberately omits the classic validation interceptor, so in a struts-bean-validation package validate(), validateXxx() and Action-validation.xml files are not run. That is the first thing to know when migrating an action: a rule that exists only in -validation.xml silently stops being enforced. The scratch app has a MixedAction with an annotation (@NotBlank title) and a classic validate() method that rejects an ISBN that is not 10 digits, mapped in the bv package:

public class MixedAction extends ActionSupport {
    @NotBlank
    private String title;
    private String isbn;

    // getters and @StrutsParameter setters as in AddBookAction

    @Override
    public void validate() {
        if (isbn == null || !isbn.matches("\\d{10}")) {
            addFieldError("isbn", "classic validate(): ISBN must have 10 digits");
        }
    }

    @Override
    public String execute() { return SUCCESS; }
}

Requested in the bv package (beanValidationDefaultStack) with title=&isbn=1, only title: must not be blank was reported: the validate() rule never ran. To use both, a package declares its own stack. The official page has an example of this, but its XML is not valid and its order is wrong (see the outdated section); the version that was verified is:

<package name="mix" extends="struts-default" namespace="/mix">
    <interceptors>
        <interceptor name="beanValidation"
                     class="org.apache.struts.beanvalidation.validation.interceptor.BeanValidationInterceptor"/>
        <interceptor-stack name="beanAfterParamsStack">
            <interceptor-ref name="basicStack"/>
            <interceptor-ref name="validation">
                <param name="excludeMethods">input,back,cancel,browse</param>
            </interceptor-ref>
            <interceptor-ref name="beanValidation">
                <param name="excludeMethods">input,back,cancel,browse</param>
            </interceptor-ref>
            <interceptor-ref name="workflow">
                <param name="excludeMethods">input,back,cancel,browse</param>
            </interceptor-ref>
        </interceptor-stack>
    </interceptors>
    <action name="both" class="org.example.bookshelf.actions.MixedAction">
        <interceptor-ref name="beanAfterParamsStack"/>
        <result name="input">/WEB-INF/views/form.jsp</result>
        <result>/WEB-INF/views/ok.jsp</result>
    </action>
</package>

Requested with title=&isbn=1, mix/both.action showed both isbn: classic validate(): ISBN must have 10 digits and title: must not be blank; with title=Dune&isbn=1 only the classic message; with a valid pair OK. The stack uses basicStack (the parameter-handling half of defaultStack) and then the three validation-related interceptors in the order validators, Bean Validation, workflow. Two warnings go with it. The workflow interceptor must be last: it is the one that turns the collected errors into the input result. And basicStack leaves out the stack’s other members (i18n, the model-driven and chain interceptors, the security ones), so a real stack lists what the application needs.

Pitfalls

Symptom Cause Fix

The application does not start and every URL answers 404; the log shows "Unable to load configuration …​ Caught Exception while registering Interceptor class …​BeanValidationInterceptor" and jakarta.validation.NoProviderFoundException

The plugin is on the class path but there is no Jakarta Validation provider; the manager builds the factory at start-up, which fails (verified by removing Hibernate Validator from the WAR; struts.beanValidation.providerClass naming a missing class only logs "Unable to find any bean validator implementation" and then falls back to the same failure)

Add org.hibernate.validator:hibernate-validator (or another provider) and, outside Tomcat, an EL implementation

The action is validated although title=Dune was submitted: "title: must not be blank"

A hand-written stack puts beanValidation before defaultStack, so before params; the action is still empty when it is validated (reproduced with the stack that the official page shows)

Put beanValidation after params and conversionError, before workflow, as beanValidationDefaultStack does

validate() and -validation.xml rules are ignored

The plugin stack has no validation interceptor

Move the rules to annotations, or use a stack with both interceptors (previous section)

The form is rejected but no message is displayed

A class-level constraint produced an action error; the page only shows field errors

Render s:actionerror too

Messages stay English when the user chose another language

The provider uses the JVM default locale, not the Struts locale

Use Struts keys for the messages (message = "book.title.required"), or supply a locale-aware message interpolator

{book.isbn.pattern} is displayed literally

A brace key is resolved by the provider, from ValidationMessages.properties, not from the action’s bundle

Put the text in ValidationMessages.properties, or write the key without braces to use a Struts bundle

A group’s constraints never fire

The running method has no @ValidationGroup, or lists only Default

Annotate the method with @ValidationGroup({Default.class, Publish.class})

A nested bean is not validated

No @Valid on the property

Add @Valid

A field is reported twice (Invalid field value ... must not be null)

The conversionError interceptor and @NotNull both complain about the same field

Define the conversion-error text in the action bundle, or drop the redundant rule

@SkipValidation has no effect

The annotation is on the class, or on a method that is not the action method configured with method=

Annotate the action method itself

Cross-site POST answers 404 after the update to 7.4.0

fetchMetadata returned 403 and the package has no 403 result

Add a global 403 result, or set fetchMetadata.disabled for the action

javax.validation.constraints or org.hibernate.validator.constraints.NotBlank does not compile

Copied from the official example or from an old book

Use jakarta.validation.constraints and the Jakarta annotations

What Changed Recently

Version Change

Struts 2.5

The plugin was introduced; classic validation stays available and the validation page of the core guide points to the plugin as the annotation alternative

Struts 7.0

The plugin moved to the Jakarta namespace (jakarta.validation) with the Jakarta EE migration; javax.validation constraint classes are not recognized by a Jakarta provider. The framework classes the examples import changed package in 7.0 (org.apache.struts2.ActionSupport)

Struts 7.0 (security)

The @StrutsParameter rule applies to every property that the request may set; the model and nested properties of a validated form need it

Struts 7.4.0 and 6.12.0

beanValidationDefaultStack includes coep, coop and fetchMetadata (WW-5718); a cross-site non-navigation request returns the result 403

Struts 7.5.0 and 6.13.0 (announced on the plugin page; not in 7.4.0)

WW-5757: the plugin looks up the annotation’s message value itself, as a Struts key, and shows a provider-produced message literally; convertMessageToUtf then applies only to messages resolved from Struts bundles. The 7.4.0 code in this page’s examples still takes the interpolated message as the key

Hibernate Validator 9

Implements Jakarta Validation 3.1; the legacy org.hibernate.validator.constraints.Email and NotBlank annotations are gone from the 9.1.4 jar, the Jakarta ones replace them

Where the Official Page and the Older Books Are Outdated

The sources of this page are the official plugin page, the Struts 2 books of the OVal/validation era and Hibernate Validator documentation of earlier versions. These points differ from what Struts 7.4.0 does:

  • The official example action imports com.opensymphony.xwork2.ActionSupport (package renamed to org.apache.struts2.ActionSupport in 7.0), javax.validation.constraints.* (now jakarta.validation.constraints), and org.hibernate.validator.constraints.Email and NotBlank, which Hibernate Validator 9.1.4 no longer has (jakarta.validation.constraints.Email and NotBlank replace them; org.hibernate.validator.constraints.URL and ScriptAssert are still present). The example’s @ScriptAssert(lang = "javascript", ...) also needs a JSR 223 script engine, which the JDK has not shipped since Java 15. The example class is titled BeanValidationAction and named BeanValidationExampleAction in the code.

  • The official page says that the plugin integrates with "AJAX Validation". The 7.4.0 stack has no AJAX validation interceptor of its own; client-side and AJAX validation features of the xhtml themes are deprecated in 7.4 (WW-5694), and the plugin does nothing for them.

  • The official second sample (own stack) is not valid XML: name="beanValidation" and class="..." are written as the text of the interceptor element, and it puts beanValidation before defaultStack, so before params. Both are shown above in corrected form.

  • The plugin page presents the WW-5757 message-lookup behaviour of 7.5.0 and 6.13.0 next to the 7.4.0 notes; the released behaviour of 7.4.0 is the earlier one described in this page.

  • Books from the Struts 2.0 and 2.1 era describe validation only as the validation interceptor with XML validators, the OVal plugin (retired: not published at 7.4.0) as the annotation-style alternative, and com.opensymphony.xwork2 package names. In 7.4.0 the Bean Validation plugin is the annotation-style alternative; the validators of the classic framework are explained in Validation.

  • struts2-bom manages jakarta.validation-api 3.0.2 although Hibernate Validator 9.x targets Jakarta Validation 3.1; the scenario worked with the managed version, but declare 3.1.1 when 3.1 features are used.

When to Choose the Plugin

Bean Validation fits when the same constraints must be shared with other layers: the entity classes of the persistence layer, a REST controller, a service method, all validated with the same annotations (the Hibernate section of this documentation covers the provider from the persistence side, see Validation with Hibernate Validator). The classic framework fits when rules depend on the request (context-dependent validators, validateXxx() per method, the visitor validator, client-side validation of the themes, validation per alias). The two can be combined in one stack, with the costs above. Neither choice changes the view: the s:fielderror, s:actionerror and field tags that show the errors work the same, and the input result of the action configuration is where the user lands (see Actions for ActionSupport and the input result, and Interceptors for the order of the stack and excludeMethods).