ModelDriven, Preparable & CRUD

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

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

This page is written against Apache Struts 7.4.0 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21; the facts were checked on 2026-10-08 against the struts-default.xml file inside the 7.4.0 struts2-core jar, the 7.4.0 sources of ModelDrivenInterceptor, ScopedModelDrivenInterceptor, PrepareInterceptor and PrefixMethodInvocationUtil, the Javadoc, and the Core Developer Guide and Getting Started pages on https://struts.apache.org/. Struts 1 had ActionForm, a bean that the framework created, filled from the request and handed to the action. Struts 2 has no forms: request parameters are copied directly into the properties of the action, or, when the action asks for it, into a separate object called the model. Two small interfaces and two interceptors handle this. ModelDriven lets an action return the object that should receive the request and be shown by the view, and Preparable gives an action a hook that runs before the parameters are bound and, with the right stack, a second time before the view is drawn again after a failed validation. Together they are the Struts 2 way to write a create, read, update and delete screen, and this page builds one. The running scenario is the Bookshelf, a catalogue of books; every code listing is the code of one Maven WAR, g9modeldriven.war, that was built, deployed to Tomcat 11.0.26 and requested with curl, and every output shown is an output of that run. Pages of this section that are written separately (value stack and OGNL, parameter injection, validation, action chaining and scopes and the others) are linked where they come up; the interceptor mechanics are on Interceptors and the action basics on Actions.

The Bookshelf Used on This Page

