Struts 1 (Classic) & Migration

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 was verified on 2026-10-08 and 2026-10-10. The Struts 1 facts come from the Apache sources of release 1.3.10 (the last Struts 1 release, December 2008) in the Apache Subversion repository, from the end-of-life announcement and press release of 2013-04-05 and from the Struts wiki; the Struts 7 facts come from Apache Struts 7.4.0 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21, where every "after" example was built into a WAR, deployed and requested with curl. The "before" examples are Struts 1 code written for this page: they are small, labelled, and never run, because Struts 1.3.10 was compiled against the javax.servlet 2.3 API and does not start on a current container. Struts 1, the framework that taught a generation of Java developers Model 2, has been unmaintained for over thirteen years, and it is still the framework underneath a surprising number of intranet applications. This page has two jobs: to let you read a legacy struts-config.xml, ActionForm and html:form without the 2002 book at hand, and to move such an application to Struts 7.4.0 (or elsewhere) one screen at a time. The running scenario is the Bookshelf: a catalogue of books with a list, an "add a book" form with validation, a double-submit guard, a lend and give-back action and a global error page, first as the Struts 1 application it might once have been, then as the Struts 7 application it becomes. The repository’s Bookshop legacy-migration scenario (a Struts 1 and JSP monolith moved to a modern stack) is linked, not reused, in The Strangler Fig and the Bookshop. The history of Struts 1 and the overview of the Struts 2 lineage are on the Introduction and History page; the Struts 7 side of every concept is on the pages named in Concept Map: Struts 1, Struts 7.4.0 and Spring MVC. Related pages are Testing, Security Hardening, Security Bulletins and What Changed and Migration.

Where Struts 1 Stands

Struts 1 was started in 2000 and had its last release, 1.3.10, in December 2008. The Struts project announced the end of life of the 1.x line on 2013-04-05, in an announcement and a press release that say the same thing: the framework "has reached its end of life and is no longer officially supported". The project’s own end-of-life page lists "1.x" with the date 5 April 2013 next to the Struts 2.3 and 2.5 lines, and says that an end-of-life version receives no more releases, security fixes or bug fixes. The announcement’s question-and-answer part, summarised on the Introduction and History page, has the sentences that matter for a legacy application: nothing is coming (you must find mitigations, patch the source or migrate), there is "no drop-in replacement", Struts 2 is the recommended successor, and Spring Web MVC, Grails and Stripes are the alternatives the team names.

What is left of the Struts 1 project is an archive. The documentation is gone from the project site (/1.x/ and the old FAQ answer 404), but struts-1.3.10-docs.zip, struts-1.3.10-src.zip, -lib.zip, -apps.zip and -all.zip are in the Apache archive, the source is in the Subversion tree under tags/STRUTS_1_3_10, and the Maven artifacts are on Maven Central (verified 2026-10-08: org.apache.struts:struts-core, struts-taglib, struts-tiles, struts-extras and struts-el all have 1.3.10 as their latest and only recent version). The documentation zip is the closest thing to the missing manual; the Subversion tree is where you read the class that a stack trace points at.

Why It Must Not Run in Production

There are three separate reasons, and any one of them is enough.

It is unpatched and its known vulnerabilities stay open. The project said so in 2013, and the CVE records that were published afterwards confirm it. All four records below were checked on 2026-10-10 against the CVE program’s record service; none of them has a fixed Struts 1 version, because there will be no further Struts 1 release.

Table 1. Verified CVE records that name Struts 1
CVE What the record says (condensed) Where the weakness is

CVE-2014-0114

Published 2014-04-30. Apache Commons BeanUtils, as distributed in lib/commons-beanutils-1.8.0.jar in Struts 1.x through 1.3.10 and in other products, does not suppress the class property, so a remote attacker can manipulate the class loader through the class request parameter and execute arbitrary code

The ActionForm population step: request parameters become property names. The same idea as the Struts 2 class-loader bulletins of the time, and the reason Struts 7 needs @StrutsParameter

CVE-2015-0899

Published 2016-07-04. The MultiPageValidator implementation in Struts 1 1.1 through 1.3.10 lets a remote attacker bypass access restrictions through a modified page parameter

The Validator’s multipage forms, which trust a request parameter for the page number

CVE-2016-1181

Published 2016-07-04. ActionServlet.java in Struts 1 1.x through 1.3.10 mishandles multithreaded access to an ActionForm instance; remote attackers can execute arbitrary code or cause a denial of service through a multipart request

The form bean shared between threads in some scopes, the flip side of the singleton design

CVE-2016-1182

Published 2016-07-04. ActionServlet.java in Struts 1 1.x through 1.3.10 does not properly restrict the Validator configuration; remote attackers can conduct cross-site scripting or cause a denial of service through crafted input

The Validator configuration reachable through request input

The records name the damage, not a recipe, and this page does not give one. What matters for a team that inherits such an application is the pattern: the weaknesses sit in the framework’s request-to-bean machinery, and the framework’s own dependencies (BeanUtils 1.8.0, Commons FileUpload 1.1.1, Commons Validator 1.3.1, Commons Digester 1.8, all declared in the 1.3.10 struts-core POM) are as old as the framework. Updating a library next to Struts 1 is the only mitigation a team can apply without touching the framework source, and it does not make the framework supported.

It does not run on a current container. The 1.3.10 struts-core POM declares javax.servlet:servlet-api:2.3 as its provided dependency, and the code, the tag libraries and the JSP support classes use the javax.servlet and javax.servlet.jsp packages. Servlet 5.0 and Pages 3.0 moved the whole platform to jakarta.*, which is the namespace of Tomcat 10 and later; Struts 1 cannot be recompiled by the project (there is no project), so it stays on Tomcat 9 and older, or on a bytecode-converted copy that nobody supports. A javax WAR copied into the webapps folder of Tomcat 11 deploys without any warning and answers 404 for its URLs (observed and documented on the Getting Started page; the Tomcat migration tool and the Host attribute legacyAppBase convert such a WAR, which is a stopgap, not a fix). The platform side of this is explained in the "javax to jakarta" part of Introduction and History. Tomcat 9 itself has an end of support date that the Tomcat site gives as "no earlier than 31 March 2027", so an application that stays on Struts 1 also stays on a servlet container with a clock running.

Nobody can hire for it, and the documentation is a zip file. The skills are shared with the Struts 2 world only in outline; the book that teaches Struts 1 (Struts in Action, 2002) describes 1.0 and 1.1 beta 2 and is outdated in the details listed in Where the Books Are Outdated.

Struts 1 Architecture, to Read Legacy Code

Struts 1 is Model 2 with a servlet at the front. One servlet, org.apache.struts.action.ActionServlet, is mapped in web.xml to a pattern (almost always *.do) and receives every request of the application. It reads one or more configuration files at start-up, and for each request it hands over to a request processor, which selects the configured action mapping, builds and fills a form bean, validates it, runs a singleton action and then forwards or redirects to the view named by the action forward the action returned. Everything below is the vocabulary of that sentence.

Two columns of boxes. Left, Struts 1.3.10: browser, ActionServlet, the request processor chain, ActionForm creation and population, validation, a singleton Action, ActionForward and the JSP view. Right, Struts 7.4.0: browser, StrutsPrepareAndExecuteFilter, ActionMapper and ActionProxy, the interceptor stack, validation and workflow, a new action per request, a Result and the JSP view
Figure 1. One request through Struts 1.3.10 and through Struts 7.4.0

The Components

Table 2. The pieces of a Struts 1.3.10 application
Piece What it is Where to find it in legacy code

ActionServlet

The front controller, a javax.servlet.http.HttpServlet. init() loads the configuration (config init parameter, default /WEB-INF/struts-config.xml) and the module configurations; doGet and doPost both call process, which delegates to the request processor of the module

web.xml: <servlet-class>org.apache.struts.action.ActionServlet</servlet-class>, a config parameter, <load-on-startup>, <url-pattern>*.do</url-pattern>

