DEV Community

Cover image for I got tired of JPA entity graphs, so I built Model Query
Rey Pham
Rey Pham

Posted on

I got tired of JPA entity graphs, so I built Model Query

Most JPA projects I've worked on hit the same wall on the read side. Writes through entities are fine. Reads are where it hurts: a list screen needs five columns and gets an entity graph, lazy proxies and the occasional N+1.

Model Query is the library I built to fix that for myself. Version 0.5.0 is on Maven Central, and before I freeze the API at 1.0 I'd like it to be tried on real codebases.

How I got here

Plain JPA first. Entities model the schema well, but reading through them drags their relationships along. Every @ManyToOne has a fetch type, and controlling what a query actually loads turns into entity graphs, join fetch per query and watching the SQL log. DTO projections avoid that, but JPQL constructor expressions are strings, and Criteria multiselects are verbose and positional.

Then Querydsl. Typed queries remove the strings. But the Q classes come from entities, so every query still writes its own joins and its own Projections.constructor(...). The DTO's shape lives in each query, not in one place.

Then Blaze-Persistence. Entity Views solve the projection problem properly, and it's a powerful library. For me the cost was adoption: an extra runtime and integration layer, and one view interface per shape. The list screen, the detail screen and the export of the same table each got their own view, plus subviews for their joins.

So I kept what worked in each: the entity mapping from JPA, generated typed constants from Querydsl, declared projections from Blaze. The projection is a plain record or class, and one model serves several shapes, because a query selects column sets of it instead of declaring a new view.

What it looks like

Here's a typical read before migration: a constructor expression and a query string that grows with each optional filter.

var jpql = new StringBuilder("select new com.acme.OrderRow(o.id, o.status, o.total,"
        + " c.name) from OrderEntity o left join o.customer c where 1 = 1");
status.ifPresent(s -> jpql.append(" and o.status = :status"));
country.ifPresent(s -> jpql.append(" and c.country = :country"));
jpql.append(" order by o.id");
var query = em.createQuery(jpql.toString(), OrderRow.class);
status.ifPresent(s -> query.setParameter("status", s));
country.ifPresent(s -> query.setParameter("country", s));
return query.setMaxResults(100).getResultList();
Enter fullscreen mode Exit fullscreen mode

With Model Query, the columns and the join move into a model, once:

@QueryModel(root = OrderEntity.class)
public class OrderView {
    @PrimaryKey
    private Long id;
    private String status;
    private BigDecimal total;
    @Join
    private Optional<CustomerView> customer = Optional.empty();
    // getters and setters
}

@QueryModel(root = CustomerEntity.class)
public record CustomerView(@PrimaryKey Long id, String name, String country) {}
Enter fullscreen mode Exit fullscreen mode

An annotation processor generates QOrderView. The joined model's columns become constants of the outer one (CUSTOMER_COUNTRY), and an empty Optional skips its filter, so the query has no branches:

var query = QOrderView.query()
        .select(QOrderView.ALL.with(QOrderView.CUSTOMER))
        .where(f -> f.eq(QOrderView.STATUS, status)
                .eq(QOrderView.CUSTOMER_COUNTRY, country))
        .orderBy(QOrderView.ID.asc())
        .build();
return executor.list(query, Limit.of(100));
Enter fullscreen mode Exit fullscreen mode

The join is added only when CUSTOMER is selected. No entities are loaded: the query selects the model's columns into a Tuple and maps them, so the fetch type on the entity no longer decides what a read loads. (Both versions are in a test in the repo that checks they return the same rows for every filter combination.)

What else is in 0.5.0

  • Paging and export: list, page, count and stream, with offset, keyset and primary-key-first paging, plus large exports.
  • Children: to-many relations loaded through fetch plans, once per page in batches, not per row.
  • Selected fields: a @Selected SelectSet<Model> field tells an unselected column apart from a selected NULL, which is handy for PATCH-style APIs.
  • Writes (incubating): bulk update and delete driven by the same filters, inserts with returned keys and conflict clauses, and entity writes that still run your callbacks and audit listeners.
  • Vendors: H2, PostgreSQL and MySQL, behind an SPI.
  • Spring Boot starter: adds query methods to your existing Spring Data repositories, even with your own repository factory bean.

It runs on Java 17+, Hibernate 6.6 and 7.x, and Spring Boot 3.4+, and it's tested on all of them in CI.

When not to use it

If you need CTEs, window functions or recursive queries everywhere, Blaze-Persistence (or jOOQ) is the better tool. If every query returns a different ad-hoc shape, Querydsl's per-query projections fit better. And Model Query is still 0.x: every public type is @Incubating until 1.0. There's a longer comparison in the docs.

Try it

The plain-JPA sample runs with one command from the repository root:

./mvnw -q -pl samples/plain-jpa -am package -DskipTests -Prun
Enter fullscreen mode Exit fullscreen mode

Or add it to your project:

<dependency>
    <groupId>io.github.rey5137</groupId>
    <artifactId>model-query-jpa</artifactId>
    <version>0.5.0</version>
</dependency>
<dependency>
    <groupId>io.github.rey5137</groupId>
    <artifactId>model-query-annotations</artifactId>
    <version>0.5.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Then register model-query-processor in your compiler's annotation processor path. The getting started guide covers both plain JPA and Spring Boot.

I'm looking for early adopters

Before 1.0 freezes the API, I'd like to hear from people with real read-heavy JPA code:

  • Did the model fit your read paths, or where did it break?
  • What did you have to work around?
  • Which API names felt wrong?

The feedback thread is here, and there are a few good first issues if you'd like to contribute. A star on GitHub helps too.

Top comments (0)