Jakarta Faces with Spring Boot

Spring Boot works with Tomcat embedded. Tomcat does not support Jakarta Faces. However, it is possible to add dependencies and use Faces. This article covers the additional configuration required to make Jakarta Faces functional with Spring Boot.

Tomcat

First, I want to see how to run WAR with Faces on standard Tomcat. In this case, Mojarra's README recommends to add this dependency

<dependency>
    <groupId>org.jboss.weld.servlet</groupId>
    <artifactId>weld-servlet-shaded</artifactId>
    <version>4.0.0.Final</version>
</dependency>

It includes Jakarta API. And it is a problem. The Tomcat libraries include Jakarta API too. But their code is different. When I debug, the IDE shows me wrong lines. So I need to exclude from maven all the libs that Tomcat has. My version is

<dependency>
    <groupId>org.apache.myfaces.core</groupId>
    <artifactId>myfaces-impl</artifactId>
    <version>4.0.0</version>
</dependency>
<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>org.jboss.weld.servlet</groupId>
    <artifactId>weld-servlet-core</artifactId>
    <version>5.1.0.Final</version>
    <exclusions>
        <exclusion>
            <groupId>jakarta.el</groupId>
            <artifactId>jakarta.el-api</artifactId>
        </exclusion>
        <exclusion>
            <groupId>jakarta.annotation</groupId>
            <artifactId>jakarta.annotation-api</artifactId>
        </exclusion>
    </exclusions>
</dependency>

Dependency weld-servlet-core includes a listeners for servlet container to initialize CDI. Dependency myfaces-impl contains the initializer to create FacesServlet.

I can add my initializer to set init parameters to servlet context.

import java.util.Set;

import jakarta.faces.application.ProjectStage;
import jakarta.faces.component.UIInput;
import jakarta.servlet.ServletContainerInitializer;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebListener;

@WebListener
public class ConfigureListener implements ServletContainerInitializer {

    @Override
    public void onStartup(Set<Class<?>> classes, ServletContext context) throws ServletException {
        context.setInitParameter(UIInput.EMPTY_STRING_AS_NULL_PARAM_NAME, Boolean.TRUE.toString());

        context.setInitParameter(ProjectStage.PROJECT_STAGE_PARAM_NAME, ProjectStage.Development.name());
    }
}

That is all. Now I can write xhtml and a backing beans.

Spring Boot

Now I will try Spring Boot. I used Spring Initializr to created project.

One more dependency for servlets:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Delete provided servlet api

<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>6.0.0</version>
    <scope>provided</scope>
</dependency>

WEBROOT for Tomcat embedded is /META-INF/resources. I added this directory to /src/main/resources. Let's create a test page.

/src/main/resources/META-INF/resources/hello.xhtml<!DOCTYPE html>
<html
    xmlns="https://jakarta.ee/xml/ns/jakartaee"
    xmlns:f="jakarta.faces.core"
    xmlns:h="jakarta.faces.html"
    xmlns:c="jakarta.tags.core">
<h:head>
    <title>Hello World</title>
    <h:outputScript library="js" name="demo.js" />
</h:head>
<h:body>
    <h1>Hello World</h1>
    <h:form id="helloForm">

        <h:inputText id="number"
            value="#{helloBacking.number}"
            onblur="demoLog(event.target.value)">
        </h:inputText>

        <h:commandButton id="submitBtn"
            value="Submit"
            type="button"
            action="#{helloBacking.submit}">
            <f:ajax execute="@form" render="output messages" />
        </h:commandButton>

        <div>
            <h:outputText id="output" value="#{helloBacking.number}" />
        </div>

        <h:messages id="messages" showSummary="true" showDetail="true"/>

    </h:form>
</h:body>
</html>

You can see here

<h:outputScript library="js" name="demo.js" />

It means that file /META-INF/resources/resources/js/demo.js exists. I call demoLog function from this file on blur event.

HelloBacking.javapackage com.example.demo.backing;

import jakarta.enterprise.context.SessionScoped;
import jakarta.inject.Named;

@Named(value = "helloWorld")
@SessionScoped
public class HelloWorld implements Serializable {
    private BigDecimal number;

    public void submit() {
        System.out.println(this.number);
    }

    public BigDecimal getNumber() {
        return number;
    }

    public void setNumber(BigDecimal number) {
        this.number = number;
    }
}

