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 a META-INF/faces-config.xml that ships inside joinfaces-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 ClassNotFoundException for ELAwareBeanManager; 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 FacesServlet that JoinFaces registers through Boot’s ServletRegistrationBean mechanism, plus Spring Security’s filter chain when it is present.

flowchart LR B[Browser] --> T[Embedded Tomcat] T --> S[Servlet filters: Spring Security, open EntityManager in view] S --> F[FacesServlet registered by JoinFaces] F --> L[Faces lifecycle: six phases] L --> E[EL: SpringBeanFacesELResolver] E --> P[Spring beans with request, session or view scope] P --> R[Services and Spring Data repositories] W[Weld: CDI for Mojarra and OmniFaces] -.-> L

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.

pom.xml: Spring Boot parent, the JoinFaces platform BOM and the Faces starter
<?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>
The application class: an ordinary Spring Boot main class
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);
    }
}
Book, BookService and the view-scoped page bean
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).

src/main/resources/META-INF/resources/index.xhtml: the catalogue view
<!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:

The layout template stays unreachable by URL, and a client view uses it
<!-- 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:

WelcomePage.java: make / show index.xhtml (Spring Boot 4 package names)
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

faces-spring-boot-starter

Meta starter: spring-boot-starter-tomcat and mojarra4-spring-boot-starter

mojarra4-spring-boot-starter

Eclipse Mojarra (pinned at 4.1.12 in 6.1.1) and the CDI starter that, by default, pulls Weld

myfaces4-spring-boot-starter

Apache MyFaces (api and impl, pinned at 4.1.3 in 6.1.1)

weld-spring-boot-starter

Weld 6.0.4.Final servlet integration (Weld 6.0.4 is the version in the platform BOM)

openwebbeans-spring-boot-starter

Apache OpenWebBeans as the CDI container (not run for this page)

primefaces-spring-boot-starter

PrimeFaces and PrimeFaces Extensions (and the PrimeFaces themes) on top of the Faces starter

omnifaces-spring-boot-starter

OmniFaces on top of the Faces and Weld starters

security-spring-boot-starter

Faces starter, spring-boot-starter-security and joinfaces-security-taglib (the facelet tags, Spring Security: Filters, URL Rules and Facelet Tags)

adminfaces-spring-boot-starter, bootsfaces-spring-boot-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.

