DEV Community

Cover image for JPA fetch control that doesn't suck
Alex Litovsky
Alex Litovsky

Posted on

JPA fetch control that doesn't suck

I got tired of JPA fetch control being broken for 20 years, so I built something.

Fetch control in JPA has always been a mess. Eager loading kills performance the moment your object graph gets non-trivial. Lazy loading hands you N+1 problems and LazyInitializationException as a reward for doing the right thing.

Most serious projects that I've seen land in one of two places: lazy + proliferation of nearly-identical queries that differ only in what they fetch, or they give up on mapping relationships altogether and treat their relational DB like a document DB.

The others either have these performance issues, or don't yet know that they have them.

ORM bell curve meme

The named query proliferation problem

Here's what that proliferation looks like in practice. You start with a simple named query:

@NamedQuery(name = "Person.findByName",
    query = "select p from Person p where p.name = ?1")

@NamedQuery(name = "Person.findByNameWithOrganization",
    query = "select p from Person p join fetch p.organization where p.name = ?1")

@NamedQuery(name = "Person.findByNameWithOrganizationAndCountry",
    query = "select p from Person p join fetch p.organization o join fetch o.country where p.name = ?1")

@NamedQuery(name = "Person.findByNameWithOrganizationAndRole",
    query = "select p from Person p join fetch p.organization join fetch p.role where p.name = ?1")

@NamedQuery(name = "Person.findByNameWithOrganizationAndCountryAndRole",
    query = "select p from Person p join fetch p.organization o join fetch o.country join fetch p.role where p.name = ?1")
Enter fullscreen mode Exit fullscreen mode

Every new use case that needs a different set of associations fetched means another query. They're all the same query — just with different join fetch clauses. This scales badly, clutters your entities, and is a maintenance nightmare.

EntityGraph was supposed to be the fix

EntityGraph was introduced to solve exactly this — call-site fetch control, so you could reuse a single query and specify what to fetch at the point of use. Great idea.

But that API...

EntityGraph disappointment meme

// Fetch organization → country, and role
EntityGraph<Person> graph = em.createEntityGraph(Person.class);
Subgraph<Organization> orgGraph = graph.addSubgraph("organization");
orgGraph.addAttributeNodes("country");
graph.addAttributeNodes("role");

em.find(Person.class, id, Map.of("jakarta.persistence.fetchgraph", graph));
Enter fullscreen mode Exit fullscreen mode

String attribute names that break silently on rename. Manual subgraph tree-building for every hop. No composability — combining two independent paths means building the whole tree by hand. And none of it is checked at compile time. In fact, I've yet to see any actual uses of EntityGraph in real production apps.

The JPA Metamodel helps with type safety, but the fundamental problem remains:

// JPA Metamodel: type-safe, but still manual tree-building
EntityGraph<Person> graph = em.createEntityGraph(Person.class);
graph.addSubgraph(Person_.organization)
     .addAttributeNodes(Organization_.country);
graph.addAttributeNodes(Person_.role);

em.find(Person.class, id, Map.of("jakarta.persistence.fetchgraph", graph));
Enter fullscreen mode Exit fullscreen mode

Still verbose. Still non-composable. The spec never finished the job.

The API that I wish for

Since we can't change the spec, jpa-fetch is the next best thing — it extends and mimics the EntityManager API, but makes fetch control a first-class concern rather than an afterthought bolted on via EntityGraph.

The preferred style uses QueryDSL-generated path expressions — compile-time checked, refactor-safe, and composable:

// Eagerly fetch organization → country, and role
entityFinder.find(Person.class, id,
    QPerson.person.organization().country(),
    QPerson.person.role());
Enter fullscreen mode Exit fullscreen mode

Those five named queries from earlier? They collapse into one:

// No fetch
entityFinder.find(Person.class, id);

// With organization
entityFinder.find(Person.class, id,
    QPerson.person.organization());

// With organization → country
entityFinder.find(Person.class, id,
    QPerson.person.organization().country());

// With organization → country and role
entityFinder.find(Person.class, id,
    QPerson.person.organization().country(),
    QPerson.person.role());
Enter fullscreen mode Exit fullscreen mode

Same idea works for inline JPQL and named queries via a fluent TypedQuery-compatible API:

entityFinder.createQuery("select p from Person p where p.name = ?1", Person.class)
        .setParameter(1, "Smith")
        .setFetchPaths(
                QPerson.person.organization().country(),
                QPerson.person.role())
        .getResultList();
Enter fullscreen mode Exit fullscreen mode
entityFinder.createNamedQuery(Person.QUERY_BY_NAME, Person.class)
        .setParameter(1, "Smith")
        .setFetchPaths(
                QPerson.person.organization().country(),
                QPerson.person.role())
        .getSingleResult();
Enter fullscreen mode Exit fullscreen mode
// Jakarta Persistence 3.2 TypedQueryReference
entityFinder.createQuery(Person_.findByName)
        .setParameter(1, "Smith")
        .setFetchPaths(
                QPerson.person.organization().country(),
                QPerson.person.role())
        .getSingleResult();
Enter fullscreen mode Exit fullscreen mode

It also works with JPA metamodel attributes if you'd rather not take a QueryDSL dependency, but that API is clunkier because the metamodel spec never gave us composable paths:

// Eagerly fetch organization → country, and role
Person person = entityFinder.find(Person.class, id,
        FetchPaths.of(Person_.organization, Organization_.country),
        FetchPaths.of(Person_.role));
Enter fullscreen mode Exit fullscreen mode

The library builds and merges the EntityGraph for you at runtime, merging shared prefixes into a single subgraph automatically.

Getting started

<dependency>
    <groupId>io.github.alterioncorp</groupId>
    <artifactId>jpa-fetch</artifactId>
    <version>1.1.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Full setup and usage in the README.


I've been using this concept in various forms in production since EntityGraphs were first introduced. Finally decided to clean it up and put it out there. Maybe others find it useful.

Top comments (0)