Bean Validation in Jakarta Faces

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.

Jakarta Validation (still widely called Bean Validation) lets a model class declare its own rules as annotations, such as @NotBlank, @Size(max = 40) or @Min(1), so that the same rules protect every layer that touches the object: the web form, the REST endpoint, the service method and the database mapping. Jakarta Faces integrates with it directly. An input component whose value expression points at a constrained property is validated against those annotations during the Process Validations phase, with no validator attached to the page, and a violation becomes an ordinary FacesMessage shown by h:message.

This page is written against Jakarta Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11), Jakarta Validation 3.1 with Hibernate Validator 9.1.4.Final as the provider, Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, with Java 17 or later. Faces 4.0 (Jakarta EE 10, Bean Validation 3.0) behaves the same for everything below; the version differences are collected in What Changed. Every example was compiled and run in a scratch Bookshelf WAR served by Tomcat 11.0.26 with Weld 6.0.4.Final, requested over HTTP, and the messages and outcomes quoted below are what came back (checked on 2026-10-08). The same scenarios were repeated on MyFaces 4.1.4 and are compared in Mojarra and MyFaces. The constraint tests were run with JUnit Jupiter 5.13.4.

Why Use Bean Validation in a Faces Application

Faces has had its own validation model since 1.0: required="true", the standard validator tags f:validateLength, f:validateLongRange, f:validateRegex and friends, custom Validator classes, and validator methods. Those rules live on the page. That works for a single form and degrades as the application grows, because the same rule ("a rating is a whole number from 1 to 5") is then repeated in every view that edits a review, again in the REST resource that accepts one, and again in the persistence layer. The specification states the goal plainly: Bean Validation avoids that duplication by defining metadata that can be shared by any layer of the application, and Faces delegates field-level validation to it so that the page author does not have to write a validator per input.

There are three parts to the integration, and the rest of this page takes them in turn:

The two systems are complementary rather than competing. The standard Faces validators remain the right tool for rules that belong to one page only, for conversion-adjacent checks, and for required with a page-specific message. The validators page of this section covers them, the converters page covers the step that runs before validation, and the core tags page lists f:validateBean and f:validateWholeBean beside the other f: tags.

Dependencies and Activation

Faces does not ship Bean Validation. It looks for a Jakarta Validation provider on the class path and, if there is none, the integration is simply off. The specification’s activation rule has three parts:

  1. If a Validation provider is present at startup, the runtime adds the validator id jakarta.faces.Bean to the application’s default validator ids (Application.addDefaultValidatorId()), which puts a BeanValidator on every EditableValueHolder in every view without any markup.

  2. If jakarta.faces.VALIDATE_EMPTY_FIELDS is not set to false, empty and null submitted values are also validated, so that @NotNull and @NotEmpty can reject them (Empty Values: Two Context Parameters That Decide Whether Constraints See Them).

  3. When the BeanValidator runs, it obtains a ValidatorFactory from the servlet context attribute named by BeanValidator.VALIDATOR_FACTORY_KEY (the string jakarta.faces.validator.beanValidator.ValidatorFactory) if one is stored there, and otherwise from Validation.buildDefaultValidatorFactory(). If no provider can be found and the validator is used, a FacesException is raised (Providing Your Own ValidatorFactory).

On a Jakarta EE server (WildFly, GlassFish, Payara, Open Liberty, TomEE; none of them was run for this page, the statement follows the platform specification) the container supplies the Validation API, a provider, an Expression Language implementation and the ValidatorFactory attribute, so the application declares nothing. On Tomcat, or any plain servlet container, the application brings the provider. The Maven coordinates used for the scratch Bookshelf (a WAR whose Faces implementation, Weld and the provider are all packaged in WEB-INF/lib):

pom.xml (dependencies for Tomcat 11 with Weld)
<dependencies>
  <dependency>
    <groupId>jakarta.platform</groupId>
    <artifactId>jakarta.jakartaee-api</artifactId>
    <version>11.0.0</version>
    <scope>provided</scope>
  </dependency>
  <dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.faces</artifactId>
    <version>4.1.17</version>
  </dependency>
  <dependency>
    <groupId>org.jboss.weld.servlet</groupId>
    <artifactId>weld-servlet-shaded</artifactId>
    <version>6.0.4.Final</version>
  </dependency>
  <!-- the Jakarta Validation provider; pulls in jakarta.validation-api 3.1.1 -->
  <dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>9.1.4.Final</version>
  </dependency>
  <!-- only for CDI injection into constraint validators, see "Injecting CDI Beans" -->
  <dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator-cdi</artifactId>
    <version>9.1.4.Final</version>
  </dependency>
</dependencies>

Hibernate Validator needs an implementation of Jakarta Expression Language to evaluate expressions in violation messages. A server provides one, and Tomcat includes its own, so nothing is added in the example above. In a unit test that runs outside any container the test needs one (org.glassfish.expressly:expressly:6.0.0 in the test scope, the artifact the Hibernate Validator guide names), and for environments that cannot provide one at all the guide describes a non-compliant ParameterMessageInterpolator that handles only {...} parameters.

Two context parameters control the automatic installation:

Parameter Default Effect

jakarta.faces.validator.DISABLE_DEFAULT_BEAN_VALIDATOR

false

When true, jakarta.faces.Bean is not added to the default validators. Bean Validation then runs only where f:validateBean is placed explicitly. It also saves a per-input lookup in applications that never use annotations.

jakarta.faces.validator.ENABLE_VALIDATE_WHOLE_BEAN

false

Enables f:validateWholeBean. Without it that tag is a no-op (Class-Level Validation with f:validateWholeBean).

A <default-validators> element in faces-config.xml can also list validator ids, and a configuration resource that declares the list overrides one from a previously processed resource. The jakarta.faces.Bean id is guaranteed to be in the result of Application.getDefaultValidatorInfo() regardless of that configuration unless DISABLE_DEFAULT_BEAN_VALIDATOR is true.

Checked: turning the default validator off

With DISABLE_DEFAULT_BEAN_VALIDATOR set to true, an input bound to a constrained property was no longer validated, while the same input with <f:validateBean/> nested inside it was. This is the escape hatch for an application that wants Bean Validation only on a few forms.

Constraining the Model

Constraints are annotations on a field, a getter or a type. The built-in ones live in the package jakarta.validation.constraints:

