Jakarta Faces on Spring Boot with JoinFaces
|
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 Jakarta Faces 4.1, JoinFaces 6.1.1 (2026-09-04) on Spring Boot 4.1.1 and Spring Framework 7.0.9, Java 17 or
later (the examples compile with java.version 21 and were run on JDK 26), PrimeFaces 16.0.0 and OmniFaces 5.5.5. The versions JoinFaces
itself selects are older than the newest releases of the libraries it integrates, and the page says which is which: the JoinFaces
6.1.1 starters pin Mojarra 4.1.12, MyFaces 4.1.3, Weld 6.0.4.Final, OmniFaces 5.5.1 and PrimeFaces 15.0.17, and Spring Boot 4.1.1 brings
Tomcat 11.0.24. Everything below was run on 2026-10-08 with a scratch Bookshelf application built on Maven; behaviour that was not run
is marked as such. Faces 4.0 differences are noted where they matter.
Spring Boot does not support Faces out of the box: it has no FacesServlet registration, no web.xml, and an embedded servlet
container that is started before any Faces initialiser would run. JoinFaces is the community project that
closes the gap. Its starters pull the Faces implementation, the CDI container Mojarra needs and, optionally, PrimeFaces or OmniFaces; its
auto-configuration registers the FacesServlet, runs the servlet container initialisers that a full server would run, maps Spring Boot
properties to Faces context parameters, and teaches Spring about the Faces scopes. The existing page
Server-Side Web UI Frameworks introduces JoinFaces next to Thymeleaf and Vaadin and calls it a
legacy choice; this page goes much deeper, and the last sections explain when the other choices are the better ones.
How JoinFaces Fits Together
Three containers coexist in a JoinFaces application, and most surprises come from forgetting that.
-
Spring creates and wires every bean you write: the EL resolver
org.springframework.web.jsf.el.SpringBeanFacesELResolver, registered by aMETA-INF/faces-config.xmlthat ships insidejoinfaces-autoconfigure, lets#{catalogueBean.books}resolve a Spring bean. -
Weld (or OpenWebBeans) is present because Mojarra 4.1 refuses to start without CDI (on Tomcat without a CDI container it fails with a
ClassNotFoundExceptionforELAwareBeanManager; Tomcat and Weld covers that setup) and because OmniFaces and PrimeFaces use CDI. In a Boot application it is a library for the framework, not the container of your beans. -
The embedded servlet container (Tomcat 11.0.24 with Spring Boot 4.1.1) hosts the
FacesServletthat JoinFaces registers through Boot’sServletRegistrationBeanmechanism, plus Spring Security’s filter chain when it is present.
Faces features that need no CDI bean of your own (the lifecycle, Facelets, f:ajax, converters registered by id, resource handling)
behave as on any other runtime and are documented in the other pages of this section; this page covers only what changes when the
container is Spring.
The Bookshelf Application
The scenario is the same Bookshelf catalogue as in the rest of this section, with the packages org.example.bookshelf.model,
.service and .web. Book is a record, BookService a Spring @Service, and the page bean is a Spring component with
the @ViewScoped annotation from Faces.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>org.example</groupId>
<artifactId>bookshelf-joinfaces</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<java.version>21</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>joinfaces-platform</artifactId>
<version>6.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>faces-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
package org.example.bookshelf;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BookshelfApplication {
public static void main(String[] args) {
SpringApplication.run(BookshelfApplication.class, args);
}
}
package org.example.bookshelf.model;
import java.io.Serializable;
public record Book(long id, String title, String author, String isbn) implements Serializable {
}
package org.example.bookshelf.service;
import java.util.List;
import org.example.bookshelf.model.Book;
import org.springframework.stereotype.Service;
@Service
public class BookService {
private final List<Book> books = List.of(
new Book(1, "Dune", "Frank Herbert", "9780441172719"),
new Book(2, "Neuromancer", "William Gibson", "9780441569595"),
new Book(3, "Solaris", "Stanislaw Lem", "9780156027601"));
public List<Book> findAll() {
return books;
}
}
package org.example.bookshelf.web;
import jakarta.faces.view.ViewScoped;
import java.io.Serializable;
import java.util.List;
import org.example.bookshelf.model.Book;
import org.example.bookshelf.service.BookService;
import org.springframework.stereotype.Component;
@Component("catalogueBean")
@ViewScoped
public class CatalogueBean implements Serializable {
private final BookService bookService;
private int clicks;
public CatalogueBean(BookService bookService) {
this.bookService = bookService;
}
public List<Book> getBooks() {
return bookService.findAll();
}
public void click() {
clicks++;
}
public int getClicks() {
return clicks;
}
}
Two things are different from a Jakarta EE server. The bean is a Spring @Component with an explicit name (the name EL uses), and its
constructor takes a Spring bean: constructor injection works because Spring, not CDI, instantiates it. The @ViewScoped annotation is
the Faces one (jakarta.faces.view.ViewScoped), and JoinFaces maps it to a Spring scope (see Scopes: Spring and CDI Annotations on Spring Beans). A view bean is held in the
view map and must be Serializable (see the serialisation advice in Scopes); its Spring
collaborators are not serialisable state, so test the configuration you deploy if view state is ever serialised (client state saving,
a clustered session).
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:f="jakarta.faces.core">
<h:head><title>Bookshelf</title></h:head>
<h:body>
<h1>Bookshelf on Spring Boot</h1>
<h:form id="f">
<h:dataTable id="books" var="b" value="#{catalogueBean.books}">
<h:column><f:facet name="header">Title</f:facet>#{b.title}</h:column>
<h:column><f:facet name="header">Author</f:facet>#{b.author}</h:column>
</h:dataTable>
<h:commandButton id="go" value="Click" action="#{catalogueBean.click}"/>
<p>clicks #{catalogueBean.clicks}</p>
</h:form>
</h:body>
</html>
The view sits under META-INF/resources of the classpath, which Tomcat serves like a web root. That is why src/main/resources and not
src/main/webapp is the location in a JAR application. Templates and includes go under META-INF/resources/WEB-INF:
<!-- src/main/resources/META-INF/resources/WEB-INF/templates/layout.xhtml -->
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:ui="jakarta.faces.facelets">
<h:head><title><ui:insert name="title">Bookshelf</ui:insert></title></h:head>
<h:body><ui:insert name="content"/></h:body>
</html>
<!-- src/main/resources/META-INF/resources/templated.xhtml -->
<ui:composition xmlns="http://www.w3.org/1999/xhtml" xmlns:ui="jakarta.faces.facelets"
template="/WEB-INF/templates/layout.xhtml">
<ui:define name="title">Templated</ui:define>
<ui:define name="content"><p>Rendered through the template</p></ui:define>
</ui:composition>
Run on 2026-10-08: GET /templated.xhtml rendered the template, while GET /WEB-INF/templates/layout.xhtml answered 404. Only
*.xhtml is a Faces view, so GET /META-INF/resources/index.xhtml is also a 404.
Mappings and the Welcome Page
JoinFaces registers one servlet named FacesServlet with the mappings /faces/*, *.jsf, *.faces and *.xhtml
(property joinfaces.faces-servlet.url-mappings). In the run, /index.xhtml, /index.jsf, /index.faces and
/faces/index.xhtml all rendered the same view. That convenience has a security cost described in Spring Security: Filters, URL Rules and Facelet Tags: every alias is a
separate URL for the same page.
GET / answered 404. Spring Boot’s welcome-page handling belongs to Spring MVC, which is not present, so Tomcat looks for a
welcome file and finds none that maps to a Faces view. The fix is a welcome file registered on the Tomcat context:
package org.example.bookshelf;
import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class WelcomePage {
@Bean
WebServerFactoryCustomizer<TomcatServletWebServerFactory> welcomeFile() {
return factory -> factory.addContextCustomizers(context -> context.addWelcomeFile("index.xhtml"));
}
}
With it, GET / answered 200. Setting joinfaces.faces.automatic-extensionless-mapping=true made GET /index answer 200 as
well (it answered 404 before); the Faces side of that setting is the extensionless mapping described in
Project Setup.
Starters, BOMs and Version Overrides
A JoinFaces application chooses its building blocks by choosing starters. The reference documents these (all in the group
org.joinfaces):
| Starter | What it brings |
|---|---|
|
Meta starter: |
|
Eclipse Mojarra (pinned at 4.1.12 in 6.1.1) and the CDI starter that, by default, pulls Weld |
|
Apache MyFaces (api and impl, pinned at 4.1.3 in 6.1.1) |
|
Weld 6.0.4.Final servlet integration (Weld 6.0.4 is the version in the platform BOM) |
|
Apache OpenWebBeans as the CDI container (not run for this page) |
|
PrimeFaces and PrimeFaces Extensions (and the PrimeFaces themes) on top of the Faces starter |
|
OmniFaces on top of the Faces and Weld starters |
|
Faces starter, |
|
AdminFaces (pulls PrimeFaces) and BootsFaces; see the other component libraries page |
Two pairs are mutually exclusive: Mojarra and MyFaces (mojarra4-spring-boot-starter against myfaces4-spring-boot-starter), and
Weld against OpenWebBeans. The platform BOM publishes the curated versions: joinfaces-bom lists the JoinFaces modules,
joinfaces-dependencies the external libraries (PrimeFaces, OmniFaces, Weld, Tobago, and so on), and joinfaces-platform combines both
with Spring Boot’s own BOM. Import the platform BOM and leave the starters without a version, as in the pom.xml above.
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>faces-spring-boot-starter</artifactId>
<exclusions>
<exclusion>
<groupId>org.joinfaces</groupId>
<artifactId>mojarra4-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>myfaces4-spring-boot-starter</artifactId>
</dependency>
Run on 2026-10-08, that application started with MyFaces 4.1.3 and no Weld on the class path, and it served the same views and the
same Spring scopes as the Mojarra application. MyFaces does not need CDI; add weld-spring-boot-starter next to it when a library
needs it (a second run with both started as well, with Weld 6.0.4.Final).
Newer Library Versions Than the Starters Pin
The versions in the table are what the 6.1.1 starters and BOM carry, which lags the libraries' newest releases. To move one forward:
-
Mojarra and OmniFaces have a plain management entry. Declaring the artifact in your own
dependencyManagement, before the imported platform BOM, was enough:org.glassfish:jakarta.faces4.1.17 andorg.omnifaces:omnifaces5.5.5 replaced 4.1.12 and 5.5.1, and the log saidInitializing Mojarra 4.1.17. MyFaces is declared the same way in its starter (myfaces-apiandmyfaces-impl); an override was not run for it. -
PrimeFaces and PrimeFaces Extensions need more. The starter depends on the artifacts with the Maven classifier
jakarta(primefaces-15.0.17-jakarta.jar), and PrimeFaces 16 does not publish a classified artifact. A management entry for the plainorg.primefaces:primefacesdoes nothing to the classified dependency, so the 15.0.17 jars stayed on the class path. The working form excludes the two artifacts from the starter and declares them directly:
<dependencyManagement>
<dependencies>
<!-- entries before the import win over the imported platform BOM -->
<dependency>
<groupId>org.primefaces</groupId>
<artifactId>primefaces</artifactId>
<version>16.0.0</version>
</dependency>
<dependency>
<groupId>org.primefaces</groupId>
<artifactId>primefaces-themes</artifactId>
<version>16.0.0</version>
</dependency>
<dependency>
<groupId>org.primefaces.extensions</groupId>
<artifactId>primefaces-extensions</artifactId>
<version>16.0.0</version>
</dependency>
<dependency>
<groupId>org.omnifaces</groupId>
<artifactId>omnifaces</artifactId>
<version>5.5.5</version>
</dependency>
<dependency>
<groupId>org.glassfish</groupId>
<artifactId>jakarta.faces</artifactId>
<version>4.1.17</version>
</dependency>
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>joinfaces-platform</artifactId>
<version>6.1.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>primefaces-spring-boot-starter</artifactId>
<exclusions>
<!-- the starter pins the 15.x "jakarta" classifier artifacts -->
<exclusion>
<groupId>org.primefaces</groupId>
<artifactId>primefaces</artifactId>
</exclusion>
<exclusion>
<groupId>org.primefaces.extensions</groupId>
<artifactId>primefaces-extensions</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.primefaces</groupId>
<artifactId>primefaces</artifactId>
</dependency>
<dependency>
<groupId>org.primefaces.extensions</groupId>
<artifactId>primefaces-extensions</artifactId>
</dependency>
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>omnifaces-spring-boot-starter</artifactId>
</dependency>
</dependencies>
The run logged Running on PrimeFaces 16.0.0, Running on PrimeFaces Extensions 16.0.0 and Initializing Mojarra 4.1.17, and
primefaces-16.0.0.jar, omnifaces-5.5.5.jar and jakarta.faces-4.1.17.jar were in BOOT-INF/lib. The JoinFaces primefaces.*
property mapping and the Weld/OmniFaces integration worked unchanged with PrimeFaces 16, but JoinFaces 6.1.1 itself is built
against PrimeFaces 15.0.17, so the combination is yours to test, and the PrimeFaces and OmniFaces pages list what
else changed in those libraries (PrimeFaces dependencies,
OmniFaces installation).
Configuration Properties
A Faces application on a server configures itself with context-param entries in web.xml and in faces-config.xml. A Boot JAR has no
web.xml, so JoinFaces maps Spring Boot properties to servlet-context init parameters. The prefix depends on the library:
| Prefix | Maps to | Example |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
how JoinFaces registers the servlet ( |
|
|
any context parameter without a dedicated property (a Spring Boot property) |
|
Most mapped properties default to null, so the parameter is not set and the library keeps its own default. The mapped values reached
the application unchanged when read back with #{initParam['…']} in a view.
# Faces: the properties are joinfaces.faces.*, not joinfaces.jsf.*
joinfaces.faces.project-stage=development
joinfaces.faces.facelets-refresh-period=1
joinfaces.faces.state-saving-method=server
joinfaces.faces.automatic-extensionless-mapping=true
# PrimeFaces: theme, CSP switch, and the secret that protects streamed-content expressions
joinfaces.primefaces.theme=arya
joinfaces.primefaces.csp=true
joinfaces.primefaces.secret=${PRIMEFACES_SECRET}
# Anything without a dedicated property
server.servlet.context-parameters.com.example.bookshelf.audit=true
# Session cookie and URL rewriting (see the session section)
server.servlet.session.tracking-modes=cookie
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.secure=true
Source: JoinFaces 6.1.1 reference: Servlet-Context Init-Parameters and Spring Boot 4.1.1: Externalized Configuration
| Property | Value read back from the running application |
|---|---|
none (no property at all) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The joinfaces.jsf.* Names Are Silently Ignored
The appendix of the JoinFaces 6.1.1 reference lists joinfaces.jsf.project-stage, joinfaces.jsf.facelets-refresh-period and the rest
under the prefix joinfaces.jsf.*, while the prose of the same document uses joinfaces.faces.*. The configuration metadata inside
joinfaces-autoconfigure-6.1.1.jar settles it: the joinfaces.jsf.* entries are marked deprecated at level error with the replacement
joinfaces.faces.*, and 28 properties are in that state. Starting the application with --joinfaces.jsf.project-stage=development produced
Production: the property was ignored and the start-up log said nothing. The same value under joinfaces.faces.project-stage gave
Development. An IDE that reads the metadata flags the old name; a command line or an environment variable does not. Treat any
snippet or blog post that uses joinfaces.jsf. as written for an older JoinFaces. The joinfaces.mojarra.*, joinfaces.myfaces.*,
joinfaces.primefaces.* and joinfaces.omnifaces.* names were not renamed.
The Default Project Stage
With no property the stage was Production, which is the Faces default but differs from what many tutorials assume for a development
run (see Configuration and Project Stages). Set the development stage in a Spring profile
(application-dev.properties) and never in the base file.
Secrets
Two JoinFaces properties carry secrets. Both must come from the environment or a secret store, never from a committed
application.properties:
-
joinfaces.primefaces.secrethas the metadata defaultprimefaces. With nothing configured, the running application reportedprimefaces.SECRETequal to the stringprimefaces, a publicly known value. PrimeFaces uses it to encrypt the value expressions that streamed content (charts, images) exposes in URLs. Withjoinfaces.primefaces.secret=${PRIMEFACES_SECRET}the parameter was set to the environment value. -
joinfaces.myfaces.secret(andmac-secret) initialise the key that encrypts and signs client-side view state. They are needed on every node of a cluster whenjoinfaces.faces.state-saving-method=client; the state-saving page explains the key format (client-state keys).
Scopes: Spring and CDI Annotations on Spring Beans
This is the part of JoinFaces that differs most from a Jakarta EE server. The reference states that "JSF and CDI scope-annotations are
automatically supported" for Spring beans: JoinFaces registers a Spring scope named view (org.joinfaces.viewscope.ViewScope, bound to
the Faces view map and cleaned up when the view is discarded or the session ends) and
BeanFactoryPostProcessor`s (property group `joinfaces.scope-configurer.cdi|faces|jsf, all enabled by default; the view scope has
joinfaces.view-scope.enabled) that map annotations to Spring scopes:
| Annotation on a Spring bean | Spring scope used |
|---|---|
|
|
|
|
|
|
|
|
the old |
request, session, application, view; |
Source: JoinFaces 6.1.1 reference: JSF and CDI scope-annotations support and Spring Framework: Bean Scopes
Note what is not in the table: @ClientWindowScoped, @FlowScoped and @Dependent. To see what a scope annotation does in practice, a probe
bean counted its instances and logged who created and destroyed each one. Eleven beans, each carrying a different annotation, were
rendered on one view; the page was requested twice, posted twice (always with the view state of the first response), and requested once more
from a second browser session. The number is the instance counter of each bean:
| Bean annotation | GET 1 | GET 2 | POST 1 | POST 2 | GET, new session |
|---|---|---|---|---|---|
|
1 |
2 |
3 |
4 |
5 |
|
1 |
2 |
3 |
4 |
5 |
|
1 |
2 |
3 |
4 |
5 |
|
1 |
1 |
1 |
1 |
2 |
|
1 |
1 |
1 |
1 |
2 |
|
1 |
1 |
1 |
1 |
2 |
|
1 |
2 |
1 |
1 |
3 |
|
1 |
2 |
1 |
1 |
3 |
|
1 |
2 |
1 |
1 |
3 |
|
1 |
1 |
1 |
1 |
1 |
|
1 |
1 |
1 |
1 |
1 |
What the numbers and the log lines show:
-
Every one of the eleven beans was created by Spring. The probe inspected the creating stack:
org.springframework.beans, neverorg.jboss.weld. A class annotated@Namedis picked up by Spring’s component scan (Spring supportsjakarta.inject.Namedas a stereotype), and the CDI scope annotation is then interpreted by JoinFaces as a Spring scope. The@Named @SessionScopedbean behaved like a Spring session bean and was destroyed with the session. Weld is installed for the framework only. -
@ViewScopedworks on a Spring component. A new view (a GET) creates a new instance, postbacks to the same view reuse it (instance 1 in both POST columns, the view of the first GET), and a click counter stored in it accumulated across postbacks (three clicks in a row gaveclicks 3on the Bookshelf page). A different session got a new instance (3). -
Destruction. Request-scoped beans were destroyed at the end of each request. After a navigation to a different view (an action returning
other), the view-scoped beans of the old view were destroyed; afterinvalidateSession()the session-scoped and the view-scoped beans were destroyed. The singleton and the@ClientWindowScopedbean were created at start-up and never destroyed. -
@ClientWindowScopedis not mapped. The annotation is accepted, nothing complains, and the bean is an ordinary singleton shared by every user and every browser window: instance 1 in every column, created at start-up, even from a second session. This is the most dangerous row of the table, because the Faces annotation suggests per-window isolation. Do not use it on Spring beans; keep window-scoped state in a@ViewScopedbean or use a real CDI bean (see Multi-Window Support). -
@FlowScopedand@Dependentare not in the documented mapping, and flows were not run on JoinFaces for this page. Treat Faces Flows as unsupported on Spring beans unless you test them. The@ConversationScopedmapping to the HTTP session means a Spring bean with that annotation never has a conversation that can begin or end.
Choosing the Annotation
For new code use the Spring annotations and the Faces @ViewScoped; they are the supported surface of the reference, and a reader
from a Spring background understands them. @Named/CDI scopes work through the same mapping, which is convenient while moving a
class from a Jakarta EE server (compare Backing Beans and CDI), but they hide the fact that
the bean is not a CDI bean: observers, interceptors, @Produces methods and @Inject of CDI-only types do not apply to it. The
rules of the Scopes page still hold on top: no request data in a session bean, a view bean must be
serialisable, and a view-scoped bean holds only what that view needs. The probe numbers above are the proof for the
view-scope lifetime; the serialisation rules for client state saving are in
the limits of view scope.
package org.example.bookshelf.web;
import java.io.Serializable;
import java.util.LinkedHashSet;
import java.util.Set;
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.SessionScope;
@Component("borrowList")
@SessionScope
public class BorrowList implements Serializable {
private final Set<Long> bookIds = new LinkedHashSet<>();
public void add(long id) {
bookIds.add(id);
}
public int getSize() {
return bookIds.size();
}
}
Spring Beans in Faces Artefacts
Spring beans are reachable from EL, so anything that accepts a value expression accepts a Spring bean. Converters and validators have two registration forms, and only one of them works with Spring injection.
package org.example.bookshelf.web;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.validator.ValidatorException;
import org.example.bookshelf.service.BookService;
import org.springframework.stereotype.Component;
@Component("isbnValidator")
public class IsbnValidator {
private final BookService bookService;
public IsbnValidator(BookService bookService) {
this.bookService = bookService;
}
public void validate(FacesContext context, UIComponent component, Object value) {
String isbn = String.valueOf(value);
boolean known = bookService.findAll().stream().anyMatch(b -> b.isbn().equals(isbn));
if (!known) {
throw new ValidatorException(
new FacesMessage(FacesMessage.SEVERITY_ERROR, "Unknown ISBN", "Unknown ISBN " + isbn));
}
}
}
package org.example.bookshelf.web;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.convert.Converter;
import java.util.Locale;
import org.example.bookshelf.service.BookService;
import org.springframework.stereotype.Component;
@Component("titleConverter")
public class TitleConverter implements Converter<String> {
private final BookService bookService;
public TitleConverter(BookService bookService) {
this.bookService = bookService;
}
@Override
public String getAsObject(FacesContext context, UIComponent component, String value) {
return value == null ? null : value.trim();
}
@Override
public String getAsString(FacesContext context, UIComponent component, String value) {
return value == null ? "" : value.toUpperCase(Locale.ROOT) + " of " + bookService.findAll().size();
}
}
<h:form id="f">
<h:inputText id="isbn" value="#{isbnForm.isbn}" validator="#{isbnValidator.validate}"/>
<h:inputText id="title" value="#{isbnForm.title}" converter="#{titleConverter}"/>
<h:commandButton id="go" value="Check"/>
<h:messages id="m"/>
</h:form>
Posting the form with an unknown ISBN produced the message Unknown ISBN from the validator, which called the injected service; a
known ISBN passed, and the converter’s getAsString (which also uses the injected BookService) rendered DUNE of 3. The validator
attribute takes a method expression, and the converter attribute of h:inputText accepts a value expression that evaluates to a
Converter instance; both are normal Faces rules (see Converters and Validators).
The cost is that the bean is a singleton shared by every request, so it must be thread-safe and stateless.
What does not work:
-
@FacesConverter(value = "bookConverter", managed = true)with@Inject BookService. The managed converter or validator form lets Faces create the instance through CDI. Under JoinFaces the request that used it failed withjakarta.faces.FacesException: @FacesConverter("bookConverter", managed=true) on …BookConverter could not be resolved by CDI. Weld does not register the classes of the application (see the next point), so the converter cannot be created through CDI. Register converters and validators as Spring components and reference them by EL, as above. -
Expecting CDI to manage your classes. A
@Named @RequestScopedclass with an@Inject BookServicefield received the Spring singleton (theBookServiceinstance printed by Spring and the one printed through the@Namedbean had the same identity), because Spring processes@Injectitself. AddingMETA-INF/beans.xml(withbean-discovery-mode="annotated"or"all") did not change that: in the executable JAR Weld loggedWELD-000119: Not generating any bean definitions from BOOT-INF.classes.org.example.bookshelf…for the application classes and registered none of them. CDI-only features on your classes (observers, producers, interceptors, decorators) would therefore not be discovered; that was not run. There is one container for them, and it is Spring. -
@FacesConverter("id")withoutmanaged. Faces instantiates the class itself, so constructor injection is impossible.
The Spring @Autowired/constructor injection that works everywhere else in Boot is the rule here. The CDI injection of Faces
artefacts documented in Backing Beans and CDI (@Inject FacesContext and the other
producers) was not run on JoinFaces for this page; call FacesContext.getCurrentInstance() in a Spring bean instead, as the invalidate
action of the probe application did.
Source: JoinFaces 6.1.1 reference and FacesConverter API
Two Resource Bundle Mechanisms
A JoinFaces application has two internationalisation systems, and the reference advises using only one. The Faces
<resource-bundle> in faces-config.xml works as everywhere (the file is META-INF/faces-config.xml on the class path), and
JoinFaces additionally exposes Spring’s MessageSource to EL as a map named sms.
<?xml version="1.0" encoding="UTF-8"?>
<faces-config xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_1.xsd"
version="4.1">
<application>
<resource-bundle>
<base-name>org.example.bookshelf.messages</base-name>
<var>msg</var>
</resource-bundle>
</application>
</faces-config>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:h="jakarta.faces.html">
<h:head><title>i18n</title></h:head>
<h:body>
<p>Faces bundle: #{msg['greeting']}</p>
<p>Spring MessageSource: #{sms['greeting']}</p>
</h:body>
</html>
With src/main/resources/org/example/bookshelf/messages.properties holding greeting=Hello from the Faces bundle and
src/main/resources/messages.properties holding greeting=Hello from Spring MessageSource, the page rendered both lines. They are
different APIs: the Faces bundle is looked up through UIViewRoot.getLocale(), the Spring one through the MessageSource that Boot
configures with spring.messages.* (UTF-8 by default, while the reference notes that Faces bundles are read as ISO-8859-1). Keep one of
the two for all texts that the views show; the Bookshelf uses the Faces bundle and msg, so the rest of this section applies unchanged
(Messages and Internationalisation).
Bean Validation
The Faces starter does not bring Bean Validation. A class with jakarta.validation.constraints.* annotations did not compile with only
faces-spring-boot-starter (and with the OmniFaces starter added): package jakarta.validation.constraints does not exist. MyFaces
logs MyFaces Core Bean Validation support disabled at start-up in that configuration. Add Spring Boot’s validation starter, which
brings the API and Hibernate Validator (9.1.3.Final with Boot 4.1.1):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
package org.example.bookshelf.web;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.io.Serializable;
import org.springframework.stereotype.Component;
@Component("reviewBean")
@ViewScoped
public class ReviewBean implements Serializable {
@NotBlank
@Size(min = 10, max = 200)
private String comment;
private String saved = "none";
public String getComment() { return comment; }
public void setComment(String comment) { this.comment = comment; }
public String getSaved() { return saved; }
public void save() {
saved = comment;
FacesContext.getCurrentInstance().addMessage(null, new FacesMessage("Saved"));
}
}
<h:form id="f">
<h:inputTextarea id="comment" value="#{reviewBean.comment}"/>
<h:commandButton id="save" value="Save" action="#{reviewBean.save}"/>
<h:messages id="m"/>
<p>saved=#{reviewBean.saved}</p>
</h:form>
Posting an empty comment returned must not be blank and size must be between 10 and 200; a text of five characters returned the size
message and left saved=none; a valid text returned Saved. The joinfaces.faces.validator.disable-default-bean-validator and
joinfaces.faces.validator.enable-validate-whole-bean properties map the two Faces switches described in
Bean Validation. A constraint on a Spring bean only protects the Faces form; the service layer validates
its own input with Spring’s method validation (the same constraints on a @Validated service), because a call that does not come from a
view never meets Faces.
PrimeFaces and OmniFaces on Spring Boot
With the versions of Starters, BOMs and Version Overrides, the Bookshelf catalogue becomes a PrimeFaces view. The namespaces are the 16.0.0 and OmniFaces 5 URNs
(primefaces and omnifaces; OmniFaces 5 prefers the o: function prefix, the old of: is deprecated, see the OmniFaces page).
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:f="jakarta.faces.core"
xmlns:p="primefaces"
xmlns:o="omnifaces">
<h:head><title>Bookshelf</title></h:head>
<h:body>
<h1>Bookshelf with PrimeFaces</h1>
<h:form id="f">
<p:growl id="msgs"/>
<p:dataTable id="books" var="b" value="#{catalogueBean.books}">
<p:column headerText="Title">#{b.title}</p:column>
<p:column headerText="Author">#{b.author}</p:column>
</p:dataTable>
<p:commandButton id="go" value="Click" action="#{catalogueBean.click}" update="info"/>
<h:panelGroup id="info">
<p>clicks #{catalogueBean.clicks} #{o:abbreviate('A very long sentence indeed', 12)}</p>
</h:panelGroup>
</h:form>
</h:body>
</html>
Source: PrimeFaces 16.0.0 documentation and JoinFaces 6.1.1 reference.
The page rendered the ui-datatable markup with the default Saga theme, the button carried an onclick="PrimeFaces.ab({…})" Ajax call, and
o:abbreviate produced A very long.... The theme, icons and the other primefaces.* parameters map as in the table of
Configuration Properties; the PrimeFaces pages cover what they do (getting started and
themes). Two integration points are Spring-specific:
-
The JoinFaces
ViewScopedbean and PrimeFaces Ajax. Theupdate="info"request above reused the sameCatalogueBeaninstance (the click counter went up), which is the view scope working under Ajax. -
CSP.
joinfaces.primefaces.csp=truesets the PrimeFaces parameter, and the security page explains what the browser then requires (Content Security Policy); Spring Security’s own CSP header configuration is separate and was not combined with it here.
A Lazy Data Table on Spring Data JPA
JoinFaces ships org.joinfaces.primefaces.SpringDataLazyDataModel (for a paging and sorting repository) and SpringDataJpaLazyDataModel
(for a JpaRepository that also implements JpaSpecificationExecutor), implementations of PrimeFaces' LazyDataModel whose load and
count methods turn the sort and filter metadata into a Spring Data Pageable and, for JPA, a Specification. This is the Spring answer to the
LazyDataModel of PrimeFaces data components; the repository side is Spring Data JPA
(Spring Data JPA).
package org.example.bookshelf.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class BookEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String author;
protected BookEntity() {
}
public BookEntity(String title, String author) {
this.title = title;
this.author = author;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
}
package org.example.bookshelf.service;
import org.example.bookshelf.model.BookEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
public interface BookEntityRepository
extends JpaRepository<BookEntity, Long>, JpaSpecificationExecutor<BookEntity> {
}
package org.example.bookshelf.web;
import jakarta.faces.view.ViewScoped;
import jakarta.persistence.EntityManager;
import java.io.Serializable;
import org.example.bookshelf.model.BookEntity;
import org.example.bookshelf.service.BookEntityRepository;
import org.joinfaces.primefaces.SpringDataJpaLazyDataModel;
import org.springframework.stereotype.Component;
@Component("lazyCatalogue")
@ViewScoped
public class LazyCatalogueBean implements Serializable {
private final SpringDataJpaLazyDataModel<BookEntity, Long, BookEntityRepository> model;
public LazyCatalogueBean(BookEntityRepository repository, EntityManager entityManager) {
this.model = new SpringDataJpaLazyDataModel<>(repository);
this.model.setEntityManager(entityManager);
}
public SpringDataJpaLazyDataModel<BookEntity, Long, BookEntityRepository> getModel() {
return model;
}
}
<h:form id="f">
<p:dataTable id="books" var="b" value="#{lazyCatalogue.model}" lazy="true" paginator="true" rows="5"
rowKey="#{b.id}">
<p:column headerText="Title" field="title" sortBy="#{b.title}" filterBy="#{b.title}">#{b.title}</p:column>
<p:column headerText="Author" field="author">#{b.author}</p:column>
</p:dataTable>
</h:form>
With 25 seeded rows and spring.jpa.show-sql=true, the first render issued select count(be1_0.id) from book_entity be1_0 and
select … from book_entity be1_0 offset ? rows fetch first ? rows only (one page of five rows reached the view), and a sort request on
the title column added order by lower(be1_0.title) desc with the same paging clause; the rows came back Title 25 down to Title 21.
This was run on PrimeFaces 16.0.0; filtering was not run. The bean keeps a repository and an EntityManager in a view bean; whether
that survives serialisation of the view state (client state saving, a replicated session) was not tested, and a clustered deployment
should be tested before relying on it (Persistence and Services discusses lazy models against the database).
JoinFaces also registers org.joinfaces.jpa.OrderedOpenEntityManagerInViewFilter, controlled by the Spring Boot property
spring.jpa.open-in-view, so an EntityManager stays open for the whole request and lazy associations load while the view renders.
That is exactly the pattern the persistence page warns about (queries from the page, N+1 selects); consider setting the property to
false and returning fully initialised objects from services. The effect of turning it off was not run.
Spring Security: Filters, URL Rules and Facelet Tags
Security on JoinFaces is Spring Security’s: the filter chain protects the URLs, the login form is a plain form that posts to the
filter, and CSRF protection is Spring’s token. The Jakarta Security material of
the security page applies to the Faces output (escaping, CSP, view state) but not to authentication. With
security-spring-boot-starter (Faces starter, spring-boot-starter-security and joinfaces-security-taglib) the facelet tag
library is registered for you.
package org.example.bookshelf.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/jakarta.faces.resource/**", "/login.xhtml", "/index.xhtml").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login.xhtml")
.loginProcessingUrl("/login")
.defaultSuccessUrl("/index.xhtml")
.failureUrl("/login.xhtml?error"))
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login.xhtml?logout"));
return http.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// Demonstration only: a real application loads users from a database or an identity provider
@Bean
UserDetailsService users(PasswordEncoder encoder,
@Value("${bookshelf.users.reader.password}") String readerPassword,
@Value("${bookshelf.users.admin.password}") String adminPassword) {
return new InMemoryUserDetailsManager(
User.withUsername("reader").password(encoder.encode(readerPassword)).roles("USER").build(),
User.withUsername("admin").password(encoder.encode(adminPassword)).roles("USER", "ADMIN").build());
}
}
# src/main/resources/application.properties: values come from the environment
bookshelf.users.reader.password=${BOOKSHELF_READER_PASSWORD}
bookshelf.users.admin.password=${BOOKSHELF_ADMIN_PASSWORD}
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:h="jakarta.faces.html">
<h:head><title>Login</title></h:head>
<h:body>
<form method="post" action="#{request.contextPath}/login">
<input type="text" name="username"/>
<input type="password" name="password"/>
<input type="hidden" name="#{_csrf.parameterName}" value="#{_csrf.token}"/>
<button type="submit">Sign in</button>
</form>
</h:body>
</html>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:h="jakarta.faces.html"
xmlns:f="jakarta.faces.core"
xmlns:sec="https://joinfaces.org/security">
<h:head><title>Bookshelf</title></h:head>
<h:body>
<p>user=#{request.userPrincipal.name}</p>
<sec:authorize access="hasRole('ADMIN')"><p>Admin tools</p></sec:authorize>
<sec:authorize ifAnyGranted="ROLE_USER"><p>Signed-in reader</p></sec:authorize>
<sec:anonymous><p>Please sign in</p></sec:anonymous>
<sec:authenticated><p>Welcome back</p></sec:authenticated>
<p>admin? #{sec:areAnyGranted('ROLE_ADMIN')}, may open the panel? #{sec:isAllowed('/admin/panel.xhtml','GET')}</p>
<sec:authorize url="/admin/panel.xhtml" method="GET"><p>Admin panel link</p></sec:authorize>
<h:form id="f">
<input type="hidden" name="#{_csrf.parameterName}" value="#{_csrf.token}"/>
<h:commandButton id="go" value="Click" action="#{catalogueBean.click}"/>
<h:commandButton id="aj" value="Ajax" action="#{catalogueBean.click}"><f:ajax render="out"/></h:commandButton>
<h:outputText id="out" value="clicks #{catalogueBean.clicks}"/>
</h:form>
</h:body>
</html>
The tags in the table are the ones the reference lists; the namespace that worked in the run was https://joinfaces.org/security (the reference
writes it as joinfaces.org/security).
| Tag or function | Purpose |
|---|---|
|
Authorisation decision with the attributes |
|
Render the body for anonymous users, for non-anonymous users, or for users who are neither anonymous nor remember-me |
|
EL functions over authority names |
|
EL functions for URL access and the authentication state |
Source: JoinFaces 6.1.1 reference: Spring Security JSF Facelet Tag support and Spring Security: Form Login
Run with two users (the passwords supplied as environment variables):
-
An anonymous request to
/index.xhtmlshowedPlease sign in;/admin/panel.xhtmlanswered302to/login.xhtml. -
After login as
reader, the page showeduser=reader, the reader block andWelcome back, andsec:areAnyGranted('ROLE_ADMIN')andsec:isAllowed(...)werefalse; after login asadminthe admin block, theurl=block and both functions weretrue. -
A Faces form post with the
_csrfhidden field answered200and ran the action; the same post throughf:ajaxalso worked (the field is part of the submitted form); a post from a form without the field answered403. The Faces view state does not replace the Spring token, and the Spring token does not replace the view state: both are sent. Add the hidden field to every form (a Facelets tag file or a template makes that a one-line include).
Pitfall 1: POST /login Returns 404 Without a Default Servlet
The first run of this configuration answered 404 to POST /login, although the filter chain was healthy (a GET /admin/panel.xhtml
was redirected to /login.xhtml). A servlet container looks up the servlet first and runs filters only when a servlet matched. A
JoinFaces application has no Spring MVC DispatcherServlet and Spring Boot does not register Tomcat’s default servlet, so /login
and /logout match nothing: the filter that would handle them never runs. Either register the default servlet,
server.servlet.register-default-servlet=true
(with it, the login answered 302 to /index.xhtml), add Spring MVC to the project, or use a processing URL that the FacesServlet maps
(for example one ending in .xhtml; not run). The same applies to the logout URL.
Pitfall 2: The /faces/* Alias Bypasses the URL Rules
The rule /admin/** protected /admin/panel.xhtml, /admin/panel.jsf and /admin/panel.faces: a reader got 403 on all three. But the
same view through the prefix mapping, /faces/admin/panel.xhtml, answered 200 and rendered the admin panel to the reader: the path
does not start with /admin/, and the only thing between the user and the page was anyRequest().authenticated(). Two defences,
use both:
-
Restrict the Faces servlet to the mapping you use. With
joinfaces.faces-servlet.url-mappings=*.xhtml, the same request to/faces/admin/panel.xhtmlanswered404, and/admin/panel.xhtmlanswered403for the reader. -
Add a rule for every prefix you keep (
/faces/admin/**next to/admin/**), and protect the data in the method layer (@PreAuthorizeon the service that the page calls) so a missed alias cannot expose it.
The same reasoning applies to the extensionless mapping of OmniFaces FacesViews and of joinfaces.faces.automatic-extensionless-mapping:
each new way to reach a view is a new URL that a rule can miss. Test the matrix of aliases for every protected view.
Sessions and Cookies
The embedded Tomcat accepts the session id in a cookie and, by default, in the URL. In the first response of a new browser, the form of
index.xhtml rendered action="/index.xhtml;jsessionid=…", and the PrimeFaces resource URLs carried the id as well (Faces
encodes URLs through the ExternalContext). A session id in a URL is exposed through logs, referrers and copy-pasted links. Restrict it
to the cookie, and set the cookie attributes with the normal Spring Boot properties:
server.servlet.session.tracking-modes=cookie
server.servlet.session.cookie.same-site=lax
server.servlet.session.cookie.secure=true
With these properties the response carried Set-Cookie: JSESSIONID=…; Path=/; Secure; HttpOnly; SameSite=Lax and no jsessionid
appeared in the page. HttpOnly was on by default. Secure makes the browser refuse the cookie over plain HTTP, so use it with
HTTPS (or a profile for local development). The security page lists the other cookie, session fixation and header concerns
(cookie attributes). JoinFaces also auto-configures a SpringSessionFixFilter (org.joinfaces.session); what it fixes was not
investigated for this page.
Deploying as a WAR
JoinFaces also supports the traditional deployment into an existing servlet container. The reference’s recipe is the standard Spring Boot
one: packaging war, the embedded container marked provided, and a SpringBootServletInitializer; it adds that the JoinFaces
joinfaces.faces-servlet.* registration options "will not work correctly" there and the servlet and filters should be declared in
web.xml when needed.
<packaging>war</packaging>
<dependencies>
<dependency>
<groupId>org.joinfaces</groupId>
<artifactId>faces-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
package org.example.bookshelf;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
@SpringBootApplication
public class BookshelfApplication extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(BookshelfApplication.class, args);
}
}
Views live in src/main/webapp here, as in any WAR. The resulting file was deployed as ROOT.war to a separate Apache Tomcat 11.0.26: the
log showed Initializing Mojarra 4.1.12 for context '', Weld 6.0.4.Final starting and Tomcat 7 detected+ from Weld, and the requests
/index.xhtml, /index.jsf, /index.faces and /faces/index.xhtml all answered 200 while GET / answered 404 (no welcome page
there either). The Spring scopes behaved as in the table of Scopes: Spring and CDI Annotations on Spring Beans. The same WAR also starts with java -jar (the executable-WAR
layout), which is useful for a first smoke test. A full Jakarta EE server (WildFly, GlassFish) already contains Faces and CDI, so a
Boot WAR for it must exclude the JoinFaces starters' Faces and Weld JARs; that deployment was not run, and the choice between a
Boot WAR and a plain Faces WAR is better made as in the Jakarta EE servers page.
The Development Loop and Tests
mvn spring-boot:run serves the views from target/classes/META-INF/resources, so a copy of an edited .xhtml into target/classes
(your IDE or mvn process-resources does that) is visible without a restart if Facelets re-checks it. With only
joinfaces.faces.project-stage=development, an edit made to target/classes/META-INF/resources/index.xhtml was not visible within
three seconds; with joinfaces.faces.facelets-refresh-period=1 as well, the edit appeared in the next request (the same behaviour the
Mojarra notes in Project Setup describe). Run with:
mvn spring-boot:run \
-Dspring-boot.run.arguments="--joinfaces.faces.project-stage=development --joinfaces.faces.facelets-refresh-period=1"
Spring Boot DevTools' restart class loader was not run with JoinFaces. A packaged JAR cannot be edited; the refresh period matters only for exploded runs.
An integration test starts the real server on a random port and fetches the page; it exercises the Faces servlet, the Spring context, Weld and the view in one go:
package org.example.bookshelf;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class CataloguePageTest {
@LocalServerPort
int port;
@Test
void catalogueListsBooks() throws Exception {
HttpResponse<String> response = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/index.xhtml")).build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("Dune").contains("jakarta.faces.ViewState");
}
}
mvn test ran it with Tests run: 1, Failures: 0, Errors: 0. A plain HTTP client sees the rendered markup and the hidden
jakarta.faces.ViewState field but cannot click; for postbacks, Ajax and PrimeFaces widgets use a real browser as in
end-to-end testing with real browsers, and see Testing for Arquillian,
Selenium and Playwright. Arquillian containers target Jakarta EE servers; the Spring test support above is the natural integration
test here. Unit tests of beans, converters and validators are plain JUnit 5 and Mockito tests as everywhere
(Spring Boot unit and integration testing).
When to Choose Thymeleaf or Vaadin Instead
JoinFaces makes Faces possible in a Spring Boot codebase; it does not make it the default. The comparison below uses what this page measured and what the neighbouring pages say.
| Question | JoinFaces (Faces) | Thymeleaf | Vaadin |
|---|---|---|---|
What is the programming model? |
Component tree and view state on the server; Facelets XHTML; Ajax through |
Server-rendered templates over Spring MVC; stateless requests; htmx or Alpine.js for interactivity |
Java UI components on the server with a client engine; session state per UI |
What does Spring Boot support? |
Third-party integration (JoinFaces): own scope mapping, own properties, versions that lag the libraries by weeks to months |
First-class auto-configuration ( |
Official Vaadin Spring add-on; Boot-aligned releases |
What does it cost to reach the same URL safely? |
Servlet aliases, a default servlet for the Spring Security filter, and a CSRF field in every form (Spring Security: Filters, URL Rules and Facelet Tags) |
Spring MVC and Spring Security as documented |
Router and Spring Security as documented |
Where is Faces the right answer? |
An existing Faces or PrimeFaces application moving to Spring Boot, a team already fluent in Faces, component-rich internal back-offices |
New content sites, SEO-sensitive pages, small CRUD |
Rich internal applications written entirely in Java by a team that does not want HTML templates |
Prefer Thymeleaf or Vaadin for new Spring Boot work; the Server-Side Web UI Frameworks page has the full decision table, the Thymeleaf Reference and the Vaadin Spring Boot integration page cover the alternatives, and the introduction of this section compares all three (framework comparison). Use JoinFaces when the Faces investment already exists, when a Jakarta EE server is not an option, or when the Spring ecosystem (Data, Security, Actuator, configuration) is the reason for the move. Do not choose it because it hides the Faces runtime behind a starter: the scopes, security aliases and library versions above are exactly the places where the hidden machinery shows.
Mojarra Versus MyFaces on JoinFaces
-
CDI. The Mojarra starter depends on the CDI starter and so on Weld; Mojarra 4.1 does not start without CDI. MyFaces started without Weld, and the Spring scopes worked the same. Add Weld only when a library requires it.
-
Version pins. The 6.1.1 starters pin Mojarra 4.1.12 and MyFaces 4.1.3; the newest releases are Mojarra 4.1.17 and MyFaces 4.1.4. Mojarra 4.1.17 was substituted successfully with a management entry (Starters, BOMs and Version Overrides).
-
Property sets.
joinfaces.mojarra.*andjoinfaces.myfaces.*expose the parameters of each implementation. Client-side state is where they differ in practice: Mojarra encrypts by default and takesjoinfaces.mojarra.client-state-timeout(seconds); MyFaces needsjoinfaces.myfaces.secretand hasjoinfaces.myfaces.client-view-state-timeout(minutes). The MyFaces run withjoinfaces.faces.state-saving-method=clientandsecret=${MYFACES_SECRET}rendered the page and processed three postbacks. -
Bean Validation. MyFaces logs that Bean Validation support is disabled when no provider is present; add the Boot validation starter for either implementation.
-
Everything else (lifecycle, rendering, Ajax, scope behaviour in the table of Scopes: Spring and CDI Annotations on Spring Beans) was identical in the runs; the general differences are in Mojarra versus MyFaces.
What Changed
-
JoinFaces 6.1.1 (2026-09-04) on Spring Boot 4.1.1. The version used on this page. The properties moved from
joinfaces.jsf.*tojoinfaces.faces.*, and the old names are deprecated at levelerrorin the metadata (28 of them), which in a run means ignored. -
Spring Boot 4. Package names of the embedded-container API moved:
TomcatServletWebServerFactoryis now inorg.springframework.boot.tomcat.servlet(used by the welcome-page class above), whileSpringBootServletInitializerstays inorg.springframework.boot.web.servlet.support. Spring Framework 7 still containsSpringBeanFacesELResolver, which JoinFaces registers. -
Faces 4.0 to 4.1. The starters in this JoinFaces release are the Faces 4.1 line (Mojarra 4.1.x, MyFaces 4.1.x), so features that need 4.1 are available; Faces 5.0 is still a milestone and nothing here describes it.
-
Not covered by the reference or this run. Native image support (the library contains AOT hint registrars; a native build was not run), the OpenWebBeans starter, Jetty and Undertow containers, Tobago,
f:websocket/@Pushon Spring beans (the propertyjoinfaces.faces.websocket-endpoint-portexists for a differing port; push was not run) and Faces Flows.
Where the Books Are Outdated
-
Anghel Leonard’s JSF 2.0 Cookbook (2010) integrates Spring 2.5 with JSF 2.0 through Spring’s
DelegatingVariableResolver. That mechanism ended long ago: the variable resolvers were removed from Faces in 4.0 (EL resolvers replaced them), and the managed-bean XML that such recipes combine it with is gone too. Today JoinFaces registers aSpringBeanFacesELResolverand Spring annotations declare the beans; the "Spring beans in EL" recipe is now a dependency and a few properties. -
The older books never show an embedded container,
@SpringBootApplication, theviewscope or Boot properties: they assume a WAR in a container withweb.xmlfor every setting. Where they say "set this context parameter", the JoinFaces form is ajoinfaces.*property orserver.servlet.context-parameters.*. -
The Mann and Dudney books integrate Faces with Struts 1, which reached end of life in 2013; Spring MVC, Spring Security and Spring Boot are the current equivalent surroundings, and a Boot migration of a Struts application goes through Faces (or a different UI technology) and not through that integration.
-
The PrimeFaces books of Çalışkan and Varaksin, and Jonna, assume a hand-edited
web.xmland a manual JAR; the starters replace both, and several parameters they name moved (the PrimeFaces pages list them). -
Burns and Schalk predate Spring Boot and Jakarta EE namespaces; every
javax.facesimport in an old Spring-and-Faces tutorial must bejakarta.facesfor JoinFaces 6.
Pitfalls
-
@ClientWindowScopedon a Spring bean. Accepted and ignored: the bean is a singleton shared by everyone (Scopes: Spring and CDI Annotations on Spring Beans). -
joinfaces.jsf.*properties. Silently ignored by 6.1.1; usejoinfaces.faces.*(Configuration Properties). -
Secrets in property files.
joinfaces.primefaces.secretdefaults to a public value, and the MyFaces secrets must be set explicitly; read them from the environment. -
Production stage by default. The stage was
Productionwith no property; setDevelopmentper profile. -
@FacesConverter(managed = true)or CDI injection of Spring beans. Fails; register Spring components and reference them from EL (Spring Beans in Faces Artefacts). -
A
beans.xmland CDI-only features. Weld does not register the application classes of an executable JAR (WELD-000119); observers, producers and interceptors written for CDI would not be found (Spring Beans in Faces Artefacts; not run). -
POST /loginreturning 404. No default servlet, so the Spring Security filter never sees the request (Spring Security: Filters, URL Rules and Facelet Tags). -
Authorisation rules that miss
/faces/*. Restrict the servlet mappings and test every alias. -
Missing CSRF token in Faces forms. The Spring token is separate from the view state; Ajax posts need it too.
-
Session ids in URLs. Set
server.servlet.session.tracking-modes=cookie. -
No welcome page.
GET /is404untilindex.xhtmlis registered as a welcome file. -
Library versions assumed from the JoinFaces version. The starters pin older Mojarra, MyFaces, OmniFaces and PrimeFaces (and the PrimeFaces
jakartaclassifier needs exclusions to override). -
Open EntityManager in view left on. Hides N+1 queries behind the rendering of a page; see Open Session in View.
-
Not marking the bean serialisable. A
@ViewScopedbean is held in the view map; keep itSerializableand test the configuration you deploy (client state saving, a replicated session) with its Spring collaborators.
Related Pages
The Jakarta EE Servers and Tomcat and Weld pages cover the runtimes where you choose the implementation; the Quarkus page covers the other framework-hosted runtime; the Persistence and Services page covers Faces beans that call JPA services; the Testing page covers Arquillian, Selenium and Playwright; and the Deployment and Clustering page covers the Production stage, Docker and session replication. Migrating to Jakarta Faces and the best-practices checklist cover moving an old application and the rules for the runtime. See also Scopes, Security, Backing Beans and CDI, Jakarta EE Servers, OmniFaces and Spring Security.
References
-
Packt: JSF 2.0 Cookbook, Anghel Leonard (publisher page)