Dependency Injection and CDI

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

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

This page is written against Apache Struts 7.4.0 (released 2026-10-02) on Apache Tomcat 11.0.26 and Java 21; the facts were checked on 2026-10-08 and 2026-10-10 against the struts2-core and struts2-cdi-plugin 7.4.0 jars (struts-beans.xml, struts-default.xml, default.properties, javap), the plugin source tagged STRUTS_7_4_0, and the Core Developers Guide, plugin and Weld pages named in the references. Every example was built into a scratch WAR from the bookshelf-struts base and requested on Tomcat 11; the CDI examples add struts2-cdi-plugin 7.4.0 and weld-servlet-shaded 6.0.4.Final (the Weld version that the Struts build itself tests against), and the persistence example adds Hibernate ORM 7.4.12.Final and H2 2.5.252. Struts has two things that are called dependency injection and a third that is optional. The first is the small container inside the framework that builds the framework’s own objects and the objects you register with it: actions, interceptors, results, validators and converters. The second is a replacement of the object factory that hands that job to Spring or to a CDI container. This page explains the internal container, shows how to extend the framework through it, and then wires the Bookshelf catalogue with CDI. The Spring integration is described on its own page, Spring Integration; the pages on interceptors, configuration and actions describe the objects that get injected.

Three Ways to Get an Object into an Action

Every object that a request needs is created by the ObjectFactory (org.apache.struts2.ObjectFactory): the action, each interceptor, the result and the validators of the mapping. Which implementation of the factory runs is a constant, struts.objectFactory, and that choice decides who injects what.

Table 1. The three injection mechanisms of a Struts application
Mechanism How it is switched on Annotation on the injection point What it injects

Struts internal container

Always on; the default object factory is the bean named struts

org.apache.struts2.inject.Inject

Framework objects, constants (as strings) and anything registered with a <bean> element

Spring

struts2-spring-plugin (the factory spring)

@Autowired or the Spring auto-wiring mode of the plugin

Spring beans; the internal container still injects framework objects

CDI

struts2-cdi-plugin (the factory cdi)

jakarta.inject.Inject

CDI beans (scoped, qualified, produced); the internal container still injects framework objects

Only one object factory is active at a time. A plugin that wants to be the factory sets struts.objectFactory in its struts-plugin.xml; two such plugins on the class path are a conflict that the official CDI page describes as "undetermined", so a project chooses one. The next diagram follows one action from the dispatcher to the injected instance. The branch on the right is what the CDI plugin changes: it asks the CDI container to produce, inject and post-construct the object, and then, when the caller asks for it, runs the internal container’s injection as well, so both annotations work on the same class (the experiment further down confirms the second half).

flowchart TD A[DefaultActionInvocation.createAction] --> B[ObjectFactory.buildAction] B --> C[ActionFactory.buildAction] C --> D["ObjectFactory.buildBean(className, context, injectInternal)"] D -->|default factory struts| E["Container.inject: Struts @Inject"] D -->|factory cdi| F["InjectionTarget.produce, inject, postConstruct: jakarta.inject.Inject"] F --> G["then injectInternalBeans: Struts @Inject"] E --> H[action instance] G --> H

The Internal Container

The Core Developers Guide says that the framework "uses its own dependency injection container that is very similar to Google Guice", and that both were originally written by Bob Lee. In 7.4.0 the container lives in the package org.apache.struts2.inject of struts2-core (it was com.opensymphony.xwork2.inject before Struts 7). The members that an application meets are few.

Table 2. The public types of org.apache.struts2.inject
Type Role

Inject

The annotation, with value (the name of the object to inject, default when empty) and required (default true); it goes on a field, a method with parameters or a constructor

Container

The built container: inject(Object), inject(Class), getInstance(Class), getInstance(Class, String) and getInstanceNames(Class); the container can itself be injected into any object

ContainerBuilder

The class that configuration providers use to register factories, aliases and constants (factory, alias, constant, injectStatics)

Scope

The enum PROTOTYPE, SINGLETON, THREAD, REQUEST, SESSION and WIZARD

Factory, InternalFactory

The creation callbacks that ContainerBuilder stores for each type and name

An application almost never touches ContainerBuilder. It describes the container in XML with the elements bean and constant and lets the configuration provider build it; the framework’s own objects are described the same way in struts-beans.xml, which struts-default.xml includes (113 bean elements in 7.4.0, among them the object factory, the action mapper, the value stack factory, the OGNL machinery and the type converters). The rest of this page uses one small domain so that the effect of each attribute is visible.

The Bookshelf Scenario Used on This Page

The scratch WAR has a service interface for the catalogue and two implementations, one action per example and a view that prints the injected values. The interface and the implementations are plain classes with no Struts import, which is the point of dependency injection.

public interface CatalogService {
    List<String> titles();
}

public class InMemoryCatalog implements CatalogService {
    public List<String> titles() { return List.of("Dune", "Neuromancer", "Hyperion"); }
}

public class ArchiveCatalog implements CatalogService {
    public List<String> titles() { return List.of("Solaris"); }
}

The bean Element

The bean element of struts.xml registers a class with the container. The Bean Configuration page lists six attributes, and the DTD of the 6.0 line (the newest that exists) accepts the same six.

Table 3. Attributes of the bean element
Attribute Required Meaning (verified against XmlDocConfigurationProvider and the experiments below)

class

yes

The class to create, or the class whose static methods receive values

type

no

The primary interface or class the object is registered under; the injection point is matched by type and name

name

no

The unique name among beans of the same type; when empty the name is default

scope

no

default, singleton, prototype, thread, request, session or wizard; see the scope table below for what really works

static

no

When true the container injects the @Inject static methods of the class once at start-up and creates no instance; do not combine it with type

optional

no

When true a bean whose class cannot be loaded is skipped; otherwise a missing class stops the start-up