Two more files.

/src/main/resources/META-INF/beans.xml<?xml version="1.0" encoding="UTF-8"?>
<beans
    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/beans_4_0.xsd"
    version="4.0" bean-discovery-mode="annotated"
>
    <!-- CDI configuration here. -->
</beans>
/src/main/resources/META-INF/resources/WEB-INF/faces-config.xml<?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_0.xsd"
    version="4.0"
>
    <!-- Faces configuration here. -->
</faces-config>

It doesn't work. Server returns my xhtml. It means there is no faces servlet. There is difference from Tomcat. Embedded version does not execute jakarta.servlet.ServletContainerInitializer classes. I must implement org.springframework.boot.web.servlet.ServletContextInitializer bean to run the initialization.

import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class Config {
    @Bean
    public ServletContextInitializer jsfInitializer() {
        return new JsfInitializer();
    }
}

There are two initializers. For CDI is org.jboss.weld.environment.servlet.EnhancedListener. For Faces is org.apache.myfaces.webapp.MyFacesContainerInitializer.

import org.apache.myfaces.webapp.MyFacesContainerInitializer;
import org.jboss.weld.environment.servlet.EnhancedListener;
import org.springframework.boot.web.servlet.ServletContextInitializer;

import jakarta.faces.application.ProjectStage;
import jakarta.faces.component.UIInput;
import jakarta.servlet.ServletContainerInitializer;
import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;

public class JsfInitializer implements ServletContextInitializer {

    @Override
    public void onStartup(ServletContext context) throws ServletException {
        context.setInitParameter(UIInput.EMPTY_STRING_AS_NULL_PARAM_NAME, Boolean.TRUE.toString());
        context.setInitParameter(ProjectStage.PROJECT_STAGE_PARAM_NAME, ProjectStage.Development.name());

        EnhancedListener cdiInitializer = new EnhancedListener();
        cdiInitializer.onStartup(null, context);

        ServletContainerInitializer myFacesInitializer = new MyFacesContainerInitializer();
        myFacesInitializer.onStartup(null, context);
	}
}

Now I get error from FacesServlet:

Servlet.init() for servlet [FacesServlet] threw exception
java.lang.IllegalStateException: No Factories configured for this Application. This happens if the faces-initialization does not work at all - make sure that you properly include all configuration settings necessary for a basic faces application and that all the necessary libs are included. Also check the logging output of your web application and your container for any exceptions!
If you did that and find nothing, the mistake might be due to the fact that you use some special web-containers which do not support registering context-listeners via TLD files and a context listener is not setup in your web.xml. A typical config looks like this;

<listener>
   <listener-class>org.apache.myfaces.webapp.StartupServletContextListener</listener-class>
</listener>

This happens because Spring does not scan classpath for Servlet annotations and web-fragment.xml. So I must add new spring bean to my configuration.

Config.javaimport org.apache.myfaces.webapp.StartupServletContextListener;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import jakarta.servlet.ServletContextListener;

@Bean
public ServletListenerRegistrationBean<ServletContextListener> facesStartupServletContextListener() {
    ServletListenerRegistrationBean<ServletContextListener> bean = new ServletListenerRegistrationBean<>();
    bean.setListener(new StartupServletContextListener());
    return bean;
}

Now it works.

Customize faces config

Let's try add something to faces-config.xml. I want to add a listener to ELContext and write an evaluation events to Java Flight Recorder.

JFR Eventimport jdk.jfr.Event;
import jdk.jfr.Label;
import jdk.jfr.Name;

@Name("com.example.faces.EvaluateExpression")
@Label("Evaluate Expression")
public class EvaluateExpressionEvent extends Event {
	
	@Label("Expression")
	private String expression;

	public String getExpression() {
		return expression;
	}

	public void setExpression(String expression) {
		this.expression = expression;
	}
}
EvaluationListener.javaimport jakarta.el.ELContext;
import com.example.demo.event.EvaluateExpressionEvent;

public class EvaluationListener extends jakarta.el.EvaluationListener {
	
	private ThreadLocal<EvaluateExpressionEvent> event = new ThreadLocal<>();

	@Override
    public void beforeEvaluation(ELContext context, String expression) {
		EvaluateExpressionEvent event = new EvaluateExpressionEvent();
		event.setExpression(expression);
		event.begin();
		this.event.set(event);
    }

