Actions
|
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 (the current 7.x release, verified on 2026-10-08), running on
Apache Tomcat 11.0.26 and Java 21; Struts 7 needs Java 17 and Jakarta Servlet 6 at least. The facts were checked against
the Struts core-developer guide, the Getting Started tutorials, the 7.4.0 Javadoc and the 7.4.0 jar itself (javap on the
classes named below), and every example was built into a Maven WAR with the struts2-bom 7.4.0, deployed to the scratch
Tomcat and exercised with curl; the outputs shown are the outputs that run produced. An action is the unit of work of
Struts 2: a small Java class, instantiated for each request, whose one method runs the application logic and returns the
name of the result that should render the answer. The running scenario is the Bookshelf catalogue that Part II builds as
bookshelf-struts: a list of titles, a form that adds a title, a reader who has a session, and a CSV export. The other pages of
this part (architecture and request lifecycle, packages and namespaces,
wildcard mappings, results, interceptors and the rest) are linked where they come up.
What an Action Is
In the model-view-controller split that Struts follows, the filter StrutsPrepareAndExecuteFilter is the front
controller, the action is the piece of code that handles one kind of request, and the result (usually a JSP or a
FreeMarker template) is the view. The action has three jobs: it receives the request data (Struts copies request
parameters onto the action’s properties), it runs the logic or hands it to a service, and it returns a short string, the
result code, that the configuration maps to a result. Everything the view needs is read back from the action’s getters
through the value stack, because the action instance is pushed on top of that stack while the result renders.
An action is not a servlet. It does not receive HttpServletRequest and HttpServletResponse as arguments, it is not
shared between threads, and it does not have to extend or implement anything. The framework creates one instance for
every request (see Thread per Request), so the instance fields are private to that request and
the usual servlet worry of shared mutable state does not apply to them. The Struts documentation says it directly: each
action caused by an HTTP request is a plain old Java object that is instantiated for each request.
Source: Apache Struts — Primer, Threads
There are three ways to write the class, and the framework treats them alike:
| Style | What the class looks like | When it fits |
|---|---|---|
POJO |
A public class with a public no-argument constructor and a public method that returns |
The smallest actions; classes that are unit-tested without the framework ( |
Implements |
The same class declares |
When the result-code constants help readability and nothing else is needed |
Extends |
The class extends |
The usual choice for actions that handle forms ( |
The Bookshelf Actions Used on This Page
All examples belong to one scratch WAR built from the bookshelf-struts base: struts2-core 7.4.0 from the
struts2-bom, log4j-slf4j2-impl, the Servlet and Pages APIs as provided, the filter StrutsPrepareAndExecuteFilter
mapped to /* in web.xml, and a package default that extends struts-default in the root namespace. The
catalogue itself is a thread-safe static list in a class Catalogue (an in-memory stand-in for the repository that Part II
adds later). The default action extension of 7.4.0 is struts.action.extension=action,, (the extension .action or no
extension at all), so /books and /books.action both reach the same mapping.
| Action name | Class | What it demonstrates |
|---|---|---|
|
|
A POJO with |
|
|
The |
|
|
|
|
|
|
|
|
|
|
|
One instance per request |
|
no class |
The default class |
|
|
Three misconfigurations that are reproduced in the pitfalls table |
How an Action Runs
The wiring behind a single action request is described in Architecture & Request Lifecycle; what an action author needs is the order of events around the action method.
The same sequence in words, with the names the Struts 7.4.0 jar uses:
-
The filter asks the
ActionMapperwhether the URL names an action. If it does not, the request is passed on to the rest of the filter chain, which is why static files and JSPs keep working next to Struts. -
An
ActionProxyis created for the mapping (namespace, action name, method) and anActionInvocationis built. The invocation instantiates the class named in the<action>element, using the Struts object factory. That instance is new for this request. -
The invocation runs the interceptors of the action’s stack in order, then the action method, then the result, and unwinds the interceptors in reverse order. In the default stack of 7.4.0 the interceptors that matter to an action author appear in this order:
exception,alias,servletConfig(injects the Aware objects),httpMethod,i18n,prepare,chain,scopedModelDriven,modelDriven,actionFileUpload,staticParams,actionMappingParams,params(copies request parameters to setters),conversionError,validationandworkflow. -
validationcallsvalidate()on an action that implementsValidateable(ActionSupportdoes);workflowchecks the error collections, and if any field, action or conversion error exists it returns the result codeinputwithout calling the action method. Both interceptors skip the methodsinput,back,cancelandbrowse. -
If nothing stopped it, the action method (
executeby default, or themethodattribute) runs and returns a string. -
The result code selects a
<result>in the mapping, which renders. Properties that the view prints are read from the action instance, which is still on top of the value stack.
The interceptor stack itself, the value stack and the result types each have their own page in this part (interceptors,
value-stack-action-context-and-ognl, results-and-result-types); this page stays on the class that the stack wraps.
The Action Mapping
The mapping is the declaration that connects a name in the URL to a class. Only the name attribute is required; the
others (class, method, converter) can be omitted, and a mapping can also carry results, exception mappings, an
interceptor stack and parameters.
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE struts PUBLIC
"-//Apache Software Foundation//DTD Struts Configuration 6.0//EN"
"https://struts.apache.org/dtds/struts-6.0.dtd">
<struts>
<package name="default" extends="struts-default" namespace="/">
<default-action-ref name="welcome"/>
<action name="welcome">
<result>/WEB-INF/views/welcome.jsp</result>
</action>
<action name="books" class="org.example.bookshelf.BookListAction">
<result>/WEB-INF/views/books.jsp</result>
<result name="empty">/WEB-INF/views/empty.jsp</result>
</action>
<action name="bookCount" class="org.example.bookshelf.BookCountAction" method="count">
<result>/WEB-INF/views/count.jsp</result>
</action>
<action name="newBook" class="org.example.bookshelf.AddBookAction" method="input">
<result name="input">/WEB-INF/views/add-form.jsp</result>
</action>
<action name="addBook" class="org.example.bookshelf.AddBookAction">
<result name="input">/WEB-INF/views/add-form.jsp</result>
<result>/WEB-INF/views/added.jsp</result>
</action>
</package>
</struts>
A <result> without a name answers to success. The DOCTYPE is the 6.0 one, which a Struts 7 application keeps
(why 6.0).
Action names and extensions
The framework drops the host, the application name and the extension from the requested URL and matches what is left
against the name attribute: a request for /g8act/books.action selects the mapping books. In 7.4.0 the extensions
accepted are those of struts.action.extension, whose default is action,,: the trailing empty entries mean the empty
extension is accepted too. Links to actions are normally produced by a Struts tag (<s:form action="addBook">,
<s:url action="books"/>), which adds the extension and the context path for the reader.
The documentation lists three limits that apply to the name itself:
-
The
DefaultActionMapperchecks the name against a regular expression,[a-zA-Z0-9._!/-]*by default; a name that does not match raises an exception, and the expression can be replaced with the constantstruts.allowed.action.names. -
A name that contains a slash (
admin/home) needsstruts.enable.SlashesInActionNamesset totrue, with side effects (validation files then replace the slash with a hyphen). -
Dots (
create.user) have no known side effects, but dashes (my-action) break the JavaScript that some tags and themes generate; camel case (createUser) or underscores (my_action) are the safe choices.
The default class and the default action
If the class attribute is left out, the package’s default-class-ref is used. In struts-default.xml of 7.4.0 that is
org.apache.struts2.ActionSupport, whose execute() returns success and whose input() returns input. The welcome
action above has no class at all and still renders its JSP, because ActionSupport.execute() returns success:
curl -s http://localhost:18080/g8act/welcome.action
<h1>Bookshelf</h1><p>Welcome, from the default ActionSupport.execute().</p>
<default-action-ref name="welcome"/> names the action that answers when no mapping matches. The same output came back for
nothing.action, which has no mapping, so a mistyped URL lands on the welcome page instead of a 404. The documentation’s
advice is one default action per namespace: if several packages declare a default action in the same namespace, there is no
guarantee which one wins. A trailing wildcard mapping <action name="*"> is the other way to catch unmatched names, and it
must be the last mapping in the file.
POJO Actions
The first Bookshelf action is a plain class. It has no base class, it filters the catalogue with a request parameter, and it
chooses between two results with its own result code, empty, which is not one of the five standard constants.
package org.example.bookshelf;
import java.util.List;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
public class BookListAction {
private String filter;
private List<String> titles = List.of();
public String execute() {
titles = Catalogue.find(filter);
return titles.isEmpty() ? "empty" : "success";
}
public String getFilter() {
return filter;
}
@StrutsParameter
public void setFilter(String filter) {
this.filter = filter;
}
public List<String> getTitles() {
return titles;
}
}
With the mapping books shown above, the view books.jsp lists the titles through the <s:iterator value="titles"> tag, and
empty.jsp prints the filter:
curl -s http://localhost:18080/g8act/books.action
curl -s "http://localhost:18080/g8act/books.action?filter=dun"
curl -s "http://localhost:18080/g8act/books.action?filter=zzz"
<ul><li>Dune</li><li>Neuromancer</li><li>Hyperion</li></ul>
<ul><li>Dune</li></ul>
<p>No titles match zzz.</p>
The request parameter filter reached setFilter because the params interceptor of the default stack copied it, and the
view read titles and filter back through their getters. The annotation @StrutsParameter on the setter is required
(see Request Parameters). Because the class does not depend on Struts at all (apart from the annotation), it
can be tested with a plain JUnit test, no container and no framework:
class BookListActionTest {
@Test
void returnsEmptyWhenNothingMatches() {
BookListAction action = new BookListAction();
action.setFilter("zzz");
assertEquals("empty", action.execute());
assertEquals(0, action.getTitles().size());
}
@Test
void returnsSuccessWhenTitlesMatch() {
BookListAction action = new BookListAction();
action.setFilter("dun");
assertEquals("success", action.execute());
assertEquals(1, action.getTitles().size());
}
}
Both tests passed under Maven Surefire (Tests run: 2, Failures: 0, Errors: 0). The limit of this approach is shown in
ActionSupport: as soon as the action calls getText, a test needs the framework running. The Struts
JUnit plugin (StrutsTestCase) starts the filter and the interceptors in a test, and Testing
covers it.
The Action Interface and Result Codes
org.apache.struts2.action.Action (in Struts 6 and earlier com.opensymphony.xwork2.Action) declares one method,
String execute() throws Exception, and five constants that name the conventional result codes. A result code is just a
string that the mapping resolves; the constants only save typing and typos.
| Constant | Value | Meaning in the Javadoc and in practice |
|---|---|---|
|
|
The execution was successful; show the result view. The result without a |
|
|
The execution was successful, but no view should be shown, because the action has produced the response itself (see |
|
|
The execution failed; show an error view |
|
|
The action could not execute because the input was missing or invalid; show the input form again. The |
|
|
The action could not execute until the user logs in; the convention for an authentication redirect |
Any other string is a valid result code: BookListAction returns empty, and the mapping defines <result name="empty">.
The interface is optional; implementing it is useful when the constants help, and it changes nothing else, because the
framework calls the method by reflection when the action does not implement it.
If a code is returned that the mapping cannot resolve (no <result> of that name in the action, and none in the package’s
global results), nothing renders. The scratch action OddCodeAction returns surprise and has a single unnamed result;
Struts 7.4.0 answered with HTTP 404 and the message No result defined for action org.example.bookshelf.OddCodeAction and result surprise.
Result lookup, global results and result types are the subject of Results & Result Types.
Answering without a view: NONE
NONE means the action has already written the response, so the framework must not render anything. The export mapping
has no <result> at all, and ExportAction writes a CSV document through the servlet response that it received from the
ServletResponseAware interface (see Session, Request and Response Access):
curl -s -i http://localhost:18080/g8act/export.action
HTTP/1.1 200
Content-Type: text/csv;charset=UTF-8
title
Dune
Neuromancer
Hyperion
The usual way to send a body that is not HTML is a stream, JSON or plain-text result, which keeps the action free of the
servlet API; the NONE form is shown because it is the only case that needs the response object inside the action.
ActionSupport
org.apache.struts2.ActionSupport is the base class that most actions extend. In the 7.4.0 jar it implements Action,
Validateable, ValidationAware, TextProvider, LocaleProvider and Serializable, and these are the roles it plays.
| Member | Role |
|---|---|
|
Returns |
|
Returns |
|
Empty by default. Override it for programmatic validation; the |
|
Errors tied to one input field (a map from field name to a list of messages); rendered by |
|
Errors that belong to the whole action; rendered by |
|
Informational messages (not errors); rendered by |
|
Aggregates over the three collections |
|
Message lookup in the resource bundles; the arguments fill the |
|
Current locale and bundle inspection |
|
Stops the invocation at once (by throwing a |
ModelDriven and Preparable are separate interfaces that an action implements next to ActionSupport; they and the CRUD
pattern are on ModelDriven, Preparable & CRUD.
A form action: validate, field errors, messages and getText
AddBookAction is the Bookshelf’s "add a title" action. It extends ActionSupport, validates in validate(), and builds its
messages with getText so that the wording lives in a properties file, not in Java.
package org.example.bookshelf;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.interceptor.parameter.StrutsParameter;
public class AddBookAction extends ActionSupport {
private String title;
@Override
public void validate() {
if (title == null || title.isBlank()) {
addFieldError("title", getText("book.title.required"));
} else if (Catalogue.contains(title)) {
addFieldError("title", getText("book.title.duplicate", new String[] {title}));
}
}
@Override
public String execute() {
Catalogue.add(title.trim());
addActionMessage(getText("book.added", new String[] {title.trim()}));
return SUCCESS;
}
public int getTotal() {
return Catalogue.size();
}
public String getTitle() {
return title;
}
@StrutsParameter
public void setTitle(String title) {
this.title = title;
}
}
book.title.required=Please enter a title
book.title.duplicate=The catalogue already contains "{0}"
book.added=Added "{0}" to the catalogue
The file sits next to the class name in the class path (AddBookAction.properties in the package directory). The lookup
order that getText follows, from the Struts documentation (see Localization and i18n), is: the action class’s own bundle, the bundles of its
interfaces, the bundles of its base classes, the bundle of the model if the action is model-driven, the package.properties
of the class’s package and of every parent package, the key hierarchy itself, and finally the global bundles. That is why
the same keys can also be shared from a package.properties.
The workflow interceptor turns the field error into a trip back to the form. The mapping has the result input, which is
the form with the error list printed by <s:fielderror fieldName="title"/>. The views of the example use
theme="simple" to keep the output short:
curl -s -d title= http://localhost:18080/g8act/addBook.action
curl -s -d title=Dune http://localhost:18080/g8act/addBook.action
curl -s -d title=Ubik http://localhost:18080/g8act/addBook.action
<form id="addBook" name="addBook" action="/g8act/addBook.action" method="post">
<input type="text" name="title" value="" id="addBook_title"/>
<ul class="errorMessage"><li><span>Please enter a title</span></li></ul>
<input type="submit" value="Submit" id="addBook_0"/>
</form>
<form id="addBook" name="addBook" action="/g8act/addBook.action" method="post">
<input type="text" name="title" value="Dune" id="addBook_title"/>
<ul class="errorMessage"><li><span>The catalogue already contains "Dune"</span></li></ul>
<input type="submit" value="Submit" id="addBook_0"/>
</form>
<ul class="actionMessage"><li><span>Added "Ubik" to the catalogue</span></li></ul>
<p>Catalogue now holds: 4</p>
(Whitespace inside the <ul> elements was collapsed for the listing.) Three things in this output are worth reading
carefully. The first is that execute() ran only for Ubik: the two invalid requests came back from workflow without
reaching it. The second is that the submitted value was put back into the text field, because the title property still
holds what params set. The third is that the message after a successful add is an action message rendered by the same
request that created it; messages do not survive a redirect unless the message store interceptor copies them, which is the
subject of the post-redirect-get material in the results and interceptors pages.
Showing the form: the input method
A request for the form itself must not run validation, otherwise the first visit shows an error. The first attempt in the
scratch app used the same mapping, addBook, for the form and the submission, and a plain GET answered with the
validation error Please enter a title: the title parameter was missing, so validate() rejected it, and workflow
sent the request to input. The fix is a second mapping for the same class with method="input", since the default
stack excludes the method input from validation and workflow:
curl -s http://localhost:18080/g8act/newBook.action
<form id="addBook" name="addBook" action="/g8act/addBook.action" method="post">
<input type="text" name="title" value="" id="addBook_title"/><input type="submit" value="Submit" id="addBook_0"/>
</form>
The method name input comes from the excludeMethods parameter of the two interceptors in the default stack of
struts-default.xml (input,back,cancel,browse). Another name for the form method would run validation again.
Testing ActionSupport outside a request
getText on an ActionSupport reaches the framework through the current ActionContext. A unit test that creates the
action with new AddBookAction() and calls validate() therefore fails before it reaches an assertion. The run of such a
test on 7.4.0 reported:
java.lang.NullPointerException: Cannot invoke "org.apache.struts2.ActionContext.getContainer()"
because the return value of "org.apache.struts2.ActionContext.getContext()" is null
The remedies are to run the test through the Struts JUnit plugin, which sets up the context, or to keep the message
construction out of the action (inject a service or keep the check in a plain method that returns a code, as
BookListAction does).
The method Attribute: More Than One Entry Point
By default the framework calls execute. Another method can be named with the method attribute, so one class can offer
several entry points (the documentation’s example is a CRUD class with create, retrieve, update and delete). The
Bookshelf’s BookCountAction has no execute at all:
public class BookCountAction extends ActionSupport {
private int count;
public String count() {
count = Catalogue.size();
return SUCCESS;
}
public int getCount() {
return count;
}
}
<action name="bookCount" class="org.example.bookshelf.BookCountAction" method="count">
<result>/WEB-INF/views/count.jsp</result>
</action>
curl -s http://localhost:18080/g8act/bookCount.action
<p>Titles in the catalogue: 4</p>
A method named by the method attribute is allowed by definition; the framework’s other ways of choosing a method
(wildcard patterns such as method="{1}", dynamic method invocation with ! and method:, and the strict method
invocation rules with <allowed-methods> and <global-allowed-methods>) are the subject of Wildcard Mappings & Method Invocation. Two facts matter here. The default struts-default package
of 7.4.0 declares <global-allowed-methods>execute,input,back,cancel,browse,save,delete,list,index</global-allowed-methods>,
so those names can be called without the method attribute; and dynamic method invocation is off:
struts.enable.DynamicMethodInvocation defaults to false in default.properties.
If the class has no execute and the mapping names no other method, the request fails. BrokenAction has only a list
method and a mapping without a method attribute; Struts 7.4.0 answered with HTTP 404 and the message
Wrong method was defined as an action method: execute. The official page says only that the framework "will throw an
exception".
Request Parameters and Properties
Struts fills an action by calling its setters. The params interceptor copies each request parameter whose name matches a
property path (filter, title, author.name, books[0].title) through OGNL, converting the string to the type of the
property. The action must also authorize the property, and in Struts 7 that is the default
(struts.parameters.requireAnnotations=true in default.properties): a setter, or a public field, or a getter for nested
objects, needs @StrutsParameter. The annotation exists to stop an attacker from setting a property that the developer
added for another reason.
UnannotatedAction has the same property as BookListAction, with the setter lacking the annotation:
curl -s "http://localhost:18080/g8act/unannotated.action?filter=dun"
curl -s "http://localhost:18080/g8act/books.action?filter=dun"
<ul><li>Dune</li><li>Neuromancer</li><li>Hyperion</li></ul>
<ul><li>Dune</li></ul>
The first call returned all three titles: the parameter was dropped silently, no error, no log line at the default level. The
second call, with the annotation, filtered. A getter alone never receives data. The parameter handling, the annotation’s
depth attribute, nested objects, collections and the OGNL expression limits are on Parameter Injection and @StrutsParameter. An action that must not receive any parameter at all can
implement the marker interface org.apache.struts2.action.NoParameters.
Session, Request, Response and Application Access
An action has no request argument, and the servlet scopes are reached through the Aware interfaces: the action declares
the object it needs by implementing an interface, and the servletConfig interceptor calls the interface’s method on the
new instance before the action runs. The Struts documentation calls this the best way to access the scopes, and it
recommends against reaching for ActionContext directly.
| Interface | Method called by the framework | Object it provides |
|---|---|---|
|
|
A map backed by the |
|
|
The servlet request; ties the action to the servlet API |
|
|
The servlet response; ties the action to the servlet API |
|
|
A map backed by the |
|
|
The servlet context itself |
|
|
The request parameters ( |
|
|
The authenticated principal and role checks |
|
|
The request cookies, by name |
The names with... replaced the older set... names when the interfaces moved from org.apache.struts2.interceptor to
org.apache.struts2.action (see What Changed Recently). The interceptor that performs the injection is
servletConfig, third in the default stack; the reference page of the interceptor lists the supported interfaces, but its
list is stale for 7.x (see Where the Books Are Outdated and the errata notes at the end of this page).
A reader with a session and a request
ReaderAction counts the visits of a reader in the session and records on the request which class served it. It also reads
the same session value a second way, through a helper that uses ActionContext, to show what the direct route looks like.
package org.example.bookshelf;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.action.ServletRequestAware;
import org.apache.struts2.action.SessionAware;
public class ReaderAction extends ActionSupport implements SessionAware, ServletRequestAware {
private Map<String, Object> session;
private HttpServletRequest request;
private String requestLine;
@Override
public void withSession(Map<String, Object> session) {
this.session = session;
}
@Override
public void withServletRequest(HttpServletRequest request) {
this.request = request;
}
@Override
public String execute() {
int visits = (Integer) session.getOrDefault("visits", 0) + 1;
session.put("visits", visits);
request.setAttribute("servedBy", getClass().getSimpleName());
requestLine = request.getMethod() + " " + request.getRequestURI();
return SUCCESS;
}
public Object getVisitsViaContext() {
return CurrentReader.attribute("visits");
}
public String getRequestLine() {
return requestLine;
}
}
public final class CurrentReader {
private CurrentReader() {
}
public static Object attribute(String key) {
return ActionContext.getContext().getSession().get(key);
}
}
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<p>visits=<s:property value="#session.visits"/> servedBy=<s:property value="#request.servedBy"/>
request=<s:property value="requestLine"/> viaContext=<s:property value="visitsViaContext"/></p>
Two requests that share a cookie jar, then one that does not:
curl -s -c jar.txt http://localhost:18080/g8act/reader.action
curl -s -b jar.txt http://localhost:18080/g8act/reader.action
curl -s http://localhost:18080/g8act/reader.action
<p>visits=1 servedBy=ReaderAction request=GET /g8act/reader.action viaContext=1</p>
<p>visits=2 servedBy=ReaderAction request=GET /g8act/reader.action viaContext=2</p>
<p>visits=1 servedBy=ReaderAction request=GET /g8act/reader.action viaContext=1</p>
The counter belongs to the session (the same cookie gives 2, a new visitor starts at 1); #session.visits and
#request.servedBy are the OGNL names that the view uses for the two scopes, and #application and #attr complete the
set (#attr searches the page, request, session and application scopes in that order). viaContext shows that
ActionContext.getContext().getSession() sees the same map: the direct route works during a request, which is why it is
acceptable in a utility that has no other way to the session. In an action, the interface is the better form because the
dependency is visible in the class signature and the action can be unit-tested by passing a plain HashMap to
withSession.
The two scope maps are not the servlet objects. SessionAware hands over a Map, not the HttpSession, so the action stays
free of the servlet API, and the session-fixation and cookie controls described on the servlet page still apply to the
real HttpSession underneath (Servlet Fundamentals, Sessions).
ActionContext
org.apache.struts2.ActionContext is the per-request, per-thread holder of everything the framework knows about the call. In
7.4.0 it is a fluent class: ActionContext.getContext() returns the context bound to the current thread, the read accessors
include getSession(), getApplication(), getParameters(), getLocale(), getActionName(), getValueStack(),
getActionInvocation(), getServletRequest(), getServletResponse(), getServletContext() and getConversionErrors(),
and the with... methods (withSession, withLocale, …) configure a context that a test builds with
ActionContext.of(...) and bind(). Outside a request, on a thread the framework did not set up, getContext() returns
null, which is the cause of the NullPointerException shown above. The Struts documentation’s advice stands: take the
scopes from the Aware interfaces, and read ActionContext only where an interface cannot be used.
The interceptor is a requirement
The Aware interfaces work only when the servletConfig interceptor is in the action’s stack. The readerNoServletConfig
mapping reuses ReaderAction with <interceptor-ref name="params"/> as its only interceptor (so the stack is replaced,
not extended):
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g8act/readerNoServletConfig.action
500
Cannot invoke "java.util.Map.getOrDefault(Object, Object)" because "this.session" is null
The framework does not warn that an Aware interface was ignored; the field stays null and the first use fails. The custom
stacks that the interceptor pages describe must include servletConfig (the predefined basicStack does) whenever
their actions are Aware.
Thread per Request
A servlet is one instance for the whole life of the application, so its fields are shared state; the servlet page of this
section calls them as dangerous as static fields (Servlet Fundamentals, Lifecycle and Threading). A Struts 2 action is the opposite: a new
instance for every request, used by one thread, and thrown away after the response. InstanceAction makes that visible:
it has an instance counter and a static counter.
public class InstanceAction extends ActionSupport {
private static final AtomicInteger TOTAL = new AtomicInteger();
private int callsOnThisInstance;
private int callsOnAllInstances;
private boolean sameAsContext;
@Override
public String execute() {
callsOnThisInstance++;
callsOnAllInstances = TOTAL.incrementAndGet();
sameAsContext = ActionContext.getContext().getActionInvocation().getAction() == this;
return SUCCESS;
}
public int getInstanceId() {
return System.identityHashCode(this);
}
// plus getters for the other three fields
}
curl -s http://localhost:18080/g8act/instance.action
curl -s http://localhost:18080/g8act/instance.action
curl -s http://localhost:18080/g8act/instance.action
<p>instance=2098901688 thisInstance=1 allInstances=1 sameAsContext=true</p>
<p>instance=1002930916 thisInstance=1 allInstances=2 sameAsContext=true</p>
<p>instance=578993255 thisInstance=1 allInstances=3 sameAsContext=true</p>
The identity hash code differs on every request, the instance counter is always 1, and only the static counter grows:
three requests, three instances. sameAsContext confirms that the action the invocation holds is the object that
execute() runs on. In practice that gives the rules below.
-
Instance fields of an action (the properties that the form fills and the view reads) need no synchronization.
-
Anything the action reaches that outlives the request is shared by all threads: static fields, the application map, the session map (shared by all the requests of one user, which can overlap), singletons, injected services and the catalogue itself. The
Catalogueof the scratch app is aCopyOnWriteArrayListfor that reason. -
An action must not be cached or kept in a field of another object. The framework creates it, uses it for one invocation and drops it; the Spring plugin’s rules for actions that are created by a Spring container are on Spring Integration.
Struts 1 was the opposite and the books on Struts 1 stress it: a Struts 1 Action was a singleton shared by all threads, and
the book’s thread-safety rules (no instance fields, no per-user state in the action) do not carry over to Struts 2.
Source: Apache Struts — Primer, Threads
Keeping Business Logic Out of Actions
The books' data-services chapters (a JDBC helper, a Lucene index, an RSS reader, an EJB call) all make the same point, and it
holds for Struts 7: the action is the adapter between the web and the model, not the model. The Getting Started tutorial
describes an action class as code that executes business logic "or calls upon other classes to do that". A class that
searches the catalogue, applies a business rule or talks to a database belongs in a plain Java class with no Struts and no
Servlet API; the action reads the request properties, calls that class, and chooses the result. Three consequences follow:
the rule is tested with an ordinary unit test, no request or ActionContext is needed; the same rule can be reused by a
REST controller or a batch job; and a database session or transaction is owned by the service and never leaks into the
action instance, which lives for one request only.
CatalogueService.java: the rule, with no framework typespublic class CatalogueService {
private final List<String> titles;
public CatalogueService(List<String> titles) {
this.titles = List.copyOf(titles);
}
public List<String> find(String filter) {
if (filter == null || filter.isBlank()) {
return titles;
}
String needle = filter.trim().toLowerCase();
return titles.stream().filter(t -> t.toLowerCase().contains(needle)).toList();
}
}
SearchAction.java: the action only adaptspublic class SearchAction {
private final CatalogueService catalogue;
private String filter;
private List<String> titles = List.of();
public SearchAction() { // used by the framework
this(Services.catalogue());
}
SearchAction(CatalogueService catalogue) { // used by tests
this.catalogue = catalogue;
}
public String execute() {
titles = catalogue.find(filter);
return titles.isEmpty() ? "empty" : "success";
}
public String getFilter() { return filter; }
@StrutsParameter
public void setFilter(String filter) { this.filter = filter; }
public List<String> getTitles() { return titles; }
}
The framework needs a public no-argument constructor, so the example obtains its service from a small factory class
(Services, a stand-in for a container). In a real application that factory is replaced by injection, through the
Spring plugin or CDI (see Spring Integration and
Dependency Injection and CDI), and the persistence work is done with
Hibernate in the service layer (see Getting Started with Hibernate and
Hibernate Transactions). The package-private constructor is what lets the test below
run without Struts, a server or a request.
SearchActionTest.java: no framework neededclass SearchActionTest {
private final SearchAction action =
new SearchAction(new CatalogueService(List.of("Dune", "Emma")));
@Test
void successWhenTitlesMatch() {
action.setFilter(" DU ");
assertEquals("success", action.execute());
assertEquals(List.of("Dune"), action.getTitles());
}
@Test
void emptyWhenNothingMatches() {
action.setFilter("zzz");
assertEquals("empty", action.execute());
}
}
Mapped as search (the same two results as the books action), the action answered on Tomcat 11.0.26 with Struts 7.4.0 as
follows, and the two tests passed (Tests run: 2, Failures: 0; observed).
curl against the scratch application)$ curl -s 'http://localhost:18080/g14dact/search.action?filter=dun'
<ul><li>Dune</li></ul>
$ curl -s 'http://localhost:18080/g14dact/search.action?filter=zzz'
<p>No titles match zzz.</p>
$ curl -s 'http://localhost:18080/g14dact/search'
<ul><li>Dune</li><li>Neuromancer</li><li>Hyperion</li></ul>
The Bookshelf End to End: Adding a Title
The worked example ties the pieces together: two mappings for one class (the form and the submission), validation by
validate(), messages from a bundle, results by code, and a listing that shows the change. It uses only the files already
shown on this page; the scratch app was freshly deployed before the requests, so the catalogue starts with three titles.
(validation and workflow skip input)"] inp --> form["add-form.jsp: the empty form"] form --> post["POST /addBook.action with title"] post --> params["params: setTitle, with the annotation"] params --> val["validate()"] val -->|"blank or duplicate title"| err["addFieldError: workflow returns input"] err --> form val -->|"title is valid"| exec["execute(): add to the catalogue,
addActionMessage, return success"] exec --> added["added.jsp: message and total"] added --> list["GET /books.action lists the new title"]
The configuration is the five mappings of the mapping example; the sequence of requests and the answers:
curl -s http://localhost:18080/g8act/books.action
curl -s http://localhost:18080/g8act/newBook.action
curl -s -d title= http://localhost:18080/g8act/addBook.action
curl -s -d title=Dune http://localhost:18080/g8act/addBook.action
curl -s -d title=Ubik http://localhost:18080/g8act/addBook.action
curl -s "http://localhost:18080/g8act/books.action?filter=u"
curl -s http://localhost:18080/g8act/bookCount.action
<ul><li>Dune</li><li>Neuromancer</li><li>Hyperion</li></ul>
(the empty form, as in the input method section)
(the form again, with "Please enter a title")
(the form again, with the value Dune and "The catalogue already contains "Dune"")
<ul class="actionMessage"><li><span>Added "Ubik" to the catalogue</span></li></ul><p>Catalogue now holds: 4</p>
<ul><li>Dune</li><li>Neuromancer</li><li>Ubik</li></ul>
<p>Titles in the catalogue: 4</p>
Reading the trace: the empty submission and the duplicate stopped in validate() and rendered the input result with a
field error, the valid one reached execute(), and the filter=u listing (a case-insensitive substring) now includes
Ubik. One real application would finish the success path with a redirect to books (post-redirect-get) so that a browser
refresh does not add the title twice; the redirect result and the message store are on Results & Result Types. The
form post here is also unprotected against forged requests, which Double-Submit Tokens & Wait Pages and
Security Hardening cover.
Pitfalls
| Symptom or error | Cause | Fix |
|---|---|---|
The form page shows a validation error on the first visit (observed) |
The form and the submission use one mapping; with no |
Map the form to the method |
A request parameter is silently ignored (observed) |
The setter lacks |
Annotate the setter or field; check the annotation depth for nested objects |
|
The action implements an Aware interface but its stack has no |
Extend |
HTTP 404 with |
The class has no |
Add |
A mistyped URL shows the welcome page (observed) |
The package has a |
Intended; remove the default action if the 404 is wanted, and keep one per namespace |
|
|
Use the Struts JUnit plugin, or keep the logic in methods that do not call |
A static method in a view expression returns nothing (observed) |
OGNL static method access is removed, and static field access is off by default in Struts 7 ( |
Expose a getter on the action, as |
HTTP 404 |
The action returned a code that has no |
Map the code, or return one that is mapped; |
A property that the view prints shows nothing |
The getter is missing, is not public, or the property has a different name; the value stack only sees getters |
Name the getter by the JavaBeans rule ( |
A field set by one user appears for another |
The action keeps request data in a static field or in a service singleton |
Keep per-request data in instance fields; protect shared data ( |
The action name contains a dash and the generated JavaScript breaks |
Dashes are not safe in names that tags turn into identifiers |
Use camel case or underscores |
|
The context is bound to the request thread |
Pass the values you need into the task; do not call the context from other threads |
Confusion between "action" in Struts and |
Both are called actions; they are unrelated |
A JSP standard action is a tag of the page (see Standard Actions & JavaBeans); a Struts action is a controller class |
The JSP standard actions that the last row mentions are explained in JSP Standard Actions and JavaBeans.
What Changed Recently
| Release | Change |
|---|---|
Struts 2.3 |
Strict dynamic method invocation: |
Struts 2.5 |
Strict method invocation is on by default in the |
Struts 6.0 |
The Aware interfaces move from |
Struts 7.0 |
Java 17 and Jakarta Servlet 6 are the minimum; the package |
Struts 7.2 to 7.4 |
|
The two jars give the same story in code: the 2.5.33 SessionAware has the single method setSession, the 6.12.0 version of
org.apache.struts2.interceptor.SessionAware still has setSession (and a default withSession) and extends the new
org.apache.struts2.action.SessionAware, and the 7.4.0 jar has only the new interface with withSession. An action written
for 2.5 therefore needs its imports and its method names changed for 7.
Where the Books Are Outdated
Two of the books of the section’s bibliography deal with actions. Struts 2 in Action (Manning, 2008, chapter 3, Actions)
is written for Struts 2.0 on Java 5, and Struts in Action (Manning, 2002, chapters 2 and 8) is about Struts 1, where
an Action is a different thing. The concepts carry over, with the exceptions below.
| The book’s era | Today (Struts 7.4) |
|---|---|
Imports from |
|
|
|
|
Aware interfaces first; the context only where an interface cannot be used. The context is built with |
Any public setter fills the action from the request; no annotations |
|
Dynamic method invocation |
Off by default ( |
URLs and links ending in |
The extension is configurable and the default accepts both |
Zero configuration and |
Replaced by the Convention plugin ( |
Struts 1 |
A Struts 2 action’s method takes no argument and returns a result string; the request data arrives through setters and the scopes through Aware interfaces. |
A Struts 1 |
A Struts 2 action is one instance per request |
|
A mapping with no class and a result (forward), the |
Notes on the Official Documentation
Reading the official pages against the 7.4.0 jar showed inconsistencies that a reader will meet; they are listed so that a mismatch is not mistaken for a mistake in the example code.
-
Several core-developer pages (
action-configuration,localization,default-workflow-interceptor) still namecom.opensymphony.xwork2.ActionSupportandcom.opensymphony.xwork2.Validateable. In 7.4.0 the classes areorg.apache.struts2.ActionSupportandorg.apache.struts2.Validateable(the 6.x to 7.x migration page lists the move; the localization page itself suggestsorg/apache/struts2/ActionSupport.propertiesin the same paragraph). -
The accessing-session-request page shows
application.set("myKey", "myValue")on aMap; aMaphasput, notset. -
The servlet config interceptor page lists
ParameterAware,HttpParametersAwareandRequestAware, which are not in the 7.4.0 jar; the interfaces present areParametersAware,ServletRequestAware,ServletResponseAware,SessionAware,ApplicationAware,ServletContextAware,PrincipalAwareandCookiesAware. -
The action-configuration page says a class without the method "will throw an exception"; 7.4.0 answers with an HTTP 404 and a message naming the missing method.
-
The migration page lists the moved class as
org.opensymphony.xwork2.Action; the real old name iscom.opensymphony.xwork2.Action.
References
-
Apache Struts — Action Configuration, Strict Method Invocation
-
Apache Struts — Accessing application, session, request objects, Accessing from Java
-
Apache Struts — Accessing application, session, request objects, Avoid using ActionContext
-
Apache Struts — Accessing application, session, request objects, Accessing from the view
-
Apache Struts — StrutsParameter Annotation, Where authorization applies
-
Apache Struts — Getting Started, Processing Form Input In The Action Class
-
Apache Struts — Getting Started, Unit Testing, Writing A Unit Test
-
Maven Central — org.apache.struts:struts2-core (2.5.33, 6.12.0 and 7.4.0 jars compared with javap)