The next struts.xml registers the two catalogues under two names, a bean without a type, a static bean and two constants. The element order is free inside the first group of the DTD: (package|include|bean|constant)* first, then at most one bean-selection, then at most one unknown-handler-stack.

<struts>
    <constant name="bookshelf.pageSize" value="5"/>
    <constant name="bookshelf.libraryName" value="Bookshelf"/>

    <bean type="org.example.bookshelf.CatalogService" name="default"
          class="org.example.bookshelf.InMemoryCatalog" scope="singleton"/>
    <bean type="org.example.bookshelf.CatalogService" name="archive"
          class="org.example.bookshelf.ArchiveCatalog"/>
    <bean class="org.example.bookshelf.RequestStamp"/>
    <bean class="org.example.bookshelf.LibraryDefaults" static="true"/>

    <package name="default" extends="struts-default" namespace="/">
        <action name="injected" class="org.example.bookshelf.InjectedBookAction">
            <result>/WEB-INF/views/injected.jsp</result>
        </action>
    </package>
</struts>

Injecting with the Struts @Inject

An action is built by the object factory and then injected, so a field, a setter or a constructor of the action can ask the container for an object. The class below uses every form that the container supports: a field that gets the bean named default, a field that names archive, a bean without a type, the container itself, a constant through a setter, and a constant that may be absent.

import org.apache.struts2.inject.Container;
import org.apache.struts2.inject.Inject;

public class InjectedBookAction extends ActionSupport {
    @Inject
    private CatalogService catalog;                  // the bean named "default"

    @Inject("archive")
    private CatalogService archive;                  // the bean named "archive"

    @Inject
    private RequestStamp stamp;                      // a bean with no type: registered under its class

    @Inject
    private Container container;                     // the container itself

    private int pageSize;
    private String optionalValue = "not set";

    @Inject("bookshelf.pageSize")
    public void setPageSize(String pageSize) {       // constants are strings: String, not int
        this.pageSize = Integer.parseInt(pageSize);
    }

    @Inject(value = "bookshelf.missing", required = false)
    public void setOptionalValue(String value) {     // not called: no such constant
        this.optionalValue = value;
    }

    public List<String> getTitles()   { return catalog.titles(); }
    public List<String> getArchived() { return archive.titles(); }
    public Set<String> getCatalogNames() { return container.getInstanceNames(CatalogService.class); }
    // getters for stampNumber, pageSize, optionalValue and libraryName omitted
}

The static bean gets its value in a static method. This is how plugins give a helper class the framework settings: the SiteMesh plugin shown on the Plugins Architecture page registers two classes with static="true" and optional="true".

public class LibraryDefaults {
    private static String libraryName;

    @Inject("bookshelf.libraryName")
    public static void setLibraryName(String name) { libraryName = name; }

    public static String libraryName() { return libraryName; }
}

The view prints the values. This is the real output of GET /injected on Tomcat 11 (served by the scratch WAR), with the markup removed:

Bookshelf
titles: Dune; Neuromancer; Hyperion;
archive: Solaris;
stamp: 6
pageSize: 5
optional: not set
names: default archive

Four facts from this run are worth keeping. The constant arrives as a string: a setter declared as setPageSize(int) fails when the action is created with MissingDependencyException: No mapping found for dependency followed by [type=int, name=...], because the container registers a constant under the type String and does not convert it. The required = false setter was simply not called. A bean registered without a name is found under the name default, and getInstanceNames reports both catalogue names. The stamp stayed the same on every request, which the scope section explains.

Scopes

A bean that is injected into an action that lives for one request still has its own life, set by the scope attribute. The Bean Configuration page says the scope "must be either default, singleton, request, session, thread". The class Scope and a probe action per scope in the scratch WAR show what 7.4.0 really does: each probe injects a RequestStamp (a class that numbers its instances) registered under a scope-specific name and prints the number.

Table 4. Scopes of a bean in the Struts container, observed on Struts 7.4.0 and Tomcat 11.0.26
scope Behaviour Observed (the number of the instance, three requests each)

omitted or default

One instance for the whole container, the same as singleton

stamp=4, 4, 4

singleton

One instance per container

stamp=5, 5, 5

prototype

A new instance for each injection (accepted by Scope.fromString although the page does not list it)

stamp=9, 10, 11

thread

One instance per thread

stamp=6, 7, 8 (Tomcat served the requests on different threads)

request, session, wizard

Need a scope strategy that the application installs with Container.setScopeStrategy

HTTP 500: IllegalStateException: Scope strategy not set. Please call Container.setScopeStrategy().

Nothing in struts2-core calls setScopeStrategy (a search of the jar finds no caller), so request, session and wizard are present in the enum and in the documentation but unusable in a normal web application. The practical rule is that a bean in the Struts container is a singleton, so it must not keep per-user state; per-request and per-session state belongs to the action, to the servlet session, or to a CDI scope when the CDI plugin is installed (later on this page).

What Gets Injected

The object factory calls the container for every object it builds, so injection is not limited to actions. The Struts @Inject works in interceptors, results, validators and converters built by the factory, and in the framework’s own objects (that is how ObjectFactory receives the container, the factories and the class loader). The next interceptor of the Bookshelf reads the archive catalogue that the container injects into it, and the view prints what it stored.

public class CatalogInterceptor extends AbstractInterceptor {
    private CatalogService catalog;

    @Inject("archive")
    public void setCatalog(CatalogService catalog) {
        this.catalog = catalog;
    }

    @Override
    public String intercept(ActionInvocation invocation) throws Exception {
        invocation.getInvocationContext().getServletRequest()
                .setAttribute("archiveSize", catalog.titles().size());
        return invocation.invoke();
    }
}

The mapping puts the interceptor in front of the default stack (the interceptors page explains stacks); a request to /books then shows archive=1, the size of the archive catalogue, which the view reads with #request.archiveSize.