Request processor

The object that runs one request. In 1.3.10 the default is org.apache.struts.chain.ComposableRequestProcessor, which executes the Commons Chain commands listed in The Request Processor Chain; the older org.apache.struts.action.RequestProcessor (1.1 and 1.2 style, with processPath, processMapping, processActionForm, processValidate and similar methods) is still in the 1.3.10 sources and is what an application that overrides processPreprocess extends

<controller processorClass="..."> in struts-config.xml; a subclass in the application’s own source

struts-config.xml

The configuration file (DTD struts-config_1_3.dtd): form-beans, global-exceptions, global-forwards, action-mappings, controller, message-resources and plug-in, in that order (see Reading a struts-config.xml)

WEB-INF/struts-config.xml, plus one file per module

ActionMapping

The configuration object for one request path: path, type, name (the form bean), scope, validate, input, parameter, forward, roles, unknown, the local forward and exception elements, plus any set-property. A custom subclass is named in type/className

<action path="/bookAdd" type="..." name="bookForm" scope="request" validate="true" input="/book/form.jsp">

Action

The unit of work. Since 1.2 the method is execute(ActionMapping, ActionForm, HttpServletRequest, HttpServletResponse) (the 1.1 method perform was removed; the 1.3.10 Action.java no longer contains it) and it returns an ActionForward. One instance serves every request of the mapping, so it must be thread safe. Action offers the helpers saveErrors, saveMessages, saveToken, isTokenValid, resetToken, isCancelled, getLocale and getResources

public class BookAddAction extends Action

ActionForm

The form bean: a class that extends org.apache.struts.action.ActionForm, with a getter and setter per field (almost always String), reset(ActionMapping, HttpServletRequest) to clear checkboxes and validate(ActionMapping, HttpServletRequest) to return ActionErrors. Its scope (request or session) is set in the mapping

<form-bean name="bookForm" type="...BookForm"/>

DynaActionForm

A form bean declared in XML with form-property elements instead of a class; a DynaValidatorForm adds the Validator. Read values with get("title")

<form-bean type="org.apache.struts.action.DynaActionForm"> with <form-property name="title" type="java.lang.String"/> (the pattern appears in the Struts Blank application’s configuration)

ActionForward

A named destination: <forward name="success" path="/book/list.do" redirect="true"/>, global or local to a mapping. ActionRedirect (in the 1.3.10 sources) builds a redirect with parameters in code. A forward is a request-dispatcher forward unless redirect is true

mapping.findForward("success") in an action; <global-forwards>

ActionMessages, ActionErrors, ActionMessage

The messages that a form or an action puts in the request or the session for the view; each has a property name and a message key resolved by the MessageResources. ActionError of the 1.0 and 1.1 days is no longer in the 1.3.10 core sources (ActionMessage replaces it)

ActionErrors errors = new ActionErrors(); errors.add("title", new ActionMessage("error.title.required"));

MessageResources

The message bundle, declared with <message-resources parameter="MessageResources"/>, which is a base name resolved on the classpath like a ResourceBundle; read with bean:message or html:errors in a page

MessageResources.properties, MessageResources_es.properties

PlugIn

An interface (init, destroy) for start-up and shut-down code of a module, declared with <plug-in className="...">. The Validator and Tiles are plug-ins

<plug-in className="org.apache.struts.validator.ValidatorPlugIn"> with pathnames of validator-rules.xml and validation.xml

Modules

Several independent struts-config files in one web application, each with its own prefix in the URL (/admin/user.do for the module /admin), request processor, resources and forwards; SwitchAction moves from one module to another

<init-param> named config/admin in web.xml

The Request Processor Chain

The default request processor of 1.3.10 is a chain of Commons Chain commands, declared in chain-config.xml inside struts-core. Reading it is the quickest way to learn what Struts 1 does between the servlet and your action, and what a legacy processPreprocess override or a custom command was hooking into. The process-action chain of the 1.3.10 file runs these commands in this order (verified by reading the file in the 1.3.10 tree):

Table 3. The process-action chain of Struts 1.3.10
# Command What it does

1

servlet.SelectLocale

Selects the locale to be used for the request

2

servlet.SetOriginalURI

Sets the servlet path (the original URI) for later steps

3

servlet.RequestNoCache

Writes the no-cache headers when the controller is configured for it

4

servlet.SetContentType

Sets the content type configured on the controller

5

RemoveCachedMessages

Removes cached messages stored in the session (a command added in 1.3.5)

6

servlet.SelectAction

Matches the request path against the action mappings and finds the ActionConfig

7

servlet.AuthorizeAction

Checks the roles attribute of the mapping against the container’s role information

8

CreateActionForm

Finds or creates the form bean named by the mapping, in the configured scope

9

servlet.PopulateActionForm

Calls reset(), then copies the request parameters onto the form bean (the BeanUtils step of CVE-2014-0114)

10

servlet.ValidateActionForm

Runs validation when the mapping says validate="true" and the request was not cancelled

11

servlet.SelectInput

When validation failed, selects the forward of the input page for the return trip

12

ExecuteCommand

Runs a Commons Chain command named by the command attribute of the mapping, if any

13

servlet.SelectForward, SelectInclude, servlet.PerformInclude

Handle mappings that carry only a forward or include attribute

14

servlet.CreateAction

Finds or creates the singleton Action for the mapping

15

servlet.ExecuteAction

Calls execute and stores the ActionForward that it returns

The process-view chain then performs the forward (ExecuteForwardCommand, servlet.PerformForward), and a servlet-exception chain handles an exception with the ExceptionHandler and the global-exceptions declarations. The Struts 7 equivalents are the interceptor stack (the params, validation and workflow interceptors are steps 9 to 11), the result and the exception mappings; the Architecture and Request Lifecycle page has the full list. The difference to keep in mind is the unit of customisation: in Struts 1 a module has one lifecycle (the chain) shared by every action in it, and in Struts 7 each action chooses its own interceptor stack.

The Action Library: Dispatch, Forward, Include and Switch

A legacy application rarely extends Action directly for every screen. The 1.3.10 struts-extras artifact (the package org.apache.struts.actions) holds the ready-made ones that old code uses:

Table 4. Struts 1 action classes found in legacy code
Class Behaviour Struts 7 counterpart

ForwardAction, IncludeAction

Forward to, or include, the path named by the parameter attribute without any code: the way to put a JSP behind an action path

An <action> with no class and a <result> (the Bookshelf book-form mapping below)

DispatchAction

Looks up a method whose name is in the request parameter named by the mapping’s parameter attribute (?method=lend) and calls it with the execute signature; unspecified and cancelled are the fall-backs

A wildcard mapping with method="{1}" and <allowed-methods>, or one action class with several methods

LookupDispatchAction

The dispatch method is chosen from the label of the submit button, through a reverse lookup in the message resources (getKeyMethodMap()), so the buttons can be localised

One mapping per button, or a method name in the button’s method: name (strict method invocation applies)

MappingDispatchAction

The method name comes from the mapping’s parameter, so one class serves several mappings

The method attribute of <action> (the Bookshelf example in Worked Example: Moving the Bookshelf from Struts 1 to Struts 7 uses a wildcard)

EventDispatchAction

Dispatches on a request parameter whose name, not value, selects the method (handy for image buttons)

The method: prefix is gone with prefixes disabled; use separate mappings or the multi-button technique of the wildcard page

SwitchAction

Moves from one module to another through the prefix and page request parameters

A different namespace (see the packages and namespaces page)

LocaleAction, DownloadAction

Change the session locale; stream a file

The i18n interceptor with request_locale; the stream result

The Tag Libraries, Validator and Tiles 1