Using MyFaces instead of Mojarra: exclude one starter, add the other
<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.faces 4.1.17 and org.omnifaces:omnifaces 5.5.5 replaced 4.1.12 and 5.5.1, and the log said Initializing Mojarra 4.1.17. MyFaces is declared the same way in its starter (myfaces-api and myfaces-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 plain org.primefaces:primefaces does 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:

pom.xml additions: PrimeFaces 16 and OmniFaces 5.5.5 on JoinFaces 6.1.1
<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

joinfaces.faces.*

jakarta.faces.* context parameters

joinfaces.faces.project-stage=development sets jakarta.faces.PROJECT_STAGE

joinfaces.mojarra.*

com.sun.faces.*

joinfaces.mojarra.client-state-timeout=60000

joinfaces.myfaces.*

org.apache.myfaces.*

joinfaces.myfaces.client-view-state-timeout=30

joinfaces.primefaces.*

primefaces.*

joinfaces.primefaces.theme=arya

joinfaces.omnifaces.*

org.omnifaces.*

joinfaces.omnifaces.combined-resource-handler-disabled=true

joinfaces.faces-servlet.*

how JoinFaces registers the servlet (url-mappings, enabled, load-on-startup, name, order, async-supported)

joinfaces.faces-servlet.url-mappings=*.xhtml

server.servlet.context-parameters.*

any context parameter without a dedicated property (a Spring Boot property)

server.servlet.context-parameters.jakarta.faces.PROJECT_STAGE=Development

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.

application.properties: a development profile, with every secret read from the environment
# 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
Table 1. Observed on 2026-10-08 (Mojarra 4.1.17 with PrimeFaces 16.0.0)
Property Value read back from the running application

none (no property at all)

facesContext.application.projectStage is Production

joinfaces.faces.project-stage=development

Development, and #{initParam['jakarta.faces.PROJECT_STAGE']} is Development

joinfaces.faces.facelets-refresh-period=1

jakarta.faces.FACELETS_REFRESH_PERIOD is 1

joinfaces.faces.state-saving-method=client

jakarta.faces.STATE_SAVING_METHOD is client

joinfaces.primefaces.theme=arya, joinfaces.primefaces.csp=true

primefaces.THEME is arya, primefaces.CSP is true

joinfaces.mojarra.client-state-timeout=60000

com.sun.faces.clientStateTimeout is 60000

joinfaces.myfaces.secret=${MYFACES_SECRET} and joinfaces.myfaces.client-view-state-timeout=30 (MyFaces)

org.apache.myfaces.SECRET is set (the placeholder resolved from the environment), the timeout parameter is 30

server.servlet.context-parameters.jakarta.faces.PROJECT_STAGE=Development

Development

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.secret has the metadata default primefaces. With nothing configured, the running application reported primefaces.SECRET equal to the string primefaces, a publicly known value. PrimeFaces uses it to encrypt the value expressions that streamed content (charts, images) exposes in URLs. With joinfaces.primefaces.secret=${PRIMEFACES_SECRET} the parameter was set to the environment value.

  • joinfaces.myfaces.secret (and mac-secret) initialise the key that encrypts and signs client-side view state. They are needed on every node of a cluster when joinfaces.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

@RequestScope, @Scope("request"); @jakarta.enterprise.context.RequestScoped

request

@SessionScope, @Scope("session"); @SessionScoped; @ConversationScoped

session

@ApplicationScope, @Scope("application"); @ApplicationScoped

application

@Scope("view"); @jakarta.faces.view.ViewScoped

view (the JoinFaces scope)

the old jakarta.faces.bean.*Scoped annotations (JSF 2.1 and earlier, kept for backwards compatibility)

request, session, application, view; @NoneScoped is prototype

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:

Table 2. Instance numbers per request (Mojarra 4.1.12, Weld 6.0.4, Spring Boot 4.1.1)
Bean annotation GET 1 GET 2 POST 1 POST 2 GET, new session

@Component @RequestScope

1

2

3

4

5

@Component @RequestScoped (CDI annotation)

1

2

3

4

5

@Named @RequestScoped

1

2

3

4

5

@Component @SessionScope

1

1

1

1

2

@Component @ConversationScoped

1

1

1

1

2

@Named @SessionScoped

1

1

1

1

2

@Component @Scope("view")

1

2

1

1

3

@Component @ViewScoped (Faces)

1

2

1

1

3

@Named @ViewScoped

1

2

1

1

3

@Component @ClientWindowScoped

1

1

1

1

1

@Component (singleton)

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, never org.jboss.weld. A class annotated @Named is picked up by Spring’s component scan (Spring supports jakarta.inject.Named as a stereotype), and the CDI scope annotation is then interpreted by JoinFaces as a Spring scope. The @Named @SessionScoped bean behaved like a Spring session bean and was destroyed with the session. Weld is installed for the framework only.

  • @ViewScoped works 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 gave clicks 3 on 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; after invalidateSession() the session-scoped and the view-scoped beans were destroyed. The singleton and the @ClientWindowScoped bean were created at start-up and never destroyed.

  • @ClientWindowScoped is 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 @ViewScoped bean or use a real CDI bean (see Multi-Window Support).

  • @FlowScoped and @Dependent are 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 @ConversationScoped mapping 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.

A session-scoped Spring bean for the shopping-cart-like state of the Bookshelf (the other lifetimes follow the same pattern)
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.

A validator and a converter written as Spring components, referenced by EL
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 with jakarta.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 @RequestScoped class with an @Inject BookService field received the Spring singleton (the BookService instance printed by Spring and the one printed through the @Named bean had the same identity), because Spring processes @Inject itself. Adding META-INF/beans.xml (with bean-discovery-mode="annotated" or "all") did not change that: in the executable JAR Weld logged WELD-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") without managed. 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.

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.

META-INF/faces-config.xml: the bundle of the Bookshelf, variable msg
<?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>
A view-scoped review bean with constraints; Faces applies them in the Process Validations phase
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).

