Faces Flows

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 Jakarta Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11) with Jakarta CDI 4.1, Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, with Java 17 or later (the examples were compiled with --release 21). The three ways of defining a flow, the wizard and every quirk reported below were run for real: they were deployed as an exploded Bookshelf WAR on Tomcat 11.0.26 with Weld 6.0.4.Final, once with Mojarra and once with MyFaces, and driven with scripted form posts and GET requests (checked on 2026-10-08). Faces 4.0 was not run separately: the one feature below that is newer than 4.0, the injectable Flow, is marked where it appears. Faces 5.0 is still a milestone; nothing about flows was found in the sources used for this page, so it is not described here.

A flow is a group of views that belong together, such as a checkout, a registration or a multi-step request form, with a defined entry, defined exits and data that lives exactly as long as the user is inside it. Before flows, Faces had the request scope, which is too short for a wizard, and the session scope, which is too long: state left in the session survives a cancelled wizard, is shared by every browser tab of the user and keeps growing. A flow gives the wizard its own scope, its own navigation graph and its own beans. The Bookshelf uses one for the multi-step borrow request.

The Concepts

The Jakarta EE Tutorial compares a flow to a subroutine: it has an entry point, a list of parameters and a return value; it has a scope that disappears when it returns; and it may call other flows, which are kept on a call stack, so that leaving a called flow returns to the calling one. Unlike a subroutine, a flow can return more than one value.

Term Meaning

Flow

An immutable, thread-safe jakarta.faces.flow.Flow object shared by the whole application. It holds the nodes, the parameters and the optional initializer and finalizer. A user’s progress through it is not stored in this object.

Node

A step of the flow graph. There are five kinds, described in The Five Node Types. Every node has an id, and a navigation outcome that equals a node id leads to that node (the general rules for outcomes are on the navigation page).

Start node

The node reached when the flow is entered. By default it is the view named like the flow.

Flow scope

The lifetime of the CDI beans annotated @FlowScoped and of the map behind the #{flowScope} expression. It begins when the flow is entered and ends when the flow is left.

Call stack

Per user and per browser window: calling a flow pushes it, returning pops it. Beans of a flow that is called again are new instances, never the earlier ones.

Defining document id

Lets two flows have the same id when they are defined in different places. It is the name of the faces-config file that defines the flow, or the empty string. This page uses the empty string everywhere.

Two dependencies matter in practice. The FlowHandler documentation states that the flow feature entirely depends on the ClientWindow feature and on CDI. CDI is needed because flow-scoped beans are CDI beans (see Flow-Scoped Beans and flowScope). The client window is how Faces tells two browser tabs of one session apart, and the flow state is stored per client window (see Windows, Tabs and the Back Button).

The Client Window Mode

The specification says that if an application has flows and the context parameter jakarta.faces.CLIENT_WINDOW_MODE is not set, the runtime must behave as if it were url. In url mode the window id travels in a request parameter called jfwid, which Faces adds to the form action and to the links it renders. Both implementations did that in the test, and flows worked with no setting at all. What differs is the log: Mojarra 4.1.17 printed a WARNING that the parameter "was set to none, but Faces Flows requires" it to be enabled and that it was setting it to url, while MyFaces 4.1.4 only logged at INFO that the parameter was not found. The warning disappears once the parameter is set explicitly, so a flow application should say what it means:

WEB-INF/web.xml (fragment)
<context-param>
    <param-name>jakarta.faces.CLIENT_WINDOW_MODE</param-name>
    <param-value>url</param-value>
</context-param>

The window id and the modes beyond url (MyFaces also documents url-redirect and client) have their own page: the multiple windows page covers the ClientWindow API, the modes and the propagation of the id, and @ClientWindowScoped on the scopes page is the other scope that depends on it.

Two more request parameters show up in the URLs of links into and out of a flow (they were observed in the test): jffi with the id of the flow to enter, and jftfdi with the defining document id of the target flow, whose value for "no flow" is the constant FlowHandler.NULL_FLOW (jakarta.faces.flow.NullFlow). The API documentation names the constants FLOW_ID_REQUEST_PARAM_NAME and TO_FLOW_DOCUMENT_ID_REQUEST_PARAM_NAME of FlowHandler for these two, and says the renderers of components in the family jakarta.faces.OutcomeTarget (h:link, h:button) must use them.

Where Flows Sit in the Lifecycle

Flows add no phase. The specification puts flow handling in two places. The default ActionListener takes special action when an action calls into a flow, and the default NavigationHandler uses the FlowHandler when it resolves an outcome. Both run in Invoke Application (see Request Processing Lifecycle). For h:link and h:button, which navigate with a plain GET request, the entry is made when that request arrives, using the request parameters above.

Entering a flow does three things, in this order: the flow-scoped beans become activatable, the initializer runs, and navigation goes to the start node. Leaving a flow does three things in this order: the finalizer runs, the flow-scoped beans are deactivated, and the return parameters are passed back to the caller.

The Bookshelf Borrow-Request Wizard

The scenario of this page: a reader presses Borrow next to a book in the catalogue and goes through a short wizard.

  1. Reader: name and e-mail.

  2. Loan period: 7, 14, 21 or 28 days. The action then checks that the book exists and is not on loan. A reader who is not a member of the library must first pay a deposit, which is a second flow.

  3. Reason: asked only when the loan is longer than 14 days.

  4. Confirm: sends the request. Cancel is possible on the first and last page.