Struts 1 shipped five tag libraries, each with its own URI in the descriptor (verified in the 1.3.10 TLDs): http://struts.apache.org/tags-html (the html prefix, 29 tags in 1.3.10: html:form, html:text, html:errors, html:submit, html:link, html:option, html:multibox and the others), tags-bean (bean:write, bean:message, bean:define), tags-logic (logic:iterate, logic:present, logic:equal), tags-nested (the same tags with a nested property path) and tags-tiles. They bind to a form bean by the property attribute and read from the page, request, session or application scope, with name and scope attributes where the tag is not inside an html:form. A section of JSP that uses logic:iterate over a collection with bean:write for each field is a typical page; the JSTL and EL replacement is in the JSTL Core and Expression Language Basics pages, and the Struts 7 tag library is introduced on the Tags Overview and Generic Tags page.

The Validator is the Commons Validator integration: a ValidatorPlugIn loads validator-rules.xml (the rules, from the struts-core jar) and validation.xml (the forms and fields of the application), a form bean extends ValidatorForm or ValidatorActionForm (or is a DynaValidatorForm) and the declared rules (required, mask, minlength, email, intRange and others) run on the server and, with html:javascript, in the browser. The declarations for a field look like this in the 1.3.10 MailReader sample: <field property="username" depends="required"><arg key="prompt.username"/></field> inside a <form name="LogonForm">.

Tiles 1 is the layout framework that Struts 1 bundled (struts-tiles): definitions in tiles-defs.xml, the tiles tags, and a TilesPlugin. It became the standalone Apache Tiles project, which was retired and moved to the Attic; a Struts 1 application on Tiles 1 and a Struts 7 application on the Tiles plugin share the vocabulary, not the code (see the Tiles & Page Layouts page).

Reading a struts-config.xml

The configuration is the map of a Struts 1 application. The DTD of 1.3 (struts-config_1_3.dtd) fixes the order of the top-level elements: form-beans?, global-exceptions?, global-forwards?, action-mappings?, controller?, message-resources* and plug-in*. The Struts Blank application, which is the usual starting file of a legacy project, shows each of them. The table gives the Struts 7 home of each element; the details are in Concept Map: Struts 1, Struts 7.4.0 and Spring MVC.

Table 5. The elements of struts-config.xml (1.3 DTD)
Element Meaning Struts 7.4.0 home

form-beans / form-bean / form-property

Declares the named form beans (name, type) and the properties of a DynaActionForm

No declaration: the action’s own properties, or a ModelDriven model

global-exceptions / exception

Maps an exception type to a message key and a path (the error page) for the whole module

<global-exception-mappings> and <exception-mapping> in a package (the exception interceptor)

global-forwards / forward

Named destinations any action of the module may return

<global-results>

action-mappings / action

One entry per request path: path, type, name, scope, validate, input, parameter, roles, forward, unknown, local forward and exception children. The 1.3 path may end in a wildcard (/edit*, with {1} in the type and input)

<action> with name, class, method and <result> children; wildcard patterns in name

controller

Module-wide request-processor settings (processorClass, contentType, locale, nocache, maxFileSize)

Constants in struts.xml and interceptor parameters

message-resources

The message bundles of the module (parameter is the base name; key names a bundle other than the default)

struts.custom.i18n.resources and package.properties (the localization page)

plug-in

Start-up classes with set-property children (Validator, Tiles)

Plugins and struts-plugin.xml, the validation interceptor and the Tiles plugin

Worked Example: Moving the Bookshelf from Struts 1 to Struts 7

This is the heart of the page. The Bookshelf of a legacy shop has a book list, an "add a book" form (title, author and ISBN, with validation and a double-submit guard), a lend and give-back action and a global error page. Each step shows the Struts 1 "before" (a labelled sketch in the style of the Struts Blank and MailReader sources, never run) and the Struts 7.4.0 "after" (built, deployed and requested). The "after" application is one WAR, g12struts1.war, with the context path /g12struts1 on Tomcat 11.0.26: the Struts BOM org.apache.struts:struts2-bom:7.4.0, struts2-core, the Servlet and Pages APIs as provided, and the filter described in step 1. Imports and package lines are left out of the listings; the package is org.example.bookshelf. Every request below was made with curl against the running context; session identifiers and token values are removed from the output shown.

Step 1: The Entry Point, ActionServlet to the Filter

Before (Struts 1.3.10, legacy sketch). The servlet and its mapping, as in Struts Blank:

<servlet>
  <servlet-name>action</servlet-name>
  <servlet-class>org.apache.struts.action.ActionServlet</servlet-class>
  <init-param>
    <param-name>config</param-name>
    <param-value>/WEB-INF/struts-config.xml</param-value>
  </init-param>
  <load-on-startup>2</load-on-startup>
</servlet>
<servlet-mapping>
  <servlet-name>action</servlet-name>
  <url-pattern>*.do</url-pattern>
</servlet-mapping>

After (Struts 7.4.0, verified). A filter, on the Jakarta 6.1 schema, mapped to everything; the filter decides per request whether the URL is an action:

<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_1.xsd"
         version="6.1">
    <filter>
        <filter-name>struts2</filter-name>
        <filter-class>org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter</filter-class>
    </filter>
    <filter-mapping>
        <filter-name>struts2</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
</web-app>

Step 2: Keeping the .do URLs While You Migrate

A migration is much easier when the URLs that bookmarks, other applications and a reverse proxy know do not change. The default action extension of Struts 7.4.0 is action,, (the .action extension or none), so a legacy book-list.do would answer 404. One constant fixes that for the length of the migration:

<constant name="struts.action.extension" value="do,action"/>

With this value the Struts 7 actions answer to the legacy .do URL and to .action. Verified requests:

$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12struts1/book-list.do
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12struts1/book-list.action
200
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12struts1/book-list
404

Two details matter. The first extension in the list is the one that Struts writes into the URLs it generates: the redirectAction result of step 5 sent Location: /g12struts1/book-list.do, so the migrated pages keep emitting the legacy URL shape. The second is that the extensionless URL stopped working, as the 404 above shows, because the trailing empty entry of the default (action,,) was not repeated; write do,action,, if the bare name must keep working too. The value is a migration aid: once the callers have been moved, return to the default and delete the constant.

Step 3: struts-config.xml to struts.xml

Before (Struts 1.3.10, legacy sketch). The Bookshelf’s configuration in the Struts 1 style: one form bean, a global exception and forward, and the mappings for the list, the form, the add action and the dispatch action:

<struts-config>
  <form-beans>
    <form-bean name="bookForm" type="org.example.legacy.BookForm"/>
  </form-beans>
  <global-exceptions>
    <exception key="error.generic" type="java.lang.IllegalStateException"
               path="/error.jsp"/>
  </global-exceptions>
  <global-forwards>
    <forward name="list" path="/bookList.do" redirect="true"/>
  </global-forwards>
  <action-mappings>
    <action path="/bookList" type="org.example.legacy.BookListAction">
      <forward name="success" path="/WEB-INF/pages/list.jsp"/>
    </action>
    <action path="/bookForm" forward="/WEB-INF/pages/form.jsp"/>
    <action path="/bookAdd" type="org.example.legacy.BookAddAction"
            name="bookForm" scope="request" validate="true"
            input="/WEB-INF/pages/form.jsp"/>
    <action path="/shelf" type="org.example.legacy.ShelfAction"
            parameter="method"/>
  </action-mappings>
  <message-resources parameter="MessageResources"/>
</struts-config>

After (Struts 7.4.0, verified). The same application as one struts.xml. The form bean declaration is gone (the action carries the properties), the global forward became a global result and a global exception mapping, and the mapping that only forwarded to a page has no class. The mapping names book-add, book-add-xml, book-add-model and shelf-* are explained in the steps that follow:

<struts>
    <constant name="struts.custom.i18n.resources" value="messages"/>
    <constant name="struts.action.extension" value="do,action"/>

    <package name="bookshelf" extends="struts-default" namespace="/">
        <global-results>
            <result name="error">/WEB-INF/views/error.jsp</result>
        </global-results>
        <global-exception-mappings>
            <exception-mapping exception="java.lang.IllegalStateException" result="error"/>
        </global-exception-mappings>

        <action name="book-list" class="org.example.bookshelf.BookListAction">
            <interceptor-ref name="store">
                <param name="operationMode">RETRIEVE</param>
            </interceptor-ref>
            <interceptor-ref name="defaultStack"/>
            <result>/WEB-INF/views/list.jsp</result>
        </action>

        <action name="book-form">
            <result>/WEB-INF/views/form.jsp</result>
        </action>

        <action name="book-add" class="org.example.bookshelf.AddBookAction">
            <interceptor-ref name="defaultStack"/>
            <interceptor-ref name="token"/>
            <interceptor-ref name="store">
                <param name="operationMode">STORE</param>
            </interceptor-ref>
            <result name="success" type="redirectAction">book-list</result>
            <result name="input">/WEB-INF/views/form.jsp</result>
            <result name="invalid.token">/WEB-INF/views/invalid.jsp</result>
        </action>

        <action name="shelf-*" class="org.example.bookshelf.ShelfAction" method="{1}">
            <result>/WEB-INF/views/shelf.jsp</result>
            <allowed-methods>lend,giveBack</allowed-methods>
        </action>

        <action name="book-boom" class="org.example.bookshelf.BoomAction"/>
    </package>
</struts>

A request to the book-list mapping, with the in-memory catalogue at its two seed titles, answers (the list page prints title, author and ISBN):

$ curl -s http://localhost:18080/g12struts1/book-list.do | grep '<li>'
<li>Dune / Frank Herbert / 9780441172719</li><li>Neuromancer / William Gibson / 9780441569595</li>

Step 4: ActionForm and Action to a Validated Action

Before (Struts 1.3.10, legacy sketch). Two classes, one for the data and one for the logic. The form validates itself and an Action copies it into the business object:

public class BookForm extends ActionForm {
    private String title;
    private String author;
    private String isbn;
    // getters and setters for the three fields

    @Override
    public void reset(ActionMapping mapping, HttpServletRequest request) {
        title = author = isbn = null;
    }

    @Override
    public ActionErrors validate(ActionMapping mapping, HttpServletRequest request) {
        ActionErrors errors = new ActionErrors();
        if (title == null || title.trim().isEmpty()) {
            errors.add("title", new ActionMessage("error.title.required"));
        }
        if (isbn != null && !isbn.matches("\\d{13}")) {
            errors.add("isbn", new ActionMessage("error.isbn.format"));
        }
        return errors;
    }
}

public class BookAddAction extends Action {
    @Override
    public ActionForward execute(ActionMapping mapping, ActionForm form,
                                 HttpServletRequest request,
                                 HttpServletResponse response) throws Exception {
        BookForm f = (BookForm) form;
        catalogue.add(new Book(f.getTitle(), f.getAuthor(), f.getIsbn()));
        return mapping.findForward("list");
    }
}

After (Struts 7.4.0, verified). One class. The properties live on the action, and the params interceptor copies a request parameter onto a property only when the setter carries @StrutsParameter (the Struts 7 replacement for the unrestricted BeanUtils population that CVE-2014-0114 abused). validate() is called by the validation interceptor before execute(); a field error makes the workflow interceptor return input:

public class AddBookAction extends ActionSupport {
    private String title;
    private String author;
    private String isbn;

    @Override
    public void validate() {
        if (title == null || title.isBlank()) {
            addFieldError("title", getText("error.title.required"));
        }
        if (isbn != null && !isbn.isBlank() && !isbn.matches("\\d{13}")) {
            addFieldError("isbn", getText("error.isbn.format"));
        }
    }

    @Override
    public String execute() {
        Catalogue.add(new Catalogue.Book(title, author, isbn));
        addActionMessage(getText("book.added", new String[] {title}));
        return SUCCESS;
    }

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

    public String getAuthor() { return author; }
    @StrutsParameter
    public void setAuthor(String author) { this.author = author; }

    public String getIsbn() { return isbn; }
    @StrutsParameter
    public void setIsbn(String isbn) { this.isbn = isbn; }
}

Posting an empty title and a two-digit ISBN returns the form again (HTTP 200, the input result) with the field errors that validate() added, and the values the user typed are still in the fields. The relevant lines of the answer:

$ curl -s -b jar -c jar -d 'struts.token.name=token&token=<token>&title=&author=X&isbn=12' \
    http://localhost:18080/g12struts1/book-add.do
  <input type="text" name="title" value="" id="book_add_title"/>
                        <li><span>Title is required</span></li>
  <input type="text" name="author" value="X" id="book_add_author"/>
  <input type="text" name="isbn" value="12" id="book_add_isbn"/>
                        <li><span>ISBN must have 13 digits</span></li>

A valid post (title Hyperion, ISBN of 13 digits) answers 302 with Location: /g12struts1/book-list.do, and the list then shows the new book. The reset() method has no Struts 7 counterpart because the action is created for each request; the Struts 1 reason for it (a shared form bean in session scope keeping the previous user’s checkboxes) disappears with the form bean.

Step 5: ActionForward to Results, and the Redirect After Post

In Struts 1 a forward is a name looked up in the mapping and then in global-forwards; redirect="true" turns it into a redirect. In Struts 7 the action returns a result code (SUCCESS, INPUT, a custom string), and the mapping or global-results binds the code to a result type. The book-add mapping of step 3 shows the three forwards of the Struts 1 bookAdd mapping:

Table 6. Forwards and their Struts 7 results
Struts 1 Struts 7 Note

input="/WEB-INF/pages/form.jsp" on the mapping

<result name="input">/WEB-INF/views/form.jsp</result>

input is the code the workflow interceptor returns when validation fails; if the mapping lacks it the request ends in 404 (see Pitfalls)

<forward name="list" path="/bookList.do" redirect="true"/>

<result name="success" type="redirectAction">book-list</result>

The post-redirect-get pattern, now by action name; the Location header carries the configured extension

<forward name="success" path="/WEB-INF/pages/list.jsp"/>

<result>/WEB-INF/views/list.jsp</result>

The default result name is success and the default type is dispatcher

mapping.findForward("failure")

return ERROR; and a <global-results> entry

A global result works for every action of the package

See the Results and Result Types page for the full catalogue (redirect, redirectAction, chain, stream, plainText, …​).

Step 6: Messages Across a Redirect (saveMessages to the store Interceptor)

Before (Struts 1.3.10, legacy sketch). An action that wants a message to survive the redirect saves it in the session and the next page’s html:messages shows it:

ActionMessages messages = new ActionMessages();
messages.add(ActionMessages.GLOBAL_MESSAGE, new ActionMessage("book.added", f.getTitle()));
saveMessages(request.getSession(), messages);
return mapping.findForward("list");

After (Struts 7.4.0, verified). The action called addActionMessage(...) in step 4, and the book-add and book-list mappings of step 3 carry the store interceptor (MessageStoreInterceptor) in STORE mode on the writing side and RETRIEVE mode on the reading side. Without it the message was lost: a request that followed the redirect printed the list and no message, because the ActionSupport collections live in the action, which is gone after the redirect. With the interceptor, the redirect-following request prints the message once and a second request does not:

$ curl -s -L -b jar -c jar -d 'struts.token.name=token&token=<token>&title=Ubik&author=PKD&isbn=9780547572291' \
    http://localhost:18080/g12struts1/book-add.do | grep -A1 actionMessage
	<ul class="actionMessage">
                <li><span>Added Ubik</span></li>
$ curl -s -b jar http://localhost:18080/g12struts1/book-list.do | grep -c Added
0

Step 7: DispatchAction to a Wildcard Mapping

Before (Struts 1.3.10, legacy sketch). One class, several methods; the mapping names the request parameter that holds the method, so shelf.do?method=lend calls lend:

<action path="/shelf" type="org.example.legacy.ShelfAction" parameter="method">
  <forward name="success" path="/WEB-INF/pages/shelf.jsp"/>
</action>
public class ShelfAction extends DispatchAction {
    public ActionForward lend(ActionMapping mapping, ActionForm form,
                              HttpServletRequest request,
                              HttpServletResponse response) throws Exception {
        // lend the book
        return mapping.findForward("success");
    }