<interceptors>
    <interceptor name="catalog" class="org.example.bookshelf.CatalogInterceptor"/>
    <interceptor-stack name="catalogStack">
        <interceptor-ref name="catalog"/>
        <interceptor-ref name="defaultStack"/>
    </interceptor-stack>
</interceptors>
<action name="books" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="catalogStack"/>
    <result>/WEB-INF/views/books.jsp</result>
</action>

Interceptors are singletons that serve every request, so an injected collaborator must be thread-safe; the writing of interceptors is covered on the custom interceptors page.

Conflicts and Missing Classes

Two beans of one type and name cannot both be registered. The second one stops the start-up with a message that names both files and lines; the way out is a different name, selected by a constant (the extension-point section below).

Bean type interface org.example.bookshelf.CatalogService with the name default has already been loaded by bean - file:.../WEB-INF/classes/struts.xml:10:76

A bean whose class does not exist stops the application too, unless optional="true" is set. With optional="true" the application starts and the container simply lacks that bean: a <bean type="org.example.bookshelf.CatalogService" name="ghost" class="org.example.bookshelf.DoesNotExist" optional="true"/> was skipped (the names line above still read default archive), and without the attribute the log has Unable to load bean: type:org.example.bookshelf.CatalogService class:org.example.bookshelf.DoesNotExist with the cause java.lang.ClassNotFoundException. This is the mechanism that lets one struts-plugin.xml mention classes of an optional library.

Extension Points

The framework replaces its own parts through extension points. An extension point is a constant that names a bean: the constant struts.date.formatter holds the name of the DateFormatter bean that the s:date tag uses, and default.properties sets it to dateTimeFormatter. The Plugins Architecture page defines it as "a name which will be used to locate other beans by the name and inject them into a given place. Extension point isn’t a bean, you cannot inject it." Adding an implementation is two steps: register a bean of the extension type with a new name, and set the constant to that name.

Table 5. Extension points of 7.4.0 that applications and plugins replace most often
Constant Type (package org.apache.struts2) Beans shipped with 7.4.0

struts.objectFactory

ObjectFactory

struts; the plugins add spring and cdi

struts.objectFactory.actionFactory and .resultFactory, .interceptorFactory, .validatorFactory, .converterFactory, .unknownHandlerFactory

factory.ActionFactory, factory.ResultFactory, factory.InterceptorFactory, factory.ValidatorFactory, factory.ConverterFactory, factory.UnknownHandlerFactory

struts for each (classes named Default... or Struts...)

struts.actionProxyFactory

ActionProxyFactory

struts, prefix

struts.mapper.class

dispatcher.mapper.ActionMapper

struts, composite, prefix, restful, restful2 (the last two deprecated for removal in favour of the REST plugin)

struts.multipart.parser

dispatcher.multipart.MultiPartRequest

jakarta, jakarta-stream

struts.patternMatcher

util.PatternMatcher

struts, namedVariable, regex

struts.valueStackFactory, struts.reflectionProvider

util.ValueStackFactory, util.reflection.ReflectionProvider

struts (OGNL implementations)

struts.ognlGuard, struts.securityMemberAccess

ognl.OgnlGuard, ognl.SecurityMemberAccess

struts

struts.localizedTextProvider, struts.textProvider

text.LocalizedTextProvider, text.TextProvider

struts and global-only; system

struts.date.formatter

components.date.DateFormatter

simpleDateFormatter, dateTimeFormatter

struts.staticContentLoader, struts.urlRenderer, struts.view.urlHelper

dispatcher.StaticContentLoader, components.UrlRenderer, views.util.UrlHelper

struts

struts.proxyService

util.ProxyService

struts; the CDI plugin adds cdi

The official table still prints the older package com.opensymphony.xwork2 for most of these types and calls one constant struts.conversion.properties.processor where the code constant is struts.converter.properties.processor; use the table above (taken from the 7.4.0 jar) when writing code. The next example replaces the date formatter with the Bookshelf one. The bean has a new name and the constant selects it; s:date then prints through it.

public class ShelfDateFormatter implements DateFormatter {
    @Override
    public String format(TemporalAccessor temporal, String format) {
        return "shelf:" + DateTimeFormatter.ISO_LOCAL_DATE.format(temporal);
    }
}
<bean type="org.apache.struts2.components.date.DateFormatter" name="shelfDates"
      class="org.example.bookshelf.ShelfDateFormatter" scope="singleton"/>
<constant name="struts.date.formatter" value="shelfDates"/>

With a LocalDate property published (1965-08-01), <s:date name="published"/> printed shelf:1965-08-01.

Defining an Extension Point of Your Own

A plugin, or an application, can add an extension point of its own. The pattern is the one the core uses: an interface, a constant name, and a bean-selection provider that ties the two with alias. The Bookshelf defines a LabelFormatter with a plain and an upper-case implementation and lets the constant bookshelf.labelFormatter choose.

public class LabelBeanSelectionProvider extends AbstractBeanSelectionProvider {
    public static final String LABEL_FORMATTER = "bookshelf.labelFormatter";

    @Override
    public void register(ContainerBuilder builder, LocatableProperties props) throws ConfigurationException {
        alias(LabelFormatter.class, LABEL_FORMATTER, builder, props, Scope.SINGLETON);
    }
}
<bean type="org.example.bookshelf.LabelFormatter" name="plain" class="org.example.bookshelf.PlainLabels"/>
<bean type="org.example.bookshelf.LabelFormatter" name="shout" class="org.example.bookshelf.ShoutLabels"/>
<constant name="bookshelf.labelFormatter" value="shout"/>

<package name="default" extends="struts-default">
    <!-- actions -->
</package>

<bean-selection name="labelBeans" class="org.example.bookshelf.LabelBeanSelectionProvider"/>