	@Override
	public void afterEvaluation(ELContext context, String expression) {
		event.get().commit();
		event.remove();
    }
}

Now I should somehow add this listener to ELContext. I do this when the context is created. One more listener.

import jakarta.el.ELContext;
import jakarta.el.ELContextEvent;

public class ELContextListener implements jakarta.el.ELContextListener {

	@Override
	public void contextCreated(ELContextEvent event) {
		ELContext elContext = event.getELContext();
		elContext.addEvaluationListener(new EvaluationListener());
	}
}

I should add this listener to Application. The third listener:

import jakarta.faces.application.Application;
import jakarta.faces.event.AbortProcessingException;
import jakarta.faces.event.SystemEvent;
import jakarta.faces.event.SystemEventListener;

public class ApplicationCreatedListener implements SystemEventListener {

	@Override
	public boolean isListenerForSource(Object source) {
		return source instanceof Application;
	}

	@Override
	public void processEvent(SystemEvent event) throws AbortProcessingException {
		Application application = (Application) event.getSource();
		application.addELContextListener(new ELContextListener());
	}
}

Now I can add this SystemEventListener to config.

faces-config.xml<application>
    <system-event-listener>
        <system-event-listener-class>com.example.demo.listener.ApplicationCreatedListener</system-event-listener-class>
        <system-event-class>jakarta.faces.event.PostConstructApplicationEvent</system-event-class>
        <source-class>jakarta.faces.application.Application</source-class>
    </system-event-listener>
</application>

Now I can analyze the page rendering with Java Mission Control. It means the face-config.xml is loaded well.

Validator and converter

I can add something custom with annotations

import jakarta.faces.component.FacesComponent;
import jakarta.faces.component.behavior.FacesBehavior;
import jakarta.faces.convert.FacesConverter;
import jakarta.faces.event.NamedEvent;
import jakarta.faces.render.FacesBehaviorRenderer;
import jakarta.faces.render.FacesRenderer;
import jakarta.faces.validator.FacesValidator;

I will try FacesValidator and FacesConverter for my number input.

Validatorimport jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.validator.FacesValidator;
import jakarta.faces.validator.Validator;
import jakarta.faces.validator.ValidatorException;

@FacesValidator(value = "demo.NumberValidator")
public class NumberValidator implements Validator<BigDecimal>{

    private Random random = new Random();

    private BigDecimal getNumber() {
        return new BigDecimal(random.nextDouble(420)).setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public void validate(FacesContext context, UIComponent component, BigDecimal value)
            throws ValidatorException {
        if (value == null) {
            return;
        }
        BigDecimal orig = getNumber();
        if (value.compareTo(orig) < 0) {
            throw new ValidatorException(
                    new FacesMessage(FacesMessage.SEVERITY_ERROR,
                            "Wrong number",
                            "Number " + value + " less than " + orig));
        }
    }
}
Converterimport jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.convert.Converter;
import jakarta.faces.convert.ConverterException;
import jakarta.faces.convert.FacesConverter;

@FacesConverter(
        value = "demo.BigDecimalConverter",
        forClass = BigDecimal.class
        )
public class BigDecimalConverter implements Converter<BigDecimal> {

    private Random random = new Random();

    private BigDecimal getNumber() {
        return new BigDecimal(random.nextDouble(420)).setScale(2, RoundingMode.HALF_UP);
    }

