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:
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.
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();
}
}
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; }
}
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; }
}
Source: Jakarta EE Tutorial: Introduction to Jakarta Persistence and Jakarta Persistence 3.2 specification, the Version annotation
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.
<?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:
<?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):
<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).
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.
Source: Jakarta Persistence 3.2 specification, the PersistenceContext annotation and container-managed persistence contexts
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:
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:
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.
-
@Transactionalon a CDI bean. The annotationjakarta.transaction.Transactionalis an interceptor binding. The Transactions specification requires the interceptors to run with the priorityInterceptor.Priority.PLATFORM_BEFORE+200, which is before the application’s own interceptors. Thevalueis aTxType, and the default isREQUIRED: join the caller’s transaction, or start one.REQUIRES_NEWsuspends the caller’s transaction and starts another, which is how an audit record survives a rollback.MANDATORY,SUPPORTS,NOT_SUPPORTEDandNEVERcomplete the set. -
A stateless session bean. The container wraps every business method of an Enterprise Bean in a transaction by default, and unlike
@Transactionalit also pools instances and supplies security and concurrency features. Faces beans inject it with@EJBor@Inject. Most new code uses the CDI route unless it needs those extras. -
UserTransactionorEntityTransactionby 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:
@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");
}
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
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 |
|
runtime |
|
unchanged (rolled back) |
checked |
|
|
|
|
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:
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; }
}
<!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>
Source: Jakarta Persistence 3.2 specification, detached entities and lazy loading and Jakarta Faces 4.1 specification, 2.2 Standard Request Processing Lifecycle Phases
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:
@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);
}
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:
<!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>
Source: Jakarta Persistence 3.2 specification, fetch joins and Jakarta EE Tutorial: The Jakarta Persistence Query Language
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 |
|---|---|---|
|
HTTP 500, |
200, 43 statements (1 list, 18 authors, 24 review collections) |
|
HTTP 500, |
200, 25 statements (1 list, 24 review collections) |
|
200, 1 statement |
200, 1 statement |
|
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:
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; }
}
<!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>
Source: Jakarta Faces 4.1 API: ViewScoped and Jakarta Persistence 3.2 specification, detached entities and lazy loading
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, aSessionor 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.findinside 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:
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:
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; }
}
<!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>
Source: Jakarta Faces 4.1 API: FacesConverter, Jakarta Faces 4.1 VDL: h:selectOneMenu and Jakarta Faces 4.1 VDL: f:selectItems
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 byequals(). The items were loaded in the first request and the converted author in the postback, so they are different instances. Withequals()removed fromAuthorEntitythe entity form failed withe:author: Validation Error: Value is not validon Mojarra, while the id form kept working. Use an identifier-basedequals(), with a constanthashCode()while the id may still benull, 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 callem.getReferenceorem.findinside 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.
AuthorPickBeanloads 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:
@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,authorNameoryear; the mapSORTABLEof the class head translates it to a JPQL path, and anything else raisesIllegalArgumentException. A sort field concatenated into the query text is a query-injection hole, because JPQLorder bycannot 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 forlike ... escape. A search for100%returned no rows (it would have matched everything unescaped), and so dido_, 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
rowsrequest parameter must be capped before it reachessetMaxResults, or one request can ask for the whole table.
Source: Jakarta Persistence 3.2 specification, restricting the query result and Jakarta EE Tutorial: The Jakarta Persistence Query Language
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:
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; }
}
<!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:
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());
}
}
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;
}
}
<!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):
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);
}
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:
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; }
}
@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());
}
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:
<!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>
<!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>
Source: Jakarta Faces 4.1 VDL: f:viewAction and Jakarta EE Tutorial: Controlling Concurrent Access to Entity Data with Locking
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 aRollbackExceptioncaused byOptimisticLockException: 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 forOptimisticLockExceptioninstead 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:metadatainsideui:defineof 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 containsf:viewand aui:insert name="metadata", and the client puts thef:metadatainto 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:metadatablock insideui:defineof 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 callsmojarra.cljs, MyFaces callsmyfaces.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, soAuthorConvertercan inject the service. In Faces 4.0 the Faces-specific managed-bean facility (@ManagedBean,@ManagedPropertyofjavax.faces.bean) was removed, so a backing bean obtains the service by@Inject. Since Faces 2.2@ViewScopedhas been a CDI scope (jakarta.faces.view.ViewScoped). -
Jakarta EE 9 (Faces 3.0): the package namespace changed from
javax.persistence,javax.transactionandjavax.ejbtojakarta.*. Every recipe with ajavaximport needs a rename, and the descriptor namespace ofpersistence.xmlmoved tohttps://jakarta.ee/xml/ns/persistence. -
Jakarta Persistence 3.2 (Jakarta EE 11): the revision history of the specification lists, among others,
getSingleResultOrNull()on queries, therunInTransaction()andcallInTransaction()methods ofEntityManagerFactoryfor application-managed contexts (which would replace the hand-written interceptor in a resource-local application) andFindOption,RefreshOptionandLockOptionarguments forfind(),refresh()andlock(). 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,
PageandCursoredPage. 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
setMaxResultsand ajoin fetchof 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 ofservice.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
@Transactionaland 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 theSpringBeanFacesELResolver(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 forjakarta.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), aHibernateUtilholding aThreadLocalsession,hbm.xmlmapping files andjavax.persistenceimports are replaced by a JPAEntityManagerin a service with@Transactional, annotation mapping andjakarta.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
@EJBor@Inject;@Statelessis 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
LazyInitializationExceptionor 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
rollbackOnor an application exception withrollback = true. -
A long transaction. Do not start one in a phase listener, a filter or an
@Observesmethod that spans the lifecycle. -
Entity converters without
equals().Validation Error: Value is not validafter 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.xmlor in the source. Use a container data source, or supply the secret from the environment at start-up. -
hbm2ddl.autowithcreateorupdatein 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.