index.xhtml with PrimeFaces components, an Ajax update and an OmniFaces function
<!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>

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 ViewScoped bean and PrimeFaces Ajax. The update="info" request above reused the same CatalogueBean instance (the click counter went up), which is the view scope working under Ajax.

  • CSP. joinfaces.primefaces.csp=true sets 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).

The entity, the repository and the bean (spring-boot-starter-data-jpa and an H2 runtime dependency are added to the pom)
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.

SecurityConfig.java: URL rules, form login and an in-memory user store whose passwords come from the environment
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}
login.xhtml: a plain HTML form (not h:form) that posts straight to the Spring Security filter, with the CSRF token
<!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>
index.xhtml: the JoinFaces security tags and functions, plus the CSRF token inside every Faces form
<!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

sec:authorize

Authorisation decision with the attributes ifAnyGranted, ifAllGranted, ifNotGranted, access (a Spring Security expression), url, method and var

sec:anonymous, sec:authenticated, sec:fullyAuthenticated

Render the body for anonymous users, for non-anonymous users, or for users who are neither anonymous nor remember-me

sec:areAllGranted, areAnyGranted, areNotGranted

EL functions over authority names

sec:isAllowed(url, method), isAnonymous, isAuthenticated, isFullyAuthenticated

EL functions for URL access and the authentication state

Run with two users (the passwords supplied as environment variables):

  • An anonymous request to /index.xhtml showed Please sign in; /admin/panel.xhtml answered 302 to /login.xhtml.

  • After login as reader, the page showed user=reader, the reader block and Welcome back, and sec:areAnyGranted('ROLE_ADMIN') and sec:isAllowed(...) were false; after login as admin the admin block, the url= block and both functions were true.

  • A Faces form post with the _csrf hidden field answered 200 and ran the action; the same post through f:ajax also worked (the field is part of the submitted form); a post from a form without the field answered 403. 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.xhtml answered 404, and /admin/panel.xhtml answered 403 for the reader.

  • Add a rule for every prefix you keep (/faces/admin/** next to /admin/**), and protect the data in the method layer (@PreAuthorize on 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.

pom.xml changes and the initialiser class
<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:

CataloguePageTest.java
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 f:ajax or PrimeFaces

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 (spring-boot-starter-thymeleaf)

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.* and joinfaces.myfaces.* expose the parameters of each implementation. Client-side state is where they differ in practice: Mojarra encrypts by default and takes joinfaces.mojarra.client-state-timeout (seconds); MyFaces needs joinfaces.myfaces.secret and has joinfaces.myfaces.client-view-state-timeout (minutes). The MyFaces run with joinfaces.faces.state-saving-method=client and secret=${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.* to joinfaces.faces.*, and the old names are deprecated at level error in the metadata (28 of them), which in a run means ignored.

  • Spring Boot 4. Package names of the embedded-container API moved: TomcatServletWebServerFactory is now in org.springframework.boot.tomcat.servlet (used by the welcome-page class above), while SpringBootServletInitializer stays in org.springframework.boot.web.servlet.support. Spring Framework 7 still contains SpringBeanFacesELResolver, 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 / @Push on Spring beans (the property joinfaces.faces.websocket-endpoint-port exists 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 a SpringBeanFacesELResolver and 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, the view scope or Boot properties: they assume a WAR in a container with web.xml for every setting. Where they say "set this context parameter", the JoinFaces form is a joinfaces.* property or server.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.xml and 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.faces import in an old Spring-and-Faces tutorial must be jakarta.faces for JoinFaces 6.

Pitfalls

  • @ClientWindowScoped on 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; use joinfaces.faces.* (Configuration Properties).

  • Secrets in property files. joinfaces.primefaces.secret defaults to a public value, and the MyFaces secrets must be set explicitly; read them from the environment.

  • Production stage by default. The stage was Production with no property; set Development per 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.xml and 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 /login returning 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 / is 404 until index.xhtml is registered as a welcome file.

  • Library versions assumed from the JoinFaces version. The starters pin older Mojarra, MyFaces, OmniFaces and PrimeFaces (and the PrimeFaces jakarta classifier 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 @ViewScoped bean is held in the view map; keep it Serializable and test the configuration you deploy (client state saving, a replicated session) with its Spring collaborators.

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.