Constraint Meaning

@NotNull, @Null

The value must (not) be null.

@NotEmpty

Not null and not empty; for a CharSequence the length, for a collection, map or array the size.

@NotBlank

A CharSequence with at least one non-white-space character.

@Size(min, max)

Length of a string, size of a collection, map or array. null is valid.

@Min, @Max, @DecimalMin, @DecimalMax

Numeric bounds; the decimal variants take a string.

@Positive, @PositiveOrZero, @Negative, @NegativeOrZero

Sign of a number.

@Digits(integer, fraction)

Maximum number of integral and fractional digits.

@Past, @PastOrPresent, @Future, @FutureOrPresent

A date or time relative to the current moment, including the java.time types.

@Pattern(regexp)

The value matches a regular expression.

@Email

A syntactically valid e-mail address.

@AssertTrue, @AssertFalse

A boolean property is true or false.

Every built-in constraint takes message, groups and payload, and every one is repeatable. Almost all of them treat null as valid, so that "the value is optional" and "the value, if present, has this shape" can be expressed separately. Presence is the job of @NotNull, @NotEmpty or @NotBlank, which is the single most important thing to remember when reading the rest of this page.

The Bookshelf review form edits a Review. It is an ordinary class, free of any Faces API:

org/example/bookshelf/model/Review.java
package org.example.bookshelf.model;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PastOrPresent;
import jakarta.validation.constraints.Size;
import java.io.Serializable;
import java.time.LocalDate;
import org.example.bookshelf.validation.Isbn;
import org.example.bookshelf.validation.Publish;

public class Review implements Serializable {

    @NotBlank
    @Size(max = 40)
    private String reviewer;

    @NotNull
    @Min(1)
    @Max(5)
    private Integer rating;

    @NotNull
    @Size(min = 3)
    private String title;

    @NotBlank
    private String author;

    @Isbn
    private String isbn;

    @Size(max = 500)
    @NotBlank(groups = Publish.class, message = "A published review needs a comment")
    private String comment;

    @PastOrPresent
    private LocalDate readOn;

    // getters and setters omitted
}

@Isbn and Publish are defined in Custom Constraints and Messages and Validation Groups and f:validateBean. The view binds each input to a property of that object. Note that nothing in the XHTML mentions Bean Validation:

review.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head><title>Review</title></h:head>
<h:body>
  <h:form id="f">
    <h:messages globalOnly="true"/>

    <h:outputLabel for="reviewer" value="Reviewer"/>
    <h:inputText id="reviewer" label="Reviewer" value="#{reviewBean.review.reviewer}"/>
    <h:message for="reviewer"/>

    <h:outputLabel for="rating" value="Rating"/>
    <h:inputText id="rating" label="Rating" value="#{reviewBean.review.rating}"/>
    <h:message for="rating"/>

    <h:inputText id="title" label="Title" value="#{reviewBean.review.title}"/>
    <h:message for="title"/>

    <h:inputText id="isbn" label="ISBN" value="#{reviewBean.review.isbn}"/>
    <h:message for="isbn"/>

    <h:inputText id="readOn" label="Read on" value="#{reviewBean.review.readOn}">
      <f:convertDateTime type="localDate" pattern="yyyy-MM-dd"/>
    </h:inputText>
    <h:message for="readOn"/>

    <h:commandButton value="Save" action="#{reviewBean.save}"/>
  </h:form>
</h:body>
</html>
org/example/bookshelf/web/ReviewBean.java
package org.example.bookshelf.web;

import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import org.example.bookshelf.model.Review;

@Named
@ViewScoped
public class ReviewBean implements Serializable {

    private final Review review = new Review();

    @Inject
    private FacesContext facesContext;

    public Review getReview() {
        return review;
    }

    public void save() {
        // reached only when every input in the form passed conversion and validation
        facesContext.addMessage(null, new FacesMessage("Saved " + review.getReviewer()));
    }
}

Submitting the form with an empty reviewer, a rating of 7, an ISBN of 123 and a future date produced, next to the respective inputs:

Input Message (English locale)

reviewer (@NotBlank)

must not be blank

rating (@Max(5))

must be less than or equal to 5

isbn (@Isbn)

Not a valid ISBN-13 (got '123')

readOn (@PastOrPresent)

must be a date in the past or in the present

The save() action did not run and the bean was not changed. That is the behavior of every validator in Faces and the subject of the next section.

How Faces Finds the Constraints

The validator does not look at the bean as a whole. For one input it asks the component’s value expression for its ValueReference, which names a base object and a property: for #{reviewBean.review.rating} the base is the Review instance and the property is rating. It then asks the Validation provider to check just that property against the submitted value (Validator.validateValue(baseClass, property, value, groups)), so only the constraints on that field or getter, in the selected groups, are evaluated. The documented consequences, each confirmed by running it:

  • A getter, a field, a nested object, a repeated row, a backing bean. Any of these works as long as the base is an object whose class has constraints. A #{r.rating} inside ui:repeat var="r" was validated per row, and the message appeared next to the row that failed. A constrained property declared directly on a @ViewScoped backing bean, bound as #{reviewBean.nickname}, was validated too, even though EL reaches the bean through a CDI client proxy.

  • A map entry or list element. #{reviewBean.extra['k']} has the map as its base. A Map has no constraints on k, so nothing was checked and the input silently accepted any value. The same applies to a literal value expression or one without a base object.

  • A class without any constraints. If the provider reports no constraints for the base class, the validator returns without doing anything. A form with hundreds of inputs over unconstrained beans costs only the descriptor lookup.

  • Nothing cascades. Only the one property is validated. The Validation specification states that @Valid is not honored by validateValue(), so a @Valid on a nested object is a rule for Validator.validate(object) and plays no part when Faces validates a single input; each nested property that is bound to an input is validated through its own value expression. The same specification says validateValue() finds the property on the given class or any of its superclasses, which is why a CDI client proxy, a subclass of the bean, still sees the constraints of the bean class.

Where Validation Happens in the Lifecycle

Bean Validation is a Faces validator, so it runs where every validator runs: in the Process Validations phase, on each input component of the submitted components in the order they appear in the tree. The sequence for one input is shown below, and the lifecycle page of this section places it among the six phases (see Process Validations there).

