Persistence and Services: JPA, Transactions and Lazy Loading

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 Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, Jakarta Persistence 3.2, Jakarta Transactions 2.0, Jakarta Data 1.0 and Java 17 or later (the examples were compiled with --release 21). The persistence provider is Hibernate ORM 7.4.12.Final with H2 2.4.240 on Tomcat (WildFly 41 runs its own Hibernate ORM 7.4.5.Final). Faces 4.0 and Jakarta EE 10 (Persistence 3.1) behave the same for everything below. Two environments were used on 2026-10-08. The first is the usual Bookshelf scratch WAR on Apache Tomcat 11.0.26 with Weld 6.0.4.Final (set up as in Tomcat and Weld), once with Mojarra and once with MyFaces, where there is no transaction manager and the page therefore uses resource-local transactions. The second is WildFly 41.0.1.Final, where a container-managed persistence context, JTA transactions, Enterprise Beans and a Jakarta Data repository are available. The requests were driven with scripted HTTP calls, and the number of JDBC statements each request prepared was read from Hibernate’s statistics. Claims that were not run are marked as such. Faces 5.0 is still a milestone release and nothing here describes it as current behaviour.

A Faces application that keeps its data in a database has one rule to learn and several ways to break it. The rule is that a backing bean does not talk to the database: it calls a service, the service opens and closes a transaction around a unit of work, and what comes back to the view is exactly the data the page will show, in a form that does not need the database any more. Almost every problem on this page, from LazyInitializationException to slow tables, comes from a view that quietly reaches back into the persistence layer during Render Response. The Hibernate and database pages of this documentation explain the mapping and the SQL side in depth; this page shows only the Faces side and links to them.

Where Persistence Sits in a Faces Request

The layers of Bookshelf are the ones introduced with backing beans and CDI: views and backing beans in org.example.bookshelf.web, services in org.example.bookshelf.service, and the persistence classes in org.example.bookshelf.jpa. The model records and data transfer objects in org.example.bookshelf.model are what travels between them. The boundary that matters is the service method. It is where a transaction begins and ends, and it is the last place where an entity is managed, which means that its lazy associations can still be loaded.

The request lifecycle (see the six phases) never keeps a transaction open from one phase to the next. Each call into a service is its own unit of work:

sequenceDiagram participant B as Browser participant L as Faces lifecycle participant V as BookEditBean (view scope) participant S as BookService participant D as Database B->>L: GET edit.xhtml?id=1 L->>V: f:viewAction load V->>S: loadForEdit(1) S->>D: begin, select book, commit S-->>V: BookEdit (plain bean, version 0) Note over L: Render Response reads BookEdit, no query B->>L: POST form Note over L: Restore View, Apply Request Values, Process Validations L->>V: Invoke Application, save V->>S: update(form) S->>D: begin, select book, compare version, update, commit S-->>V: done, every entity is detached again V-->>L: redirect outcome L-->>B: 302 back to the GET

Three consequences follow, and the rest of the page is their detail.

  • Converters, validators, value-change listeners and actions that call a service each get their own transaction and their own persistence context. An entity returned from a converter is already detached when the action runs.

  • Render Response runs after every service call has returned. A view that touches an unloaded association there fails, or silently runs a query per row.

  • A bean that survives the request (view, flow, conversation or session scope) must not hold managed entities. By the next request they are detached, and in a cluster they also have to be serialisable (Serialisable Beans and Components).

The Bookshelf Data Model

The scratch model has three entities: an author, a book written by one author, and the reviews of a book. This is the flat BookEntity of the PrimeFaces data components page grown into a real object graph, with the author as an association instead of a string. All associations are lazy, which is the right default.

jpa/AuthorEntity.java
package org.example.bookshelf.jpa;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.io.Serializable;

@Entity
@Table(name = "author")
public class AuthorEntity implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String name;

    protected AuthorEntity() {
    }

    public AuthorEntity(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public String getName() { return name; }

    @Override
    public boolean equals(Object other) {
        return this == other || other instanceof AuthorEntity that && id != null && id.equals(that.getId());
    }

    @Override
    public int hashCode() {
        return AuthorEntity.class.hashCode();
    }
}
jpa/BookEntity.java
package org.example.bookshelf.jpa;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "book")
public class BookEntity implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private long version;

    @Column(nullable = false)
    private String title;

    @Column(name = "published_year")
    private int year;

    private boolean available;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "author_id")
    private AuthorEntity author;

    @OneToMany(mappedBy = "book")
    private List<ReviewEntity> reviews = new ArrayList<>();

    protected BookEntity() {
    }

    public BookEntity(String title, AuthorEntity author, int year, boolean available) {
        this.title = title;
        this.author = author;
        this.year = year;
        this.available = available;
    }

    public Long getId() { return id; }
    public long getVersion() { return version; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public int getYear() { return year; }
    public void setYear(int year) { this.year = year; }
    public boolean isAvailable() { return available; }
    public AuthorEntity getAuthor() { return author; }
    public List<ReviewEntity> getReviews() { return reviews; }
}
jpa/ReviewEntity.java
package org.example.bookshelf.jpa;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.io.Serializable;

@Entity
@Table(name = "review")
public class ReviewEntity implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "book_id")
    private BookEntity book;

    private int rating;

    @Column(nullable = false)
    private String text;

    protected ReviewEntity() {
    }

    public ReviewEntity(BookEntity book, int rating, String text) {
        this.book = book;
        this.rating = rating;
        this.text = text;
    }

    public Long getId() { return id; }
    public BookEntity getBook() { return book; }
    public int getRating() { return rating; }
    public String getText() { return text; }
}

Four details are deliberate. @Version on BookEntity is the optimistic lock that the edit form below relies on. The no-argument constructors are protected, as the specification allows. All three classes implement Serializable, so that an entity held for a moment in a view-scoped bean does not break state saving. And AuthorEntity implements equals() by identifier with a constant hashCode(), which is what makes an entity usable in a select menu (Entity Converters and Select Menus). An entity that is serialised is a snapshot: after a round trip through the view state it is a detached copy.

The Persistence Unit

The persistence unit is the same on every platform except for three lines, which say where the data source comes from. On a Jakarta EE server the unit is JTA-managed and names a container data source by its JNDI name. No password appears in the file, because the credentials belong to the data source that the server holds.

META-INF/persistence.xml on WildFly 41
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.2">
    <persistence-unit name="bookshelf" transaction-type="JTA">
        <jta-data-source>java:jboss/datasources/ExampleDS</jta-data-source>
        <class>org.example.bookshelf.jpa.AuthorEntity</class>
        <class>org.example.bookshelf.jpa.BookEntity</class>
        <class>org.example.bookshelf.jpa.ReviewEntity</class>
        <properties>
            <property name="hibernate.hbm2ddl.auto" value="create-drop"/>
            <property name="hibernate.generate_statistics" value="true"/>
        </properties>
    </persistence-unit>
</persistence>

The java:jboss/datasources/ExampleDS name is WildFly’s built-in demonstration H2 data source and is server-specific. The hibernate.hbm2ddl.auto line creates and drops the tables for a lab and does not belong in production, where a migration tool owns the schema (see the schema evolution section). hibernate.generate_statistics is what counted the statements in the runs below, and it also costs time. The classes are listed because the unit was written for a WAR that also runs outside a server, where entity scanning is not guaranteed.

On Tomcat there is no JNDI data source and no JTA, so the unit is RESOURCE_LOCAL and names the provider:

META-INF/persistence.xml on Tomcat
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence" version="3.2">
    <persistence-unit name="bookshelf" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
        <class>org.example.bookshelf.jpa.AuthorEntity</class>
        <class>org.example.bookshelf.jpa.BookEntity</class>
        <class>org.example.bookshelf.jpa.ReviewEntity</class>
        <properties>
            <property name="hibernate.generate_statistics" value="true"/>
            <property name="hibernate.show_sql" value="false"/>
        </properties>
    </persistence-unit>
</persistence>

The JDBC URL, user and password are not in the file. In the scratch run the URL came from a system property (bookshelf.jdbc.url, defaulting to an in-memory H2 database), and a real deployment passes the user and the password to Persistence.createEntityManagerFactory(name, properties) from the environment or from the server’s secret store, never from a committed file. The dependencies of this variant are the provider, a JDBC driver and the Transactions API for the annotation (a scratch pom.xml that only collected the libraries for the WAR):

pom.xml (dependencies of the Tomcat variant, as resolved in the scratch run)
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>x</groupId>
  <artifactId>g9libs</artifactId>
  <version>1</version>
  <dependencies>
    <dependency>
      <groupId>org.hibernate.orm</groupId>
      <artifactId>hibernate-core</artifactId>
      <version>7.4.12.Final</version>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId>
      <artifactId>h2</artifactId>
      <version>2.4.240</version>
    </dependency>
    <dependency>
      <groupId>jakarta.data</groupId>
      <artifactId>jakarta.data-api</artifactId>
      <version>1.0.1</version>
    </dependency>
    <dependency>
      <groupId>jakarta.transaction</groupId>
      <artifactId>jakarta.transaction-api</artifactId>
      <version>2.0.1</version>
    </dependency>
  </dependencies>