An action with @Inject public void setLabels(LabelFormatter labels) then printed DUNE for labels.label("Dune"). Three details were learned from the failures on the way. First, the bean-selection element must come after the packages, beans and constants (the DTD content model is (package|include|bean|constant)*, bean-selection?, unknown-handler-stack?); put before a package it stops the start-up with a SAXParseException. Second, the DTD allows one bean-selection per file. Third, alias maps the constant’s value to a bean name, so with the constant missing nothing is registered under the name default and the injection fails with No mapping found for dependency [type=...LabelFormatter, name='default']; a plugin that wants a default sets the constant in its own struts-plugin.xml. A plugin normally puts the bean-selection in struts-deferred.xml so that it loads after the application’s struts.xml.

Plugins, struts-plugin.xml and the Load Order

A plugin is a jar with a struts-plugin.xml (and optionally a struts-deferred.xml) that follows the format of struts.xml, so it can add beans, constants and packages. The Plugins Architecture page gives the order in which the files are loaded: struts-default.xml from the core jar, every struts-plugin.xml on the class path, the application’s struts.xml, and finally every struts-deferred.xml. A later file overrides a constant of an earlier one, which is why a plugin can set a default and the application can change it. The Bookshelf checks this with a plugin jar placed in WEB-INF/lib; the plugin sets two constants and registers a catalogue bean, and the deferred file sets a third.

<!-- bookshelf-plugin.jar!/struts-plugin.xml -->
<struts>
    <constant name="bookshelf.libraryName" value="Set by struts-plugin.xml"/>
    <constant name="bookshelf.pluginName" value="Set by struts-plugin.xml"/>
    <bean type="org.example.bookshelf.CatalogService" name="plugin"
          class="org.example.plugin.PluginCatalog"/>
</struts>

<!-- bookshelf-plugin.jar!/struts-deferred.xml -->
<struts>
    <constant name="bookshelf.deferredName" value="Set by struts-deferred.xml"/>
</struts>

The application’s struts.xml sets bookshelf.libraryName to Bookshelf and bookshelf.deferredName to Set by struts.xml. An action that injects the three constants and the plugin’s catalogue printed:

titles=Contributed by the plugin jar
libraryName=Bookshelf
pluginName=Set by struts-plugin.xml
deferredName=Set by struts-deferred.xml

So struts.xml beat the plugin (libraryName), the plugin value stayed where the application did not set one (pluginName), and the deferred file beat struts.xml (deferredName). Writing a complete plugin (packages, results, interceptors, the plugin registry) is covered on the page about the other plugins in this part; the plugins that matter for layout are on the Tiles and page layouts page.

The ObjectFactory and Its Dedicated Factories

The object factory is the one extension point whose replacement changes who injects. A custom factory extends ObjectFactory and is registered either by class name (struts.objectFactory=foo.bar.MyFactory, as the page says) or, as in the experiment below, as a bean and selected by name. The factory’s buildBean is the method that every object goes through, so overriding it shows everything the framework builds. The Bookshelf factory records the simple class names.

public class AuditObjectFactory extends ObjectFactory {
    public static final List<String> BUILT = new CopyOnWriteArrayList<>();

    @Override
    public Object buildBean(String className, Map<String, Object> extraContext, boolean injectInternal)
            throws Exception {
        BUILT.add(className.substring(className.lastIndexOf('.') + 1));
        return super.buildBean(className, extraContext, injectInternal);
    }
}
<bean type="org.apache.struts2.ObjectFactory" name="audit" class="org.example.bookshelf.AuditObjectFactory"/>
<constant name="struts.objectFactory" value="audit"/>

After one request to /books, /injected and /audit the list held 87 entries; the beginning and the end read:

NoOpInterceptor
ExceptionMappingInterceptor
ServletConfigInterceptor
HttpMethodInterceptor
PrepareInterceptor
...
RequiredFieldValidator
RequiredStringValidator
IntRangeFieldValidator
...
BookAction
ServletDispatcherResult
InjectedBookAction
ServletDispatcherResult
AuditAction
ServletDispatcherResult

The interceptors and validators were built while the configuration was loaded and on first use, and the actions and results were built per request: an action and its result are created for every request, which is why an action field is never shared between users. The factory that builds only actions is smaller. The page "ObjectFactory" explains that the original factory was split into dedicated factories, each with its own constant. The Bookshelf ActionFactory records the name and the class of each action and leaves the building to the default factory.

public class AuditActionFactory extends DefaultActionFactory {
    public static final List<String> BUILT = new CopyOnWriteArrayList<>();

    @Override
    public Object buildAction(String actionName, String namespace, ActionConfig config,
                              Map<String, Object> extraContext) throws Exception {
        BUILT.add(namespace + actionName + " -> " + config.getClassName());
        return super.buildAction(actionName, namespace, config, extraContext);
    }
}
<bean type="org.apache.struts2.factory.ActionFactory" name="audit" class="org.example.bookshelf.AuditActionFactory"/>
<constant name="struts.objectFactory.actionFactory" value="audit"/>

After two requests the list was /books -> org.example.bookshelf.BookAction and /audit -> org.example.bookshelf.AuditAction. The official page lists dedicated factories for results, actions, interceptors, validators, converters and (since 2.3.18) unknown handlers; the 7.4.0 constants are struts.objectFactory.actionFactory, .resultFactory, .interceptorFactory, .validatorFactory, .converterFactory and .unknownHandlerFactory.

The CDI Plugin

CDI (Contexts and Dependency Injection) is the Jakarta EE standard for dependency injection, and it builds on the jakarta.inject annotations. The plugin struts2-cdi-plugin makes Struts build its objects through a CDI container, so an action can inject scoped, qualified and produced beans with @Inject. The 7.4.0 jar is tiny: CdiObjectFactory, CdiProxyService and a struts-plugin.xml that registers both and makes them the defaults.