flowchart TD S["Submitted value of one input"] --> C["Convert: converter or implicit type conversion"] C -->|conversion error| X1["Message added, input invalid, no validator runs"] C --> R{"Empty and required is true?"} R -->|yes| X2["Required message, validators skipped"] R -->|no| E{"Empty and VALIDATE_EMPTY_FIELDS false?"} E -->|yes| OK1["No validator runs, value accepted"] E -->|no| V["Run the input's validators, including the default jakarta.faces.Bean"] V --> B["BeanValidator: validateValue for base class, property, value and groups"] B -->|violations| X3["ValidatorException with one FacesMessage per violation"] B -->|none| OK2["Value kept as local value, written to the model later in Update Model Values"]

Four observations follow from the diagram, and each was confirmed on Mojarra and MyFaces:

  • A conversion failure hides the constraints. A readOn of bad produced only the converter’s message (Read on: 'bad' could not be understood as a date. Example: 2026-10-08). The converter’s job comes first, and a value that cannot be converted has nothing to validate.

  • required="true" wins over everything. An empty author bound to a @NotBlank property and marked required="true" produced only Author: Validation Error: Value is required., and the Bean Validation message never appeared. A required input is rejected before the validators are consulted. Declare presence once, either with required and a page-specific requiredMessage, or with @NotNull or @NotBlank on the model. Mixing both is harmless but gives the user the page’s wording instead of the model’s.

  • One invalid input does not stop the others from being validated. Each input is processed and each one with a violation gets its own message. When any message has been queued, the lifecycle skips Update Model Values and Invoke Application and goes to Render Response. The model objects keep their previous values, which is the property that makes "validate, then apply" safe.

  • The action method is not called. That is why save() above did not run. A Cancel button that must work with invalid fields uses immediate="true", with the consequences described in the immediate section of the lifecycle page.

Empty Values: Two Context Parameters That Decide Whether Constraints See Them

A text field that the user leaves empty is submitted as the empty string. Two context parameters decide what the validation machinery does with it, and getting them wrong is the commonest reason a @NotNull seems to be ignored.

jakarta.faces.VALIDATE_EMPTY_FIELDS

Controls whether validators run at all when the submitted value is empty or null. The values are true, false and auto, which is the default when the parameter is missing: auto behaves as true when a Bean Validation provider is present and as false otherwise. Older Faces releases skipped validators for an empty value (stated in the project notes for this page, not re-checked against the 1.x specification). Since Bean Validation is part of Faces, validators must run on empty values, otherwise a @NotNull could never reject anything, as a validator that does not run cannot fail.

jakarta.faces.INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL

When true, UIInput.validate() turns a zero-length submitted string into null before converting and validating. The default is false, so an empty text field reaches the model as "", not null. It is a global switch, not an attribute of a single input.

The two interact with the constraints in a way that surprises people. The table shows what a blank title produced with @NotNull @Size(min = 3) on the property, on Mojarra 4.1.17 (the same on MyFaces 4.1.4):

Configuration Outcome for an empty title

Defaults (VALIDATE_EMPTY_FIELDS auto, INTERPRET... false)

@NotNull passes, because the value is "", not null. @Size(min = 3) fails: size must be between 3 and 2147483647.

INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL true

@NotNull fails: must not be null. @Size(min = 3) passes, because null is valid for @Size.

VALIDATE_EMPTY_FIELDS false

No validator runs for an empty field. The review was saved with an empty reviewer and a null rating, although both are constrained as required.

The third row is the dangerous one: false is a configuration that was needed to keep old validators from receiving empty values, but it also switches off every @NotNull, @NotEmpty and @NotBlank in the application. Leave the parameter alone unless an existing validator cannot cope.

The validators page shows the same two parameters with the standard validator tags. The practical rule is to pick one convention and write constraints that match it:

  1. Set INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL to true in web.xml, so that "nothing typed" means null everywhere (the Jakarta EE Tutorial recommends exactly this for Bean Validation to work as intended). Then @NotNull means "required", and optional text fields with @Size(min = 3) or @Pattern accept an empty input without a complaint.

  2. Use @NotBlank for required text. It rejects both null and "" and, unlike @NotNull, does not depend on the parameter. This is what the Review above does for reviewer and author.

  3. Do not combine @NotNull @Size(min = 3) on a text field and expect the user to see the @NotNull message when the field is empty, unless the parameter is true.

web.xml
<context-param>
  <param-name>jakarta.faces.INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL</param-name>
  <param-value>true</param-value>
</context-param>

Numeric and date inputs behave differently from text. An empty Integer field is converted to null by the standard converter, so @NotNull on rating rejected it (must not be null) under the defaults. The parameter matters for String properties.

Validation Groups and f:validateBean

A validation group is an interface that tags constraints so that a validation call can select a subset of them. A constraint without groups belongs to the built-in group jakarta.validation.groups.Default. Groups answer questions the single-set model cannot, such as "a draft may be saved with an empty comment, but a published review needs one":

org/example/bookshelf/validation/Publish.java
package org.example.bookshelf.validation;

/** Validation group: constraints enforced only when a review is published. */
public interface Publish {
}

Review.comment above carries @NotBlank(groups = Publish.class, ...). By default Faces validates the Default group only, so this constraint is dormant. f:validateBean chooses the groups for the inputs it applies to. Its attributes come from the VDL:

Attribute Type Meaning

validationGroups

String (EL allowed)

A comma-separated list of validation groups, each the fully qualified class name of the group interface. Blank, or only commas and non-word characters (the documented pattern ^[\W,]*$), counts as empty.

disabled

Boolean (EL allowed)

Whether this validator is turned off for the enclosing component or branch (Turning Bean Validation Off Where It Does Not Belong).

for

String

Used inside a composite component: names an attached object that the validator is applied to instead of the component it is nested in.

binding

BeanValidator expression

Binds the validator instance to a bean property. Rarely needed.

The tag works in two ways. Nested in one input, it applies to that input only. Wrapped around several inputs, it sets a branch default for every input inside it. The validation groups come from the nearest enclosing f:validateBean that has a value, and if there is none, the Default group is used. A page that edits a review in "publish" mode therefore wraps its inputs once instead of nesting a tag in each:

publish.xhtml
<f:validateBean validationGroups="jakarta.validation.groups.Default,org.example.bookshelf.validation.Publish">
  <h:inputText id="reviewer" label="Reviewer" value="#{reviewBean.review.reviewer}"/>
  <h:message for="reviewer"/>
  <h:inputTextarea id="comment" label="Comment" value="#{reviewBean.review.comment}"/>
  <h:message for="comment"/>
</f:validateBean>

The observed behaviors of this branch, with reviewer empty, rating 9 and comment empty:

validationGroups Result

none (the Default group)

reviewer: must not be blank and rating: must be less than or equal to 5. No comment error, as that constraint is in Publish.

org.example.bookshelf.validation.Publish

Only comment: A published review needs a comment. The Default constraints on reviewer and rating were not evaluated, because Default was not in the list.

jakarta.validation.groups.Default,org.example.bookshelf.validation.Publish

All three messages.

Publish (the short name)

The request failed with a FacesException, Validation group not found: Publish, raised in Process Validations (an HTTP 500 response in the scratch run).

a blank or comma-only value

Treated as no value: the Default group was used.

Two consequences of the table. The group list replaces the default, it does not add to it, so a branch that lists only Publish stops validating the Default constraints; add jakarta.validation.groups.Default explicitly when you want both. And a group name is a class name, so a typo is an exception at the first submit, not a silently ignored rule. A @GroupSequence on a group interface, described in the Validation specification, orders groups so that expensive checks run only after cheap ones pass, and is selected through the same attribute.

Choosing the Group by Button

A common requirement is one form with a Save draft button and a Publish button, validated differently. The validation groups are evaluated while the view is processed, before any action runs, so a bean property set by the action cannot choose them. What the request does carry is the name of the button that was pressed. Evaluating the EL in validationGroups against the request parameters worked in a scratch run:

draft.xhtml
<h:form id="f">
  <h:messages globalOnly="true"/>
  <f:validateBean validationGroups="#{empty param['f:publish'] ? 'jakarta.validation.groups.Default' : 'jakarta.validation.groups.Default,org.example.bookshelf.validation.Publish'}">
    <h:inputText id="reviewer" label="Reviewer" value="#{reviewBean.review.reviewer}"/>
    <h:message for="reviewer"/>
    <h:inputTextarea id="comment" label="Comment" value="#{reviewBean.review.comment}"/>
    <h:message for="comment"/>
  </f:validateBean>
  <h:commandButton id="save" value="Save draft" action="#{reviewBean.save}"/>
  <h:commandButton id="publish" value="Publish" action="#{reviewBean.save}"/>
</h:form>

Pressing Save draft with an empty comment saved the review. Pressing Publish gave Comment: A published review needs a comment. The expression is re-evaluated on every request, so it picks up the button of the current postback even though the view itself was restored from an earlier request. The coupling is to the client id (f:publish), so this is a technique to keep in one place. When the two actions are really two screens, two forms each with its own f:validateBean are easier to read.

Turning Bean Validation Off Where It Does Not Belong

The disabled attribute switches the validator off for the input or the branch it applies to. It takes a boolean or an EL expression. Two cases are worth knowing.

  • One input inside an active branch. <f:validateBean disabled="true"/> nested in an input wins over the branch settings for that input. A field whose value is checked elsewhere (a search box, a password field that is validated by the identity provider) opts out like this. Run on Mojarra 4.1.17 (Tomcat 11.0.26, Hibernate Validator 9.1.4): a rating of 9 against @Max(5) posted with the tag nested in the input, once on its own and once inside an enclosing f:validateBean validationGroups="jakarta.validation.groups.Default", produced no message for the rating while the empty reviewer next to it still failed. MyFaces was not run for this check.

  • A whole branch. <f:validateBean disabled="#{...}"> around a group of inputs disables Bean Validation for all of them. With disabled true on the branch, a rating of 9 was accepted and saved in the scratch run.

opt out of a single field
<h:inputText id="isbn" label="ISBN" value="#{reviewBean.review.isbn}">
  <f:validateBean disabled="true"/>
</h:inputText>

A form that edits only part of an object needs no disabled at all. Constraints are evaluated property by property for the inputs that are actually in the submitted form, never on the whole object, so a "quick rating" widget that binds only rating is not blocked by a missing title. This is a difference from calling Validator.validate(review) yourself, which checks every property.

Class-Level Validation with f:validateWholeBean

Field-level validation cannot express a rule over several properties. The specification explains why: Faces conversion and validation finish for every input before any value is pushed into the model, so at validation time the bean still holds the old values, and a constraint on the class would see yesterday’s data. Faces 2.3 added f:validateWholeBean for this case. It is activated by jakarta.faces.validator.ENABLE_VALIDATE_WHOLE_BEAN, which is false by default; with the parameter unset the tag is a no-op. The scratch Bookshelf confirms it: an invalid period was accepted when the parameter was missing and rejected when it was true.

The mechanism, from the VDL:

sequenceDiagram participant I as Field inputs (f:validateBean with groups) participant W as f:validateWholeBean (a UIInput) participant C as Temporary copy of the bean participant V as Jakarta Validation I->>I: validate each field in the listed groups W->>W: skip if any contributing field was invalid W->>C: copy the bean, set the validated field values W->>V: validate the copy in the listed groups V-->>W: constraint violations, if any W-->>I: ValidatorException, contributing inputs marked invalid Note over C: the copy is discarded either way

A class-level constraint is an annotation on the type plus a ConstraintValidator for it. The Bookshelf borrow form asks for a pick-up and a return date and requires the second to come after the first:

org/example/bookshelf/validation/ValidPeriod.java
package org.example.bookshelf.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Constraint(validatedBy = ValidPeriodValidator.class)
@Target(TYPE)
@Retention(RUNTIME)
public @interface ValidPeriod {

    String message() default "The return date must be after the pick-up date";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
org/example/bookshelf/validation/ValidPeriodValidator.java
package org.example.bookshelf.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.example.bookshelf.model.BorrowRequest;

public class ValidPeriodValidator implements ConstraintValidator<ValidPeriod, BorrowRequest> {

    @Override
    public boolean isValid(BorrowRequest request, ConstraintValidatorContext context) {
        if (request == null || request.getFrom() == null || request.getTo() == null) {
            return true; // a missing value is reported by the @NotNull constraints on the fields
        }
        return request.getFrom().isBefore(request.getTo());
    }
}
org/example/bookshelf/model/BorrowRequest.java
package org.example.bookshelf.model;

import jakarta.validation.constraints.NotNull;
import java.io.Serializable;
import java.time.LocalDate;
import org.example.bookshelf.validation.PeriodChecks;
import org.example.bookshelf.validation.ValidPeriod;

@ValidPeriod(groups = PeriodChecks.class)
public class BorrowRequest implements Serializable {