</project>

(The four dependencies are the whole list; jakarta.persistence-api 3.2.0 arrives transitively with Hibernate. The Jakarta Data API is only needed to compile the repository of Jakarta Data: Repositories with Pages, and is not used by the Tomcat WAR. A real pom.xml scopes the driver as runtime.) On a Jakarta EE server (see Jakarta EE Servers) none of these belong in the WAR: the server ships the provider, the Persistence and Transactions APIs and, on WildFly 41, the Jakarta Data API. The scratch WAR for WildFly contained no libraries at all.

The Service Layer

The BookService is an @ApplicationScoped CDI bean. It owns the EntityManager, every public method is a unit of work, and its return types are the records of the model package. The beans of the web tier inject it with @Inject; they never see an EntityManager or a query. This is the same role the in-memory BookService played in the earlier pages, with the same name, now backed by a database.

The class has several groups of methods. The first listing is its head: the fields, and a map of the only sort keys the browser may ask for. The tables of Faces components send a sort field as a string, and a string must never be concatenated into a query (Paging Data Tables Against the Database).

service/BookService.java (head of the class)
package org.example.bookshelf.service;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.PersistenceContext;
import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import jakarta.persistence.OptimisticLockException;
import jakarta.persistence.TypedQuery;
import jakarta.transaction.Transactional;
import java.util.List;
import java.util.Map;
import org.example.bookshelf.jpa.AuthorEntity;
import org.example.bookshelf.jpa.BookEntity;
import org.example.bookshelf.model.AuthorOption;
import org.example.bookshelf.model.BookDetail;
import org.example.bookshelf.model.BookEdit;
import org.example.bookshelf.model.BookSummary;
import org.example.bookshelf.model.PageSlice;
import org.example.bookshelf.model.ReviewView;

/** The service layer over the database; the same role as the in-memory BookService of the backing-bean pages. */
@ApplicationScoped
public class BookService {

    /** The only sort keys the browser may ask for, mapped to JPQL paths; anything else is refused. */
    private static final Map<String, String> SORTABLE = Map.of(
        "title", "b.title",
        "authorName", "a.name",
        "year", "b.year");

    @PersistenceContext
    EntityManager em;

On a Jakarta EE server the EntityManager is a container-managed, transaction-scoped persistence context, injected with @PersistenceContext as above. The persistence context lives exactly as long as the JTA transaction that the service method runs in; when the method returns, the context is closed and every entity it loaded becomes detached. That is the behaviour the rest of the page works with. The specification leaves the details of how the container ties context and transaction together to the container.

Without a Container: a Request-Scoped EntityManager on Tomcat

Tomcat has neither @PersistenceContext nor a transaction manager. The scratch app replaces both with two small classes, so that the same BookService source (with @Inject in place of @PersistenceContext) runs there. The first is an application-scoped bean that creates the EntityManagerFactory once and produces one EntityManager per request:

jpa/PersistenceResources.java (Tomcat only)
package org.example.bookshelf.jpa;

import jakarta.annotation.PreDestroy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.Initialized;
import jakarta.enterprise.context.RequestScoped;
import jakarta.enterprise.event.Observes;
import jakarta.enterprise.inject.Disposes;
import jakarta.enterprise.inject.Produces;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;
import java.util.HashMap;
import java.util.Map;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;

/**
 * Plain-Tomcat plumbing: one EntityManagerFactory for the application and one EntityManager per request.
 * On a Jakarta EE server this class is replaced by a container-managed persistence context.
 */
@ApplicationScoped
public class PersistenceResources {

    private EntityManagerFactory emf;

    @Inject
    SeedData seed;

    void start(@Observes @Initialized(ApplicationScoped.class) Object init) {
        Map<String, Object> props = new HashMap<>();
        props.put("jakarta.persistence.jdbc.url", System.getProperty("bookshelf.jdbc.url", "jdbc:h2:mem:bookshelf;DB_CLOSE_DELAY=-1"));
        props.put("hibernate.hbm2ddl.auto", "create-drop");
        emf = Persistence.createEntityManagerFactory("bookshelf", props);
        seed.populate(emf);
    }

    @Produces
    @RequestScoped
    EntityManager entityManager() {
        return emf.createEntityManager();
    }

    void close(@Disposes EntityManager em) {
        if (em.isOpen()) {
            em.close();
        }
    }

    public Statistics statistics() {
        return emf.unwrap(SessionFactory.class).getStatistics();
    }

    @PreDestroy
    void stop() {
        if (emf != null) {
            emf.close();
        }
    }
}

A request-scoped EntityManager is closed when the request ends, so every query of the request, including those made while rendering, shares one persistence context. That is exactly what is called open-session-in-view (Open Session in View), and it is why the lazy-loading comparison of Loading Exactly What the View Needs behaves differently from the same code on WildFly. The second class is a minimal implementation of @Transactional:

jpa/ResourceLocalTransactionInterceptor.java (Tomcat only)
package org.example.bookshelf.jpa;

import jakarta.annotation.Priority;
import jakarta.inject.Inject;
import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityTransaction;
import jakarta.transaction.Transactional;

/**
 * A minimal resource-local implementation of jakarta.transaction.Transactional for a container that has no
 * transaction manager (Tomcat): TxType.REQUIRED only, rollback on runtime exceptions and errors.
 */
@Transactional
@Interceptor
@Priority(Interceptor.Priority.PLATFORM_BEFORE + 200)
public class ResourceLocalTransactionInterceptor {

    @Inject
    EntityManager em;

    @AroundInvoke
    Object around(InvocationContext ctx) throws Exception {
        EntityTransaction tx = em.getTransaction();
        if (tx.isActive()) {
            return ctx.proceed();
        }
        tx.begin();
        try {
            Object result = ctx.proceed();
            tx.commit();
            detachUnlessOpenInView();
            return result;
        } catch (RuntimeException | Error e) {
            if (tx.isActive()) {
                tx.rollback();
            }
            detachUnlessOpenInView();
            throw e;
        } catch (Exception checked) {
            tx.commit();
            detachUnlessOpenInView();
            throw checked;
        }
    }

    /** A container-managed, transaction-scoped context ends with the transaction; keeping it open is open-session-in-view. */
    private void detachUnlessOpenInView() {
        if (!Boolean.getBoolean("bookshelf.osiv")) {
            em.clear();
        }
    }
}

It is a teaching aid and not a substitute for a transaction manager: it implements only TxType.REQUIRED, it knows nothing about rollbackOn, dontRollbackOn or JTA, and it has no XA support. A production Tomcat application either adds a JTA implementation (not run for this page) or uses a framework that supplies one, as the Spring Data JPA page and the Quarkus datasources and transactions page describe. The remainder of the page states which results came from WildFly and which from Tomcat. The interceptor honours the system property bookshelf.osiv: with true the persistence context stays open after the transaction, with false it is cleared, which approximates the transaction-scoped context of a server.

Transactions

A transaction is the unit of work: all the changes of one user action are written together or not at all. In a Jakarta EE application there are three ways to get one around a service method, and they all give the same shape.

  • @Transactional on a CDI bean. The annotation jakarta.transaction.Transactional is an interceptor binding. The Transactions specification requires the interceptors to run with the priority Interceptor.Priority.PLATFORM_BEFORE+200, which is before the application’s own interceptors. The value is a TxType, and the default is REQUIRED: join the caller’s transaction, or start one. REQUIRES_NEW suspends the caller’s transaction and starts another, which is how an audit record survives a rollback. MANDATORY, SUPPORTS, NOT_SUPPORTED and NEVER complete the set.

  • A stateless session bean. The container wraps every business method of an Enterprise Bean in a transaction by default, and unlike @Transactional it also pools instances and supplies security and concurrency features. Faces beans inject it with @EJB or @Inject. Most new code uses the CDI route unless it needs those extras.

  • UserTransaction or EntityTransaction by hand. This is bean-managed demarcation: the code begins and commits. It is what the Tomcat interceptor above does on the application’s behalf, and it is the right tool only where no container exists.

Where a transaction begins matters more than how. The unit of work is the service call that the user action needs. A transaction must not span a request lifecycle: Faces would have to keep it open across Process Validations, Update Model Values and Invoke Application, with the user’s input in between, and the first time a validator failed or the browser went away the transaction would sit open until a timeout. The one-method-one-transaction rule also keeps converters and validators honest. A converter that calls findAuthor() gets its own short transaction and returns a detached entity.

Which Exceptions Roll Back

The specification of @Transactional says that a RuntimeException or an Error marks the transaction for rollback, and that a checked exception does not, unless the annotation says otherwise: rollbackOn lists exceptions that must cause a rollback and dontRollbackOn lists exceptions that must not. A service that throws a checked business exception from a method that has already changed an entity therefore commits the half-finished change, which is rarely what the author meant. The scratch service has three methods that rename a book, flush, and fail in the three ways:

service/BookService.java (rollback rules)
    @Transactional
    public void renameThenFail(long id, String title, String how) throws CatalogueException {
        em.find(BookEntity.class, id).setTitle(title);
        em.flush();
        switch (how) {
            case "runtime" -> throw new IllegalStateException("runtime failure");
            case "checked" -> throw new CatalogueException("checked failure");
            default -> { }
        }
    }