    @Override
    public BigDecimal getAsObject(FacesContext context, UIComponent component, String value)
            throws ConverterException {
        if (value == null || value.trim().length() == 0) {
            return getNumber();
        }
        try {
            return new BigDecimal(value.trim()).setScale(2, RoundingMode.HALF_UP);
        } catch (NumberFormatException e) {
            throw new ConverterException(e.getMessage());
        }
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, BigDecimal value)
            throws ConverterException {
        if (value == null) {
            return "";
        }
        return value.toPlainString();
    }
}
hello.xhtml <h:inputText id="number"
            value="#{helloBacking.number}"
            onblur="demoLog(event.target.value)">
    <f:converter converterId="demo.BigDecimalConverter" />
    <f:validator validatorId="demo.NumberValidator" /> 
</h:inputText>

But it does not work. I get errors

Could not find any registered converter-class by converterId : demo.BigDecimalConverter

Unknown validator id 'demo.NumberValidator'.

Because MyFaces searches classes with annotations in /WEB-INF/classes or in jars. It is not WAR and there is no /WEB-INF/classes. When I run program from IDE, my classes is not in jar. If I package and run jar, then it works. But I want run from IDE too. There are three variants to solve problem.

First, I can write my org.apache.myfaces.spi.AnnotationProvider. Register service by adding new file

src/main/resources/META-INF/services/org.apache.myfaces.spi.AnnotationProvidercom.example.demo.config.AnnotationProvider

Write provider with ClassPathScanningCandidateComponentProvider from Spring.

import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
import org.springframework.core.type.filter.AnnotationTypeFilter;

import jakarta.faces.component.FacesComponent;
import jakarta.faces.component.behavior.FacesBehavior;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.convert.FacesConverter;
import jakarta.faces.event.NamedEvent;
import jakarta.faces.render.FacesBehaviorRenderer;
import jakarta.faces.render.FacesRenderer;
import jakarta.faces.validator.FacesValidator;

public class AnnotationProvider extends org.apache.myfaces.spi.AnnotationProvider {
    
    private static Set<Class<? extends Annotation>> annotationsToScan;

    static {
        annotationsToScan = new HashSet<>(7, 1f);
        annotationsToScan.add(FacesComponent.class);
        annotationsToScan.add(FacesBehavior.class);
        annotationsToScan.add(FacesConverter.class);
        annotationsToScan.add(FacesValidator.class);
        annotationsToScan.add(FacesRenderer.class);
        annotationsToScan.add(NamedEvent.class);
        annotationsToScan.add(FacesBehaviorRenderer.class);
    }

    @Override
    public Map<Class<? extends Annotation>, Set<Class<?>>> getAnnotatedClasses(ExternalContext ctx) {
        Map<Class<? extends Annotation>, Set<Class<?>>> result = new HashMap<>();
        
        ClassLoader cl = getClass().getClassLoader();
        Package[] packages = cl.getDefinedPackages();
        for (Package pack : packages) {
            pack.getName();
        }
        ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
        for (Class<? extends Annotation> a : annotationsToScan) {
            provider.addIncludeFilter(new AnnotationTypeFilter(a));
        }
        Set<BeanDefinition> components = provider.findCandidateComponents("com.example.demo");
        for (BeanDefinition c : components) {
            Class<?> clazz;
            try {
                clazz = cl.loadClass(c.getBeanClassName());
            } catch (ClassNotFoundException e) {
                e.printStackTrace();
                continue;
            }
            for (Class<? extends Annotation> a : annotationsToScan) {
                Annotation an = clazz.getDeclaredAnnotation(a);
                if (an != null) {
                    Set<Class<?>> annotationSet = result.get(a);
                    if (annotationSet == null) {
                        annotationSet = new HashSet<>();
                        result.put(a, annotationSet);
                    }
                    annotationSet.add(clazz);
                }
            }
        }
        return result;
    }

    @Override
    public Set<URL> getBaseUrls(ExternalContext ctx) throws IOException {
        return null;
    }
}

The second variant is add init parameter.

JsfInitializer.javaimport org.apache.myfaces.config.webparameters.MyfacesConfig;

context.setInitParameter(MyfacesConfig.SCAN_PACKAGES, "com.example.demo");

This parameter activates another scanning logic. It looks like in my provider but without Spring dependencies. Why are there two approaches? Because load all classes from all dependencies to check annotation is expensive. So the first approach is just read headers from binary *.class files and load only files with annotation. Or I must set one package to load.

The third variant is set parameter

context.setInitParameter(MyfacesConfig.USE_CDI_FOR_ANNOTATION_SCANNING, Boolean.TRUE.toString());

It means MyFaces calls CDI to search beans with annotations. But our classes is not CDI beans. I should add CDI annotation @Dependent and this variant works.

Inject beans to converter and validator

Let's move random generator to repository and inject it to converter and validator.

@Named
@ApplicationScoped
public class DemoRepository {

	private Random random = new Random();