    @NotNull(groups = PeriodChecks.class)
    private LocalDate from;

    @NotNull(groups = PeriodChecks.class)
    private LocalDate to;

    // getters and setters omitted
}

PeriodChecks is an empty marker interface like Publish. Notice that every constraint carries the group, and that isValid tests for null and treats a missing value as valid, because a missing value is the job of the @NotNull constraints. When f:validateWholeBean calls the validator, the specification guarantees that the individual properties have already passed, so the guard is never needed there; but other callers give no such guarantee. Anyone who calls Validator.validate(request) in the PeriodChecks group directly gets the validator called with whatever the object holds, and the same goes for the class-level check of OmniFaces o:validateBean (see the OmniFaces page): an earlier version of this listing without the guard failed with an HTTP 500 under o:validateBean in a scratch run made while writing that page (not repeated here). The guarded listing above was compiled with Hibernate Validator 9.1.4 and run directly: an empty request produced only the two @NotNull violations, a return date before the pick-up date produced The return date must be after the pick-up date, and a valid period produced none.

The view uses the group on each contributing input and on the tag. The VDL states that the groups attribute is required on f:validateWholeBean and that the feature requires it on the f:validateBean tags too:

borrow.xhtml
<h:form id="f">
  <h:messages globalOnly="true"/>

  <h:inputText id="from" label="From" value="#{borrowBean.request.from}">
    <f:convertDateTime type="localDate" pattern="yyyy-MM-dd"/>
    <f:validateBean validationGroups="org.example.bookshelf.validation.PeriodChecks"/>
  </h:inputText>
  <h:message for="from"/>

  <h:inputText id="to" label="To" value="#{borrowBean.request.to}">
    <f:convertDateTime type="localDate" pattern="yyyy-MM-dd"/>
    <f:validateBean validationGroups="org.example.bookshelf.validation.PeriodChecks"/>
  </h:inputText>
  <h:message for="to"/>

  <f:validateWholeBean id="period" value="#{borrowBean.request}"
                       validationGroups="org.example.bookshelf.validation.PeriodChecks"/>
  <h:message for="period"/>

  <h:commandButton value="Request" action="#{borrowBean.submit}"/>
</h:form>

With a pick-up of 2026-10-10 and a return of 2026-10-09 the request produced The return date must be after the pick-up date, attached to the period component. With the pick-up empty the result was From: must not be null only: the class-level check was skipped because a contributing field was invalid, as specified. With 2026-10-01 and 2026-10-09 the action ran.

The rules of the tag, each one a way the feature can fail:

  1. Enable the parameter. Otherwise the tag does nothing, with no log line to say so.

  2. Place it after all the inputs it covers, in the same form. The specification says the result is unspecified otherwise. Mojarra enforces it: the same view with f:validateWholeBean before the inputs failed to render with an IllegalArgumentException, f:validateWholeBean must be placed at the end of UIForm, and MyFaces also failed with an HTTP 500 in that case.

  3. The bean must be copyable. The VDL lists the order in which a copy is attempted: newInstance() on the bean’s class, then Serializable, then Cloneable, then a copy constructor, and a FacesException if none yields a copy. A class that has none of those failed in the scratch run on both implementations; Mojarra’s message was Can't copy object of type class ... No copier appeared to be capable of copying it. Make the model class Serializable, which a @ViewScoped or session-held object has to be anyway.

  4. The value is any object, not necessarily a backing bean. The specification describes "CDI based backing beans", but #{borrowBean.request} pointed at a plain nested object and worked. The constraint lives on that object’s class.

  5. Messages are attached to the whole-bean component, so give it an id and a matching h:message for. Its FacesMessage text is the constraint’s message; when the application overrides the BeanValidator message format with a label placeholder (Messages: From Violation to FacesMessage), this component has no label and the client id (f:period) appears as the prefix.

  6. A group is mandatory. The VDL marks validationGroups as required on f:validateWholeBean. Putting the class-level constraint in a dedicated group also keeps it out of the Default group, which other code paths (a service call, persistence) validate on objects whose dates may not be populated yet.

Custom Constraints and Messages

The built-in annotations cover generic shapes. A domain rule, such as "this is a valid ISBN-13", is a custom constraint: an annotation plus a ConstraintValidator. The annotation must declare the three standard elements message, groups and payload:

org/example/bookshelf/validation/Isbn.java
package org.example.bookshelf.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.PARAMETER;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

@Documented
@Constraint(validatedBy = IsbnValidator.class)
@Target({FIELD, METHOD, PARAMETER})
@Retention(RUNTIME)
public @interface Isbn {

    String message() default "{org.example.bookshelf.validation.Isbn.message}";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};
}
org/example/bookshelf/validation/IsbnValidator.java
package org.example.bookshelf.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class IsbnValidator implements ConstraintValidator<Isbn, String> {

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.isEmpty()) {
            return true; // presence is the job of @NotNull or @NotBlank
        }
        String digits = value.replace("-", "");
        if (digits.length() != 13 || !digits.chars().allMatch(Character::isDigit)) {
            return false;
        }
        int sum = 0;
        for (int i = 0; i < 13; i++) {
            sum += (digits.charAt(i) - '0') * (i % 2 == 0 ? 1 : 3);
        }
        return sum % 10 == 0;
    }
}
src/main/resources/ValidationMessages.properties
org.example.bookshelf.validation.Isbn.message=Not a valid ISBN-13 (got '${validatedValue}')

Four rules make validators well-behaved:

  • Treat null (and, for text, the empty string) as valid. That keeps the constraint orthogonal to presence, exactly like the built-ins. The validator above returns true for both.

  • Keep the validator stateless and fast. One instance can serve concurrent requests and is created through the provider’s ConstraintValidatorFactory.

  • Do not touch the persistence layer. The specification forbids calling an EntityManager that might call back into validation from a validator, because it would flush recursively.

  • Give the annotation a message key and ship the text separately. The key goes in ValidationMessages.properties (and ValidationMessages_es.properties, …​) at the root of the class path, which the provider searches before its own built-in bundle. The same file can override built-in messages: with jakarta.validation.constraints.NotBlank.message=Please fill in this field in it, the reviewer message read Please fill in this field.