    public ActionForward giveBack(ActionMapping mapping, ActionForm form,
                                  HttpServletRequest request,
                                  HttpServletResponse response) throws Exception {
        // take the book back
        return mapping.findForward("success");
    }
}

After (Struts 7.4.0, verified). The mapping shelf-* of step 3 takes the method from the URL (shelf-lend.do calls lend()), and <allowed-methods> lists the methods that a URL may reach. Because the DTD wants the allowed-methods element after the results, putting it first stops the application from starting (see Pitfalls):

public class ShelfAction extends ActionSupport {
    private String message;

    public String lend() {
        message = "lend() ran";
        return SUCCESS;
    }

    public String giveBack() {
        message = "giveBack() ran";
        return SUCCESS;
    }

    public String getMessage() { return message; }
}
$ curl -s http://localhost:18080/g12struts1/shelf-lend.do
<p>shelf: lend() ran</p>
$ curl -s http://localhost:18080/g12struts1/shelf-giveBack.action
<p>shelf: giveBack() ran</p>
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/g12struts1/shelf-bogus.do
404

The 404 for an unlisted method name is the point: the Struts 1 DispatchAction calls any public method whose name arrives in the request parameter, and Struts 7 calls only the methods that the configuration lists. One name outside the list still answered: shelf-execute.do returned 200, because the struts-default package declares execute, input, back, cancel, browse, save, delete, list and index as globally allowed methods. The wildcard page has the details, including regular-expression lists and the annotation.

Step 8: The Validator to XML Validation

Before (Struts 1.3.10, legacy sketch). The Commons Validator declares the rules in validation.xml, and the form bean extends ValidatorForm and keeps only the fields (no validate()). The messages errors.required and errors.mask come from the message resources:

<form-validation>
  <formset>
    <form name="bookForm">
      <field property="title" depends="required">
        <arg key="label.title"/>
      </field>
      <field property="isbn" depends="mask">
        <arg key="label.isbn"/>
        <var>
          <var-name>mask</var-name>
          <var-value>^\d{13}$</var-value>
        </var>
      </field>
    </form>
  </formset>
</form-validation>

After (Struts 7.4.0, verified). The Struts 7 file is named after the action class (AddBookXmlAction-validation.xml, next to the class on the classpath) and uses validators with the same intent as the Commons Validator rules:

<validators>
    <field name="title">
        <field-validator type="requiredstring">
            <message key="error.title.required"/>
        </field-validator>
    </field>
    <field name="isbn">
        <field-validator type="regex">
            <param name="regex"><![CDATA[\d{13}]]></param>
            <message key="error.isbn.format"/>
        </field-validator>
    </field>
</validators>

The mapping book-add-xml (class AddBookXmlAction, which has only the properties and execute()) behaves like step 4: an empty title and the ISBN 1 return the form with Title is required and ISBN must have 13 digits, and a valid post answers 302 to book-list.do. The Commons Validator’s html:javascript client-side checks have no maintained Struts 7 counterpart: the JavaScript client validation of the xhtml and css_xhtml themes is deprecated as of 7.4.0 (WW-5694), so keep browser-side checks in your own script or HTML5 attributes and treat the server rule as the authority.

See the Validation page and the Bean Validation Plugin page.

Step 9: DynaActionForm to a ModelDriven Model

Before (Struts 1.3.10, legacy sketch). A form without a class, declared in the configuration and read by name:

<form-bean name="quickAddForm" type="org.apache.struts.action.DynaActionForm">
  <form-property name="title" type="java.lang.String"/>
  <form-property name="isbn" type="java.lang.String"/>
</form-bean>
DynaActionForm f = (DynaActionForm) form;
String title = (String) f.get("title");

After (Struts 7.4.0, verified). A plain model object, returned by getModel(), which the modelDriven interceptor pushes on the value stack so that request parameters reach it (the action’s own members are not needed). The Bookshelf’s BookForm has no Struts import at all:

public class BookForm {
    private String title;
    private String isbn;

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public String getIsbn() { return isbn; }
    public void setIsbn(String isbn) { this.isbn = isbn; }
}

public class AddBookModelAction extends ActionSupport implements ModelDriven<BookForm> {
    private final BookForm form = new BookForm();

    @Override
    public BookForm getModel() {
        return form;
    }

    @Override
    public String execute() {
        Catalogue.add(new Catalogue.Book(form.getTitle(), "unknown", form.getIsbn()));
        return SUCCESS;
    }
}

Requesting book-add-model.do with title=Emma&isbn=9780141439587 and following the redirect added the book to the list (verified). In this run the setters of the model carried no @StrutsParameter: the Struts 7 rule exempts the members of the ModelDriven model, which is the reason the DynaActionForm-to-model conversion is the cheapest of the form conversions (and a reason to review such models for fields that must not be bound).

Step 10: saveToken and isTokenValid to the token Interceptor

Before (Struts 1.3.10, legacy sketch). The synchronizer token: the action that shows the form calls saveToken, the html:form tag writes the token into a hidden field, and the action that processes it checks and resets it:

// in the action that displays the form
saveToken(request);

// in BookAddAction.execute()
if (!isTokenValid(request, true)) {
    return mapping.findForward("list");
}

After (Struts 7.4.0, verified). The form carries <s:token/> (step 11) and the book-add mapping lists the token interceptor after defaultStack, with an invalid.token result. Reading the form once and posting it twice with the same token gave the guarded behaviour: the first post redirected to the list (302), the second, with the token already used, returned the invalid.token page:

$ curl -s -b jar -c jar -D - -o /dev/null -d 'struts.token.name=token&token=<token>&title=Hyperion&author=Dan+Simmons&isbn=9780553283686' \
    http://localhost:18080/g12struts1/book-add.do
HTTP/1.1 302
Location: /g12struts1/book-list.do
$ curl -s -b jar -c jar -d 'struts.token.name=token&token=<token>&title=Hyperion&author=Dan+Simmons&isbn=9780553283686' \
    http://localhost:18080/g12struts1/book-add.do
<p>invalid.token: <ul class="errorMessage">
  <li><span>The form has already been processed or no token was supplied, please try again.</span></li></ul>
</p>

Step 11: html, bean and logic Tags to the Struts Tags

Before (Struts 1.3.10, legacy sketch). The form and the list with the three Struts 1 libraries:

<%@ taglib uri="http://struts.apache.org/tags-html" prefix="html" %>
<%@ taglib uri="http://struts.apache.org/tags-bean" prefix="bean" %>
<%@ taglib uri="http://struts.apache.org/tags-logic" prefix="logic" %>

<html:form action="/bookAdd">
  <html:text property="title"/> <html:errors property="title"/>
  <html:text property="author"/>
  <html:text property="isbn"/> <html:errors property="isbn"/>
  <html:submit><bean:message key="label.save"/></html:submit>
</html:form>

<ul>
<logic:iterate id="book" name="books">
  <li><bean:write name="book" property="title"/></li>
</logic:iterate>
</ul>

After (Struts 7.4.0, verified). The form page of the Bookshelf (theme simple, so no wrapper markup is generated) and the list page. The s:form action name is the mapping name with no extension; the tag adds the configured one:

<%@ taglib prefix="s" uri="/struts-tags" %>
<s:actionerror/>
<s:form action="book-add" theme="simple">
  <s:textfield name="title"/><s:fielderror fieldName="title"/>
  <s:textfield name="author"/>
  <s:textfield name="isbn"/><s:fielderror fieldName="isbn"/>
  <s:token/>
  <s:submit key="label.save"/>
</s:form>
<%@ taglib prefix="s" uri="/struts-tags" %>
<h1><s:text name="app.title"/></h1>
<s:actionmessage/>
<ul>
<s:iterator value="books"><li><s:property value="title"/> / <s:property value="author"/> / <s:property value="isbn"/></li></s:iterator>
</ul>
<a href="<s:url action='book-form'/>">Add</a>