    @Transactional(rollbackOn = CatalogueException.class)
    public void renameThenFailRollingBack(long id, String title) throws CatalogueException {
        em.find(BookEntity.class, id).setTitle(title);
        em.flush();
        throw new CatalogueException("checked failure with rollbackOn");
    }
service/CatalogueException.java
package org.example.bookshelf.service;

/** A checked business exception: unless the annotation says otherwise, a Jakarta Transactions interceptor commits when it escapes. */
public class CatalogueException extends Exception {
    public CatalogueException(String message) {
        super(message);
    }
}

A request-scoped test bean called each variant on WildFly 41 and then read the book’s title in a fresh transaction:

Call Exception seen by the caller Title before Title after

no failure

none

Book 01

changed-none (committed)

renameThenFail(..., "runtime")

IllegalStateException

changed-none

changed-none (rolled back)

renameThenFail(..., "checked")

CatalogueException

changed-none

changed-checked (committed)

renameThenFailRollingBack(...) with rollbackOn

CatalogueException

changed-checked

changed-checked (rolled back)

The Tomcat run printed the same for the first three rows and committed the fourth, because the toy interceptor ignores rollbackOn. That is a property of the toy, and it is the reason this table is from WildFly.

Enterprise Beans in the Service Layer

A stateless session bean as the service has the same one-method-one-transaction shape, and one difference that matters to a backing bean: the exceptions are classified differently. A runtime exception thrown by an Enterprise Bean method is a system exception: the container rolls the transaction back and wraps the failure in an EJBException before the caller sees it. A checked exception is an application exception and does not roll back by default. The annotation @ApplicationException changes both: it marks any exception, including a runtime one, as an application exception that reaches the caller unwrapped, and rollback = true makes it roll the transaction back.

data/CatalogueEjb.java and data/CatalogueRejectedException.java (WildFly)
package org.example.bookshelf.data;

import jakarta.ejb.Stateless;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.example.bookshelf.jpa.BookEntity;
import org.example.bookshelf.service.CatalogueException;

@Stateless
public class CatalogueEjb {

    @PersistenceContext
    EntityManager em;

    public long count() {
        return em.createQuery("select count(b) from BookEntity b", Long.class).getSingleResult();
    }

    public void renameThenFail(long id, String title, String how) throws CatalogueException {
        em.find(BookEntity.class, id).setTitle(title);
        em.flush();
        switch (how) {
            case "runtime" -> throw new IllegalStateException("runtime failure");
            case "checked" -> throw new CatalogueException("checked failure");
            case "app" -> throw new CatalogueRejectedException("application exception with rollback");
            default -> { }
        }
    }
}

package org.example.bookshelf.data;

import jakarta.ejb.ApplicationException;

/** A business failure that reaches the caller unwrapped and still rolls the container transaction back. */
@ApplicationException(rollback = true)
public class CatalogueRejectedException extends RuntimeException {
    public CatalogueRejectedException(String message) {
        super(message);
    }
}

The test bean called renameThenFail for book 3 with each failure and read the title afterwards, on WildFly 41:

Failure Exception seen by the caller Title after

none

none

ejb-none (committed)

runtime

EJBException with an IllegalStateException cause

unchanged (rolled back)

checked CatalogueException

CatalogueException

ejb-checked (committed)

@ApplicationException(rollback = true)

CatalogueRejectedException, unwrapped

unchanged (rolled back)

So a backing bean that calls an Enterprise Bean must unwrap EJBException (or let one exception-handler do it, see unwrapping exceptions), whereas a @Transactional CDI bean throws what the method threw. A business exception that the view should show as a message is best declared as an application exception with rollback = true. The backing bean then catches its own type and adds a FacesMessage.

Lazy Loading and LazyInitializationException

JPA associations are lazy by choice (FetchType.LAZY) or by default (@OneToMany and @ManyToMany). A lazy association is represented by a proxy or by a collection that fetches its content the first time it is used, and it can do that only while its persistence context is open. After the service method returns the context is closed, the entity is detached, and the first access to an unloaded part fails. The Jakarta Persistence specification states the portable rule: the state of a detached entity’s lazy attributes may or may not have been loaded, and an application that needs interoperability across vendors must not rely on lazy loading of detached entities. Hibernate reports it as org.hibernate.LazyInitializationException.

In a Faces application the access happens in the worst possible place: an EL expression such as #{book.author.name} evaluated during Render Response. The lab page is the textbook mistake. A request-scoped bean loads the entities in its @PostConstruct method, and the view prints their association:

web/QueryLabBean.java (scratch lab)
package org.example.bookshelf.web;

import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.util.List;
import org.example.bookshelf.jpa.BookEntity;
import org.example.bookshelf.model.BookSummary;
import org.example.bookshelf.service.BookService;
import jakarta.faces.context.FacesContext;

/** Scratch lab: loads the same list four ways, chosen by the "mode" request parameter. */
@Named("queryLab")
@RequestScoped
public class QueryLabBean {

    @Inject
    BookService books;

    @Inject
    FacesContext facesContext;

    private List<BookEntity> entities = List.of();
    private List<BookSummary> summaries = List.of();

    @PostConstruct
    void load() {
        String mode = facesContext.getExternalContext().getRequestParameterMap().getOrDefault("mode", "entities");
        switch (mode) {
            case "summaries" -> summaries = books.summaries();
            case "fetch" -> entities = books.findAllEntitiesFetchingAuthor();
            case "collpage" -> entities = books.pageWithCollectionFetch(0, 5);
            case "graph" -> entities = books.findAllEntitiesWithGraph();
            default -> entities = books.findAllEntities();
        }
    }