<!-- struts-plugin.xml of struts2-cdi-plugin 7.4.0 -->
<struts>
    <bean type="org.apache.struts2.ObjectFactory" name="cdi" class="org.apache.struts2.cdi.CdiObjectFactory" />
    <constant name="struts.objectFactory" value="cdi" />

    <bean type="org.apache.struts2.util.ProxyService" name="cdi" class="org.apache.struts2.cdi.CdiProxyService" />
    <constant name="struts.proxyService" value="cdi" />
</struts>

The plugin does not bring a CDI container. A Jakarta EE application server (GlassFish, WildFly, TomEE and similar) already has one; on Tomcat or Jetty the application ships one, and Weld Servlet is the reference choice. The plugin’s own POM declares the Jakarta CDI API and weld-api as provided; it compiles against Weld (the Struts build uses 6.0.4.Final), so Weld is the implementation that this page uses. The CDI page of the official site describes a javax setup (Maven coordinates javax.enterprise:cdi-api:1.0-SP1 and Weld 1.0.1) that does not apply to a Jakarta EE 10 or 11 container; the table sets the old and the 7.4.0 names side by side.

Table 6. The CDI plugin page against the 7.4.0 jars
The official page says Struts 7.4.0

javax.enterprise:cdi-api 1.0-SP1, Weld 1.0.1-Final

jakarta.enterprise:jakarta.enterprise.cdi-api and org.jboss.weld:weld-api 6.0.Final (provided); runtime: org.jboss.weld.servlet:weld-servlet-shaded 6.0.4.Final on Tomcat

import javax.inject.Inject

import jakarta.inject.Inject

com.opensymphony.xwork2.ActionSupport

org.apache.struts2.ActionSupport

com.opensymphony.xwork2.inject.Inject is the annotation not to use

org.apache.struts2.inject.Inject is the one not to use for CDI beans

DTD struts-2.3.dtd

struts-6.0.dtd

Setup on Tomcat 11 with Weld Servlet

Four things make the combination work, and each one failed in turn when it was left out. The Maven dependencies come first; the Struts BOM manages the plugin’s version.

<dependency>
    <groupId>org.apache.struts</groupId>
    <artifactId>struts2-cdi-plugin</artifactId>
</dependency>
<dependency>
    <groupId>org.jboss.weld.servlet</groupId>
    <artifactId>weld-servlet-shaded</artifactId>
    <version>6.0.4.Final</version>
</dependency>

Second, Tomcat has no BeanManager in JNDI by itself; the META-INF/context.xml of the web application binds one with Weld’s object factory, and web.xml declares the reference and registers Weld’s listener, which must be present so that Weld is started before the Struts filter initialises.

<!-- META-INF/context.xml -->
<Context>
    <Resource name="BeanManager" auth="Container"
              type="jakarta.enterprise.inject.spi.BeanManager"
              factory="org.jboss.weld.resources.ManagerObjectFactory"/>
</Context>
<!-- WEB-INF/web.xml (before the Struts filter) -->
<listener>
    <listener-class>org.jboss.weld.environment.servlet.Listener</listener-class>
</listener>
<resource-env-ref>
    <resource-env-ref-name>BeanManager</resource-env-ref-name>
    <resource-env-ref-type>jakarta.enterprise.inject.spi.BeanManager</resource-env-ref-type>
</resource-env-ref>

Third, WEB-INF/beans.xml must exist; an empty beans element with the discovery mode annotated is enough.

<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">
</beans>

Without beans.xml the Weld listener logs WELD-ENV-000028: Weld initialization skipped - no bean archive found, and the Struts plugin then fails (see the pitfalls table), which confirms the official page’s sentence that "it is an inevitable requirement to provide at least an empty beans.xml in WEB-INF" for a servlet container. On an application server that supplies CDI, none of the three Tomcat steps is needed and the plugin finds the BeanManager at java:comp/BeanManager; the application server case was not run for this page, and the official page describes it.

How the Plugin Finds the BeanManager

CdiObjectFactory looks the BeanManager up in JNDI, trying three names in order. With Struts' log level raised to debug for the package org.apache.struts2.cdi the start-up of the working Bookshelf printed:

INFO  CdiObjectFactory - Initializing Struts2 CDI integration...
INFO  CdiObjectFactory - [lookup]: Checking for BeanManager under JNDI key java:comp/BeanManager
DEBUG CdiObjectFactory - [lookup]: BeanManager lookup failed for JNDI key java:comp/BeanManager
INFO  CdiObjectFactory - [lookup]: Checking for BeanManager under JNDI key java:app/BeanManager
DEBUG CdiObjectFactory - [lookup]: BeanManager lookup failed for JNDI key java:app/BeanManager
INFO  CdiObjectFactory - [lookup]: Checking for BeanManager under JNDI key java:comp/env/BeanManager
INFO  CdiObjectFactory - Struts2 CDI integration initialized.
Table 7. The BeanManager names tried by the plugin (the order of CdiObjectFactory.findBeanManager)
JNDI name Where it applies

java:comp/BeanManager

The name in the CDI specification; Jakarta EE application servers

java:app/BeanManager

The name in the Weld documentation

java:comp/env/BeanManager

Servlet containers such as Tomcat and Jetty; this is the one that the context.xml above creates

The official page adds a constant, struts.objectFactory.cdi.jndiKey, for a container that uses another name. On 7.4.0 that constant has no effect. The experiment bound the BeanManager only at java:comp/env/bookshelf/BeanManager, set the constant to that name, and the log showed the three default lookups and then Struts2 CDI integration could not be initialized. The reason is in the source: the lookup runs in the constructor of CdiObjectFactory, while the key arrives afterwards through the @Inject setter setJndiKey, so the field is still empty when the lookup runs. On a server whose BeanManager has another name, bind the manager under one of the three names, or subclass CdiObjectFactory and override findBeanManager(), which is protected for that purpose.