	public BigDecimal getNumber() {
		return new BigDecimal(random.nextDouble(420)).setScale(2, RoundingMode.HALF_UP);
	}
}
public class BigDecimalConverter implements Converter<BigDecimal> {
    @Inject
	private DemoRepository demoRepository;

I got NPE. DemoRepository is null. Because converter is not CDI bean. If you use CDI for annotation scanning, it doesn't matter. Scanning produces map of classes. When request happens, MyFaces creates converter instance by constructor without arguments. At most, it adds properties from faces-config. To change that I should set managed = true to converter and validator annotations. So MyFaces will ask them from CDI. I get error shortly thereafter.

Cannot invoke "jakarta.faces.convert.Converter.getAsString(jakarta.faces.context.FacesContext, jakarta.faces.component.UIComponent, Object)" because the return value of "org.apache.myfaces.cdi.wrapper.FacesConverterCDIWrapper.getWrapped()" is null

CDI doesn't have this bean. The converter should has @Dependent annotation. Try again - same error. Bean exists. But CDI can't find it. It happens because the converter must has ID

<f:converter converterId="demo.BigDecimalConverter" />

So Myfaces requests the bean which has annotation

@FacesConverter(
        value = "demo.BigDecimalConverter",
        forClass = Object.class,
        managed = true
        )

But my bean has forClass = BigDecimal.class. That is why. I should remove forClass from annotation. Or I can remove tag f:converter and remove value from annotation. I removed forClass. It allows me to use my strange converter for inputs with converter-tag and standard converter for other BigDecimal inputs.

Now the converter and the validator work with injection.

Inject Faces Artifacts

Jakarta Faces adds faces artifacts like FacesContext, ExternalContext, ResourceHandler, and others. Let's inject init parameters to backing bean.

import jakarta.faces.annotation.InitParameterMap;

@Inject
@InitParameterMap
private Map<String, String> initParam;

System.out.println("Empty as null = " + this.initParam.get(UIInput.EMPTY_STRING_AS_NULL_PARAM_NAME));

I got error:

ConfigServletWebServerApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'helloBacking': Unsatisfied dependency expressed through field 'initParam': No qualifying bean of type 'java.util.Map<java.lang.String, java.lang.String>' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@jakarta.inject.Inject(), @jakarta.faces.annotation.InitParameterMap()}

Spring creates beans and tries to use Jakarta annotations for scan. ClassPathScanningCandidateComponentProvider has includedFilters, which in Spring Boot 3 contains AnnotationTypeFilters, which are these:

    import org.springframework.stereotype.Component;
    import jakarta.annotation.ManagedBean;
    import jakarta.inject.Named;

I fix this by adding the @ComponentScan configuration to the Spring application.

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.stereotype.Component;

@ComponentScan(
        useDefaultFilters = false,
        includeFilters = {
            @ComponentScan.Filter(type = FilterType.ANNOTATION, value = Component.class)
        }
    )
@SpringBootApplication

Using Spring beans

If you use Spring Boot, I guess you have Spring components. And maybe you want to use them for Faces. Let's convert DemoRepository to Spring bean.

import org.springframework.stereotype.Repository;

@Repository
public class DemoRepository {

Now there is error

Caused by: org.jboss.weld.exceptions.DeploymentException: WELD-001408: Unsatisfied dependencies for type DemoRepository with qualifiers @Default
at injection point [BackedAnnotatedField] @Inject private com.example.demo.converter.BigDecimalConverter.demoRepository
at com.example.demo.converter.BigDecimalConverter.demoRepository(BigDecimalConverter.java:0)

I can write a producer for Spring ApplicationContext and get bean from it in @PostConstruct method.

import org.springframework.context.ApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;

import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.inject.Produces;
import jakarta.servlet.ServletContext;

@Dependent
public class SpringBeanProducer {