    public List<BookEntity> getEntities() { return entities; }
    public List<BookSummary> getSummaries() { return summaries; }
}
lab.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:ui="jakarta.faces.facelets">
<ui:composition template="/WEB-INF/templates/layout.xhtml">
  <ui:define name="title">Entities in the view</ui:define>
  <ui:define name="content">
    <h1>Catalogue</h1>
    <ul>
      <ui:repeat value="#{queryLab.entities}" var="book">
        <li>#{book.title} by #{book.author.name} -- #{book.reviews.size()} reviews</li>
      </ui:repeat>
    </ul>
  </ui:define>
</ui:composition>
</html>

With the default list query (select b from BookEntity b order by b.title) the page answered HTTP 500 on Mojarra, on MyFaces and on WildFly, in the development stage with Faces' error page, and with this message at the bottom of the cause chain:

org.hibernate.LazyInitializationException: Could not initialize proxy [org.example.bookshelf.jpa.AuthorEntity#6] - no session

Source: output of the run described above (Hibernate ORM 7.4.12.Final; Hibernate ORM 7.4 User Guide).

Mojarra logged the failure as JSF1073: jakarta.el.ELException caught during processing of RENDER_RESPONSE 6, so the exception is wrapped in an ELException and the lifecycle phase number is the clue to where it happened. The data was loaded in a transaction that had finished long before Render Response. Adding join fetch b.author to the query moves the failure one step: the authors load, and the page now fails on the review collection (Cannot lazily initialize collection of role 'org.example.bookshelf.jpa.BookEntity.reviews'). Whatever the page touches must have been loaded inside the transaction.

Loading Exactly What the View Needs

There are three ways to give the view complete data, and they differ in how much of the model leaks into it.

Data Transfer Objects

A DTO is a plain class or record with just the values of the page, filled inside the transaction. A list page shows five values per row and no association, so the query selects them directly into a record with a JPQL constructor expression:

service/BookService.java (summaries and details)
    @Transactional
    public List<BookSummary> summaries() {
        return em.createQuery("""
                select new org.example.bookshelf.model.BookSummary(b.id, b.title, a.name, b.year, b.available)
                from BookEntity b join b.author a order by b.title
                """, BookSummary.class)
            .getResultList();
    }

    @Transactional
    public BookDetail detail(long id) {
        BookEntity b = em.createQuery("""
                select b from BookEntity b join fetch b.author left join fetch b.reviews where b.id = :id
                """, BookEntity.class)
            .setParameter("id", id)
            .getSingleResult();
        List<ReviewView> reviews = b.getReviews().stream().map(r -> new ReviewView(r.getRating(), r.getText())).toList();
        return new BookDetail(b.getId(), b.getTitle(), b.getAuthor().getName(), b.getYear(), reviews);
    }
model/BookSummary.java, model/BookDetail.java and model/ReviewView.java
package org.example.bookshelf.model;

import java.io.Serializable;
/** What a list row needs: five values, no association, nothing lazy. */
public record BookSummary(long id, String title, String authorName, int year, boolean available) implements Serializable {
}

package org.example.bookshelf.model;

import java.io.Serializable;
import java.io.Serializable;
import java.util.List;

/** What the details page needs, copied out of the entities while the transaction is still open. */
public record BookDetail(long id, String title, String authorName, int year, List<ReviewView> reviews) implements Serializable {
}

package org.example.bookshelf.model;

import java.io.Serializable;
public record ReviewView(int rating, String text) implements Serializable {
}

The list page then iterates over records. It cannot trigger a query, because there is nothing in a record that is lazy:

dto.xhtml
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
      xmlns:h="jakarta.faces.html"
      xmlns:ui="jakarta.faces.facelets">
<ui:composition template="/WEB-INF/templates/layout.xhtml">
  <ui:define name="title">Summaries in the view</ui:define>
  <ui:define name="content">
    <h1>Catalogue</h1>
    <ul>
      <ui:repeat value="#{queryLab.summaries}" var="book">
        <li>#{book.title} by #{book.authorName} (#{book.year})</li>
      </ui:repeat>
    </ul>
  </ui:define>
</ui:composition>
</html>

detail(id) shows the other pattern: one query with join fetch for the author and a left join fetch for the reviews, then a copy of the loaded data into BookDetail inside the transaction. A collection fetch join multiplies rows (one per review), which is why it belongs in a query for one book and not in a paged list (Paging Data Tables Against the Database). Records are the natural DTOs since Java 16. They implement Serializable here because a view-scoped bean may hold them.

Entity Graphs and Fetch Joins

When the view really does need entities, for example to feed a component that expects them, the query must load the whole graph that the page walks. A fetch join in JPQL does it for the associations the query names. A Jakarta Persistence entity graph does it without changing the query text, by a hint:

    @Transactional
    public List<BookEntity> findAllEntities() {
        return em.createQuery("select b from BookEntity b order by b.title", BookEntity.class).getResultList();
    }

    @Transactional
    public List<BookEntity> findAllEntitiesFetchingAuthor() {
        return em.createQuery("select b from BookEntity b join fetch b.author order by b.title", BookEntity.class)
            .getResultList();
    }

    @Transactional
    public List<BookEntity> findAllEntitiesWithGraph() {
        EntityGraph<BookEntity> graph = em.createEntityGraph(BookEntity.class);
        graph.addAttributeNodes("author", "reviews");
        return em.createQuery("select distinct b from BookEntity b order by b.title", BookEntity.class)
            .setHint("jakarta.persistence.fetchgraph", graph)
            .getResultList();
    }

The three requests of lab.xhtml differ only in the query. The numbers are the JDBC statements prepared by the whole request, with 24 books, 18 authors and one to three reviews per book, and are identical on Mojarra and MyFaces:

Query in QueryLabBean Without open-session-in-view With open-session-in-view

findAllEntities() (nothing fetched)

HTTP 500, LazyInitializationException on the author

200, 43 statements (1 list, 18 authors, 24 review collections)

findAllEntitiesFetchingAuthor() (join fetch b.author)

HTTP 500, LazyInitializationException on the reviews

200, 25 statements (1 list, 24 review collections)

findAllEntitiesWithGraph() (entity graph with author and reviews)

200, 1 statement

200, 1 statement

summaries() (DTO query)

200, 1 statement

200, 1 statement

The graph query returned the books with their authors and reviews in a single statement. The fetch graph hint jakarta.persistence.fetchgraph is the one the Jakarta Persistence specification defines (see the fetch graph semantics), and the Hibernate pages of this documentation compare it with fetch joins, @BatchSize and subselect fetching: see fetching and the N+1 problem. The last two columns also show what open-session-in-view does: it turns the exception into 42 extra statements and hides the problem until the table is big.

Open Session in View

Open session in view (OSIV) keeps the persistence context open until the response has been rendered, so that lazy loading keeps working in the view. In a web tier it is usually a servlet filter or an interceptor that opens a session when the request starts and closes it at the end. It is the oldest workaround for LazyInitializationException and it has real costs:

  • the database connection is held, or reacquired on every lazy access, for the whole of Render Response, including time spent on the network and in the template engine;

  • every lazy access is a query run by the view, a query per row for a table, and nothing marks it in the code (the 43 statements above);

  • it moves data access out of the service, where a transaction, a timeout and a test cover it, into the XHTML;

  • it does not help beans that live across requests (Entities in View-Scoped Beans).

OSIV is not available by default on a Jakarta EE server: the container-managed persistence context is bound to the transaction, and the WildFly runs above failed exactly as the Tomcat runs without OSIV did. It exists where an application assembles its own persistence context, as the request-scoped EntityManager of the scratch Tomcat variant does (-Dbookshelf.osiv=true produced the right-hand column above), and as Spring Boot’s spring.jpa.open-in-view property does. That property defaults to true; the Spring Boot Hibernate page and the Hibernate and Spring Boot page explain why it is better to turn it off, and the same advice applies when Faces runs on Spring Boot through JoinFaces (Spring Boot with JoinFaces shows that setup).

The recommended setup for a Faces application is therefore the opposite of OSIV: leave the persistence context closed at Render Response, and let a lazy load that slips through fail loudly in development, where it is cheap to fix. Fetching exactly what the view needs (Loading Exactly What the View Needs) is the cure; a persistence context left open is a symptom-hider.

Entities in View-Scoped Beans

A @ViewScoped bean lives across the postbacks of one view (see the view scope), and the temptation is to load a list of entities once in @PostConstruct and keep it. The first request works, because the data was loaded with everything the page needs at that time. The lab holds the entities with their authors fetched and shows only three titles; a panel that prints the review count of the sixth book appears after the first click:

web/HeldBooksBean.java (scratch lab)
package org.example.bookshelf.web;

import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.List;
import org.example.bookshelf.jpa.BookEntity;
import org.example.bookshelf.service.BookService;

/** Scratch lab: entities loaded once in a view-scoped bean and rendered again on every postback. */
@Named("heldBooks")
@ViewScoped
public class HeldBooksBean implements Serializable {

    @Inject
    BookService books;

    private List<BookEntity> entities;
    private int clicks;

    @PostConstruct
    void load() {
        entities = books.findAllEntitiesFetchingAuthor();
    }

    public void click() {
        clicks++;
    }

    public List<BookEntity> getEntities() { return entities; }
    public int getClicks() { return clicks; }
}
held.xhtml
<!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>Held entities</title></h:head>
<h:body>
  <h:form id="form">
    <ui:repeat value="#{heldBooks.entities}" var="b" end="2">
      <p>#{b.title}</p>
    </ui:repeat>
    <h:panelGroup rendered="#{heldBooks.clicks gt 0}"><p id="late">sixth book has #{heldBooks.entities[5].reviews.size()} reviews</p></h:panelGroup>
    <h:commandButton id="click" value="Click" action="#{heldBooks.click}"/>
    <h:outputText id="clicks" value="clicks=#{heldBooks.clicks}"/>
  </h:form>
</h:body>
</html>

The GET answered 200. The POST of the button answered HTTP 500 on Mojarra and on MyFaces, with and without open-session-in-view, and also on WildFly:

org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'org.example.bookshelf.jpa.BookEntity.reviews' with key '3' (no session)

Source: output of the run described above (Hibernate ORM 7.4.12.Final; Hibernate ORM 7.4 User Guide).

The persistence context of the first request was gone, so the entities in the bean were detached, and a request-scoped context opened for the postback does not know them. OSIV therefore cannot rescue this case. The rules for a bean that outlives a request are:

  • keep DTOs, ids and plain values, not managed entities;

  • if a detached entity must be kept, treat it as read-only and only for what was loaded with it, and never rely on its lazy parts;

  • never keep an EntityManager, a Session or a query result that is a live cursor in a serialisable bean: they are not serialisable and they would pin a connection;

  • reload by id in the action (em.find inside the service) and apply changes to the fresh instance (Editing with Optimistic Locking).

Detached entities that were fully loaded do serialise. A run of the same entity classes in a plain Java program serialised a detached book whose author was an uninitialised proxy without error, because Hibernate writes the proxy’s identifier and the missing state is only discovered if somebody dereferences it later. That is what makes this mistake survive the first tests. See serialisation of scoped beans for the passivation rules.

Entity Converters and Select Menus

A select menu offers objects and posts back a string, so the component needs a converter that turns an object into a key and a key into an object (see entity converters for the mechanism). With a database behind it, the converter calls the service:

web/AuthorConverter.java
package org.example.bookshelf.web;

import jakarta.enterprise.context.Dependent;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.convert.Converter;
import jakarta.faces.convert.ConverterException;
import jakarta.faces.convert.FacesConverter;
import jakarta.inject.Inject;
import org.example.bookshelf.jpa.AuthorEntity;
import org.example.bookshelf.service.BookService;

/** Entity converter: the id travels in the page, the entity is loaded again on submit. */
@Dependent
@FacesConverter(value = "bookshelf.authorConverter", managed = true)
public class AuthorConverter implements Converter<AuthorEntity> {

    @Inject
    BookService books;

    @Override
    public AuthorEntity getAsObject(FacesContext context, UIComponent component, String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        try {
            AuthorEntity author = books.findAuthor(Long.parseLong(value));
            if (author == null) {
                throw new ConverterException("Unknown author");
            }
            return author;
        } catch (NumberFormatException e) {
            throw new ConverterException("Not an author id", e);
        }
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, AuthorEntity value) {
        return value == null ? "" : String.valueOf(value.getId());
    }
}

@FacesConverter(managed = true) makes the converter a CDI bean, so @Inject works (Faces 2.3 and later, see managed converters). Mojarra and MyFaces both injected the service. The bean behind the menu offers the same choice in two ways, so that the two can be compared in one page:

web/AuthorPickBean.java
package org.example.bookshelf.web;

import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.List;
import org.example.bookshelf.jpa.AuthorEntity;
import org.example.bookshelf.model.AuthorOption;
import org.example.bookshelf.service.BookService;

@Named("authorPick")
@ViewScoped
public class AuthorPickBean implements Serializable {

    @Inject
    BookService books;

    private List<AuthorEntity> authors;
    private List<AuthorOption> options;
    private AuthorEntity author;
    private Long authorId;
    private String chosen = "";

    @PostConstruct
    void init() {
        authors = books.authors();
        options = books.authorOptions();
    }

    public String pickEntity() {
        chosen = author == null ? "none" : author.getName();
        return null;
    }

    public String pickId() {
        chosen = authorId == null ? "none" : books.authorName(authorId);
        return null;
    }

    public List<AuthorEntity> getAuthors() { return authors; }
    public List<AuthorOption> getOptions() { return options; }
    public AuthorEntity getAuthor() { return author; }
    public void setAuthor(AuthorEntity author) { this.author = author; }
    public Long getAuthorId() { return authorId; }
    public void setAuthorId(Long authorId) { this.authorId = authorId; }
    public String getChosen() { return chosen; }
}
pick.xhtml
<!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">
<ui:composition template="/WEB-INF/templates/layout.xhtml">
  <ui:define name="title">Pick an author</ui:define>
  <ui:define name="content">
    <h:messages id="msgs" globalOnly="false"/>
    <h:form id="e">
      <h:selectOneMenu id="author" value="#{authorPick.author}" converter="bookshelf.authorConverter">
        <f:selectItem itemLabel="-- choose --" noSelectionOption="true"/>
        <f:selectItems value="#{authorPick.authors}" var="a" itemValue="#{a}" itemLabel="#{a.name}"/>
      </h:selectOneMenu>
      <h:commandButton id="go" value="Pick (entity)" action="#{authorPick.pickEntity}"/>
    </h:form>
    <h:form id="i">
      <h:selectOneMenu id="author" value="#{authorPick.authorId}">
        <f:selectItem itemLabel="-- choose --" noSelectionOption="true"/>
        <f:selectItems value="#{authorPick.options}" var="o" itemValue="#{o.id}" itemLabel="#{o.name}"/>
      </h:selectOneMenu>
      <h:commandButton id="go" value="Pick (id)" action="#{authorPick.pickId}"/>
    </h:form>
    <p id="chosen">Chosen: #{authorPick.chosen}</p>
  </ui:define>
</ui:composition>
</html>

The top form binds the menu to an AuthorEntity through the converter; the bottom form binds it to a Long id, with AuthorOption records as items and no converter at all (the standard LongConverter is selected by the property type). Both worked on both implementations. What the run showed is worth knowing before choosing:

  • The converter costs a query per postback. getAsObject() runs in Process Validations and prepared one statement (the author lookup) every time the form was posted; the id version also ran one, in the action, only if it needed the name. The converter’s query happens before validation of the other fields, so an invalid form still pays for it.

  • The entity must implement equals(). A select component checks that the converted value is one of the offered items by equals(). The items were loaded in the first request and the converted author in the postback, so they are different instances. With equals() removed from AuthorEntity the entity form failed with e:author: Validation Error: Value is not valid on Mojarra, while the id form kept working. Use an identifier-based equals(), with a constant hashCode() while the id may still be null, as the listing does.

  • The converted entity is detached. findAuthor() returned it from a finished transaction. Assigning it to a managed book (book.setAuthor(author)) in a later service call only works with a reference to the same row, so pass the id on to the service and let it call em.getReference or em.find inside its own transaction. That is also why a converter never needs to return a full object.

  • Treat the id as input. Anyone can post any id. The converter must reject an unknown one with a ConverterException (the listing does), and authorisation (may this user pick this author?) belongs in the service. The menu offering only some authors is not a protection.

  • The list belongs in the bean, not in a getter. AuthorPickBean loads the lists once in @PostConstruct; a getter that queried would run several times per request, as the performance page measures.

The id-based design is the simpler one and is the default recommendation: the page carries a key, the view-scoped bean holds records, and the entity is only ever loaded by the service that changes it. A generic entity converter for the whole application is what OmniFaces offers (see the OmniFaces page); it faces the same detached-entity and equals() rules.

Paging Data Tables Against the Database

A table over a few hundred rows can load them all. A table over a catalogue pages: the database counts, sorts and filters, and the page fetches one window. The data table page covers the components; this section is the service behind them. The service has two methods, one count and one findPage, and both use the same where clause so the pager and the rows agree:

service/BookService.java (paging)
    @Transactional
    public long count(String titleContains) {
        TypedQuery<Long> count = em.createQuery("select count(b) from BookEntity b" + where(titleContains), Long.class);
        bind(count, titleContains);
        return count.getSingleResult();
    }

    @Transactional
    public List<BookSummary> findPage(int first, int size, String sortKey, boolean ascending, String titleContains) {
        String order = SORTABLE.get(sortKey == null ? "title" : sortKey);
        if (order == null) {
            throw new IllegalArgumentException("Unknown sort key: " + sortKey);
        }
        TypedQuery<BookSummary> select = em.createQuery(
            "select new org.example.bookshelf.model.BookSummary(b.id, b.title, a.name, b.year, b.available) "
                + "from BookEntity b join b.author a" + where(titleContains)
                + " order by " + order + (ascending ? " asc" : " desc") + ", b.id asc",
            BookSummary.class);
        bind(select, titleContains);
        return select.setFirstResult(first).setMaxResults(size).getResultList();
    }

    @Transactional
    public PageSlice<BookSummary> page(int first, int size, String sortKey, boolean ascending, String titleContains) {
        return new PageSlice<>(findPage(first, size, sortKey, ascending, titleContains), count(titleContains));
    }

    private static String where(String titleContains) {
        return titleContains == null || titleContains.isBlank() ? "" : " where lower(b.title) like :title escape '\\'";
    }

    private static void bind(TypedQuery<?> query, String titleContains) {
        if (titleContains != null && !titleContains.isBlank()) {
            String escaped = titleContains.trim().toLowerCase().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
            query.setParameter("title", "%" + escaped + "%");
        }
    }

    /** The trap: a collection join fetch combined with a row limit. */
    @Transactional
    public List<BookEntity> pageWithCollectionFetch(int first, int size) {
        return em.createQuery("select distinct b from BookEntity b left join fetch b.reviews order by b.title", BookEntity.class)
            .setFirstResult(first).setMaxResults(size).getResultList();
    }

Four details are about security and correctness and not about speed.

  • The sort key is a lookup, not a fragment. The browser sends title, authorName or year; the map SORTABLE of the class head translates it to a JPQL path, and anything else raises IllegalArgumentException. A sort field concatenated into the query text is a query-injection hole, because JPQL order by cannot be a bind parameter.

  • The search text is a bind parameter, and its wildcards are escaped. The text is bound with setParameter, and %, _ and the backslash are escaped for like ... escape. A search for 100% returned no rows (it would have matched everything unescaped), and so did o_, which has no such substring. The service needs to do this itself: the persistence provider binds the value, but it does not escape the wildcards in it.

  • The order is total. Every sort ends with , b.id asc. Without a unique tiebreaker the database may order equal rows differently on each query, and a row can appear on two pages or on none.

  • The page size is the service’s decision. The bean passes a constant; a rows request parameter must be capped before it reaches setMaxResults, or one request can ask for the whole table.

A Plain h:dataTable with Previous and Next

The standard table has no pager. The view-scoped bean keeps the position and the current page of records, and loads a new page when a button or a column header is clicked:

web/PagerBean.java
package org.example.bookshelf.web;

import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import java.util.List;
import org.example.bookshelf.model.BookSummary;
import org.example.bookshelf.model.PageSlice;
import org.example.bookshelf.service.BookService;

/** A plain h:dataTable paged against the database: one count and one page query per page change. */
@Named("pager")
@ViewScoped
public class PagerBean implements Serializable {

    private static final int SIZE = 5;

    @Inject
    BookService books;

    private int first;
    private String sort = "title";
    private boolean ascending = true;
    private String titleContains;
    private PageSlice<BookSummary> slice;

    @PostConstruct
    void init() {
        reload();
    }

    public String search() {
        first = 0;
        reload();
        return null;
    }

    public String next() {
        if (first + SIZE < slice.total()) {
            first += SIZE;
            reload();
        }
        return null;
    }

    public String previous() {
        first = Math.max(0, first - SIZE);
        reload();
        return null;
    }

    public String sortBy(String key) {
        ascending = !key.equals(sort) || !ascending;
        sort = key;
        first = 0;
        reload();
        return null;
    }

    private void reload() {
        slice = books.page(first, SIZE, sort, ascending, titleContains);
    }

    public List<BookSummary> getRows() { return slice.content(); }
    public long getTotal() { return slice.total(); }
    public int getFrom() { return slice.total() == 0 ? 0 : first + 1; }
    public int getTo() { return (int) Math.min(slice.total(), first + SIZE); }
    public boolean isHasPrevious() { return first > 0; }
    public boolean isHasNext() { return first + SIZE < slice.total(); }
    public String getTitleContains() { return titleContains; }
    public void setTitleContains(String titleContains) { this.titleContains = titleContains; }
}
pager.xhtml
<!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">
<ui:composition template="/WEB-INF/templates/layout.xhtml">
  <ui:define name="title">Catalogue</ui:define>
  <ui:define name="content">
    <h:form id="form">
      <h:inputText id="q" value="#{pager.titleContains}"/>
      <h:commandButton id="find" value="Search" action="#{pager.search}"/>
      <h:dataTable id="books" value="#{pager.rows}" var="book">
        <h:column>
          <f:facet name="header">
            <h:commandLink id="byTitle" value="Title" action="#{pager.sortBy('title')}"/>
          </f:facet>
          #{book.title}
        </h:column>
        <h:column>
          <f:facet name="header">
            <h:commandLink id="byAuthor" value="Author" action="#{pager.sortBy('authorName')}"/>
          </f:facet>
          #{book.authorName}
        </h:column>
        <h:column>
          <f:facet name="header">
            <h:commandLink id="byYear" value="Year" action="#{pager.sortBy('year')}"/>
          </f:facet>
          #{book.year}
        </h:column>
      </h:dataTable>
      <h:commandButton id="prev" value="Previous" action="#{pager.previous}" disabled="#{not pager.hasPrevious}"/>
      <h:outputText id="range" value="#{pager.from}-#{pager.to} of #{pager.total}"/>
      <h:commandButton id="next" value="Next" action="#{pager.next}" disabled="#{not pager.hasNext}"/>
    </h:form>
  </ui:define>
</ui:composition>
</html>

The bean loads in @PostConstruct and again in each action, and the getters only return what was loaded. Each request that loaded a page prepared two statements (count and page) on Tomcat, and rendering the table prepared none. A first click on a column sorts ascending and a second sorts descending. In the run the first page of 24 books showed 1-5 of 24, Next moved to 6-10, sorting by year ascending began with 1811 and descending with 1903, and the search th narrowed the table to 7 books. The bean is @ViewScoped because the position must survive the postbacks of one view and not those of the session; with a request scope the position would be lost every time. Mojarra and MyFaces behaved the same. The position and the sort key are a plain int and a String: nothing here holds an entity.

For a catalogue that is read in order and very large, an offset gets slow when it is far from the start. The pagination strategies page explains keyset paging, which this section’s Page and CursoredPage variants support (Jakarta Data: Repositories with Pages).

PrimeFaces LazyDataModel

PrimeFaces' p:dataTable pages, sorts and filters in one component, and with lazy="true" it asks a LazyDataModel for each window. The model translates the table’s state into the two service calls above:

web/BookLazyModel.java
package org.example.bookshelf.web;

import java.util.List;
import java.util.Map;
import org.example.bookshelf.model.BookSummary;
import org.example.bookshelf.service.BookService;
import org.primefaces.model.FilterMeta;
import org.primefaces.model.LazyDataModel;
import org.primefaces.model.SortMeta;

/** Translates the table's page, sort and filter state into one count and one page query. */
public class BookLazyModel extends LazyDataModel<BookSummary> {

    private final transient BookService books;

    public BookLazyModel(BookService books) {
        this.books = books;
    }

    @Override
    public int count(Map<String, FilterMeta> filterBy) {
        return (int) books.count(filterText(filterBy));
    }

    @Override
    public List<BookSummary> load(int first, int pageSize, Map<String, SortMeta> sortBy, Map<String, FilterMeta> filterBy) {
        String key = "title";
        boolean ascending = true;
        if (!sortBy.isEmpty()) {
            SortMeta sort = sortBy.values().iterator().next();
            key = sort.getField();
            ascending = sort.getOrder().isAscending();
        }
        return books.findPage(first, pageSize, key, ascending, filterText(filterBy));
    }

    @Override
    public String getRowKey(BookSummary row) {
        return String.valueOf(row.id());
    }

    private static String filterText(Map<String, FilterMeta> filterBy) {
        FilterMeta meta = filterBy.get("title");
        return meta == null || meta.getFilterValue() == null ? null : String.valueOf(meta.getFilterValue());
    }
}
web/PagedCatalogueBean.java
package org.example.bookshelf.web;

import jakarta.annotation.PostConstruct;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.Serializable;
import org.example.bookshelf.model.BookSummary;
import org.example.bookshelf.service.BookService;
import org.primefaces.model.LazyDataModel;

@Named("pagedCatalogue")
@ViewScoped
public class PagedCatalogueBean implements Serializable {

    @Inject
    BookService books;

    private LazyDataModel<BookSummary> model;

    @PostConstruct
    void init() {
        model = new BookLazyModel(books);
    }

    public LazyDataModel<BookSummary> getModel() {
        return model;
    }
}
paged.xhtml
<!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"
      xmlns:p="primefaces">
<ui:composition template="/WEB-INF/templates/layout.xhtml">
  <ui:define name="title">Paged catalogue</ui:define>
  <ui:define name="content">
    <h:form id="form">
      <p:dataTable id="books" value="#{pagedCatalogue.model}" var="book" lazy="true" rowKey="#{book.id}"
                   paginator="true" rows="5" rowsPerPageTemplate="5,10" sortMode="single" emptyMessage="No books match">
        <p:column headerText="Title" field="title" filterMatchMode="contains"/>
        <p:column headerText="Author" field="authorName"/>
        <p:column headerText="Year" field="year"/>
      </p:dataTable>
    </h:form>
  </ui:define>
</ui:composition>
</html>

On Tomcat the first request and each paging, sorting or filtering Ajax request prepared two statements (a count and a page), with five rows in each response. The filter emma returned one row, and the second page began with Frankenstein. Two things are worth noticing in the listing. count() receives the filter state, so the pager shows the filtered total. And getRowKey() returns the id of the row, which is what the data table needs for selection and row expansion to survive a page change. The bean is view-scoped, and the model holds a transient reference to the service (a CDI proxy must not be serialised with the view). For the model itself, filters on several columns, the JPALazyDataModel helper and the settings of the table, see the PrimeFaces data components page.

Jakarta Data: Repositories with Pages

Jakarta EE 11 adds a specification that writes repositories as interfaces. Jakarta Data 1.0 is part of the Jakarta EE 11 Platform and Web Profile, and a provider generates the implementation at build time. The repository below was compiled with the Hibernate ORM annotation processor (hibernate-processor 7.4.12.Final, run with javac -proc:full, because JDK 26 does not run annotation processors by default) and ran on WildFly 41.0.1.Final, which supplies the Jakarta Data API (1.0.1):

data/Books.java (WildFly)
package org.example.bookshelf.data;

import jakarta.data.Order;
import jakarta.data.page.CursoredPage;
import jakarta.data.page.Page;
import jakarta.data.page.PageRequest;
import jakarta.data.repository.Find;
import jakarta.data.repository.OrderBy;
import jakarta.data.repository.Query;
import jakarta.data.repository.Repository;
import jakarta.data.repository.Save;
import jakarta.transaction.Transactional;
import java.util.Optional;
import org.example.bookshelf.jpa.BookEntity;
import org.example.bookshelf.model.BookSummary;

@Transactional
@Repository
public interface Books {

    @Find
    Optional<BookEntity> byId(Long id);

    @Find
    @OrderBy("title")
    Page<BookEntity> availability(boolean available, PageRequest pageRequest, Order<BookEntity> order);

    @Query("select new org.example.bookshelf.model.BookSummary(b.id, b.title, b.author.name, b.year, b.available) from BookEntity b where lower(b.title) like :pattern order by b.title")
    Page<BookSummary> search(String pattern, PageRequest pageRequest);

    @Find
    @OrderBy("year")
    @OrderBy("id")
    CursoredPage<BookEntity> byYearCursor(boolean available, PageRequest pageRequest, Order<BookEntity> order);

    @Save
    BookEntity save(BookEntity book);
}
web/DataLabBean.java (WildFly)
package org.example.bookshelf.web;

import jakarta.data.page.CursoredPage;
import jakarta.data.page.Page;
import jakarta.data.page.PageRequest;
import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import org.example.bookshelf.data.Books;
import org.example.bookshelf.model.BookSummary;

@Named("dataLab")
@RequestScoped
public class DataLabBean {

    @Inject
    Books books;

    @Inject
    FacesContext facesContext;

    private String outcome;

    public void run() {
        int page = Integer.parseInt(facesContext.getExternalContext().getRequestParameterMap().getOrDefault("page", "1"));
        Page<BookSummary> found = books.search("%book%", PageRequest.ofPage(page).size(5));
        CursoredPage<org.example.bookshelf.jpa.BookEntity> c1 = books.byYearCursor(true, PageRequest.ofSize(4), jakarta.data.Order.by());
        CursoredPage<org.example.bookshelf.jpa.BookEntity> c2 = c1.hasNext() ? books.byYearCursor(true, c1.nextPageRequest(), jakarta.data.Order.by()) : c1;
        String lazy;
        try {
            lazy = books.byId(1L).get().getAuthor().getName();
        } catch (RuntimeException e) {
            lazy = e.getClass().getSimpleName() + ": " + e.getMessage();
        }
        outcome = "lazy author: " + lazy + " | " + "page=" + found.pageRequest().page() + " rows=" + found.numberOfElements() + " total=" + found.totalElements()
            + " totalPages=" + found.totalPages() + " first=" + found.content().get(0).title()
            + " | cursor1=" + c1.content().stream().map(b -> b.getTitle()).toList()
            + " cursor2=" + c2.content().stream().map(b -> b.getTitle()).toList();
    }

    public String getOutcome() { return outcome; }
}

Page is offset paging: the request PageRequest.ofPage(1).size(5) returned five rows, a total of 24 and five pages. The search query uses the same constructor expression as the hand-written service, so it fills the same record. CursoredPage is keyset paging: the first page of four available books ordered by year and id was Book 01, Book 02, Book 03, Book 05, and nextPageRequest() produced the cursor that gave Book 06, Book 07, Book 09, Book 10, with no offset. A Faces bean keeps the current PageRequest (a value, serialisable in the view scope) and shows content(), totalElements() and hasNext(); a cursor-based pager offers Next and Previous only. The same run showed the lazy-loading rule is not removed by a repository: books.byId(1L).get().getAuthor().getName() raised LazyInitializationException: Could not initialize proxy [org.example.bookshelf.jpa.AuthorEntity#1] - no session, because the entity left the repository’s transaction. Return records from the query methods, as search does.

The repository’s @Transactional is the same annotation as in the service. How a given server provides the repository implementation (which provider, which build step) was verified here only for WildFly with Hibernate; Payara, Open Liberty and GlassFish are not covered, and Tomcat needs a provider added by the application. See the Jakarta EE servers page for what each server ships.

Editing with Optimistic Locking

Two users can open the same book and save. Without protection the second save silently overwrites the first. JPA’s answer is optimistic locking with a @Version field: the provider adds and version = ? to every update and fails if no row matched. The edit form carries the version it loaded, in a plain bean and not in an entity, and the service compares it before changing anything:

model/BookEdit.java
package org.example.bookshelf.model;

import java.io.Serializable;

/** The edit form's state: a plain bean that may live in a view scope, carrying the version it was loaded at. */
public class BookEdit implements Serializable {

    private long id;
    private long version;
    private String title;
    private int year;

    public long getId() { return id; }
    public void setId(long id) { this.id = id; }
    public long getVersion() { return version; }
    public void setVersion(long version) { this.version = version; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public int getYear() { return year; }
    public void setYear(int year) { this.year = year; }
}
service/BookService.java (editing)
    @Transactional
    public BookEdit loadForEdit(long id) {
        BookEntity b = em.find(BookEntity.class, id);
        if (b == null) {
            return null;
        }
        BookEdit form = new BookEdit();
        form.setId(b.getId());
        form.setVersion(b.getVersion());
        form.setTitle(b.getTitle());
        form.setYear(b.getYear());
        return form;
    }

    @Transactional
    public void update(BookEdit form) {
        BookEntity b = em.find(BookEntity.class, form.getId());
        if (b == null) {
            throw new IllegalStateException("Book " + form.getId() + " no longer exists");
        }
        if (b.getVersion() != form.getVersion()) {
            throw new OptimisticLockException("Book " + form.getId() + " was changed by someone else (loaded at version "
                + form.getVersion() + ", now " + b.getVersion() + ")");
        }
        b.setTitle(form.getTitle());
        b.setYear(form.getYear());
    }
web/BookEditBean.java
package org.example.bookshelf.web;

import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.faces.view.ViewScoped;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.persistence.OptimisticLockException;
import java.io.Serializable;
import org.example.bookshelf.model.BookEdit;
import org.example.bookshelf.service.BookService;

@Named("bookEdit")
@ViewScoped
public class BookEditBean implements Serializable {

    @Inject
    BookService books;

    @Inject
    FacesContext facesContext;

    private long id;
    private BookEdit form;

    /** viewAction: load once per view, not per Ajax request. */
    public String load() {
        form = books.loadForEdit(id);
        return form == null ? "notfound?faces-redirect=true" : null;
    }

    public String save() {
        try {
            books.update(form);
            facesContext.getExternalContext().getFlash().setKeepMessages(true);
            facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_INFO, "Saved.", null));
            return "edit?faces-redirect=true&includeViewParams=true";
        } catch (OptimisticLockException e) {
            facesContext.addMessage(null, new FacesMessage(FacesMessage.SEVERITY_ERROR,
                "Someone else changed this book while you were editing. Reload and try again.", null));
            return null;
        }
    }

    public long getId() { return id; }
    public void setId(long id) { this.id = id; }
    public BookEdit getForm() { return form; }
}

The view loads the bean from the id request parameter with f:viewParam and f:viewAction (see view actions); the action runs once when the view is first requested and not on postbacks. The metadata sits in a slot of the template, not in the template client’s content area:

WEB-INF/templates/layout.xhtml
<!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">
<f:view>
  <ui:insert name="metadata"/>
  <h:head>
    <title><ui:insert name="title">Bookshelf</ui:insert></title>
  </h:head>
  <h:body>
    <ui:insert name="content"/>
    <hr/>
    <p>JDBC statements prepared by this request: <span id="stmts">#{stmts.count}</span></p>
  </h:body>
</f:view>
</html>
edit.xhtml
<!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">
<ui:composition template="/WEB-INF/templates/layout.xhtml">
  <ui:define name="metadata">
    <f:metadata>
      <f:viewParam name="id" value="#{bookEdit.id}" required="true"/>
      <f:viewAction action="#{bookEdit.load}"/>
    </f:metadata>
  </ui:define>
  <ui:define name="title">Edit a book</ui:define>
  <ui:define name="content">
    <h:messages id="msgs" globalOnly="true"/>
    <h:form id="form">
      <h:inputText id="title" value="#{bookEdit.form.title}" required="true"/>
      <h:inputText id="year" value="#{bookEdit.form.year}"/>
      <h:commandButton id="save" value="Save" action="#{bookEdit.save}"/>
      <h:outputText id="version" value="version #{bookEdit.form.version}"/>
    </h:form>
  </ui:define>
</ui:composition>
</html>

In the run two sessions loaded book 1 at version 0. The first saved and was redirected to edit.xhtml?id=1 (post-redirect-get with includeViewParams=true, see flash scope and post-redirect-get); the bean asks the flash to keep the confirmation message across that redirect, and the message display itself was not checked in the run. The second save returned status 200 with the message "Someone else changed this book while you were editing. Reload and try again." and changed nothing; a reload then showed version 1 on the form of the first user. The message was identical on Mojarra, MyFaces and WildFly.

Four notes belong to this example.

  • Hibernate increments the version only when something changed. In a second run two sessions loaded book 4 at version 0. The first saved the unchanged title and year and the version stayed at 0, so the second user’s real change then went through (version 1) and no conflict arose. A conflict needs two real changes.

  • The explicit comparison is not the only line of defence. If two transactions pass the comparison at the same moment, the provider fails the second commit: a separate plain Java run of these entity classes made commit() throw a RollbackException caused by OptimisticLockException: Unexpected row count (expected row count 1 but was 0). Inside a JTA service method the same failure arrives when the interceptor commits, so the caller sees whatever the container wraps it in; it was not run, so unwrap the cause chain and look for OptimisticLockException instead of expecting the type at the top.

  • The version travels with the view. The bean is view-scoped, so the version is in the server-side view state. With client-side state saving it is in the (encrypted) view state, which a user cannot change (see client state saving). In a stateless view the bean is rebuilt on each request and the version has to be posted in a hidden field that the application protects.

  • The metadata slot. On MyFaces an f:metadata inside ui:define of the content area failed with <f:metadata> Parent UIComponent j_id_7 should be instance of UIViewRoot, while Mojarra accepted it. The portable form is the one in the listing: the template contains f:view and a ui:insert name="metadata", and the client puts the f:metadata into that slot. This agrees with the placement rules in the core tags page.

The model validators of the form (a required title, a range for the year) are Bean Validation constraints and Faces validators as usual (see the service layer in the Bean Validation page). The persistence side of that integration is the jakarta.persistence.validation.mode property: with a Bean Validation provider on the class path and the default auto mode the persistence provider validates entities itself before it writes them, so the database has a second line of defence even if a service skipped the check.

Spring Data and Quarkus

The layering is the same on the other runtimes of this section; the annotations differ. On Spring Boot (through JoinFaces) the service is a Spring bean with Spring’s @Transactional, the repository is a Spring Data interface, and the Faces beans reach Spring beans through the Spring EL integration. The Spring Data JPA page and the transaction isolation and locking page cover the Spring side, and Spring Boot with JoinFaces shows the Faces side. On Quarkus the service is an @ApplicationScoped bean with @Transactional, and Hibernate ORM with Panache supplies the persistence: see the Quarkus Hibernate ORM and JPA page and the Panache page, and Quarkus for the Faces extension. Nothing in the rules above changes: a DTO from the service, a short transaction, no lazy access in the view. Those two pages were not run for this page and are named here only to say where to look next.

Testing the Service Layer

A service that owns its transactions is testable without Faces: call the method and assert on the records. Integration tests that need a real persistence context run with an embedded database and the provider, as in the Hibernate integration testing page. The testing page covers unit tests of backing beans with Mockito, Arquillian tests that deploy the WAR to a container and the browser tests that exercise a whole page; the count()/findPage() pair above is an ordinary candidate for the first two.

Mojarra versus MyFaces

The persistence code does not depend on the Faces implementation, and the runs on Mojarra 4.1.17 and MyFaces 4.1.4 agreed on every point above: the same LazyInitializationException where lazy access failed, the same statement counts (1, 25, 43), the same entity-converter behaviour including the Validation Error: Value is not valid without equals(), the same optimistic-lock message and the same paging results. The differences that did appear are about the view and not the data:

  • An f:metadata block inside ui:define of a template client works on Mojarra and fails on MyFaces. Use a slot of the template (Editing with Optimistic Locking).

  • The client ids of the header facet of a table include the table (form:books:byYear), and the JavaScript that a command link renders differs (Mojarra calls mojarra.cljs, MyFaces calls myfaces.oam.submitForm). A scripted test or a locator must not depend on either.

  • The sort and filter keys of a PrimeFaces table are generated component ids on the first request and differ between the two implementations; a test must read them from the page.

What Changed

  • Faces 2.3 (2017): CDI injection into converters and validators with managed = true, so AuthorConverter can inject the service. In Faces 4.0 the Faces-specific managed-bean facility (@ManagedBean, @ManagedProperty of javax.faces.bean) was removed, so a backing bean obtains the service by @Inject. Since Faces 2.2 @ViewScoped has been a CDI scope (jakarta.faces.view.ViewScoped).

  • Jakarta EE 9 (Faces 3.0): the package namespace changed from javax.persistence, javax.transaction and javax.ejb to jakarta.*. Every recipe with a javax import needs a rename, and the descriptor namespace of persistence.xml moved to https://jakarta.ee/xml/ns/persistence.

  • Jakarta Persistence 3.2 (Jakarta EE 11): the revision history of the specification lists, among others, getSingleResultOrNull() on queries, the runInTransaction() and callInTransaction() methods of EntityManagerFactory for application-managed contexts (which would replace the hand-written interceptor in a resource-local application) and FindOption, RefreshOption and LockOption arguments for find(), refresh() and lock(). Persistence 3.1 and Faces 4.0 work with every example on this page except those that name these methods.

  • Jakarta Data 1.0 (Jakarta EE 11): the repository interfaces, Page and CursoredPage. Jakarta Data 1.1 is still in milestone releases (1.1.0-M4 in Maven Central on 2026-10-08).

  • Hibernate 7.x: the scratch app compiled and ran with Hibernate ORM 7.4.12.Final. A query with setMaxResults and a join fetch of a collection ran as one statement that limited the books in a subquery and joined the reviews, and Hibernate 7.4 logged no warning, so five books came back (a plain Java run of the same entity classes, with the query of service.pageWithCollectionFetch). This is provider behaviour and not part of the specification, and other providers and versions may behave differently; avoid a collection fetch in a paged query anyway (Paging Data Tables Against the Database), and treat the Hibernate documentation as the source for it.

  • Faces 5.0 (planned): not described here; it will build on Jakarta EE 12, and the Persistence and Transactions APIs beside it will move on.

Where the Books Are Outdated

Anghel Leonard’s JSF 2.0 Cookbook (Packt, 2010) has recipes that connect Faces to JPA and Enterprise Beans, to Spring and to Hibernate, and relies on Seam 2 in places. Treat those recipes as history, for these reasons.

  • Seam 2 and the other frameworks of that era. Recipes that put the persistence context into Seam 2’s conversation scope, or that rely on Seam components and its entity-home classes, depend on a framework that ended. Its job is now done by CDI scopes (the view and flow scopes of the scopes page), by @Transactional and by DTOs. Do not look for a drop-in replacement of Seam’s extended persistence context; the design above deliberately has none.

  • Spring 2.5 integration. Spring bean access from Faces through a JSF 1.1 VariableResolver, DelegatingVariableResolver, is deprecated since Spring 3.2 in favour of the SpringBeanFacesELResolver (Spring’s own javadoc for 3.2 says so), and the modern way is Spring Boot with JoinFaces. The Spring Framework 6.0 javadoc still lists an Expression Language resolver for jakarta.faces, but this was not run, and the current recommendation belongs to Spring Boot with JoinFaces.

  • Hibernate 3-era recipes. A session-per-request servlet filter that opens a Hibernate Session (the original open-session-in-view), a HibernateUtil holding a ThreadLocal session, hbm.xml mapping files and javax.persistence imports are replaced by a JPA EntityManager in a service with @Transactional, annotation mapping and jakarta.persistence. The old filter is also the OSIV pattern criticised above.

  • Managed beans and @ManagedProperty. The books inject services into backing beans with the Faces managed-bean facility, which was removed in Faces 4.0. Use CDI.

  • Enterprise Beans. The EJB 3 session bean recipes still apply to the stateless session bean, but entity beans (EJB 2.x) are gone from Jakarta EE and the injection is by @EJB or @Inject; @Stateless is not removed, only de-emphasised.

  • The persistence context as view state. Recipes that kept an extended persistence context or a list of managed entities in a session or conversation bean to avoid the lazy problem produce the pitfalls of Entities in View-Scoped Beans and of memory growth; today they are replaced by fetching exactly what is needed and by optimistic locking.

Common Pitfalls

  • Entities in the view. They look convenient and fail with LazyInitializationException or run a query per row. Pass DTOs. The cure is in the service, not in a wider persistence context.

  • Queries in getters. A getter runs several times per request; a query in one is several queries. Load in @PostConstruct, in a view action or in the action that changes the page.

  • OSIV as a fix. It hides the missing fetch behind 42 more statements. Leave it off and fix the query.

  • Holding managed entities in a view, flow, conversation or session bean. They detach after the first request; the 500 arrives on the postback, after the first request looked fine.

  • A checked exception that was meant to roll back. The default is to commit. Declare rollbackOn or an application exception with rollback = true.

  • A long transaction. Do not start one in a phase listener, a filter or an @Observes method that spans the lifecycle.

  • Entity converters without equals(). Validation Error: Value is not valid after a successful conversion.

  • A sort field or a search text in the query string. Look sort keys up in a map; escape % and _ yourself.

  • An unstable sort. Add the identifier to the end of the order by.

  • Unbounded page size. Cap it in the service.

  • Collection fetch with a row limit. Page the parents, fetch the collection afterwards, or use a DTO query.

  • Credentials in persistence.xml or in the source. Use a container data source, or supply the secret from the environment at start-up.

  • hbm2ddl.auto with create or update in production. A migration tool owns the schema.

  • Two saves of the same version. Without a version check the second user wins silently.

  • Blind trust in a posted id. Authorise in the service, not in the menu.

The data rules of the section are collected in the best-practices checklist, and Migrating to Jakarta Faces shows how the older bean and service habits are replaced.