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();
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) {}
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));
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 selectedNULL, 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
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>
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)