The rendered form (token value and session identifier removed) used the legacy URL shape for the action:

<form id="book_add" name="book_add" action="/g12struts1/book-add.do" method="post">
  <input type="text" name="title" value="" id="book_add_title"/>
  <input type="text" name="author" value="" id="book_add_author"/>
  <input type="text" name="isbn" value="" id="book_add_isbn"/>
  <input type="hidden" name="struts.token.name" value="token" />
<input type="hidden" name="token" value="<token>" />
  <input type="submit" value="Save" id="book_add_label_save" name="label.save"/>
</form>

The one-to-one mapping of the tags is in Concept Map: Struts 1, Struts 7.4.0 and Spring MVC. bean:write has a filter attribute that escapes characters sensitive in HTML, and s:property has escapeHtml, which defaults to true; a legacy page that printed markup by switching the Struts 1 filtering off needs the explicit escapeHtml="false" and a review of where that markup comes from.

Step 12: MessageResources, global-exceptions and the Error Page

Before (Struts 1.3.10, legacy sketch). MessageResources.properties on the classpath, read with bean:message, and a declared exception that sends an IllegalStateException to an error page:

label.title=Title
errors.required={0} is required
error.generic=Something went wrong

After (Struts 7.4.0, verified). The properties file is a global bundle named by the constant of step 3 (struts.custom.i18n.resources=messages), read with getText() in actions and s:text in pages; the action-specific bundles (AddBookAction.properties) and the package-level package.properties are the Struts 7 answer to a single big MessageResources file. The global exception mapping of step 3 catches the exception thrown by an action; the page reads it from the value stack:

public class BoomAction extends ActionSupport {
    @Override
    public String execute() {
        throw new IllegalStateException("catalogue is locked");
    }
}
<%@ taglib prefix="s" uri="/struts-tags" %>
<p>error page: <s:property value="exception.message"/> / <s:text name="error.generic"/></p>
$ curl -s http://localhost:18080/g12struts1/book-boom.do
<p>error page: catalogue is locked / Something went wrong</p>

Concept Map: Struts 1, Struts 7.4.0 and Spring MVC

The table is the page’s reference. Read a row from the left when you meet a Struts 1 construct in legacy code, and from the right when you choose the target of the migration. The Struts 7 column links the page of this section that covers the concept; the Spring MVC column is the destination when the target is Spring Boot (the Spring MVC with JSP page shows the controller side with JSP views, and the backend section’s Spring Boot pages the rest).

Table 7. Struts 1 concepts and their successors
Struts 1.3.10 Struts 7.4.0 Spring MVC (Spring Framework 6/7 reference)

ActionServlet, *.do

StrutsPrepareAndExecuteFilter, struts.action.extension (the architecture page)

DispatcherServlet; handler mappings on the URL

RequestProcessor, chain-config.xml

The interceptor stack and the ActionInvocation (the interceptors page)

HandlerInterceptor, @ControllerAdvice and servlet filters

struts-config.xml

struts.xml, struts-default.xml, struts-plugin.xml (the configuration page)

@Configuration classes and @RequestMapping annotations

ActionMapping (path, type, name, scope, validate, input)

<action> (name, class, method) with results, or the Convention plugin’s @Action

@RequestMapping / @GetMapping / @PostMapping on a handler method

Action.execute(mapping, form, request, response) (singleton)

execute() or a named method of an action created per request (the actions page)

A @Controller bean, a singleton by default; methods take parameters by argument

ActionForm, DynaActionForm

Action properties, a ModelDriven model, @StrutsParameter (the parameter-injection page)

A command object (@ModelAttribute), a record or a DTO bound by name

reset()

Not needed: one action per request

Not needed: a new command object per request

ActionForm.validate(), ValidatorForm, validation.xml

validate(), -validation.xml, annotations, the Bean Validation plugin (the validation pages)

Bean Validation annotations with @Valid and BindingResult

ActionForward, findForward, global-forwards

A result code, <result>, <global-results> (the results page)

A view name returned by the method, or redirect: and RedirectView

redirect="true", ActionRedirect

redirectAction and redirect results

redirect: and RedirectAttributes (flash attributes)

ActionMessages, ActionErrors, saveMessages

addActionMessage, addFieldError, the store interceptor

BindingResult, flash attributes

MessageResources, bean:message

messages*.properties, getText, s:text, the i18n interceptor (the localization page)

MessageSource and the spring:message tag

global-exceptions, ExceptionHandler

<global-exception-mappings>, the exception interceptor (the exception page)

@ExceptionHandler and @ControllerAdvice

DispatchAction, LookupDispatchAction

method="{1}" wildcards, <allowed-methods> (the wildcard page)

One handler method per mapping

isCancelled(request) and the html:cancel button

No cancel flag in the framework: the cancel control is a plain s:url link, or a second mapping without validation

A separate handler method or link

saveToken, isTokenValid

<s:token/>, the token and tokenSession interceptors (the tokens page)

Post-redirect-get; the CSRF token of Spring Security (a different purpose)

html:form, html:text, html:errors, html:select

s:form, s:textfield, s:fielderror, s:select (the UI tags page)

form:form, form:input, form:errors of the Spring tag library, or Thymeleaf

bean:write, bean:message, bean:define

s:property, s:text, s:set (the generic tags page)

EL ${...} and c:out; spring:message

logic:iterate, logic:present, logic:equal

s:iterator, s:if, s:elseif

c:forEach, c:if (JSTL) or Thymeleaf th:each

nested taglib

OGNL paths such as book.author.name in name and value

path="book.author.name" in the form tags

Modules, SwitchAction

Packages and namespaces (the packages page)

Separate controllers, @RequestMapping prefixes, or separate applications

PlugIn

Plugins, a ServletContextListener, the Spring plugin

@Configuration, ApplicationRunner, @PostConstruct

Tiles 1, tiles-defs.xml

The Tiles plugin (retired Apache Tiles inside it), SiteMesh or tag files (the Tiles page)

Thymeleaf layouts or JSP tag files (the Tag Files and Layouts page)

Commons Validator JavaScript

Deprecated client-side validation of the themes (7.4.0); your own script

Client-side validation is the front end’s job

Struts 1 Plugin (org.apache.struts2.s1)

Dropped in 2.5; last release 2.3.37

Not applicable

The wiki’s "Comparing Struts 1 and 2" page is the official source for the differences in the middle column. It was last edited in April 2007, so it describes Struts 2.0; the table above applies the changes since. The differences it names (Struts 1 actions are singletons and must be thread safe; they depend on the servlet API; form beans are a second input object; the EL is JSTL’s; type conversion is BeanUtils, mostly strings; validation by validate() or the Commons Validator; one lifecycle per module) are the reasons for the structure of the migration plan below.

Migrating to Struts 7 (and Elsewhere)

The project’s own advice for migration is short, and old. The wiki page "Migration Strategies" (last edited by a Struts committer in 2023) opens with "four non-exclusive paths" and lists three:

Table 8. The strategies of the wiki page "Migration Strategies"
Strategy As the wiki words it, condensed

Dual processor, shared resources

Add the Struts 2 JARs to the existing Struts 1.3 application; let Struts 2 handle *.action and Struts 1 handle *.do, side by side in one web application; build new features in the new framework and migrate the old pieces as needed; optionally share messages, validation rules and Tiles configuration

Study known Struts 1 applications rewritten for Struts 2

Familiar applications such as the Struts MailReader were rewritten to show best practice

Use a conversion wizard

A tool might read web.xml and the Struts 1 configuration and generate the Struts 2 classes, pages and configuration, marking what needs attention; full compatibility is unlikely