The members are looked up on the server from the e-mail address. The wizard never lets the browser say "I am a member", because a value posted from a form is not a decision the server may trust.

stateDiagram-v2 [*] --> borrow : enter the flow, initializer runs borrow --> period : Next borrow --> borrowCancel : Cancel period --> borrow : Back period --> checkAvailability : Check checkAvailability --> period : unknown book, or on loan checkAvailability --> payDeposit : not a member checkAvailability --> route : available payDeposit --> route : deposit paid payDeposit --> period : deposit cancelled state route <<choice>> route --> reason : more than 14 days route --> confirm : otherwise reason --> period : Back reason --> confirm : Next confirm --> period : Back confirm --> borrowDone : Send request confirm --> borrowCancel : Cancel borrowDone --> [*] : finalizer runs, flow-scoped beans end borrowCancel --> [*] : finalizer runs, flow-scoped beans end

The diagram uses the node ids of the code below. borrow, period, reason and confirm are view nodes; checkAvailability is a method call node; route is a switch node (drawn as a choice); payDeposit is a flow call node that enters the separate deposit flow; borrowDone and borrowCancel are return nodes.

The files of the example, with the three styles of definition side by side:

src/main/webapp/
  index.xhtml                         the catalogue, outside every flow
  borrow-done.xhtml                   where the borrow flow returns to
  reservation-return.xhtml            where the reservation flow returns to
  borrow/                             flow "borrow": defined in Java (FlowBuilder)
    borrow.xhtml  period.xhtml  reason.xhtml  confirm.xhtml
  deposit/                            flow "deposit": defined in deposit-flow.xml
    deposit-flow.xml  deposit.xhtml
  reservation/                        flow "reservation": directory convention, empty XML file
    reservation-flow.xml  reservation.xhtml  reservation-details.xhtml
  borrowxml/                          the borrow flow again, as XML (to compare the two)
    borrowxml-flow.xml  borrowxml.xhtml  period.xhtml  reason.xhtml  confirm.xhtml
  WEB-INF/beans.xml                   bean-discovery-mode="annotated"
src/main/java/org/example/bookshelf/
  service/LoanService.java
  web/BorrowFlow.java  BorrowFlowBean.java  DepositFlowBean.java  BorrowXmlFlowBean.java

The Five Node Types

The FlowHandler documentation defines the flow graph as views plus four more kinds of node. Edges are ordinary Faces navigation: an outcome that is a node id, or a navigation rule.

Node What it does Builder call and XML element

View

A normal Facelets view that belongs to the flow.

viewNode(id, path) and <view id><vdl-document>

Switch

A list of Expression Language conditions. They are evaluated in order, and the first one that is true selects the next node. If none is true, control goes to the default outcome.

switchNode(id) with switchCase().condition(...).fromOutcome(...) and defaultOutcome(...); <switch> with <case><if>, <from-outcome> and <default-outcome>

Method call

Runs a method expression at any point of the flow. The returned string is the next outcome; for a void method or a null result the default outcome is used.

methodCallNode(id).expression(...).defaultOutcome(...); <method-call> with <method> and <default-outcome>

Return

Leaves the flow. Its outcome is resolved in the calling context: a view outside the flow, or a node of the calling flow.

returnNode(id).fromOutcome(...); <flow-return> with <from-outcome>

Flow call

Enters another flow and keeps the current one active until the called flow returns. May pass outbound parameters.

flowCallNode(id).flowReference(documentId, flowId).outboundParameter(...); <flow-call> with <flow-reference><flow-id> and <outbound-parameter>

The flow itself also has an optional initializer (a method expression run on entry), an optional finalizer (run on exit), and any number of inbound parameters. The builder methods are initializer(...), finalizer(...) and inboundParameter(name, expression); the XML elements are <initializer>, <finalizer> and <inbound-parameter>.

Flow-Scoped Beans and flowScope

jakarta.faces.flow.FlowScoped is a CDI normal scope declared as @NormalScope(passivating=true). Its required value must equal the id of a flow, and the optional definingDocumentId names the defining document when the id is not unique. The documentation says the runtime creates such beans lazily, when they are first referenced after the user entered the flow, and makes them available for garbage collection when the user leaves it. Because the scope is passivating, the bean must be passivation capable: in the test a @FlowScoped bean that did not implement Serializable made the deployment fail (Mojarra run; the scopes page explains the passivation rules) with WELD-000072: Bean declaring a passivating scope must be passivation capable.

BorrowFlowBean.java: the state of one run of the wizard
package org.example.bookshelf.web;

import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.flow.Flow;
import jakarta.faces.flow.FlowScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.logging.Logger;
import org.example.bookshelf.service.BookService;
import org.example.bookshelf.service.LoanService;

@Named
@FlowScoped("borrow")
public class BorrowFlowBean implements Serializable {

    private static final long serialVersionUID = 1L;
    private static final Logger LOG = Logger.getLogger(BorrowFlowBean.class.getName());

    @Inject
    private FacesContext facesContext;
    @Inject
    private Flow currentFlow;
    @Inject
    private BookService books;
    @Inject
    private LoanService loans;

    private String isbn;
    private String reader;
    private String email;
    private int days = 14;
    private String reason;