A new constraint can also be composed from existing ones, with @Constraint(validatedBy = {}) and the building-block annotations on it, as in the tutorial’s @USPhoneNumber, which applies a @Pattern. A composed constraint needs no validator class.

Messages use two forms of interpolation: {key} looks up a bundle entry or an annotation attribute ({min}), and ${...} evaluates a Jakarta Expression Language expression, such as ${validatedValue} for the offending value.

Injecting CDI Beans into Constraint Validators

A validator often needs a service: "this ISBN exists in the catalogue", "this username is free". The natural code injects it:

org/example/bookshelf/validation/KnownIsbnValidator.java
package org.example.bookshelf.validation;

import jakarta.inject.Inject;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import org.example.bookshelf.model.Catalogue;

public class KnownIsbnValidator implements ConstraintValidator<KnownIsbn, String> {

    @Inject
    private Catalogue catalogue;

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value == null || value.isEmpty() || catalogue.contains(value);
    }
}

@KnownIsbn is declared like @Isbn. On a Jakarta EE server it works as written, because the specification requires a Jakarta EE container’s Validation provider to integrate with CDI, so the ValidatorFactory is CDI-aware and validators are created through the bean manager. On Tomcat with Weld it does not: Faces asks for Validation.buildDefaultValidatorFactory(), which knows nothing about the bean manager, and the field stays null. The scratch run on Tomcat 11.0.26, Weld 6.0.4.Final, Mojarra 4.1.17 and Hibernate Validator 9.1.4.Final produced an HTTP 500 with NullPointerException: Cannot invoke "...Catalogue.contains(String)" because "this.catalogue" is null. Adding hibernate-validator-cdi to the class path by itself did not change that, because Faces still built its own default factory. The fix that worked is to give Faces the CDI-aware factory under the documented servlet context attribute, from a listener (Providing Your Own ValidatorFactory).

Providing Your Own ValidatorFactory

The ValidatorFactory that Faces uses is read from the servlet context attribute BeanValidator.VALIDATOR_FACTORY_KEY. Anything stored there wins over the default bootstrap, which makes it the extension point for a custom message interpolator, a custom ConstraintValidatorFactory, a configured clock or a ParameterMessageInterpolator. With hibernate-validator-cdi on the class path the extension registers a CDI-aware ValidatorFactory bean, which a @WebListener can inject (Weld on Tomcat supports injection in listeners) and publish:

org/example/bookshelf/validation/ValidatorFactoryListener.java
package org.example.bookshelf.validation;

import jakarta.faces.validator.BeanValidator;
import jakarta.inject.Inject;
import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import jakarta.servlet.annotation.WebListener;
import jakarta.validation.ValidatorFactory;

@WebListener
public class ValidatorFactoryListener implements ServletContextListener {

    @Inject
    private ValidatorFactory validatorFactory; // CDI-aware, provided by hibernate-validator-cdi

    @Override
    public void contextInitialized(ServletContextEvent event) {
        event.getServletContext().setAttribute(BeanValidator.VALIDATOR_FACTORY_KEY, validatorFactory);
    }
}

With this listener the reservation form accepted 978-0-306-40615-7 (Reserved 978-0-306-40615-7) and rejected 978-1-4028-9462-6 with This ISBN is not in the catalogue, on Mojarra and on MyFaces. The same effect without the extra dependency was obtained by building the factory by hand with Validation.byDefaultProvider().configure().constraintValidatorFactory(...), where the factory creates each validator through BeanManager.getInjectionTargetFactory(...) (produce, inject, postConstruct) and releases it with preDestroy and dispose, which is the sequence the Validation specification prescribes. The provider owns the factory’s life cycle, so the injected variant needs no contextDestroyed; a hand-built factory should be closed there.

Messages: From Violation to FacesMessage

Each ConstraintViolation becomes a FacesMessage whose summary and detail are both the violation’s interpolated message, and the whole set is carried by one ValidatorException. Three things shape what the user reads.

The message format. The text goes through the Faces message jakarta.faces.validator.BeanValidator.MESSAGE (BeanValidator.MESSAGE_ID). It has two placeholders: {0} is the interpolated Validation message and {1} is the label of the input. The default format is just {0}, so by default the Validation provider’s wording is shown untouched. An application that wants the label in front of every message, in line with the standard Faces messages that say Rating: Validation Error: ..., overrides it in a message bundle registered with <message-bundle>:

WEB-INF/faces-config.xml
<faces-config xmlns="https://jakarta.ee/xml/ns/jakartaee" version="4.1">
  <application>
    <message-bundle>org.example.bookshelf.FacesMessages</message-bundle>
  </application>
</faces-config>
src/main/resources/org/example/bookshelf/FacesMessages.properties
jakarta.faces.validator.BeanValidator.MESSAGE={1}: {0}

With that bundle, the messages read Reviewer: must not be blank and Rating: must be less than or equal to 5, using the label attribute of each input. An input without a label shows its client id instead (f:rating: must be less than or equal to 5 in the scratch run), so give every input a label when you use {1}. Faces' own messages for conversion and required are separate keys in the same bundle. The messages and internationalization page lists the standard keys and builds the English and Spanish bundles.

Several violations on one input. The component receives all of them. h:message renders only the first one for a component, as its documentation says, while h:messages lists every one. An ISBN that was both malformed and unknown to the catalogue showed This ISBN is not in the catalogue in h:message and both lines in h:messages. The order of the violations is not specified by Validation, so do not write a page that depends on it.

Locale. The specification asks Faces to give the provider a message interpolator that resolves the Locale from the current UIViewRoot, and the BeanValidator does so (it decorates the factory’s interpolator). The messages therefore follow f:view locale or the browser’s negotiated locale, not the JVM default:

localized review view
<f:view locale="#{param.lang == null ? 'en' : param.lang}">
  <h:head><title>Review</title></h:head>
  <h:body>
    <h:form id="f">
      <h:inputText id="rating" label="Rating" value="#{reviewBean.review.rating}"/>
      <h:message for="rating"/>
    </h:form>
  </h:body>
</f:view>