	@Produces
	public ApplicationContext springContext(ServletContext sctx) {
		ApplicationContext ctx = WebApplicationContextUtils.getRequiredWebApplicationContext(sctx);
		return ctx;
	}
}
BigDecimalConverter.javaimport jakarta.annotation.PostConstruct;

private DemoRepository demoRepository;

@Inject
private ApplicationContext springContext;

@PostConstruct
private void init() {
    this.demoRepository = springContext.getBean(DemoRepository.class);
} 

Can I write a producer for spring beans? I tried that

BigDecimalConverter.javaimport com.example.demo.config.SpringBeanProducer.SpringBean;

@Inject
@SpringBean
private DemoRepository demoRepository;
SpringBeanProducer.javaimport jakarta.enterprise.inject.spi.InjectionPoint;
import jakarta.inject.Qualifier;

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
public @interface SpringBean {
}

@Produces
@SpringBean
public Object create(InjectionPoint ip, ServletContext sctx) {
    Class beanClass = (Class) ip.getType();
    Set<Annotation> qualifiers = ip.getAnnotated().getAnnotations();
    String beanName = null;
    for (Annotation a : qualifiers) {
        if (a instanceof org.springframework.beans.factory.annotation.Qualifier q) {
            beanName = q.value();
        }
    }

    ApplicationContext ctx = WebApplicationContextUtils.getRequiredWebApplicationContext(sctx);
    if (beanName != null && !beanName.isBlank()) {
        return ctx.getBean(beanName, beanClass);
    }
        
    return ctx.getBean(beanClass);
}

This does not work. Because CDI does not match DemoRepository.class and Object.class. @SpringBean qualifier doesn't help. It means I should inject bean in the converter like Object. So it is better to use a setter injection.

import org.springframework.beans.factory.annotation.Qualifier;

private DemoRepository demoRepository;

@Inject
public void setDemoRepository(@SpringBean @Qualifier("demoRepository") Object repository) {
    this.demoRepository = (DemoRepository) repository;
}

Now I can inject Spring to CDI. Can I inject CDI to Spring? I can do it with @PostConstruct. For example

import jakarta.enterprise.inject.spi.CDI;
import jakarta.enterprise.util.TypeLiteral;
import jakarta.faces.annotation.InitParameterMap;

private Map<String, String> initParam;

@PostConstruct
private void init() {
    this.initParam = CDI.current().select(
            new TypeLiteral<Map<String, String>>() {},
            new InitParameterMap.Literal()
        ).get();
}

The last step is to use Spring beans as backing beans for my pages. I need ELResolver for that. Spring has one.

faces-config.xml<application>
    <el-resolver>org.springframework.web.jsf.el.SpringBeanFacesELResolver</el-resolver>
</application>

Build JAR

This configuration is useful and I can test a build.

mvn package

The result jar will not work. Because Spring Boot makes custom jar with dependencies inside and uses custom ClassLoader to load all this.

I can replace

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
</plugin>

with

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
        <execution>
            <id>copy-dependencies</id>
            <phase>prepare-package</phase>
            <goals>
                <goal>copy-dependencies</goal>
            </goals>
            <configuration>
                <outputDirectory>
                    ${project.build.directory}/libs
                </outputDirectory>
            </configuration>
        </execution>
    </executions>
</plugin>

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <configuration>
        <archive>
            <manifest>
                <addClasspath>true</addClasspath>
                <classpathPrefix>libs/</classpathPrefix>
                <mainClass>
                    com.example.demo.DemoApplication
                </mainClass>
            </manifest>
        </archive>
    </configuration>
</plugin>

On my opinion it is the best variant. The Spring fat jar is complicated and adds points of failure. Not all libraries can work with that file structure.

If you want to keep a Spring Boot package, there are two problems to deal with.

1. CDI

CDI searches beans by crawling the jar file tree. And a class name is builded by path. So the class name will be BOOT-INF.classes.com.example.demo.converter.BigDecimalConverter

You should write your discovery strategy. I use jandex.

<dependency>
    <groupId>org.jboss</groupId>
    <artifactId>jandex</artifactId>
    <version>3.1.1</version>
</dependency>

And I can add to that strategy my custom BeanArchiveHandler. I should register it as service

META-INF/services/org.jboss.weld.environment.deployment.discovery.BeanArchiveHandlercom.example.demo.config.SpringBootFileSystemBeanArchiveHandler
import org.jboss.weld.environment.deployment.discovery.BeanArchiveBuilder;
import org.jboss.weld.environment.deployment.discovery.jandex.JandexFileSystemBeanArchiveHandler;
import jakarta.annotation.Priority;

@Priority(100)
public class SpringBootFileSystemBeanArchiveHandler extends JandexFileSystemBeanArchiveHandler {

    private static final String BOOT_INF_CLASSES = "BOOT-INF/classes/";

    @Override
    public BeanArchiveBuilder handle(String path) {
        // use only for spring boot build
        if (!path.toLowerCase().endsWith(".jar")) {
            // try other handlers
            return null;
        }
        return super.handle(path);
    }