The WAR is the Struts Bookshelf of the Getting Started page of this section (the Struts BOM org.apache.struts:struts2-bom:7.4.0 imported in dependencyManagement, struts2-core, the Servlet and Pages APIs with scope provided, StrutsPrepareAndExecuteFilter mapped to /*, the constant struts.allowlist.packageNames set to org.example.bookshelf so that OGNL may read the example classes) with extra actions for the experiments. It is deployed under the context path /g9modeldriven, and every URL uses the default extension .action. Imports and package lines are left out of the listings; the package is org.example.bookshelf.

Table 1. The classes of the example WAR
Class Mapping Concept

BookForm

(model)

The request DTO of the Bookshelf: title, author and year, with getters and setters and a factory from(Book)

Book, BookStore

(model)

The entity with an id, a featured flag and the three fields, and an in-memory store that stands for the database

PlainModelAction

plain-model

The smallest ModelDriven action, with one annotated and one unannotated setter of its own

EntityModelAction

entity-model

A ModelDriven action whose model is the Book entity (the anti-pattern)

NewEachTimeAction, NullModelAction, RefreshAction

new-each-time, null-model, refresh-off, refresh-on

What the interceptor does when getModel() returns a new instance, null, or a changed instance

ValidatedModelAction

vm

Validation of the model, driven by an XML file

PrepLogAction

prep-*, prepn-*, prepd-*, prepx-*

Records which prepare methods were called and in which order, under four configurations of the prepare interceptor

BookCrudAction

book-*, book-list, bookd-*

The CRUD action: list, input, edit, save, delete, behind paramsPrepareParamsStack (and, for bookd, behind defaultStack)

WizardAction

wizard-*, wizreq-*

A ScopedModelDriven action that keeps a three step form in the session

ModelDriven and the Model Driven Interceptor

By default the parameters of a request are copied into the action, and the action is what the view reads. That mixes two roles in one class: a controller that decides what happens and a bean that holds the form data. ModelDriven separates them. An action that implements org.apache.struts2.ModelDriven<T> has one extra method, T getModel(), and the modelDriven interceptor, which belongs to defaultStack, calls it when the action is about to run. The Core Developer Guide describes the result in three sentences: Struts populates the fields of the returned object with the request parameters, the object is put on top of the value stack when the action executes, and validation is also performed on the model instead of the action.

Piece Role

org.apache.struts2.ModelDriven<T>

The interface (the package was com.opensymphony.xwork2 before 7.0); one method, T getModel(). The Javadoc warns that all setters and getters of every depth on the returned object are available for user parameter injection

org.apache.struts2.interceptor.ModelDrivenInterceptor

Registered as modelDriven; pushes the model on the value stack and, since the value stack is where parameters are bound and expressions resolved, makes the model the target of both

org.apache.struts2.interceptor.ScopedModelDriven<T>

A ModelDriven that also receives its model (setModel) and a key (setScopeKey), see the section on scoped models

org.apache.struts2.interceptor.ScopedModelDrivenInterceptor

Registered as scopedModelDriven, sits just before modelDriven in defaultStack

The interceptor is short. It is the 7.4.0 code, reduced to the lines that matter (threadAllowlist is the OGNL allowlist of the 7.x series, which the interceptor extends with the model’s class so that its properties can be evaluated):

ModelDrivenInterceptor.intercept(), the part that does the work
public String intercept(ActionInvocation invocation) throws Exception {
    Object action = invocation.getAction();

    if (action instanceof ModelDriven<?> modelDriven) {
        ValueStack stack = invocation.getStack();
        Object model = modelDriven.getModel();
        if (model != null) {
            stack.push(model);
            threadAllowlist.allowClassHierarchy(model.getClass());
        }
        if (refreshModelBeforeResult) {
            invocation.addPreResultListener(new RefreshModelBeforeResult(modelDriven, model));
        }
    }
    return invocation.invoke();
}

Three facts follow from these lines, and each is demonstrated below. The model is pushed only when it is not null, so a null model is silently ignored. getModel() is called once by the interceptor, and the object it returned then stays on the stack for the rest of the request, whatever later calls return. The model is pushed above the action, so a property that exists on both is read, and written, on the model.

The Smallest ModelDriven Action

PlainModelAction keeps a BookForm in a final field and returns it. It also has two properties of its own, note and flag, whose setters are written like those of any action; only setFlag carries @StrutsParameter.

PlainModelAction.java, an action with a model
public class PlainModelAction extends ActionSupport implements ModelDriven<BookForm> {

    private final BookForm form = new BookForm();
    private String note = "unset";
    private String flag = "unset";

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

    public String getNote() { return note; }
    public void setNote(String note) { this.note = note; }   // no annotation

    public String getFlag() { return flag; }

    @StrutsParameter
    public void setFlag(String flag) { this.flag = flag; }
}
BookForm.java, the request DTO
public class BookForm {
    private String title;
    private String author;
    private int year;

    public static BookForm from(Book book) {
        BookForm form = new BookForm();
        form.title = book.getTitle();
        form.author = book.getAuthor();
        form.year = book.getYear();
        return form;
    }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public String getAuthor() { return author; }
    public void setAuthor(String author) { this.author = author; }
    public int getYear() { return year; }
    public void setYear(int year) { this.year = year; }
}

The mapping needs nothing special: the action inherits defaultStack, which already contains modelDriven.

struts.xml, the mapping of the plain model action
<action name="plain-model" class="org.example.bookshelf.PlainModelAction">
    <result>/WEB-INF/views/model.jsp</result>
</action>

Requesting it with four parameters, two of which belong to the model and two to the action, gave this answer on Tomcat 11.0.26 (the view prints the properties with <s:property value="title"/> and so on):

GET /g9modeldriven/plain-model.action?title=Dune&author=X&year=1965&note=n1&flag=f1

title=Dune
author=X
year=1965
note=unset
flag=f1

The model received title, author and year although none of its setters is annotated; the action received flag (annotated) and did not receive note (not annotated, value still unset). That is the rule of Struts 7: the model is the authorization target of the parameter binding, and its members are exempt from the @StrutsParameter requirement, while the action’s own setters still need the annotation (the next section).

The 7.x Authorization Rule for Models

Since Struts 7.0.0 the params interceptor requires @StrutsParameter on a setter (or on a getter, with a depth, to reach into an object) before it copies a request parameter there; the constant struts.parameters.requireAnnotations defaults to true in 7.4.0. A ModelDriven action is the exception, and the exception is written into the interface’s contract: returning an object from getModel() designates that object as the request surface. The Core Developer Guide states the rule in detail:

  • The model, not the action, is the authorization target once modelDriven has pushed it, and the whole model is bindable, nested properties included, whether or not any member carries @StrutsParameter. This has been so since 7.0.0.

  • The exemption holds on every input channel (request parameters, and the JSON and REST bodies of the plugins), because they all resolve the authorization target the same way.

  • Since 7.4.0 the exemption covers the model and only the model. A parameter name is resolved against the whole value stack, so it can land on a property of the action; the decision is made in this order: the model declares the property (bound, no annotation needed); the action declares the property (bound only if annotated); neither declares it (bound, to keep models with custom OGNL accessors, such as a Map-backed model, working). Because the model is checked first, a model property that has the same name as an action property is bound without an annotation.

  • The exemption lifts only the annotation requirement. The accepted and excluded parameter-name patterns and ParameterNameAware still apply to a model’s parameters.

  • Before 7.4.0 every parameter sent to a ModelDriven action was exempt, including those that reached the action’s own unannotated setters. An application that relied on that annotates those setters, or sets struts.parameters.requireAnnotations.transitionMode to true while it does so.

The run above is the 7.4.0 behavior: note was not bound. The guide’s practical advice follows from the rule. Because the whole model is bindable, shape it as a request DTO that carries only the fields the action means to accept, never a domain or persistence entity; where member-level control is needed, use annotated action properties instead of ModelDriven.

A Model That Is an Entity

EntityModelAction shows what the advice prevents. Its model is the Book entity, which has an id and a featured flag that only an administrator should set.

EntityModelAction.java, the anti-pattern
public class EntityModelAction extends ActionSupport implements ModelDriven<Book> {

    private final Book book = new Book();

    @Override
    public Book getModel() {
        return book;
    }
}
GET /g9modeldriven/entity-model.action?title=Dune&featured=true&id=5

title=Dune
featured=true

The visitor chose featured=true and the application accepted it. The same request against plain-model (whose model is the DTO BookForm, which has no featured property) would have been ignored. The classic name for this is mass assignment, and it is the main reason why Struts 7 spells the model rule out so carefully.

What getModel() May Return

The interceptor calls getModel() and uses the return value for the rest of the request. Four behaviors of that call are worth knowing, and each has a Bookshelf action.

Table 2. The behaviors of getModel() observed on Tomcat 11.0.26
Mapping What getModel() does Observed result

new-each-time?title=Hello

Returns new BookForm() and counts its calls

The view shows title=Hello: the instance created by the interceptor’s call was filled by params and stays on the stack. The action’s execute() called getModel() again and got another, empty instance: calls=2 and titleSeenByExecute is empty

null-model?title=abc

Returns null

The interceptor ignored the model (nothing pushed); the action’s own getTitle() answered, title=title of the action, and abc went nowhere

refresh-off / refresh-on

execute() replaces the model field with a copy of book 2 (Neuromancer)

refresh-off: the view shows title= and calls=1. refresh-on (modelDriven.refreshModelBeforeResult set to true): title=Neuromancer, author=William Gibson, year=1984, calls=2

plain-model

Returns the same final field every time

The usual and recommended form: one stable instance

The rule is that getModel() must return the same instance every time it is called during a request, unless the action deliberately replaces it and refreshModelBeforeResult is on. The parameter is the only configuration of the interceptor: when it is true a PreResultListener runs after the action and before the result, calls getModel() again, and if the instance is not already on the stack removes the old model and pushes the new one. That is useful when the action loads the model from the data layer inside the action method. The Javadoc states the cost: getModel() is called at least twice.

struts.xml, switching the refresh on for one mapping (the dotted name modelDriven.refreshModelBeforeResult addresses the interceptor inside the stack)
<action name="refresh-on" class="org.example.bookshelf.RefreshAction">
    <interceptor-ref name="defaultStack">
        <param name="modelDriven.refreshModelBeforeResult">true</param>
    </interceptor-ref>
    <result>/WEB-INF/views/model.jsp</result>
</action>

The simplest way to avoid every problem in the table is to create the model once, in a field or lazily, and return it: the CRUD action below uses if (form == null) { form = new BookForm(); }.

The Model Driven Interceptor Must Come Before params

The interceptor guide says that modelDriven must come before staticParams and params if the parameters are to be applied to the model, because it is the push on the value stack that redirects the binding. In defaultStack (position 10 of 24, right after scopedModelDriven) and in the sample modelDrivenStack (modelDriven followed by basicStack) that is true. A hand-written stack that lists params first fills the action instead of the model, which is also what the paramsPrepareParamsStack does on its first params, on purpose, as shown below.

Validation of the Model

The guide says that validation is performed on the model, not on the action. ValidatedModelAction has no validation code of its own: it implements ModelDriven<BookForm>, returns a final BookForm, and also has a getter getTitle() that always answers a non-empty string, so that the test can tell the model apart from the action. An XML file named after the action class and the mapping, ValidatedModelAction-vm-validation.xml, declares the rules for the field names:

ValidatedModelAction-vm-validation.xml
<!DOCTYPE validators PUBLIC
        "-//Apache Struts//XWork Validator 1.0.3//EN"
        "https://struts.apache.org/dtds/xwork-validator-1.0.3.dtd">
<validators>
    <field name="title">
        <field-validator type="requiredstring">
            <message>A title is required</message>
        </field-validator>
    </field>
    <field name="year">
        <field-validator type="int">
            <param name="min">1450</param>
            <param name="max">2100</param>
            <message>Year must be between 1450 and 2100</message>
        </field-validator>
    </field>
</validators>

The mapping sends the input result to a status-only result:

struts.xml, a model validated from XML
<action name="vm" class="org.example.bookshelf.ValidatedModelAction">
    <result>/WEB-INF/views/model.jsp</result>
    <result name="input" type="httpheader"><param name="status">422</param></result>
</action>
GET /g9modeldriven/vm.action?title=&year=3000        ->  HTTP 422
GET /g9modeldriven/vm.action?title=Dune&year=1965    ->  HTTP 200, title=Dune, year=1965

The empty title failed requiredstring although the action’s own getTitle() is never empty: the field was resolved on the model, which sits on top of the stack. The Validation page of this section covers the framework in full; for a model that contains other objects the visitor validator (see the Core Developer Guide page on it) applies the rules of the nested class.

Preparable and the Prepare Interceptor

The second half of the pair solves a different problem. Often a form shows data that comes from a lookup, such as the list of authors in a drop-down. The lookup is done by the action method that displays the form (input). When the user submits the form and the validation fails, the validation and workflow interceptors return input and the framework renders the input view, but the input method is not run again, so the lookup result is missing and the page is rendered without its drop-down. The Getting Started tutorial on the Preparable interface describes exactly this problem and its remedy: put the statements that must run whatever method is called, and also when validation fails, in a prepare() method.

Table 3. The members of the interface and the interceptor
Piece Role

org.apache.struts2.Preparable

One method, void prepare() throws Exception. Since 7.2.0 it is a default method with an empty body, so an action that implements only per-method variants such as prepareEdit() need not override it

org.apache.struts2.interceptor.PrepareInterceptor

Registered as prepare. Extends MethodFilterInterceptor, so includeMethods and excludeMethods work. Runs only for actions that implement Preparable

prepareXxx()

A public method named prepare plus the name of the action method with its first letter in upper case: prepareSave() before save(), prepareInput() before input(), prepareExecute() before execute()

prepareDoXxx()

The alternative spelling: prepareDoSave() before save(); used only when the prepareXxx() variant does not exist

alwaysInvokePrepare

Interceptor parameter, default true: also call the general prepare()

firstCallPrepareDo

Interceptor parameter, default false: look for prepareDoXxx() first and prepareXxx() second. Present in 7.4.0 and not mentioned on the Prepare Interceptor page

The interceptor, in the 7.4.0 source, decides the order in four lines of code, which is why the observed order below is not the order that people expect on first reading:

PrepareInterceptor.doIntercept(), reduced
public String doIntercept(ActionInvocation invocation) throws Exception {
    Object action = invocation.getAction();

    if (action instanceof Preparable) {
        String[] prefixes = firstCallPrepareDo
                ? new String[] {"prepareDo", "prepare"}
                : new String[] {"prepare", "prepareDo"};
        PrefixMethodInvocationUtil.invokePrefixMethod(invocation, prefixes);   // first method found, only one

        if (alwaysInvokePrepare) {
            ((Preparable) action).prepare();                                   // after the prefixed one
        }
    }
    return invocation.invoke();
}

(The 7.4.0 source wraps the prefixed call in a try block that unwraps InvocationTargetException, so an exception that a prepare method throws reaches the exception interceptor as itself; that part is left out.) PrefixMethodInvocationUtil looks up the method name from the action proxy (execute when none is configured), tries each prefix in order and invokes only the first method that exists.

Which prepare Methods Are Called, in What Order

PrepLogAction implements Preparable, has all of prepare(), prepareInput(), prepareDoInput() and prepareSave(), and records every call in a list that the view prints. Four mappings use it with a wildcard method (the wildcard mappings are covered on the Wildcard Mappings and Method Invocation page of this section).

PrepLogAction.java, the recorder
public class PrepLogAction extends ActionSupport implements Preparable {

    private final List<String> calls = new ArrayList<>();

    @Override
    public void prepare() { calls.add("prepare"); }

    public void prepareInput() { calls.add("prepareInput"); }

    public void prepareDoInput() { calls.add("prepareDoInput"); }

    public void prepareSave() { calls.add("prepareSave"); }

    @Override
    public String input() { calls.add("input"); return SUCCESS; }

    public String save() { calls.add("save"); return SUCCESS; }

    @Override
    public String execute() { calls.add("execute"); return SUCCESS; }

    public List<String> getCalls() { return calls; }
}
struts.xml, four configurations of the prepare interceptor
<action name="prep-*" class="org.example.bookshelf.PrepLogAction" method="{1}">
    <result>/WEB-INF/views/calls.jsp</result>
</action>
<action name="prepn-*" class="org.example.bookshelf.PrepLogAction" method="{1}">
    <interceptor-ref name="defaultStack">
        <param name="prepare.alwaysInvokePrepare">false</param>
    </interceptor-ref>
    <result>/WEB-INF/views/calls.jsp</result>
</action>
<action name="prepd-*" class="org.example.bookshelf.PrepLogAction" method="{1}">
    <interceptor-ref name="defaultStack">
        <param name="prepare.firstCallPrepareDo">true</param>
    </interceptor-ref>
    <result>/WEB-INF/views/calls.jsp</result>
</action>
<action name="prepx-*" class="org.example.bookshelf.PrepLogAction" method="{1}">
    <interceptor-ref name="defaultStack">
        <param name="prepare.excludeMethods">save</param>
    </interceptor-ref>
    <result>/WEB-INF/views/calls.jsp</result>
</action>
Table 4. The calls recorded for each request on Tomcat 11.0.26
Request Calls, in order Reading

prep-save.action

prepareSave, prepare, save

The prefixed method comes first, then the general prepare(), then the action method

prep-input.action

prepareInput, prepare, input

prepareDoInput is not called: only the first method found is invoked

prep-execute.action

prepare, execute

No prepareExecute() exists, so only the general method runs

prepn-save.action

prepareSave, save

alwaysInvokePrepare is false: prepare() is skipped

prepd-input.action

prepareDoInput, prepare, input

firstCallPrepareDo is true: the prepareDo form wins

prepx-save.action

save

excludeMethods is save: the interceptor does nothing for that method, not even prepareSave()

The documentation sentence "the prepare interceptor will call the prepareInput and the prepare methods before calling the input method" is correct about the set of methods, and the table gives the order. The alwaysInvokePrepare setting is the switch to use when prepare() holds work that only some methods need: set it to false and put the work in the prepareXxx() methods. excludeMethods and includeMethods take the action method names (comma separated), never the prepare method names, and includeMethods wins when both are present.

The paramsPrepareParams Stack

The second use of Preparable in the Prepare Interceptor page is the one that gives the stack its name: load an object from the database so that request parameters can be set on the loaded object. The difficulty is the order. In defaultStack of 7.4.0 the prepare interceptor is at position 7 and params at position 17, so prepare() runs before any request parameter has been bound. A prepare() that wants the id of the book finds the field empty. The sample stack paramsPrepareParamsStack of struts-default.xml fixes this by running params twice: once before prepare and once after modelDriven.

flowchart TD P1["params (first pass): only parameters the action itself may receive, such as id"] S["servletConfig, httpMethod"] PR["prepare: prepareEdit() or prepareSave(), then prepare()"] C["chain"] M["modelDriven: pushes getModel() on the value stack"] O["actionFileUpload, staticParams, actionMappingParams"] P2["params (second pass): the rest of the request fills the model that prepare loaded"] CE["conversionError"] V["validation"] W["workflow: errors return input"] A["Action method: edit, save, delete"] P1 --> S --> PR --> C --> M --> O --> P2 --> CE --> V --> W --> A

The figure is the order of paramsPrepareParamsStack in the 7.4.0 jar, ignoring the first three entries (exception, alias, i18n) and checkbox, datetime and multiselect. Two details matter. The first params runs while the value stack holds only the action, so only parameters that name an action property are applied, and in 7.x only those with @StrutsParameter (the id below; the rule is the topic of Parameter Injection and @StrutsParameter); every other parameter, such as title, finds no property and is ignored on this pass. The second params runs when the model is on the stack, so the same request values now reach the model, which prepare may have replaced. The model interceptor comes after prepare in both stacks: that is what allows prepare to create or load the model and getModel() to return it afterwards.

The Same Action Behind Both Stacks

BookCrudAction, shown in full in the next section, is mapped twice for the edit page: book-edit (through the book-* wildcard, with paramsPrepareParamsStack) and bookd-edit (through the bookd-* wildcard, with the inherited defaultStack). The action records the id that prepareEdit() saw in the field idSeenByPrepare and the form prints it.

Table 5. Output of the form page under the two stacks (book 1 is Dune)
Request prepareEdit() saw id The form shows

book-edit.action?id=1 (paramsPrepareParamsStack)

1

title filled with Dune, the author drop-down with Frank Herbert selected, year with 1965

bookd-edit.action?id=1 (defaultStack)

null

Empty title, no author selected, year 0: the hidden id field is 1 (the parameter did arrive, later) but the book was never loaded

That is the trap: nothing fails, the page just shows an empty form. The same lesson is on the interceptors page of this section with a smaller example.

The stack has costs that the Core Developer Guide does not list. Parameters are bound twice, so setters must be idempotent; every parameter that names an annotated action property reaches the action before prepare() has authorized or loaded anything; and because the first pass applies request values to the action, the id setter needs @StrutsParameter, as the CRUD action does. Without the annotation the first params would ignore id, as it ignored note in the first example, and prepareEdit() would see null.

A Full CRUD Action

The Bookshelf’s administration screen has five operations on the book resource: a list, a form for a new book, a form for an existing book, a save that creates or updates, and a delete. One action class serves them all, each operation being one method selected through the wildcard book-*. The model is the DTO BookForm; the entity Book never meets the request.

flowchart LR L["book-list: list()"] -->|"edit link, id=1"| E["book-edit: prepareEdit() loads the form, edit() returns input"] L -->|"add a book"| I["book-input: prepare() loads the authors, input()"] E --> F["form.jsp"] I --> F F -->|"POST book-save"| S{"validateSave() adds errors?"} S -->|"no: save() stores the book"| R["redirectAction book-list"] S -->|"yes: workflow returns input"| F R --> L L -->|"POST book-delete, id=2"| D["delete() then redirectAction book-list"] D --> L

The pattern is post, redirect, get: after a successful save or delete the result is a redirectAction to the list, so a browser refresh of the list does not repeat the POST. A failed validation returns input and re-renders the form in the same request, with the field errors and the values the user typed.

BookCrudAction.java, a ModelDriven and Preparable CRUD action
public class BookCrudAction extends ActionSupport implements ModelDriven<BookForm>, Preparable {

    private Long id;
    private BookForm form;
    private List<Book> books;
    private List<String> authors;
    private String idSeenByPrepare = "n/a";

    public Long getId() { return id; }

    @StrutsParameter
    public void setId(Long id) { this.id = id; }

    @Override
    public BookForm getModel() {
        if (form == null) {
            form = new BookForm();
        }
        return form;
    }

    @Override
    public void prepare() {
        authors = BookStore.authors();              // needed by every view that renders the form
    }

    public void prepareEdit() {
        idSeenByPrepare = String.valueOf(id);
        Book book = BookStore.find(id);
        if (book != null) {
            form = BookForm.from(book);
        }
    }

    public void prepareSave() {
        Book book = BookStore.find(id);
        if (book != null) {
            form = BookForm.from(book);             // start from the stored values
        }
    }

    public String list() {
        books = BookStore.all();
        return SUCCESS;
    }

    public String edit() {
        return BookStore.find(id) == null ? "notfound" : INPUT;
    }

    public void validateSave() {
        BookForm f = getModel();
        if (f.getTitle() == null || f.getTitle().isBlank()) {
            addFieldError("title", "A title is required");
        }
        if (f.getYear() < 1450 || f.getYear() > 2100) {
            addFieldError("year", "Year must be between 1450 and 2100");
        }
    }

    public String save() {
        Book book = id == null ? new Book() : BookStore.find(id);
        if (book == null) {
            return "notfound";
        }
        book.setTitle(form.getTitle());
        book.setAuthor(form.getAuthor());
        book.setYear(form.getYear());
        BookStore.save(book);
        return SUCCESS;
    }

    public String delete() {
        BookStore.delete(id);
        return SUCCESS;
    }

    public List<Book> getBooks() { return books; }
    public List<String> getAuthors() { return authors; }
    public String getIdSeenByPrepare() { return idSeenByPrepare; }
}

The class has these parts, in the order in which a request reaches them. setId is annotated because the first params pass needs it. prepare() fills authors for every method, including after a failed validation, which is the whole point of the interface. prepareEdit() loads the stored book into a fresh BookForm. prepareSave() does the same, so that a failed validation redisplays the stored values for the fields that were not submitted, with the submitted values written over them by the second params. getModel() creates the form lazily so that the input page for a new book has an empty one. validateSave() is the per-method validation hook that ActionSupport offers (see the actions page): it runs before save() and not before the other methods. The action method save() copies the DTO into the entity field by field, which is the only place where the entity is written, and its list is explicit: a field added to the entity later is not bindable until someone adds it here. The notfound result code handles an unknown id with a 404.

The Mapping

struts.xml, the CRUD mapping
<action name="book-*" class="org.example.bookshelf.BookCrudAction" method="{1}">
    <interceptor-ref name="paramsPrepareParamsStack"/>
    <result name="success" type="redirectAction">book-list</result>
    <result name="input">/WEB-INF/views/form.jsp</result>
    <result name="notfound" type="httpheader">
        <param name="status">404</param>
    </result>
    <allowed-methods>list,input,edit,save,delete</allowed-methods>
</action>
<action name="book-list" class="org.example.bookshelf.BookCrudAction" method="list">
    <interceptor-ref name="paramsPrepareParamsStack"/>
    <result>/WEB-INF/views/list.jsp</result>
</action>

Four points about the mapping. method="{1}" selects the method from the wildcard, and in Struts 7 a wildcard method must be listed in allowed-methods or be one of the global ones (execute,input,back,cancel,browse,save,delete,list,index in struts-default.xml); edit is the only one outside the global list, which is why the list is spelled out. The fixed book-list mapping takes precedence over the wildcard, because an exact name always beats a pattern, and it has its own success result (the list view) so that list() does not redirect to itself. The input result is returned by the workflow interceptor when validation fails and by edit() and by the inherited input(). And notfound is a result code of the action itself, mapped to the status-only httpheader result.

The Views

form.jsp uses the simple theme of the Struts tags, so the output is a plain form; the field names are the names of the model’s properties, with no prefix.

form.jsp, the form for the model
<s:fielderror/>
<s:form action="book-save" method="post" theme="simple">
  <s:hidden name="id"/>
  Title: <s:textfield name="title"/>
  Author: <s:select name="author" list="authors"/>
  Year: <s:textfield name="year"/>
  <s:submit/>
</s:form>
<p>idSeenByPrepare=<s:property value="idSeenByPrepare"/></p>

name="title" is evaluated against the value stack: the model is on top, so the field reads form.getTitle() and, on submit, params writes setTitle on the model. name="id" and list="authors" find the action’s properties because the model has none of that name; authors is filled by prepare(). The list view iterates books and builds the edit link book-edit.action?id=... from each row.

The Whole Flow, Request by Request

The following requests were made against g9modeldriven.war in this order (the store was reset first and holds Dune, id 1, and Neuromancer, id 2; the verdicts are the HTTP status and the observed output):

Table 6. The CRUD operations observed on Tomcat 11.0.26
Request Status Observed result

GET book-list.action

200

Table with both books and the edit links

GET book-edit.action?id=1

200

Form filled with Dune, Frank Herbert, 1965; the author drop-down lists the three authors; idSeenByPrepare=1

GET book-input.action

200

Empty form (year 0), drop-down present, idSeenByPrepare=n/a

POST book-save.action with id=1&title=Dune Messiah&author=Frank Herbert&year=1969

302

Location is book-list.action; the list shows Dune Messiah

POST book-save.action with id=1, empty title and year=3000

200

The form again, with the errors "A title is required" and "Year must be between 1450 and 2100", the typed year 3000 in its field, the drop-down still populated, and idSeenByPrepare=n/a: prepare() ran, prepareEdit() did not

POST book-save.action with title=Hyperion&author=Ursula K. Le Guin&year=1989 (no id)

302

A third row, id 3, Hyperion

POST book-save.action with id=99&title=Ghost&...

404

prepareSave() found nothing, save() returned notfound

POST book-delete.action?id=2

302

Redirect to the list, which no longer shows Neuromancer

GET book-edit.action?id=99

404

edit() returned notfound

GET book-nope.action

404

The method nope is not in allowed-methods: the mapping does not match

The failed save deserves a second look, because it is the reason for the pattern: the page that came back after the validation failure has a populated drop-down. Without prepare(), the authors field would be null, the select would have nothing to list and the user would lose the choice they were making. The validation interceptor runs after prepare in the stack and the input result is rendered by the same request, so everything the view needs must be prepared by an interceptor, not by the input method.

ScopedModelDriven

A model that lives longer than a request is the Struts answer to the Struts 1 ActionForm with scope="session": a multi-page wizard, where each step fills part of the same object. The tool is ScopedModelDriven, an extension of ModelDriven that adds three methods, and the scopedModelDriven interceptor. The interceptor acts only on actions that implement the interface and only when their getModel() returns null; it then loads the model from the scope (building it with the object factory when the scope has none), hands it to the action with setModel, and tells the action the key it used with setScopeKey. The actual pushing on the stack is still done by modelDriven, which follows it in defaultStack.

Parameter Meaning

className

Class name of the model; defaults to the class that getModel() is declared to return

name

Key in the scope; defaults to the model class name

scope

request (default) or session

WizardAction.java, a three step wizard whose model is in the session
public class WizardAction extends ActionSupport implements ScopedModelDriven<BookForm> {

    private BookForm form;
    private String scopeKey;

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

    @Override
    public void setModel(BookForm model) { this.form = model; }

    @Override
    public void setScopeKey(String key) { this.scopeKey = key; }

    @Override
    public String getScopeKey() { return scopeKey; }

    public String title() { return SUCCESS; }

    public String author() { return SUCCESS; }

    public String review() { return SUCCESS; }
}

getModel() returns the field, null at first, which is what makes the interceptor act; the three step methods do nothing because the framework does the work. Two mappings point to the class, one with the interceptor parameters of the session scope and one with the defaults:

struts.xml, a session scoped model and a default one
<action name="wizard-*" class="org.example.bookshelf.WizardAction" method="{1}">
    <interceptor-ref name="defaultStack">
        <param name="scopedModelDriven.scope">session</param>
        <param name="scopedModelDriven.name">bookWizard</param>
        <param name="scopedModelDriven.className">org.example.bookshelf.BookForm</param>
    </interceptor-ref>
    <result>/WEB-INF/views/model.jsp</result>
    <allowed-methods>title,author,review</allowed-methods>
</action>
<action name="wizreq-*" class="org.example.bookshelf.WizardAction" method="{1}">
    <result>/WEB-INF/views/model.jsp</result>
    <allowed-methods>title,author,review</allowed-methods>
</action>

Three requests of one browser session (cookie kept) to the session mapping, and two separate requests to the default one:

GET wizard-title.action?title=Foundation    ->  title=Foundation          author=       scopeKey=bookWizard
GET wizard-author.action?author=Asimov      ->  title=Foundation          author=Asimov scopeKey=bookWizard
GET wizard-review.action                    ->  title=Foundation          author=Asimov scopeKey=bookWizard

GET wizreq-title.action?title=Foundation    ->  title=Foundation          author=       scopeKey=org.example.bookshelf.BookForm
GET wizreq-author.action?author=Asimov      ->  title=                    author=Asimov scopeKey=org.example.bookshelf.BookForm

In the session scope the second step still sees the title from the first and the third step sees both, because the same BookForm instance is stored in the HttpSession under bookWizard; in the request scope each request gets a new model, and the key defaults to the class name. The 7.4.0 source of resolveModel shows where the models live: the session scope is the HttpSession map of the ActionContext, and the request scope is the ActionContext context map, that is, a map that lives for the request and is not the servlet request’s attributes.

Remember that a session scoped model is shared by every browser tab of the session: two tabs that run the wizard overwrite each other’s fields, and nothing removes the model when the wizard is finished. A real wizard ends with a save method that removes the key from the session map (for instance through SessionAware or ActionContext.getSession()). The session and scope interceptors, and the chain result, are on the Action Chaining, Sessions and Scopes page of this section.

Pitfalls

Table 7. ModelDriven and Preparable pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26 with Struts 7.4.0)
Symptom Cause Fix

The form for an existing book is empty, the id is in the hidden field (observed with bookd-edit)

prepare runs before params in defaultStack, so prepareEdit() sees null

Use paramsPrepareParamsStack, or load the object in the action method

prepareEdit() still sees null after switching to paramsPrepareParamsStack

The id setter has no @StrutsParameter, so the first params ignores it (an unannotated action setter was not bound in the observed run)

Annotate the setter that prepare needs

A visitor can set a field they should not, such as featured=true (observed with entity-model)

The model is a persistence entity and every property of the model is bindable without annotation

Use a request DTO; copy it into the entity in the action method

A request parameter for the action’s own property is ignored on a ModelDriven action (observed with note)

Since 7.4.0 the exemption covers the model only; the action’s setters need @StrutsParameter

Annotate the setter, or move the property to the model

Values typed into the form are lost and title is empty (observed with new-each-time)

getModel() returns a new instance each call; the interceptor’s instance has the data and the action’s does not

Create the model once and return the same instance

The model never receives parameters, and getModel() seems to be ignored (observed with null-model)

getModel() returned null; the interceptor pushes only a non-null model

Initialize the model in a field, or lazily, before returning it

The view shows the old model after the action replaced it (observed with refresh-off)

The model pushed before the action stays on the stack

Set modelDriven.refreshModelBeforeResult to true, or fill the existing instance instead of replacing it

scopedModelDriven is configured but a stack of the form paramsPrepareParamsStack is used

That sample stack of 7.4.0 does not contain scopedModelDriven (only modelDriven)

Add the interceptor before modelDriven, or build the stack from defaultStack

prepare() is not called for one method (observed with prepx)

excludeMethods contains the method (the prepareXxx method is skipped too)

Remove the entry; put method names in includeMethods only on purpose

prepareDoSave() is not called although it exists (observed)

prepareSave() exists too, and only the first method found is called

Keep one spelling, or set firstCallPrepareDo for the other

The drop-down is empty after a failed validation

The data was loaded only in input(), which does not run again

Load it in prepare(), which runs before validation

The edit URL answers 404 although the method exists

The method is not in allowed-methods and not in the global list (book-nope is the observed 404)

List it in allowed-methods

The wizard model is shared by two tabs

A session scoped model is one object per session

Key the model by a conversation id, or keep wizard state in a form token

scopedModelDriven does nothing

The action’s getModel() already returns a non-null instance, and the interceptor only acts when it is null (source of intercept)

Return null from getModel() until setModel has been called

What Changed Recently

Table 8. ModelDriven and Preparable changes by Struts version (from the Core Developer Guide pages, the migration notes and the 7.4.0 jar)
Version Change

2.0.2 (XWork)

PrepareInterceptor extends MethodFilterInterceptor (includeMethods, excludeMethods); the prepareXxx()/prepareDoXxx() methods and alwaysInvokePrepare are described on the same page as later additions

2.0.7

The interceptor names with hyphens (model-driven, scoped-model-driven) became camel case (modelDriven, scopedModelDriven); the old forms were kept until 2.1.0

7.0.0

Packages renamed: ModelDriven, Preparable and ActionSupport are in org.apache.struts2 (they were in com.opensymphony.xwork2); the model is the target of @StrutsParameter authorization, exempt from the annotation requirement; struts.parameters.requireAnnotations is true by default

7.2.0

Preparable.prepare() is a default method with an empty body; the authorization is enforced on every input channel

7.4.0

The exemption covers the model and only the model; an unannotated setter on the action itself is no longer bound on a ModelDriven action; the setting struts.parameters.requireAnnotations.transitionMode exempts non-nested parameters on ModelDriven actions as well while annotations are added

Where the Books Are Outdated

The books of the section explain the idea correctly: a model object separate from the action, a prepare step before the parameters, and a stack that loads parameters twice. The details changed.

Table 9. Model and prepare material in the books and what is current
In the books Struts 7.4.0

The ModelDriven interface in com.opensymphony.xwork2

org.apache.struts2.ModelDriven<T>; the same for Preparable, ScopedModelDriven (org.apache.struts2.interceptor) and the interceptor classes

ModelDriven as a free way to bind any form to a domain object

The model is bindable in full, without annotations, so a domain or persistence entity as a model is a security risk; use a DTO

Any public setter of the action is bound, with or without a model

Only @StrutsParameter setters are bound on the action (and in 7.4.0 on a ModelDriven action too)

@Result(name="SUCCESS") style constants in the actions chapter, and the interceptor spelled scoped-model-driven

Result names are the lower case values (success); the interceptor is scopedModelDriven

A Preparable action must implement an empty prepare()

Since 7.2.0 prepare() is a default method

prepare() called "before the action" with no ordering of the prepareXxx method

prepareXxx() (or prepareDoXxx()) first, then prepare(); verified in the table above

paramsPrepareParams introduced as a workaround for a missing feature

Still the standard answer, still a sample stack (not in defaultStack); it has no scopedModelDriven, csp, coep, coop, fetchMetadata or debugging, which an action behind it therefore does not get

Struts 1 ActionForm with reset() and validate(), form scope in the mapping (scope="session")

No form class: a model, validated by the validation interceptor, and ScopedModelDriven for a longer scope

The Struts 1 ActionForm concepts map as follows: the form bean is the model (or the action’s own properties), reset() is the constructor of a fresh model, validate() is validate() or XML validation on the model, the scope attribute of the mapping is scopedModelDriven.scope, and the form acting as a "firewall" between the request and the business objects is the DTO.

Inconsistencies in the Official Documentation

Collected while verifying this page against the 7.4.0 jar:

  • The Scoped Model Driven Interceptor page shows <-- Basic usage --> (not a valid XML comment, it should be <!-- ... -->) and names the class com.opensymphony.interceptor.ScopedModelDrivenInterceptor, a package that does not exist; the class is org.apache.struts2.interceptor.ScopedModelDrivenInterceptor, registered as scopedModelDriven in struts-default.xml. The page calls the default scope request, but the source reads the ActionContext map, not the servlet request.

  • The Prepare Interceptor page lists only alwaysInvokePrepare; the class also has firstCallPrepareDo and the excludeMethods and includeMethods it mentions in a note. It also still refers to com.opensymphony.xwork2.ActionProxy.

  • The Model Driven page’s example ModelDrivenAction implements the raw ModelDriven ; the interface is generic (ModelDriven<T>).

  • The Getting Started tutorial on the interface is published under the misspelled address preperable-interface (the page title is "Preparable Interface"); preparable-interface answers 404.

Summary

  • ModelDriven<T> lets an action return a model; the modelDriven interceptor pushes a non-null model on the value stack, where parameters are bound and expressions resolved, and validation also sees the model.

  • In 7.x the model is the @StrutsParameter authorization target, with every member bindable and nothing annotated; the action’s own setters still need the annotation (7.4.0). Use a request DTO as the model, never an entity.

  • getModel() must return one stable instance; null is ignored; modelDriven.refreshModelBeforeResult pushes a replaced instance before the result and calls getModel() at least twice.

  • Preparable.prepare() (a default method since 7.2.0) runs in prepare, which precedes validation, so it is the place for lookups that the input view needs after a failed validation.

  • The order is prepareXxx() (or prepareDoXxx(), only the first one found), then prepare() unless alwaysInvokePrepare is false, then the action method; excludeMethods skips all of them.

  • paramsPrepareParamsStack runs params twice so that prepare() can load by id and the second pass fills the loaded model; the id setter needs @StrutsParameter.

  • ScopedModelDriven with the scopedModelDriven interceptor keeps a model in the session (or the action context for request), only when getModel() returns null.

  • A CRUD action is list, input, edit, save and delete on one class, a DTO model, prepare for the lookups, validateSave() for the checks, and redirectAction after a successful write.

References