Two sibling wiki pages describe the tooling that was imagined in the Struts 2.0 days. "Migration Tools" is explicitly "a whiteboard describing tools that volunteers might write, given sufficient interest": a TextProvider that reads the Struts 1 message resources, an interceptor that applies Struts 1 validations to Struts 2 actions, an XSLT conversion from struts-config.xml to struts.xml. "Struts 1 Plugin" documents what was actually built: the struts2-struts1-plugin with a wrapper action org.apache.struts2.s1.Struts1Action that runs an unmodified Struts 1 Action and ActionForm inside a Struts 2 application (package struts1-default), plus two interceptors that apply form validation and reset(). It never converted the JSPs: the page’s own warning says the Struts 1 tag libraries cannot continue to be used, and the JSPs have to be rewritten for the Struts 2 tags. That plugin is gone: the 2.3 to 2.5 migration guide lists the "Struts1 Plugin" among the plugins that were "dropped and aren’t supported anymore", and Maven Central’s last struts2-struts1-plugin release is 2.3.37 (verified 2026-10-08), so no Struts 7 application can use it.

The wiki advice has to be adjusted to the situation of 2026 in one important way: the two frameworks cannot both be on the target platform. Struts 1.3.10 needs the javax namespace (Tomcat 9 and older), and Struts 7.4.0 needs Java 17 and Jakarta Servlet 6 (Tomcat 10.1 and 11), as the Struts 6 to 7 migration guide says in its first paragraphs. Struts 6.x is the bridge: the 2.5 to 6.0.0 migration guide says Struts 6 needs Java 8 and Servlet API 3.1, so Struts 6.12.0 (released the same day as 7.4.0 and still supported) runs in the same javax container as Struts 1. The migration therefore has two axes, the framework and the platform, and the plan below orders them so that only one moves at a time.

The Migration Path

flowchart TD A["Struts 1.3.10 monolith: javax, Tomcat 9 or older, unsupported"] --> B["1. Inventory and characterization tests: every .do URL, form, rule, message"] B --> C["2. Freeze and contain: no new Struts 1 features, update libraries, put a WAF in front"] C --> D{"3. How do the two coexist?"} D -->|"same WAR, same container"| E["3a. Add Struts 6.x beside Struts 1: .do stays, new screens on the new filter"] D -->|"new deployment"| F["3b. New Struts 7 or Spring Boot app behind a reverse proxy: the strangler fig"] E --> G["4. Move screens one at a time: form, action, validation, messages, tags, tests"] F --> G G --> H["5. Retire Struts 1: remove ActionServlet, struts-config.xml, taglibs, Tiles 1"] H --> I["6. Platform step: Struts 6.x to 7.4.0, javax to jakarta, Java 17+, Tomcat 10.1 or 11"] I --> J["Struts 7.4.0 on Tomcat 11, supported"]
Figure 2. A piecemeal migration of a Struts 1 application to Struts 7.4.0

The steps, with what each needs:

  1. Inventory and characterization tests. List every mapping of every struts-config.xml (modules included), every form bean and its scope, every validation.xml form and the messages. For each .do URL record a request and the response it gives today (a characterization test: it pins the current behaviour, wrong or right, so a regression is visible). The inventory also finds the unusual: custom processorClass subclasses, PlugIn classes, ActionMapping subclasses and Commons Chain commands have no one-to-one successor and become interceptors or filters.

  2. Freeze and contain. No new feature in Struts 1. Update the libraries that sit next to the framework (the BeanUtils named by CVE-2014-0114 first), restrict the request parameters at a gateway or filter, and put a web application firewall in front. This lowers the risk during the migration; it is not a fix and does not extend any support.

  3. Choose the coexistence. Option 3a is the wiki’s dual processor, brought up to date: add Struts 6.x (the line that still uses javax) to the same WAR, map its filter so that it takes the new URLs, leave ActionServlet on *.do, and migrate screen by screen. It shares the session, the container-managed security and the database pool, and it carries the cost of two frameworks and two sets of Commons libraries in one class loader; whether Struts 1 and 6.x coexist cleanly in a given application is not something this page verified (Struts 1 could not be started on the verification platform), so prove it on a copy first. Option 3b is the strangler fig: build the new application as a separate deployment, put a reverse proxy in front of both, and move one URL prefix at a time. It needs the session and authentication shared or re-established (a token or a gateway) and gives the cleanest end state.

  4. Move one screen at a time. For each screen apply steps 3 to 12 of Worked Example: Moving the Bookshelf from Struts 1 to Struts 7: the mapping, the form and action (collapsed into one action or a ModelDriven model), the validation, the forwards as results, the messages, the tags and the characterization test run against the new URL. Keep the .do extension (step 2) while callers still use it.

  5. Retire Struts 1 when the last mapping has moved: remove the servlet, the struts-config.xml files, the five taglibs and Tiles 1, and the struts-* 1.3.10 jars (and with them the libraries that only they needed).

  6. Take the platform step. With only Struts 6.x left, upgrade to Struts 7.4.0 following the Struts 6 to 7 migration guide (the repackaging of com.opensymphony.xwork2 into org.apache.struts2, @StrutsParameter, exact namespaces, the jakarta namespace and Java 17); the What Changed and Migration page of this section carries the full checklist. If the target is Spring Boot instead of Struts 7, the same inventory drives the controller conversion in the concept map above.

The Strangler Fig and the Bookshop

The strangler fig is the migration discipline the third option relies on: put a facade (a reverse proxy) in front of the legacy application, route a growing set of URLs to the new one, and delete the legacy code that no longer receives traffic, instead of rewriting everything behind a big-bang switch. The repository’s decisions-and-migrations section applies it to a concrete legacy system, the Bookshop (a Java EE 7, Struts 1 and JSP monolith, bookshop-web.war, on WebLogic), and covers what this page leaves out: the reverse proxy and its routing rules, separating presentation from backend first, branch by abstraction, the anti-corruption layer, data synchronisation between the old and the new schema, and the exit criteria for switching the legacy system off. Read the Migration Path: From a Legacy Stack to a Modern One page for that part, and the Migration Overview page for the discipline that all migration paths share. What this page adds to it is the technology-specific half: how a Struts 1 screen is translated (Worked Example: Moving the Bookshelf from Struts 1 to Struts 7), what the legacy constructs are (Struts 1 Architecture, to Read Legacy Code) and what each becomes (Concept Map: Struts 1, Struts 7.4.0 and Spring MVC). The Bookshelf of this page and the Bookshop are different systems on purpose: the Bookshelf is a teaching application small enough to show every step, the Bookshop a modernization case study.

A Checklist per Screen

Table 9. The conversion of one Struts 1 screen
# Struts 1 artefact What to do

1

<action path> and its forward/input/parameter

Write the <action> with results; keep the .do URL through struts.action.extension while callers exist

2

ActionForm / DynaActionForm

Move the fields to the action (with @StrutsParameter on the setters) or to a ModelDriven model; delete reset()

3

Action.execute

Return a result code; move servlet API calls to Aware interfaces only where unavoidable; make shared state explicit (the singleton is gone)

4

validate() / validation.xml

Port to validate(), XML validators or annotations; keep the message keys

5

ActionMessages / saveMessages

addActionMessage and addFieldError; add the store interceptor if the message must survive a redirect

6

saveToken / isTokenValid

<s:token/> and the token interceptor with the invalid.token result

7

html, bean, logic, nested tags

The Struts tags, or JSTL and EL where no form binding is needed

8

Tiles definition

A tile in the Tiles plugin, or a tag-file layout

9

global-exceptions

<global-exception-mappings> in the package

10

The characterization test

Run it against the new mapping; compare the status code, the redirect target and the visible text

Pitfalls

Table 10. Mistakes seen when reading Struts 1 code or migrating it (those marked verified were reproduced on Struts 7.4.0 and Tomcat 11.0.26)
Mistake What happens Fix

Copying the structure of struts-config.xml without the intent

A Struts 2 application with one action class per URL and a form class per action, which keeps the second input object that Struts 2 removed

Use the action’s properties or a ModelDriven model; collapse the form and the action

Setting struts.action.extension to do,action and expecting the bare URL to keep working (verified)

/book-list answers 404 while /book-list.do and /book-list.action answer 200