    /** Flow initializer: runs once, on entry, after the flow-scoped beans are active. */
    public void begin() {
        isbn = facesContext.getExternalContext().getRequestParameterMap().get("isbn");
        LOG.info(() -> "Borrow flow started for book " + isbn + " in flow " + currentFlow.getId());
    }

    /** Method call node. */
    public String checkAvailability() {
        if (books.findByIsbn(isbn) == null) {
            facesContext.addMessage(null, new FacesMessage("Unknown book."));
            return "period";
        }
        if (!loans.isAvailable(isbn)) {
            facesContext.addMessage(null, new FacesMessage("This book is on loan."));
            return "period";
        }
        return loans.isMember(email) ? null : "payDeposit";
    }

    public String submit() {
        loans.register(isbn, reader, days);
        return "borrowDone";
    }

    /** Flow finalizer: runs on every exit, whichever return node is used. */
    public void end() {
        LOG.info(() -> "Borrow flow finished for " + reader);
    }

    public boolean isExtendedLoan() {
        return days > 14;
    }

    public int getDepositAmount() {
        return 10;
    }

    public String getFlowId() {
        return currentFlow.getId();
    }

    public String getIsbn() { return isbn; }
    public String getReader() { return reader; }
    public void setReader(String reader) { this.reader = reader; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public int getDays() { return days; }
    public void setDays(int days) { this.days = days; }
    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }
}

What to notice in the bean:

  • begin() is the initializer and end() the finalizer. They are plain methods that the flow definition names with method expressions. The finalizer runs on every exit, whichever return node is used.

  • checkAvailability() is the method of the method call node. It returns an outcome (period, a view node, or payDeposit, a flow call node) or null, which means "use the default outcome of the node".

  • The bean never trusts its inputs: the ISBN comes from a request parameter (see Inbound and Outbound Parameters), so the method looks the book up in the BookService and reports an unknown book as a message. The value is only ever printed through #{...} in Facelets text, which escapes it; the test with the ISBN <script>x</script> rendered it as text.

  • Fields that the browser does not control, such as the membership, are decided in the bean from the e-mail address.

Besides beans, a flow has the #{flowScope} implicit object, a map backed by FlowHandler.getCurrentFlowScope() that is null when no flow is active. A view can read and write it directly (#{flowScope.amount}), which is how the deposit and reservation flows below keep data without any bean.

Calling a flow again creates new instances. The FlowHandler documentation gives the example of flow A calling flow B and B calling A: the second entry into A gets a new MyBeanA, not the earlier one.

Reading a Flow Bean After the Flow

Once the flow has ended, its beans are gone. A page that is shown after the return node and still contains #{borrowFlowBean.reader} fails. In the test both implementations answered with a 500 page carrying WELD-001303: No active contexts for scope type jakarta.faces.flow.FlowScoped. Copy what the next page needs, in the action that ends the flow, to a longer-lived place (here the LoanService receives the request in submit()).

The same applies to #{flowScope}, with a difference between the implementations that is listed in Mojarra and MyFaces.

Style 1: @FlowDefinition and FlowBuilder

A flow can be defined in Java with a CDI producer method. The API documentation requires: the method carries @Produces and @FlowDefinition, takes one parameter of type FlowBuilder annotated @FlowBuilderParameter, and returns what FlowBuilder.getFlow() gives it. The runtime finds all such methods at startup and adds the flows to the FlowHandler. The builder is described as semantically identical to the <flow-definition> element.

BorrowFlow.java: the borrow flow, in Java
package org.example.bookshelf.web;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import jakarta.faces.flow.Flow;
import jakarta.faces.flow.builder.FlowBuilder;
import jakarta.faces.flow.builder.FlowBuilderParameter;
import jakarta.faces.flow.builder.FlowDefinition;
import java.io.Serializable;

@ApplicationScoped
public class BorrowFlow implements Serializable {

    @Produces
    @FlowDefinition
    public Flow defineBorrow(@FlowBuilderParameter FlowBuilder builder) {
        String flowId = "borrow";
        builder.id("", flowId);

        // view nodes: id, then the path of the XHTML file
        builder.viewNode(flowId, "/borrow/borrow.xhtml").markAsStartNode();
        builder.viewNode("period", "/borrow/period.xhtml");
        builder.viewNode("reason", "/borrow/reason.xhtml");
        builder.viewNode("confirm", "/borrow/confirm.xhtml");

        // method call node: the returned String is the next outcome, null means the default outcome
        builder.methodCallNode("checkAvailability")
                .expression("#{borrowFlowBean.checkAvailability()}")
                .defaultOutcome("route");

        // switch node: the first case whose condition is true wins, otherwise the default outcome
        builder.switchNode("route")
                .defaultOutcome("confirm")
                .switchCase().condition("#{borrowFlowBean.extendedLoan}").fromOutcome("reason");

        // flow call node: enters the deposit flow and hands over two outbound parameters
        builder.flowCallNode("payDeposit")
                .flowReference("", "deposit")
                .outboundParameter("amount", "#{borrowFlowBean.depositAmount}")
                .outboundParameter("isbn", "#{borrowFlowBean.isbn}");

        // return nodes: the outcome is a view id outside the flow
        builder.returnNode("borrowDone").fromOutcome("/borrow-done?faces-redirect=true");
        builder.returnNode("borrowCancel").fromOutcome("/index?faces-redirect=true");

        builder.initializer("#{borrowFlowBean.begin()}");
        builder.finalizer("#{borrowFlowBean.end()}");
        return builder.getFlow();
    }
}