Using CDI Beans in Actions

With the plugin in place the Bookshelf catalogue becomes CDI beans. The service is application scoped and the reader’s list is session scoped; both are plain annotated classes (only the discovery mode annotated and a bean-defining annotation are needed).

@ApplicationScoped
public class ShelfService {
    private final AtomicInteger calls = new AtomicInteger();

    public List<String> titles() {
        calls.incrementAndGet();
        return List.of("Dune", "Neuromancer", "Hyperion");
    }

    public int getCalls() { return calls.get(); }
}

@SessionScoped
public class ReaderSession implements Serializable {
    private final List<String> reading = new ArrayList<>();

    public void add(String title) { reading.add(title); }
    public List<String> getReading() { return reading; }
}

The action uses the Jakarta annotation. Everything else is ordinary Struts: an action with a method, a request parameter that needs @StrutsParameter, and a view.

import jakarta.inject.Inject;

public class CdiBookAction extends ActionSupport {
    @Inject
    private ShelfService shelf;

    @Inject
    private ReaderSession reader;

    private String title;

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

    public String borrow() {
        reader.add(title);
        return SUCCESS;
    }

    @StrutsParameter
    public void setTitle(String title) { this.title = title; }

    public List<String> getTitles()  { return shelf.titles(); }
    public List<String> getReading() { return reader.getReading(); }
    public int getCalls()            { return shelf.getCalls(); }
}

Two readers, one cookie each. The first GET /shelf and two POST /borrow requests with the same cookie jar, then a GET /shelf without a cookie, printed:

GET  shelf                -> titles=Dune, Neuromancer, Hyperion,   reading=            calls=1
POST borrow title=Dune    -> titles=Dune, Neuromancer, Hyperion,   reading=Dune,       calls=2
POST borrow title=Solaris -> titles=Dune, Neuromancer, Hyperion,   reading=Dune, Solaris,  calls=3
GET  shelf (new session)  -> titles=Dune, Neuromancer, Hyperion,   reading=            calls=4

The call counter climbs across both readers (one application-scoped instance), while each session has its own list. The JSESSIONID cookie value is not shown.

Injection into Interceptors, and the Request Scope

The CDI object factory builds interceptors too, so an interceptor can inject CDI beans. A request-scoped bean inside an interceptor is the usual way to carry a per-request value between the interceptor and the action without a servlet attribute. The Bookshelf LoanCounter is @RequestScoped; the interceptor increments it and stores the number in the request.

@RequestScoped
public class LoanCounter {
    private int loans;
    public int next() { return ++loans; }
}

public class LoanInterceptor extends AbstractInterceptor {
    @Inject
    private LoanCounter counter;

    @Override
    public String intercept(ActionInvocation invocation) throws Exception {
        int n = counter.next();
        invocation.getInvocationContext().getServletRequest().setAttribute("loanNumber", n);
        return invocation.invoke();
    }
}

Mapped in front of defaultStack for the borrow action, two borrow requests both printed loan=1: the interceptor is a singleton, but the injected counter is a client proxy for the counter of the current request, so every request starts at one.

The Two @Inject Annotations

The official page calls the choice of @Inject "the most common pitfall": the container inside Struts has its own annotation, and the one that a CDI developer types is the Jakarta one. Both were run against the CDI plugin and against the default factory.

Table 8. What happens with each annotation and each object factory
Annotation on the field Object factory Observed on Struts 7.4.0

jakarta.inject.Inject

cdi (plugin installed)

The CDI bean is injected

org.apache.struts2.inject.Inject on a CDI bean type

cdi

HTTP 500: MissingDependencyException: No mapping found for dependency [type=org.example.bookshelf.ShelfService, name='default']; the Struts container knows nothing about CDI beans

jakarta.inject.Inject

struts (no plugin)

No error and no injection: the field stays null; a getter that dereferences it is evaluated to an empty value in the view

org.apache.struts2.inject.Inject on a framework object or a <bean>

cdi

Still injected: the plugin runs the internal injection after the CDI injection

The failure that stays silent is the third row: a Jakarta-annotated action on a Struts application without the plugin compiles, deploys and returns 200, and the property comes out empty. Check the imports, and check that the plugin jar and its CDI container are in the WAR.

Proxied Beans and OGNL (Struts 7.3.0 and later)

A normal-scoped CDI bean (@SessionScoped, @RequestScoped, @ApplicationScoped) is injected as a client proxy, a generated subclass of the bean. OGNL checks every member it reaches against the allowlist and refuses proxies, so a view expression that walks through a proxy is a security question. Since 7.3.0 the plugin ships CdiProxyService, which teaches Struts to find the real class behind a Weld client proxy (WW-5604). The experiment exposes getReader() on the action and prints reader.reading in the view; the log shows how the pieces interact.

Table 9. OGNL access through a Weld client proxy on Struts 7.4.0 (view expression reader.reading, a session-scoped bean)
Configuration Result

Defaults (struts.disallowProxyObjectAccess=true)

Empty output; WARN Access to proxy is blocked! Target [...ReaderSession@...], proxy class [...ReaderSession$Proxy$_$$_WeldClientProxy]

struts.disallowProxyObjectAccess=false, class not allowlisted

Empty output; WARN Declaring class [class org.example.bookshelf.ReaderSession] of member type ... is not allowlisted! Add to 'struts.allowlist.classes' or 'struts.allowlist.packageNames'

The two above plus struts.allowlist.classes=org.example.bookshelf.ReaderSession

reading=Dune, (works)

The same with struts.proxyService=struts (the plugin’s service switched off)

Empty output; the allowlist check names the proxy class ReaderSession$Proxy$_$$_WeldClientProxy as not allowlisted, which is the behaviour that WW-5604 describes