With ?lang=es the rating message came back as Rating: debe ser menor que o igual a 5, and the custom @Isbn message came from ValidationMessages_es.properties. One trap in the scratch run is a plain Java one: on a machine whose JVM default locale was Spanish, a request in English found no ValidationMessages_en.properties and the resource bundle lookup fell back to the JVM default locale (ValidationMessages_es) before the base file, so an English view showed the Spanish @Isbn text. Java’s ResourceBundle consults the default locale before the root bundle. Ship an explicit file for every locale you support, including the one in the base file (ValidationMessages_en.properties next to the unsuffixed one), or pin the default locale on servers.

Escaping and sensitive values. The message may contain the rejected value through ${validatedValue}. Faces escapes message text when h:message renders it: an ISBN of <script>alert(1)</script> came out as &lt;script&gt;... in the page (output escaping in general is on the security page). The risk lies in rendering messages with something that does not escape, such as a component or a custom renderer with escaping disabled, or in logging the message. Never echo the value of a password, token or card-number field in a message, and never use escape="false" on a component that renders messages built from user input.

Bean Validation with Ajax

Nothing about Bean Validation changes under f:ajax (see the Ajax page for execute and render): the Ajax request runs the same six phases on the components in its execute list, and the validators run in Process Validations. The scope of execute decides which constraints are evaluated. An input with <f:ajax event="blur" render="m_rating"/> has the default execute="@this", so a blur validates that single input and renders its message, and a button with <f:ajax execute="@form" render="panel"/> validates the whole form.

ajax.xhtml
<h:form id="f">
  <h:panelGroup id="panel" layout="block">
    <h:messages globalOnly="true"/>
    <h:inputText id="rating" label="Rating" value="#{reviewBean.review.rating}">
      <f:ajax event="blur" render="m_rating"/>
    </h:inputText>
    <h:message id="m_rating" for="rating"/>
    <h:commandButton value="Save" action="#{reviewBean.save}">
      <f:ajax execute="@form" render="panel"/>
    </h:commandButton>
  </h:panelGroup>
</h:form>

Replaying both requests against Mojarra, the blur request returned a partial response whose update for f:m_rating was Rating: must be less than or equal to 5, and the save request re-rendered the panel with the same message. Three details matter in practice. The message component must be inside the render list, or the user sees nothing change. A message for an input that is not in execute is not produced, so a blur on field A never reports a problem with field B. And an invalid field in an Ajax submit skips the action exactly as in a full submit, which is why the Save button above shows messages and does not call save().

Defence in Depth: The Service Layer and the Database

Validation in the web tier protects the user experience, not the data. A REST call, a batch job, a test or a future screen reaches the same model without a Faces form in front of it. The constraints on the model are the common ground, and Jakarta Validation offers two more places to enforce them.

  • Method validation on CDI beans. The specification requires that parameters and return values of constrained methods of CDI beans are validated when the provider integrates with CDI (priority Interceptor.Priority.PLATFORM_AFTER+800). With hibernate-validator-cdi on Tomcat, a service method register(@NotBlank @Isbn String isbn) on an @ApplicationScoped bean threw a ConstraintViolationException for 123 and for an empty string, which the backing bean caught and turned into FacesMessage`s (the messages were `Not a valid ISBN-13 (got '123') and, with the NotBlank override shown earlier, Please fill in this field). The property path reads register.arg0 unless the code is compiled with -parameters.

  • Persistence. The Validation specification leaves the integration with Jakarta Persistence to the persistence specification, which can validate entities on lifecycle events; that integration is outside this page (the persistence page shows the service and repository layers it sits in).

A Faces form that catches ConstraintViolationException from the service and shows it is the right fallback for the cases the page-level constraints cannot see, such as a rule that depends on other rows. The rule of thumb is to keep the constraints on the model, let Faces enforce the field-level ones through the default validator, and let the service enforce the rest.

Testing Constraints

A constraint is plain Java and needs no container to test; the testing page covers the wider test strategy (constraints, converters, validators and the form itself). Build a Validator, validate an object and assert on the violations. Assert on the message template rather than the interpolated text: the text depends on the default locale of the machine running the build (a test that compared must be less than or equal to 5 failed on a Spanish-locale workstation in the scratch run).

org/example/bookshelf/model/ReviewConstraintsTest.java
package org.example.bookshelf.model;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import java.util.Set;
import org.example.bookshelf.validation.Publish;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

class ReviewConstraintsTest {

    private static ValidatorFactory factory;
    private static Validator validator;

    @BeforeAll
    static void createValidator() {
        factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    @AfterAll
    static void closeFactory() {
        factory.close();
    }

    private static Review validReview() {
        Review review = new Review();
        review.setReviewer("Ann");
        review.setRating(4);
        review.setTitle("Dune");
        review.setAuthor("Frank Herbert");
        return review;
    }

    @Test
    void validReviewHasNoViolations() {
        assertTrue(validator.validate(validReview()).isEmpty());
    }

    @Test
    void ratingAboveFiveIsRejected() {
        Review review = validReview();
        review.setRating(9);

        Set<ConstraintViolation<Review>> violations = validator.validate(review);

        assertEquals(1, violations.size());
        ConstraintViolation<Review> violation = violations.iterator().next();
        assertEquals("rating", violation.getPropertyPath().toString());
        assertEquals("{jakarta.validation.constraints.Max.message}", violation.getMessageTemplate());
    }

    @Test
    void publishingRequiresAComment() {
        Review review = validReview();

        assertTrue(validator.validate(review).isEmpty());
        assertEquals(1, validator.validate(review, Publish.class).size());
    }

    @Test
    void isbnChecksumIsVerified() {
        Review review = validReview();
        review.setIsbn("978-0-306-40615-8");

        assertEquals(1, validator.validateProperty(review, "isbn").size());
        review.setIsbn("978-0-306-40615-7");
        assertTrue(validator.validateProperty(review, "isbn").isEmpty());
    }
}

The test needs junit-jupiter in the test scope and an Expression Language implementation, for instance org.glassfish.expressly:expressly:6.0.0 (the @Isbn message uses ${validatedValue}). All four tests passed. The validator under test is the very one that Faces uses, so a constraint that passes this test but fails in the form points at the form (the property path, a converter, a group) and not at the rule. A constraint on a validator that injects a CDI bean is tested with a stubbed field or with the validator’s constructor, since no container provides the injection in a plain test.

Mojarra and MyFaces

The specification fixes the contract, and the scratch application was run on both implementations for the scenarios in this page. The results were the same for the default validator, the labelled message format, the Default and Publish groups, branch disabled, the validationGroups short-name failure (HTTP 500 with a FacesException on both), f:validateWholeBean including the non-copyable and the placed-before-the-inputs failures, the CDI-injected validator, and the localized messages. Mojarra and MyFaces differ in the following small respects only:

  • The MyFaces form needs its own hidden marker field when a request is replayed from a script (a field named like the form id plus _SUBMIT). That affects only test scripts, not applications.

  • The texts of exceptions differ. Only Mojarra’s wording of the f:validateWholeBean placement and copy failures was recorded for this page.

Anything that depends on the wording of a Faces-generated message (the Validation Error: Value is required. text, the converter message) belongs to the implementation’s message bundle, not to Bean Validation, and is overridden through the <message-bundle> mechanism on both.

What Changed

Version Change

JSF 2.0 (2009)

Bean Validation integration is part of the specification: f:validateBean, the BeanValidator and the automatic default validator javax.faces.Bean, VALIDATE_EMPTY_FIELDS, INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL and DISABLE_DEFAULT_BEAN_VALIDATOR. Bean Validation 1.0.

JSF 2.3

f:validateWholeBean and ENABLE_VALIDATE_WHOLE_BEAN for class-level validation. Bean Validation 2.0 (built-in @NotBlank, @NotEmpty, @Email, @Positive, @PastOrPresent and others, java.time types for @Past and @Future, container element constraints, repeatable annotations).

Faces 3.0

The packages become jakarta.faces.* and jakarta.validation.*; the validator id is jakarta.faces.Bean. Bean Validation 3.0 contains no other change.

Faces 4.0

Jakarta EE 10, Bean Validation 3.0. The tag libraries use the jakarta.faces.* URNs.

Faces 4.1

Jakarta EE 11. Bean Validation is renamed Jakarta Validation 3.1, with clarified support for Java records (immutable records cannot be form targets, as an input must be able to write the property).

Upcoming

Jakarta Validation 4.0 and Faces 5.0 are in development: jakarta.validation-api 4.0.0-M1 and Faces 5.0.0-M9 milestones exist on Maven Central. Neither is current behavior, and this page describes none of their content.

Where the Books Are Outdated

The older Faces books cover validation in the JSF 2.0 era, and the concepts they teach (that conversion precedes validation, that validators queue messages and short-circuit the lifecycle, that custom validators are classes or methods) still hold. Compared with the current specification:

  • Bean Validation 1.0 versus 3.1. JavaServer Faces 2.0: The Complete Reference targets JSF 2.0 and Bean Validation 1.0 (JSR 303). Packages are javax.validation there and jakarta.validation today, and the built-in list was short: @NotBlank, @NotEmpty and @Email were Hibernate extras then and are standard since Bean Validation 2.0. java.time support, container element constraints and repeatable annotations also came later.

  • No f:validateWholeBean. It arrived in 2.3, after all of the books, so cross-field validation there is done with custom validators or listeners. f:validateWholeBean is the class-level alternative described above, and the validators page compares the options.

  • Managed beans and custom validators. Books of that era use @ManagedBean backing beans and <validator> registrations. Today beans are CDI @Named and validators can be managed (injectable) with @FacesValidator(managed = true); for validators that belong to the model, a Jakarta Validation ConstraintValidator with CDI injection is preferred.

  • The JSF 2.0 Cookbook. About half of its recipes rely on extension libraries (RichFaces, Tomahawk, Trinidad, Seam 2) that ended. The Faces core mechanism it shows for validators is unchanged; use the XHTML and the Java on this page with the current namespaces.

  • PrimeFaces client-side validation. The PrimeFaces Cookbook describes client-side validation that mirrors Bean Validation constraints in the browser. That is a PrimeFaces feature, covered on the PrimeFaces Ajax, forms and validation page of this section, and it is an addition to, not a substitute for, the server-side validation here.

These statements about the books rest on the scope and edition data recorded for the section’s bibliography and on the official specifications; the books themselves were consulted only for coverage. The official documentation wins on any point where they differ.

Source: Ed Burns, Chris Schalk and Neil Griffin, JavaServer Faces 2.0: The Complete Reference (McGraw-Hill, 2010, ISBN 978-0-07-162509-8) and Anghel Leonard, JSF 2.0 Cookbook (Packt, 2010, ISBN 978-1-847199-52-2), with the Jakarta Validation 3.1 specification

Common Mistakes

  • Short names in validationGroups. validationGroups="Publish" fails at the first submit with Validation group not found. Use the fully qualified class name of the group interface.

  • @NotNull on a text field, expecting "required". With the default INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL of false, the empty string passes. Use @NotBlank, or set the parameter to true (Empty Values: Two Context Parameters That Decide Whether Constraints See Them).

  • VALIDATE_EMPTY_FIELDS set to false. It turns off every presence constraint in the application.

  • Forgetting ENABLE_VALIDATE_WHOLE_BEAN. f:validateWholeBean then silently does nothing.

  • Putting f:validateWholeBean before its inputs, or using a bean that cannot be copied. Both failed with exceptions on Mojarra and MyFaces.

  • A class-level constraint in the Default group. It would be evaluated against a bean that has not been populated yet, by every code path that validates Default. Put it in a dedicated group.

  • Expecting constraints on a map entry, a literal, or a bean without annotations to be checked. A value expression with no constrained base class is skipped without a message.

  • @Valid cascade expectations. Faces validates one property at a time; a cascading annotation is not consulted.

  • Constraint messages that depend on the machine. A missing _en bundle falls back to the JVM default locale, and a test that compares interpolated text fails on another workstation.

  • Injecting into a constraint validator and getting null. On servlet containers, publish a CDI-aware ValidatorFactory as the servlet context attribute (Injecting CDI Beans into Constraint Validators).

  • Echoing sensitive input in messages. ${validatedValue} on a password or token field reveals what the user typed in the page and in logs.

  • Treating client-side checks or Faces validation as the only line of defence. A request can bypass the page. Keep the constraints on the model and enforce them again at the service boundary.

  • Writing logic or database queries in a constraint validator. It runs for every validation call, including every input on every postback. Keep it cheap and do not call the persistence layer from it.