Points that were verified on both implementations:

  • The class that holds the producer must be a bean. With bean-discovery-mode="annotated", which the section’s beans.xml uses (see Project Setup), the class needs a bean-defining annotation; @ApplicationScoped is used here. Without it the producer is never called and the flow does not exist, silently: the button that should enter the flow simply redisplayed the catalogue, with only a log warning (Mojarra: JSF1090: Navigation case borrow not resolved; MyFaces: Could not determine NavigationCase for the link). With bean-discovery-mode="all" the class works with no annotation, which is why a sample class without one can look correct.

  • builder.id("", flowId) takes the defining document id first (empty here) and then the flow id.

  • The first argument of viewNode(...) is the node id, the second the path of the XHTML file. markAsStartNode() selects the start node; every node builder has it.

  • The calls that follow switchNode(...) are ordered by the API: defaultOutcome(...) returns a SwitchCaseBuilder, and switchCase() is available on that, so the default outcome is declared first and the cases after it. The cases are still tried in the order they are declared.

  • The outcome of a return node may carry a query string such as ?faces-redirect=true. The run ended on the catalogue or the confirmation page after a redirect (the URL had jftfdi=jakarta.faces.flow.NullFlow), which gives the usual Post-Redirect-Get behaviour when the user leaves the flow.

  • The outbound parameter values #{borrowFlowBean.depositAmount} and #{borrowFlowBean.isbn} are Expression Language expressions, evaluated when the flow is called. The FlowBuilder example in the API documentation passes literal strings; an expression worked as well.

  • The builder has more: methodCallNode(...).expression(String, Class[]) and parameters(List<Parameter>) for a method call with arguments, and navigationCase() for explicit navigation rules. Those two were not run.

Style 2: The flow.xml File

A flow can also be a <flow-definition> element in a Faces configuration file. The Jakarta EE Tutorial says that a flow configured in its own file is named flowName-flow.xml, and the specification says the files are looked up as /<flowName>/<flowName>-flow.xml or /WEB-INF/<flowName>/<flowName>-flow.xml, at most one directory below the web root or WEB-INF. In the test both implementations found the file with no jakarta.faces.CONFIG_FILES entry (the MyFaces log says "Reading config /borrowxml/borrowxml-flow.xml"). The same element can also sit in the normal faces-config.xml, whose <name> then becomes the defining document id.

The deposit flow is small enough for XML. It is entered from the borrow flow, receives two inbound parameters, and returns to the caller with one of two outcomes:

deposit/deposit-flow.xml
<?xml version="1.0" encoding="UTF-8"?>
<faces-config xmlns="https://jakarta.ee/xml/ns/jakartaee"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_1.xsd"
              version="4.1">
  <flow-definition id="deposit">
    <flow-return id="depositPaid">
      <from-outcome>#{depositFlowBean.returnValue}</from-outcome>
    </flow-return>
    <flow-return id="depositCancelled">
      <from-outcome>period</from-outcome>
    </flow-return>
    <inbound-parameter>
      <name>amount</name>
      <value>#{flowScope.amount}</value>
    </inbound-parameter>
    <inbound-parameter>
      <name>isbn</name>
      <value>#{flowScope.isbn}</value>
    </inbound-parameter>
  </flow-definition>
</faces-config>
deposit/deposit.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head><title>Deposit</title></h:head>
<h:body>
  <h1>Pay a deposit of #{flowScope.amount} for book #{flowScope.isbn}</h1>
  <h:form id="form">
    <h:outputLabel for="holder" value="Card holder"/>
    <h:inputText id="holder" value="#{depositFlowBean.holder}" required="true"/>
    <h:commandButton id="pay" value="Pay" action="depositPaid"/>
    <h:commandButton id="cancel" value="Cancel" action="depositCancelled" immediate="true"/>
  </h:form>
</h:body>
</html>
DepositFlowBean.java
package org.example.bookshelf.web;

import jakarta.faces.flow.FlowScoped;
import jakarta.inject.Named;
import java.io.Serializable;

@Named
@FlowScoped("deposit")
public class DepositFlowBean implements Serializable {

    private static final long serialVersionUID = 1L;

    private String holder;

    public String getHolder() { return holder; }
    public void setHolder(String holder) { this.holder = holder; }

    /** Used by the flow-return node of deposit-flow.xml: an outcome of the CALLING flow. */
    public String getReturnValue() {
        return "route";
    }
}

The two return nodes show how a return outcome is read. depositCancelled has the literal outcome period, and depositPaid takes it from #{depositFlowBean.returnValue}, which is route. Both are node ids of the calling flow (a view node and a switch node): the outcome of a return node is resolved after the called flow has ended, in the caller. A flow that is reused by several callers therefore agrees with them on outcome names, or returns to a view outside every flow.

To compare the two styles, the whole borrow flow was also written as XML, with the same views, in a second directory. It behaves as the Java version on both implementations:

borrowxml/borrowxml-flow.xml: the borrow flow as XML
<?xml version="1.0" encoding="UTF-8"?>
<faces-config xmlns="https://jakarta.ee/xml/ns/jakartaee"
              xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
              xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_1.xsd"
              version="4.1">
  <flow-definition id="borrowxml">
    <view id="borrowxml"><vdl-document>/borrowxml/borrowxml.xhtml</vdl-document></view>
    <view id="period"><vdl-document>/borrowxml/period.xhtml</vdl-document></view>
    <view id="reason"><vdl-document>/borrowxml/reason.xhtml</vdl-document></view>
    <view id="confirm"><vdl-document>/borrowxml/confirm.xhtml</vdl-document></view>
    <switch id="route">
      <case>
        <if>#{borrowXmlFlowBean.extendedLoan}</if>
        <from-outcome>reason</from-outcome>
      </case>
      <default-outcome>confirm</default-outcome>
    </switch>
    <flow-return id="borrowDone">
      <from-outcome>/borrow-done?faces-redirect=true</from-outcome>
    </flow-return>
    <flow-return id="borrowCancel">
      <from-outcome>/index?faces-redirect=true</from-outcome>
    </flow-return>
    <flow-call id="payDeposit">
      <flow-reference>
        <flow-id>deposit</flow-id>
      </flow-reference>
      <outbound-parameter>
        <name>amount</name>
        <value>#{borrowXmlFlowBean.depositAmount}</value>
      </outbound-parameter>
      <outbound-parameter>
        <name>isbn</name>
        <value>#{borrowXmlFlowBean.isbn}</value>
      </outbound-parameter>
    </flow-call>
    <method-call id="checkAvailability">
      <method>#{borrowXmlFlowBean.checkAvailability()}</method>
      <default-outcome>route</default-outcome>
    </method-call>
    <initializer>#{borrowXmlFlowBean.begin()}</initializer>
    <finalizer>#{borrowXmlFlowBean.end()}</finalizer>
  </flow-definition>
</faces-config>

The beans of the twin are one class: it extends BorrowFlowBean and only changes the flow id (and the bean name borrowXmlFlowBean that the XML refers to):

BorrowXmlFlowBean.java
package org.example.bookshelf.web;

import jakarta.faces.flow.FlowScoped;
import jakarta.inject.Named;

@Named
@FlowScoped("borrowxml")
public class BorrowXmlFlowBean extends BorrowFlowBean {
    private static final long serialVersionUID = 1L;
}

The elements must follow the order of the schema: start-node, view, switch, flow-return, navigation-rule, flow-call, method-call, initializer, finalizer, inbound-parameter. When <start-node> is missing, the start node is <flowName>.xhtml.

On MyFaces 4.1.4 one detail is not optional. The XML file above lists the four <view> elements. When they were left out, so that the views of the flow came only from the directory convention, the flow could be entered and navigated, but the return from the called deposit flow failed: the user stayed on the page and the message read "No navigation case match for viewId /deposit/deposit.xhtml, action deposit and outcome route" (the same with the outcome confirm). Mojarra 4.1.17 returned correctly with or without the <view> elements. Declaring the views explicitly in flows that call other flows is therefore the portable choice.

Style 3: The Directory Convention

For a flow that is only a sequence of pages, no definition is needed. The specification calls these conventions and the tutorial’s simple-flow example is the smallest case: a directory named like the flow, containing a file named <flowName>-flow.xml that may even be empty, and the pages. The rules (from the specification, which says that an explicit definition in the XML file overrides them):

  • every view file in the directory is a view node of the flow;

  • the start node is the view named like the flow (reservation.xhtml);

  • navigation between the views of the directory is inside the flow;

  • the defining document id is the empty string;

  • for a zero-length file there is also one return node, whose id is the flow id with -return appended and whose outcome is /<flowId>-return.

The Bookshelf uses it for a "reserve a book" flow, whose data stays in the flow scope map:

reservation/reservation-flow.xml        (empty file)
reservation/reservation.xhtml           start node
reservation/reservation-details.xhtml
reservation-return.xhtml                outside the directory: the outcome of the implicit return node
reservation/reservation.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html">
<h:head><title>Reserve</title></h:head>
<h:body>
  <h1>Reserve a book: who?</h1>
  <h:form id="form">
    <h:outputLabel for="note" value="Note"/>
    <h:inputText id="note" value="#{flowScope.note}"/>
    <h:commandButton id="next" value="Next" action="reservation-details"/>
  </h:form>
</h:body>
</html>
reservation/reservation-details.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html">
<h:head><title>Reserve</title></h:head>
<h:body>
  <h1>Reserve a book: details</h1>
  <p>Your note: #{flowScope.note}</p>
  <h:form id="form">
    <h:commandButton id="done" value="Done" action="reservation-return"/>
  </h:form>
</h:body>
</html>

The first page enters the flow from the catalogue with action="reservation", the flow id as the outcome. The second page reaches the implicit return node with action="reservation-return". Both worked on both implementations. What the tutorial says about the return page, that a flowScope value there "should be empty", is true only on Mojarra: see Mojarra and MyFaces, where the portable alternative is stated.

Flows can also ship inside a JAR, which the specification requires the runtime to support. The flow definition must be in META-INF/faces-config.xml, its views in META-INF/flows/<flowName>/, and a META-INF/beans.xml is needed when the JAR contains @FlowScoped beans or @FlowDefinition producers. No directory convention applies there. This was not run.