So on 7.4.0 the plugin’s proxy support is necessary but not sufficient: the allowlist names the real class (as for any model class), and the proxy-access switch must be relaxed. The safe pattern avoids both: the action exposes a plain getter that delegates (getReading() in the action above returned the list and printed reading=Dune, with no extra constants), so the view never walks through the proxy. Relax struts.disallowProxyObjectAccess only when a view really needs to read properties of a CDI bean, and allowlist that one class, never a whole package of beans.

JPA and the Open EntityManager in View

The Struts in Action books show a Spring plus JPA integration in which a servlet filter (Spring’s OpenEntityManagerInViewFilter) keeps one EntityManager open for the whole request, so that lazy associations load while the view renders. The Spring plugin page carries the Spring version of that pattern; with CDI the same effect comes from a request-scoped producer. The producer creates the EntityManager when the first action injects it and closes it when the request scope ends, which Weld Servlet does after the response is written, and that is after the JSP has been rendered. The Bookshelf entity Book has a lazy @OneToMany list of Review objects.

@ApplicationScoped
public class Persistence {
    @Produces
    @ApplicationScoped
    EntityManagerFactory factory() {
        return jakarta.persistence.Persistence.createEntityManagerFactory("bookshelf");
    }

    void closeFactory(@Disposes EntityManagerFactory factory) { factory.close(); }

    @Produces
    @RequestScoped
    EntityManager entityManager(EntityManagerFactory factory) {   // one per request
        return factory.createEntityManager();
    }

    void closeEntityManager(@Disposes EntityManager em) { em.close(); }   // after the view
}

An action that injects the request-scoped manager returns before the lazy list is touched; the view loads it.

public class LazyAction extends ActionSupport {
    @Inject
    private EntityManager em;               // request scoped: open until the response is written

    private Book book;

    @Override
    public String execute() {
        book = em.createQuery("select b from Book b where b.title = 'Dune'", Book.class).getSingleResult();
        return SUCCESS;                     // reviews are still unloaded here
    }

    public Book getBook() { return book; }
}

The view iterates book.reviews (the model package is allowlisted with struts.allowlist.packageNames). GET /lazy printed title=Dune, review=A classic and review=Long, but worth it. The contrasting action closes the manager inside execute(), the way a method-scoped transaction would, and the same view failed with HTTP 500; the log held org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'org.example.bookshelf.Book.reviews' with key '1' (no session).

The pattern has the usual cost: a database connection stays attached for the whole request, including the JSP rendering and, on a slow client, the time spent writing the response. Prefer to load what the view needs in the service layer (a fetch join or a DTO), keep the manager request-scoped only where lazy loading in the view is a deliberate choice, and see the Hibernate pages of this site on fetching and N+1 queries and the persistence context for the options. Transactions are a separate concern: the example reads only; a write needs a transaction that the application starts and commits (the seeding method in the example does that with EntityManager.getTransaction()), or a CDI transaction extension.

Choosing Between the Internal Container, CDI and Spring

Table 10. Which container suits which application
Situation Choice Why

Few collaborators, nothing outside Struts

The internal container with <bean> and @Inject

No library; the beans are singletons and configured in struts.xml

Jakarta EE server, or an application already built on CDI

The CDI plugin

Scopes (@SessionScoped, @RequestScoped), producers, qualifiers and events are available in actions and interceptors

A Spring application (Spring Data, Spring Security, transactions)

The Spring plugin

Struts creates actions through the Spring context; see Spring Integration

Both Spring and CDI wanted

Do not combine the two factories

Only one struts.objectFactory runs; the official page calls the result "undetermined"

A move to Quarkus or Vaadin

Their own CDI integrations

The @Inject services carry over; see the pages below

The CDI model itself (scopes, producers, qualifiers, events) is described for two other stacks of this site, and the Struts annotations map directly: CDI and the programming model in Quarkus explains ArC, the build-time CDI implementation of Quarkus, and where it differs from a runtime container like Weld, and Jakarta EE and CDI in Vaadin shows a Jakarta EE server with Vaadin views as CDI beans. A Struts application that moves to one of them replaces its actions by routes or views and keeps the @Inject services unchanged.

Pitfalls

Table 11. Dependency-injection pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Struts 7.4.0 and Tomcat 11.0.26)
Symptom or error Cause Fix

HTTP 500 MissingDependencyException ... [type=int, name=...] (observed)

The container registers constants as strings, so an int or boolean parameter has no mapping

Take a String and convert it, or register a typed bean

A field annotated with jakarta.inject.Inject is null, no error (observed)

No CDI or Spring plugin: the default object factory ignores the Jakarta annotation

Add the plugin and its container, or use org.apache.struts2.inject.Inject with a <bean>

HTTP 500 No mapping found for dependency [type=...ShelfService, name='default'] with the CDI plugin (observed)

The action imports the Struts @Inject, which asks the Struts container for a CDI bean

Import jakarta.inject.Inject

IllegalStateException: Scope strategy not set (observed)

A bean with scope="request", "session" or "wizard" and no scope strategy

Use singleton, prototype or thread; use CDI scopes for request and session state

The application does not start: Bean type ... with the name default has already been loaded (observed)

Two bean elements of the same type and name

Give the second a name and select it with the extension-point constant

The application does not start with Unable to load bean and ClassNotFoundException (observed)

A bean names a class that is not on the class path

Fix the class, or add optional="true" when the library is optional

SAXParseException …​ The content of element type "struts" must match (observed)

A bean-selection element before a package, bean or constant, or two of them

Put the single bean-selection last

An extension point injection fails with name='default' (observed)

alias maps the constant’s value to a bean name and the constant is not set

Set the constant, in the plugin’s struts-plugin.xml for a default

The application does not start, NullPointerException …​ "this.beanManager" is null, log Could not find BeanManager instance (observed)