    @Override
    protected void add(Entry entry, BeanArchiveBuilder builder) throws MalformedURLException {
        if (!entry.getName().startsWith(BOOT_INF_CLASSES)
                || !entry.getName().endsWith(CLASS_FILE_EXTENSION)) {
            // skip spring classes for loader and other resources 
            return;
        }
        entry = new SpringBootEntry(entry);
        super.add(entry, builder);
    }
    
    protected class SpringBootEntry implements Entry {

        private Entry delegate;
        
        public SpringBootEntry(Entry entry) {
            this.delegate = entry;
        }

        @Override
        public String getName() {
            String name = delegate.getName();
            // cut off prefix from class name
            name = name.substring(BOOT_INF_CLASSES.length());
            return name;
        }

        @Override
        public URL getUrl() throws MalformedURLException {
            return delegate.getUrl();
        }
        
    }
}

@Priority(100) is important. Hight value means that my handler will be sorted and used in the first place.

2. Tomcat static resources

Tomcat creates static resources from jars. Tomcat asks all urls from URLClassLoader and searches /META-INF/resources in that locations. Spring Boot moves /src/main/resources to root of jar. But does not add the root to ClassLoader. Why does spring not package resources to /BOOT-INF/classes? There is issue. When they talk about jar, they forget that it is executable. On their opinion, jar is a dependency for another war project.

I wrote my ConfigurableTomcatWebServerFactory with a listener. The listener adds my resources to Tomcat.

@Bean
public TomcatServletWebServerFactory tomcatFactory() {
    TomcatFactory factory = new TomcatFactory();
    return factory;
}
import org.apache.catalina.Context;
import org.apache.catalina.Lifecycle;
import org.apache.catalina.LifecycleEvent;
import org.apache.catalina.LifecycleListener;
import org.apache.catalina.WebResourceRoot.ResourceSetType;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;

public class TomcatFactory extends TomcatServletWebServerFactory {

    @Override
    protected void postProcessContext(Context context) {
        context.addLifecycleListener(new WebResourcesConfigurer(context));
    }
    
    public class WebResourcesConfigurer implements LifecycleListener {
        
        private static final String META_INF_RESOURCES = "META-INF/resources";
        private Context context;

        public WebResourcesConfigurer(Context context) {
            this.context = context;
        }

        @Override
        public void lifecycleEvent(LifecycleEvent event) {
            if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
                ClassLoader classLoader = getClass().getClassLoader();
                if (!(classLoader instanceof URLClassLoader)) {
                    return;
                }
                URL jarRoot = classLoader.getResource(META_INF_RESOURCES);
                if (jarRoot == null) {
                    logger.warn("Web resources not found");
                    return;
                }

                try {
                    int innerRootIndex = jarRoot.getPath().indexOf("!/");
                    String path = jarRoot.getPath().substring(0, innerRootIndex);
                    jarRoot = new URL(path);
                } catch (Exception e) {
                    logger.warn("Web resources URL error", e);
                    return;
                }
                context.getResources().createWebResourceSet(ResourceSetType.RESOURCE_JAR,
                        "/", jarRoot, "/" + META_INF_RESOURCES);
                logger.info("Web resources were added to Tomcat");
            }
        }
    }
}

Mojarra

Above I experimented with Apache MyFaces. If you want to use Eclipse Mojarra, change the dependencies.

<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.faces</artifactId>
    <version>4.0.0</version>
</dependency>
<dependency> <!-- Optional, only when <f:websocket> is used. -->
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.json</artifactId>
    <version>2.0.0</version>
</dependency>

Change ServletContainerInitializer

JsfInitializer.javaimport com.sun.faces.config.FacesInitializer;

ServletContainerInitializer facesInitializer = new FacesInitializer();
facesInitializer.onStartup(null, context);

Conclusion

It is as easy to run Faces with Spring Boot as to run initializers for embedded servlet container. But the packaging makes things worse.

The WAR package requires no additional configuration. Just two initializers. And it works like Tomcat. The problem is to run it from a IDE. Running the main class DemoApplication from the IDE is not the same as running java -jar demo.war. DX is bad.

So I prefer to build JAR and to package it with dependencies outside. When I run the project from the IDE, I can change xhtml, java and changes are visible after page reload.

Links

  1. Faces specification
  2. CDI specification
  3. Servlet specification
  4. Spring Boot
  5. Mojarra