Choosing a Style

Style Good for Watch out for

Directory convention

A linear sequence of pages whose data fits in #{flowScope}.

It has no parameters and no extra nodes; anything more needs an explicit definition.

flow.xml file

Flows that a designer reads without Java, and flows packaged in a library.

The element order; on MyFaces, declare the views when the flow calls another flow.

@FlowDefinition producer

Flows that sit next to their beans and are refactored with them; compile-time checking of the calls.

The producer class must be a bean; node ids and outcomes are still strings.

Entering and Leaving a Flow

A flow is entered by an outcome equal to its id (or to the id of a flow call node): action="borrow" on a command button, outcome="borrow" on h:link or h:button, action="reservation" for the directory flow, or a String returned by an action method. Inside the flow, the views may navigate to each other freely; the tutorial notes that moving between the pages of a flow is generally permitted, so Back buttons are plain outcomes. The specification adds that an attempt to navigate into a flow other than through its start node should cause a FacesException; that was not run.

A flow is left by a return node, whose outcome is resolved in the context of the caller, or it is suspended by calling another flow until that flow returns. The initializer and the finalizer are the two hooks to prepare and to release resources.

Inbound and Outbound Parameters

Data crosses a flow boundary in two ways.

Between two flows. The calling flow declares the values on its flow call node with outboundParameter(name, value) (XML <outbound-parameter>); the called flow declares inboundParameter(name, expression) (XML <inbound-parameter>). When the flow is entered through the call, the value of each outbound parameter is stored through the expression of the inbound parameter with the same name. In the example, borrow sends amount and isbn, and deposit-flow.xml stores them in #{flowScope.amount} and #{flowScope.isbn}, which deposit.xhtml then prints. Names have to match exactly.

Into a flow from a normal view. Inbound parameters are filled from an outbound parameter of a calling flow, so a catalogue page that is not in a flow cannot use them. What worked in the test, on both implementations, is to send an ordinary request parameter and read it in the initializer:

index.xhtml: two ways to start the wizard for one book
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core"
      xmlns:ui="jakarta.faces.facelets">
<h:head><title>Bookshelf</title></h:head>
<h:body>
  <h1>Catalogue</h1>
  <h:form id="catalogue">
    <ui:repeat value="#{catalogueBean.books}" var="book">
      <p>#{book.title}
        <h:commandButton id="borrow" value="Borrow" action="borrow">
          <f:param name="isbn" value="#{book.isbn}"/>
        </h:commandButton>
        <h:link value="Borrow (link)" outcome="borrow">
          <f:param name="isbn" value="#{book.isbn}"/>
        </h:link>
      </p>
    </ui:repeat>
    <h:commandButton id="xml" value="Borrow (XML version of the flow)" action="borrowxml"/>
    <h:commandButton id="res" value="Reserve a book" action="reservation"/>
  </h:form>
</h:body>
</html>

The button posts the isbn request parameter (an f:param, see Core Tags) and the link puts it in the URL; in both cases begin() of BorrowFlowBean read it through ExternalContext.getRequestParameterMap(). Entering the flow with a f:viewAction (see view actions that navigate) was also verified: a view with <f:viewAction action="borrow"/> in its f:metadata redirected to the first page of the flow on both implementations, but the query parameter of that request was not visible to the initializer afterwards. Use a button or a link when the flow needs a value at entry.

Whatever the entry, treat the value as untrusted input: it can be edited in the URL or the form. In the wizard it is validated by looking it up, never concatenated into a query or printed unescaped.

The Views of the Wizard

The wizard’s views are ordinary Facelets pages. The only flow-specific things are the outcomes in action: they are node ids (period, checkAvailability, borrowCancel) rather than file names.

borrow/borrow.xhtml: the start node
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head><title>Borrow a book</title></h:head>
<h:body>
  <h1>Borrow request: reader</h1>
  <p>Book #{borrowFlowBean.isbn}, flow #{borrowFlowBean.flowId}</p>
  <h:form id="form">
    <h:messages/>
    <h:panelGrid columns="2">
      <h:outputLabel for="reader" value="Name"/>
      <h:inputText id="reader" value="#{borrowFlowBean.reader}" required="true"/>
      <h:outputLabel for="email" value="E-mail"/>
      <h:inputText id="email" value="#{borrowFlowBean.email}"/>
    </h:panelGrid>
    <h:commandButton id="next" value="Next" action="period"/>
    <h:commandButton id="cancel" value="Cancel" action="borrowCancel" immediate="true"/>
  </h:form>
</h:body>
</html>
borrow/period.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head><title>Borrow a book</title></h:head>
<h:body>
  <h1>Borrow request: loan period</h1>
  <h:form id="form">
    <h:messages/>
    <h:outputLabel for="days" value="Days"/>
    <h:selectOneMenu id="days" value="#{borrowFlowBean.days}">
      <f:selectItem itemValue="7"/>
      <f:selectItem itemValue="14"/>
      <f:selectItem itemValue="21"/>
      <f:selectItem itemValue="28"/>
    </h:selectOneMenu>
    <h:commandButton id="back" value="Back" action="borrow" immediate="true"/>
    <h:commandButton id="next" value="Check" action="checkAvailability"/>
  </h:form>