No BeanManager in JNDI under the three names (the Tomcat Resource is missing)

Add the Resource and the resource-env-ref, or bind the manager under java:comp/env/BeanManager

Log WELD-ENV-000028: Weld initialization skipped - no bean archive found (observed)

WEB-INF/beans.xml is missing

Add a beans.xml (empty, discovery mode annotated)

struts.objectFactory.cdi.jndiKey is ignored (observed)

The lookup runs in the constructor, before the setter is injected

Bind the manager under one of the three default names

A view expression over a @SessionScoped or @RequestScoped bean prints nothing, WARN Access to proxy is blocked! (observed)

Weld client proxy plus struts.disallowProxyObjectAccess=true and the OGNL allowlist

Expose a plain getter on the action, or relax the proxy switch and allowlist the real class

LazyInitializationException with (no session) in the view (observed)

The EntityManager was closed before the JSP rendered

Load what the view needs in the action, or make the manager request-scoped and understand the cost

A singleton bean keeps data of one user

Beans in the Struts container are singletons by default

Keep per-user state in the session, in the action, or in a CDI @SessionScoped bean

The application behaves differently after adding a second plugin

Two plugins both set struts.objectFactory; the winner is undetermined

Keep one object factory plugin; select it explicitly with the constant in struts.xml

What Changed Recently

Table 12. Changes to injection between Struts 2.3 and 7.4
Release Change

Struts 2.3.16 and 2.3.18

The dedicated factories (action, result, interceptor, validator, converter) are split out of ObjectFactory (2.3.16), and the unknown-handler factory follows (2.3.18)

Struts 6.0

struts.date.formatter is added as an extension point ("since 6.0.0" on the Plugins Architecture page); the DTD is struts-6.0.dtd, the newest that exists

Struts 6.4

SecurityMemberAccess, OgnlGuard and RootAccessor become extension points (struts.securityMemberAccess, struts.ognlGuard, struts.compoundRootAccessor); the @StrutsParameter model begins

Struts 7.0

Package com.opensymphony.xwork2.inject becomes org.apache.struts2.inject; the Jakarta EE namespace (jakarta.*) replaces javax.*, so the CDI plugin is built against jakarta.enterprise and Weld 6; Java 17 is the minimum

Struts 7.3.0

The CDI plugin registers CdiProxyService, so Weld client proxies are recognised by the OGNL allowlist (WW-5604)

Struts 7.4.0

Verified here: Java 21 and Tomcat 11.0.26 run struts2-cdi-plugin 7.4.0 with Weld Servlet 6.0.4.Final; the plugin’s struts-plugin.xml still declares the 6.0 DTD (no 7.0 DTD exists)

Where the Books Are Outdated

Struts 2 in Action (Manning, 2008) deals with the Spring and JPA integration in chapter 9 and with the plugin model and the internal container (bean, constant, @Inject and the extension points) in chapter 12. The ideas stand; the details have moved, as the table lists.

Table 13. The book’s era against Struts 7.4
The book’s era Today (Struts 7.4)

com.opensymphony.xwork2.inject.Inject and com.opensymphony.xwork2.ObjectFactory

org.apache.struts2.inject.Inject and org.apache.struts2.ObjectFactory (Struts 7 repackaging)

The Spring plugin as the one injection route, SpringObjectFactory with its autowiring modes

Still present in the Spring plugin; CDI is a second route (struts2-cdi-plugin); the Spring page of this part has the details

Spring 2 XML and OpenEntityManagerInViewFilter

Spring Framework 7 with the plugin; with CDI a @RequestScoped producer plays the same role (the example above)

Request, session and thread scopes of a bean in the internal container

Only singleton, prototype and thread work; there is no scope strategy for request or session (verified)

struts.properties for the object factory (struts.objectFactory=spring)

A <constant> in struts.xml (and default.properties as the reference); the factory can also be a bean name

SiteMesh, Tiles and JFreeChart as plugins of the same era

The SiteMesh plugin is not published at 7.4.0 (the last release is 6.12.0), while the Tiles and JFreeChart plugins are; the other-plugins page of this part has the removed-plugins table

A plugin is a jar with a struts-plugin.xml

Still true, and struts-deferred.xml (since 2.3.5) loads last; the load order is struts-default.xml, struts-plugin.xml, struts.xml, struts-deferred.xml

Dojo, ajax theme and datetimepicker as tags

Removed; see the Ajax page of this part

Notes on the Official Documentation

Reading the pages against the 7.4.0 jars showed inconsistencies that a reader will meet; they are listed so that a mismatch is not mistaken for an error in the example code.

  • The CDI Plugin page is written for javax and CDI 1.0: its Maven coordinates, imports (javax.inject, javax.enterprise, com.opensymphony.xwork2.ActionSupport) and the 2.3 DTD must be read as jakarta.*, org.apache.struts2.* and the 6.0 DTD.

  • The constant struts.objectFactory.cdi.jndiKey is described as a working option; on 7.4.0 it is ignored, because the plugin looks up the BeanManager in its constructor before the key is injected.

  • The "Proxied beans" section does not mention that struts.disallowProxyObjectAccess (default true) and the allowlist still apply to a view expression through a client proxy.

  • The Bean Configuration page lists the scopes request and session; no scope strategy is installed, so they fail with Scope strategy not set. It also shows com.opensymphony.xwork2.ObjectFactory in its example.

  • The Plugins Architecture extension-point table prints com.opensymphony.xwork2 types (now org.apache.struts2), names struts.conversion.properties.processor where the constant is struts.converter.properties.processor, and shows the DTDs 2.5 and 2.6 in its examples (the DTD of 7.4.0 is 6.0).

  • The Dependency Injection page names the Plexus plugin; Plexus is deprecated since 6.0.0 and not published at 7.4.0.

References