Add the trailing empty entry (do,action,,) if the bare name is needed, and remove the constant when the callers are gone

Putting <allowed-methods> before the <result> elements (verified)

The filter fails to start with a parse error from the DTD (the content of the action element must be params, results, interceptor references and exception mappings, then allowed-methods), and every URL answers 404

Put allowed-methods last in the <action> (the DTD order)

Expecting a method not in <allowed-methods> to answer (verified)

shelf-bogus.do answers 404; but shelf-execute.do answers 200 because execute is in the global list of struts-default

List every method that a URL may reach; know the global list of the base package

A mapping without an input result and a validation error (verified)

404 with the message "No result defined for action org.example.bookshelf.AddBookAction and result input"

Define <result name="input">, as the Struts 1 mapping’s input attribute did

addActionMessage before a redirect without the store interceptor (verified)

The message is gone on the next page; the Struts 1 saveMessages(session, ...) kept it

store in STORE mode on the writing action and RETRIEVE on the reading one

Forgetting @StrutsParameter on a setter of the action

The property stays null and validation reports a required field that the user filled in

Annotate the setters (the action’s members), or use a ModelDriven model, whose members are exempt

Forgetting that Struts 1 actions were singletons

Legacy code kept state in static or shared fields, expecting a single instance (a synchronized block in an action is a sign); Struts 7 creates an action per request and the old shared fields are now per request or stale

Move shared state to a service or a scope; remove the synchronisation that only guarded the action’s own fields

Using a record (read with bare names inside s:iterator) or a DTO class that the OGNL allowlist does not cover in a view (verified with a record)

The list printed + / / + for every book: empty values instead of an error

Use bean classes with getters in an allowlisted package (Java Records in OGNL)

Trying to run Struts 1 on Tomcat 10 or 11

The javax WAR deploys silently and answers 404 (see the Getting Started page); converting it with the migration tool is unsupported

Keep Struts 1 on Tomcat 9 during the migration, or migrate first

Expecting the Struts 1 plugin to help

No Struts 7 artifact exists; the plugin ended with 2.3.37 and never supported the Struts 1 tags

Convert the screens; use the .do extension and a proxy for coexistence

Treating html:javascript as portable

No Struts 7 equivalent; client-side validation of the themes is deprecated as of 7.4.0

Keep browser checks in your own script or HTML5 attributes; the server rule decides

Migrating the platform and the framework at the same time

Two sources of failure in one release; no way to tell which one broke a screen

Do the framework step on javax (Struts 6.x), then the platform step (Struts 7.4.0 and jakarta)

What Changed Recently

Table 11. Dated changes that matter to a Struts 1 migration
Date or version Change

2008-12

Struts 1.3.10, the last Struts 1 release

2013-04-05

End-of-life announcement and press release for Struts 1; Struts 2 had had as many as 23 releases by then

2014-04-30

CVE-2014-0114 published against BeanUtils as distributed in Struts 1.x; no fix for Struts 1

2016-07-04

CVE-2015-0899, CVE-2016-1181 and CVE-2016-1182 published against Struts 1 (the Validator and ActionServlet)

Struts 2.5

The Struts 1 plugin is dropped (last release 2.3.37)

2019

Apache Tiles, which grew out of Struts 1’s Tiles, moves to the Attic; the Struts Tiles plugin keeps the code it needs

Struts 6.0.0

Java 8 and Servlet API 3.1 minimum, still javax

Struts 7.0.0

Java 17, Jakarta Servlet 6, the com.opensymphony.xwork2 packages renamed to org.apache.struts2, @StrutsParameter required, exact namespaces by default

2026-10-02

Struts 7.4.0 and 6.12.0 released and supported; Struts 1 still end of life

2027-03-31

Earliest date Tomcat 9, the last container that runs Struts 1, may end support (the Tomcat site says "no earlier than")

Where the Books Are Outdated

Struts in Action (Husted, Dumoulin, Franciscus and Winterfeldt; Manning, 2002) is the reference book on Struts 1. It was written against Struts 1.0.2 and 1.1 beta 2, Servlet 2.3 and JSP 1.2, and defers to an online errata page for the differences of the final 1.1; its closing chapters on migrating from 1.0 to 1.1 cover a change that is two decades old. Struts 2 in Action (Brown, Davis and Stanlick; Manning, 2008) has a chapter, "Migration from Struts Classic", that covers the concept mapping, running both frameworks side by side, the piecemeal conversion and the Struts 1 plugin, against Struts 2.0. Neither book is quoted here; the table gives the points to correct.

Table 12. Corrections to the Struts 1 and migration material of the books
What the book teaches Current situation (verified against the sources and sites on 2026-10-08 and 2026-10-10)

Struts 1 as a framework to start a project with

End of life since 2013-04-05, with four verified CVEs that will never be fixed; read it to migrate, do not build on it

perform() on Action

Removed: the 1.3.10 Action.java contains only execute

ActionError

Gone from the 1.3.10 core sources; ActionMessage and ActionMessages replace it

data-sources in struts-config.xml

Not in the 1.3 DTD (the top-level elements are form-beans, global-exceptions, global-forwards, action-mappings, controller, message-resources and plug-in); use a container data source

The RequestProcessor as the extension point

1.3 introduced the Commons Chain request processor ComposableRequestProcessor as the default; the old class remains

DispatchAction and its relatives in struts-core

In 1.3.10 they are in struts-extras, package org.apache.struts.actions

Servlet 2.3 and JSP 1.2 as the platform

The 1.3.10 POM still declares servlet-api 2.3; Struts 1 stays on javax; Struts 7.4.0 needs Jakarta Servlet 6

The Struts 2.0 filter FilterDispatcher (with ActionContextCleanUp) in the migration chapter

org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter

Struts 2 class names com.opensymphony.xwork2.* in the migration examples

Renamed to org.apache.struts2.* in 7.0.0 (org.apache.struts2.ActionSupport, org.apache.struts2.action.Action)

Running Struts 1 and Struts 2 side by side with *.do and *.action

Still the wiki’s advice, but Struts 1 needs javax and Struts 7 needs jakarta, so the second framework in the same WAR must be Struts 6.x; the wiki’s own text promises four strategies and lists three

The Struts 1 plugin to run old actions in Struts 2

Dropped in 2.5; last release 2.3.37; it never supported the Struts 1 tags

The "conversion wizard" and migration tools

A whiteboard of tools "volunteers might write", last edited in 2011; none exists

Wildcard action paths and dynamic method invocation as the Struts 2 answer to DispatchAction

Strict method invocation with <allowed-methods> replaced free dynamic method invocation in 2.5; the action: prefix is disabled by default

Form beans populated by every request parameter

Struts 7 needs @StrutsParameter on setters; the open population is the weakness of CVE-2014-0114

The Struts 1 documentation on the project site

Offline: /1.x/ and the FAQ return 404; use the 1.3.10 documentation zip, the source tree and the EOL pages

Artimus, the book’s sample application with container-managed security, Tiles and CRUD

Reuse the ideas, not the code: the container security is the topic of the JSP security page, the layout of the Tiles page and the CRUD of the ModelDriven page

The patterns of the appendices (Service to Worker, Composite View, Synchronizer Token)

Still valid as vocabulary: the front controller is the filter, the composite view is Tiles or a tag file, the synchronizer token is the token interceptor

The Struts 2 wiki "Comparing Struts 1 and 2" as current

Last edited April 2007 (Struts 2.0); several rows (OGNL for type conversion, the XWork validation framework) predate the 7.0 repackaging

Where to Go Next

The Architecture and Request Lifecycle page has the Struts 7 side of the request flow; the Actions, Interceptors and Configuration pages are the destination of steps 3 and 4; the Parameter Injection and @StrutsParameter page explains the rule that replaced the open ActionForm population. The security, testing and deployment pages of this section, and the what changed and migration page that carries the 2.3 to 2.5 to 6 to 7 checklist, cover the rest.

References