</h:body>
</html>
borrow/reason.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head><title>Borrow a book</title></h:head>
<h:body>
  <h1>Borrow request: why more than 14 days?</h1>
  <h:form id="form">
    <h:messages/>
    <h:outputLabel for="reason" value="Reason"/>
    <h:inputText id="reason" value="#{borrowFlowBean.reason}" required="true"/>
    <h:commandButton id="back" value="Back" action="period" immediate="true"/>
    <h:commandButton id="next" value="Next" action="confirm"/>
  </h:form>
</h:body>
</html>
borrow/confirm.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:f="jakarta.faces.core">
<h:head><title>Borrow a book</title></h:head>
<h:body>
  <h1>Borrow request: confirm</h1>
  <p>#{borrowFlowBean.reader} borrows #{borrowFlowBean.isbn} for #{borrowFlowBean.days} days.</p>
  <h:form id="form">
    <h:commandButton id="back" value="Back" action="period"/>
    <h:commandButton id="send" value="Send request" action="#{borrowFlowBean.submit}"/>
    <h:commandButton id="cancel" value="Cancel" action="borrowCancel"/>
  </h:form>
</h:body>
</html>

A few details that follow from the lifecycle rather than from flows. The Back and Cancel buttons use immediate="true" so they do not run validation of the page being left (see Request Processing Lifecycle); that is acceptable here because leaving discards the page, but it would not be for a button that saves. h:messages shows the message added by checkAvailability().

The supporting service, which stands in for the database (the persistence and services page builds the real service layer):

LoanService.java
package org.example.bookshelf.service;

import jakarta.enterprise.context.ApplicationScoped;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;

@ApplicationScoped
public class LoanService {

    private final Set<String> onLoan = Set.of("9780547928227"); // The Hobbit is out
    private final Set<String> members = Set.of("ana@example.org", "luis@example.org");
    private final List<String> requests = new CopyOnWriteArrayList<>();

    public boolean isAvailable(String isbn) {
        return !onLoan.contains(isbn);
    }

    public boolean isMember(String email) {
        return email != null && members.contains(email);
    }

    public void register(String isbn, String reader, int days) {
        requests.add(isbn + "/" + reader + "/" + days);
    }

    public List<String> getRequests() {
        return requests;
    }
}

What the Wizard Does at Runtime

These sequences were scripted against both implementations; the result was the same on Mojarra and on MyFaces.

Input Result

Reader with a member e-mail, 14 days

Reader, period, then checkAvailability returns null, so the default outcome route is used; the switch picks the default confirm. After Send request the user is redirected to borrow-done.xhtml and the finalizer logs the end of the flow.

Member, 21 days

The switch condition extendedLoan is true, so the user passes through reason before confirm.

Not a member, 7 days

checkAvailability returns payDeposit. The deposit page shows the amount and ISBN passed as parameters; Pay returns the outcome route to the switch, which continues to confirm. The BorrowFlowBean still holds the name and the period.

Not a member, 28 days

As above, but after the deposit the switch goes to reason.

Not a member, deposit cancelled

depositCancelled returns period: the user is back in the borrow flow.

The Hobbit (on loan)

checkAvailability adds "This book is on loan." and returns period.

ISBN edited to an unknown value

"Unknown book." is shown on the period page; the ISBN is printed escaped.

Cancel on the last page

The borrowCancel return node redirects to the catalogue and the finalizer runs.

Windows, Tabs and the Back Button

A flow belongs to a browser window, not to the session. The FlowHandler documentation says flows "must be associated with the current ClientWindow". In the test the same session ran two wizards, each in its own client window (a second GET of the catalogue without the jfwid parameter starts a new window), at different steps and with different readers; each kept its own bean and neither disturbed the other, and sending the second one still registered the right reader. This is the difference from a session-scoped wizard bean, where two tabs overwrite each other’s data.

Faces adds the window id to the links and forms it renders (the form action carries jfwid and a hidden field named jakarta.faces.ClientWindow is rendered), so a flow page needs no extra code. A request without the id is a request from a new window, which does not see the flow state of the others. The multiple windows page also explains what several windows do to the server-side view state.

Injecting the Current Flow (Faces 4.1)

Faces 4.1 added @Inject Flow currentFlow (specification change log for 4.1, issue 1342), and the Flow API documentation now shows it. BorrowFlowBean above uses it twice: getFlowId() prints currentFlow.getId() on the first page and the initializer logs it. Through the injected object, a bean in a flow can read the id, the start node id, the nodes and the parameters of its flow without going through the FlowHandler.

The injected Flow only exists while a flow is active. Injected into a bean that is used outside every flow, it failed in the test, in a different way on each implementation: Mojarra answered with a NullPointerException when the object was first used (the proxy had no target), MyFaces with IllegalProductException: WELD-000052: Cannot return null from a non-dependent producer method when the bean was created. For code that can run both inside and outside a flow, ask the FlowHandler, which returns null when there is no current flow:

FlowInfoBean.java: safe outside a flow
package org.example.bookshelf.web;

import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.context.FacesContext;
import jakarta.faces.flow.Flow;
import jakarta.faces.flow.FlowHandler;
import jakarta.inject.Inject;
import jakarta.inject.Named;

@Named
@RequestScoped
public class FlowInfoBean {

    @Inject
    private FacesContext facesContext;

    /** Safe everywhere: null when no flow is active. */
    public String getCurrentFlowId() {
        FlowHandler handler = facesContext.getApplication().getFlowHandler();
        Flow flow = handler.getCurrentFlow(facesContext);
        return flow == null ? "(none)" : flow.getId();
    }

    public boolean isBorrowActive() {
        return facesContext.getApplication().getFlowHandler().isActive(facesContext, "", "borrow");
    }
}

On both implementations the page printed current=(none) borrowActive=false outside the wizard and current=borrow borrowActive=true on a wizard page. Without the injection, the way to the current flow is the FlowHandler, reached through FacesContext.getApplication().getFlowHandler(). The injection itself was run only on 4.1.

Mojarra and MyFaces

Both implementations ran the wizard, the XML twin, the deposit call and the directory-convention flow with the same results. The differences below are what the test found; the causes were not investigated, so they are reported as behaviour of these two versions.

Situation Mojarra 4.1.17 MyFaces Core 4.1.4

A switch node whose outcome (default or case) is a flow call node or a return node

Fails: a 500 page ("Request to activate bean in flow 'deposit', but that flow is not active", or WELD-001303 for a return node). A switch that leads to a view node works.

Works. The same switch, in the Java and in the XML definition, entered the deposit flow and left through the return node.

#{flowScope.note} on a page shown after the flow has ended

Renders an empty string, as the tutorial describes.

500, WELD-001303.

@Inject Flow in a bean used outside a flow

NullPointerException when used.

IllegalProductException (WELD-000052) at creation.

XML flow without <view> elements calls another flow

Return works.

Return fails with the message "No navigation case match for viewId /deposit/deposit.xhtml, action deposit and outcome route".

CLIENT_WINDOW_MODE not set, flows present

WARNING in the log, switches to url.

INFO, nothing else; flows still work.

Flow definition missing (producer class not a bean)

Log: JSF1090: Navigation case ... not resolved.

Log: "Could not determine NavigationCase for UIOutcomeTarget component".

Practical rules that make the wizard portable: route switch outcomes only to view nodes and let a method call node or an action choose flow call and return nodes (the example does that: checkAvailability returns payDeposit itself, and the switch route leads only to views); keep #{flowScope} and flow beans out of the pages shown after the flow; declare <view> elements in XML flows that call other flows.

What Changed Between Versions

Version Change

JSF 2.2

Flows are new (the version matrix is on Versions and Implementations): Flow, FlowHandler, @FlowScoped, @FlowDefinition, FlowBuilder and its node builders, the flowScope implicit object and the <flow-definition> element are all marked Since: 2.2 in the API documentation.

JSF 2.3

Fixes recorded in the specification change log: flow-scoped beans become re-entrant (issue 1361), @FlowScoped is declared @NormalScope(passivating=true) (issue 1306), and a flow can be entered with f:viewAction (issue 1403).

Faces 3.0

The package rename: javax.faces.flow becomes jakarta.faces.flow.

Faces 4.0

No flow-specific change appears in the 4.0 change log of the specification.

Faces 4.1

@Inject Flow currentFlow (issue 1342). The events @Initialized, @BeforeDestroyed and @Destroyed must also be fired for the built-in scopes, which the FlowScoped documentation repeats for the flow scope (issue 1739; the scopes page shows an observer and what the two implementations delivered).

Faces 5.0 (upcoming, milestone 5.0.0-M9)

Not described here as current behaviour.

Where the Books Are Outdated

Faces Flows were added in JSF 2.2, after the books consulted for this section were written, and none of those books covers them. The specification says the feature was influenced by ADF Task Flows, Spring Web Flow and Apache MyFaces CODI. For a multi-step process the books can only offer the tools of their time:

  • Session-scoped beans for wizard state, a practice this section does not reproduce: the data outlives a cancelled wizard and is shared by every tab. A @FlowScoped bean replaces them.

  • Conversation scopes of Seam 2 or hidden fields and request parameters threaded by hand. Seam 2 is no longer maintained; the CDI @ConversationScoped (the scopes page) exists but is not tied to the navigation graph, while a flow carries its own graph and entry and exit.

  • faces-config.xml navigation rules and managed beans in the 1.x style. The flow replaces the rules for a wizard with node ids, and @ManagedBean is gone since Faces 4.0 in favour of CDI beans.

  • The older books show no jakarta.faces.flow API at all: packages are javax.faces.* and views are JSP pages or early Facelets. Every snippet on this page uses jakarta.faces.flow, Facelets and CDI.

Common Mistakes

  • The producer class is not a bean. In annotated discovery mode the flow silently does not exist. Add a scope annotation.

  • A flow-scoped bean that is not Serializable. Weld refuses to deploy (WELD-000072).

  • Reading a flow bean after the return node. WELD-001303. Copy the data out in the action that leaves the flow.

  • Routing a switch node to a flow call or return node. It failed on Mojarra 4.1.17. Route switches to views.

  • No CLIENT_WINDOW_MODE. It works through the spec’s default, but Mojarra warns; set url explicitly.

  • Trusting what was posted to decide a business rule. The membership and the book come from the server side, not from fields.

  • Using a flow for something that is not a sequence. A catalogue with filters is not a flow; @ViewScoped is the right scope for it.

  • Business logic in a getter of a flow bean. Getters run several times per request, flow scope or not.