1. Overview
EasyJPA — Criteria API power. Three lines, not thirty.
EasyJPA is a Spring Boot starter that puts a fluent, lambda-driven API over the JPA Criteria API.
Every condition is a method reference, every join has a name, and not a single JPQL string is
written. Joins, subqueries, grouping, pagination, updates and native SQL all compose in one chain
you read top to bottom.
Here is the entire pitch:
// ── The Criteria API ──────────────────────────────────────────────────────────
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> root = cq.from(User.class);
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("username"), "Jack"));
predicates.add(cb.equal(root.get("password"), "123456"));
cq.select(root).where(cb.and(predicates.toArray(new Predicate[0])));
User user = em.createQuery(cq).getSingleResult();
// ── EasyJPA ───────────────────────────────────────────────────────────────────
User user = userDao.query()
.filter(new FilterList().eq(User::getUsername, "Jack").eq(User::getPassword, "123456"))
.selectThis().one();
Same query, same type safety, same generated SQL.
2. What Problem Does It Solve?
The Criteria API is the only type-safe way to build a dynamic JPA query, and almost nobody enjoys
using it. A three-condition filter costs ten lines of CriteriaBuilder plumbing; a multi-branch
join means tracking Join<?, ?> variables by hand; a correlated subquery is close to unreadable.
So teams fall back on JPQL strings or Spring Data method names — and lose type safety, or
composability, or both.
The second problem is quieter and costs more. A paginated query is really two statements, the
listing one and the counting one, and they are written separately. They drift. And when the query
has a group by, the obvious count(*) counts rows rather than groups, so the total is simply
wrong. EasyJPA derives both statements from one definition, and counts groups through a derived
table.
| Keeps | Criteria's type safety and dynamic composition |
| Drops |
CriteriaBuilder / Root / Predicate[] boilerplate |
| Fixes | pagination counts that drift, and group counts that count rows |
3. Quick Start
Install — the current version is 2.0.0-SNAPSHOT, in the Central Portal snapshot repository,
which Maven reads from no project that has not named it:
<dependency>
<groupId>com.github.paganini2008</groupId>
<artifactId>easyjpa-spring-boot-starter</artifactId>
<version>2.0.0-SNAPSHOT</version>
</dependency>
<repositories>
<repository>
<id>central-portal-snapshots</id>
<url>https://central.sonatype.com/repository/maven-snapshots/</url>
<releases><enabled>false</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
Name the provider:
@EntityScan(basePackages = {"com.example.entity"})
@EnableJpaRepositories(repositoryFactoryBeanClass = HibernateEntityDaoFactoryBean.class,
basePackages = {"com.example.dao"})
@Configuration(proxyBeanMethods = false)
public class JpaConfig {
}
Extend EntityDao:
public interface UserDao extends EntityDao<User, Long> {
}
EntityDao is a JpaRepositoryImplementation, so save, findById and deleteAll are
untouched. Query:
List<User> users = userDao.query()
.filter(Restrictions.eq(User::getVip, true))
.sort(JpaSort.asc(User::getUsername))
.selectThis().list();
That is the whole setup. No properties to add, nothing to tune.
4. Requirements
| Minimum | Notes | |
|---|---|---|
| Java | 17 | |
| Spring Boot | 4.0 | Spring Boot 3.1 – 3.5 takes the 1.0.x line |
| Jakarta Persistence | 3.2 | |
| JPA provider | Hibernate 7.4 | or EclipseLink 5.0.1, or any Criteria implementation |
| Build | Maven 3.9 |
The version line follows the Spring Boot line, and the two are maintained apart:
| Your Spring Boot | Use |
|---|---|
| 4.0 and later | 2.0.x, this one |
| 3.1 – 3.5 | 1.0.x |
| 3.0 and earlier | not supported |
5. How It Works
┌─────────────────────────────────────────────────────────────────────────────┐
│ YOUR CODE userDao.query().filter(...).sort(...).selectThis().list() │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ method references + aliases
┌──────────────────────────────────▼──────────────────────────────────────────┐
│ EASYJPA Model ─────────── resolves an alias to a table │
│ Filter · Field · Column · JpaSort │
│ JpaPageResultSet ─ one definition, two statements │
└──────────────────────────────────┬──────────────────────────────────────────┘
│ "may I use a derived table here?"
┌──────────────────────────────────▼──────────────────────────────────────────┐
│ JpaProvider Hibernate │ EclipseLink │ Standard │
│ supportsDerivedTable() · supportsRightJoin() · … │
└──────────────────────────────────┬──────────────────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────────────┐
│ JAKARTA PERSISTENCE CriteriaBuilder · CriteriaQuery · Root · Predicate │
└──────────────────────────────────┬──────────────────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────────────┐
│ DATABASE H2 · PostgreSQL · MySQL · SQL Server · SQLite · Oracle │
└─────────────────────────────────────────────────────────────────────────────┘
| Mechanic | In one line |
|---|---|
| Alias resolution | A lambda names an entity, not a table; the alias is looked up inside the statement being built and never outside it — so an outer query and its subquery keep their tables apart |
| Two statements, one definition |
JpaPageResultSet holds the listing query and derives the counting query from it, so they cannot drift |
| Providers are asked, not assumed | Every optional feature is a boolean on JpaProvider, readable at runtime |
And the pagination split, which is the part worth seeing:
customPage().join(...).filter(...).groupBy(...).select(...)
one definition
│
┌──────────────────┴──────────────────┐
▼ ▼
list(10, 0) rowCount()
│ │
select … offset ? rows no group by → select count(1) from (…) d
fetch first ? rows only group by → select count(1) from
( <the groups> ) d
6. Code Examples
Every SQL block below is what Hibernate actually emitted, captured from the test suite against H2.
The model:
User ──< Order ──< OrderProduct >── Product User.vip, Order.status, Order.totalPrice
│ Product.name/price/location
Stock Stock.amount
Example 1 — Nested conditions
Input — vip or (username in ('Scott','Lee') and email is not null), by username.
Execution
List<User> users = userDao.query()
.filter(Restrictions.eq(User::getVip, true)
.or(new FilterList().in(User::getUsername, List.of("Scott", "Lee"))
.and().notNull(User::getEmail)))
.sort(JpaSort.asc(User::getUsername))
.selectThis().list();
Output
select u1_0.id, u1_0.email, u1_0.password, u1_0.username, u1_0.vip
from example_user u1_0
where u1_0.vip = ? or u1_0.username in (?, ?) and u1_0.email is not null
order by u1_0.username
Example 2 — Four tables, two branches
Input — order lines with their order, customer and product; only discounted products on paid or
shipped orders; newest first.
Execution
orderProductDao.customPage()
.join(OrderProduct::getOrder, "o", null) // OrderProduct → Order
.join(Order::getUser, "u", null) // Order → User
.join(OrderProduct::getProduct, "p", null) // OrderProduct → Product, second branch
.filter(new FilterList().notNull(Product::getDiscount).and()
.in(Property.forName("o", "status"), List.of(OrderStatus.PAID, OrderStatus.SHIPPED)))
.sort(JpaSort.desc(Order::getOrderDate), JpaSort.asc(Product::getName))
.select(new ColumnList().addColumns(User::getUsername)
.addColumns(Order::getId, Order::getOrderDate, Order::getStatus)
.addColumns(Product::getName, Product::getPrice)
.addColumns(OrderProduct::getAmount)
.addColumns(Fields.multiply(Property.forName(Product::getPrice),
Property.forName(OrderProduct::getAmount)).as("subtotal")));
Output
select u1_0.username, o1_0.id, o1_0.order_date, o1_0.status,
p1_0.name, p1_0.price, op1_0.amount, (p1_0.price * op1_0.amount)
from example_order_product op1_0
join example_order o1_0 on o1_0.id = op1_0.order_id
join example_user u1_0 on u1_0.id = o1_0.user_id
join example_product p1_0 on p1_0.id = op1_0.product_id
where p1_0.discount is not null and o1_0.status in (?, ?)
order by o1_0.order_date desc, p1_0.name
offset ? rows fetch first ? rows only
The third join branches back off OrderProduct rather than continuing from User: the lambda
carries its own entity, so the tree comes out the way it reads.
Example 3 — A grouping pagination that counts groups
Input — best sellers: units sold, distinct orders and turnover per product, keeping products
sold more than once. Then the total, for the page navigation.
Execution
JpaPageResultSet<Tuple> resultSet = orderProductDao.customPage()
.join(OrderProduct::getProduct, "p", null)
.groupBy(new FieldList().addFields(Product::getName))
.having(Restrictions.gt(Fields.count(OrderProduct::getId), 1L))
.sort(JpaSort.desc(2))
.select(new ColumnList().addColumns(Product::getName)
.addColumns(Fields.sum(OrderProduct::getAmount).as("soldAmount"),
Fields.countDistinct(Property.forName("this", "order.id")).as("orderAmount"),
Fields.sum(Fields.multiply(Property.forName(Product::getPrice),
Property.forName(OrderProduct::getAmount))).as("turnover")));
long groups = resultSet.rowCount();
List<SalesVo> rows = resultSet.setTransformer(Transformers.asBean(SalesVo.class)).list();
Output — the listing query
select p1_0.name, sum(op1_0.amount), count(distinct op1_0.order_id),
sum((p1_0.price * op1_0.amount))
from example_order_product op1_0
join example_product p1_0 on p1_0.id = op1_0.product_id
group by p1_0.name having count(op1_0.id) > ?
order by 2 desc
offset ? rows
Output — the counting query, derived from the same definition
select count(1) from (
select 1 from example_order_product op1_0
join example_product p1_0 on p1_0.id = op1_0.product_id
group by p1_0.name having count(op1_0.id) > ?
) derived1_0(c)
One statement, having honoured, no groups fetched. This is the bug you do not have to find.
Example 4 — A correlated subquery
Input — the users who have ever ordered.
Execution
JpaQuery<User, User> query = userDao.query();
JpaSubQuery<Order, Long> subQuery = query.subQuery(Order.class, "o", Long.class)
.filter(Restrictions.eq(Order::getUser, User::getId))
.select(Order::getId);
List<User> customers = query.filter(Restrictions.exists(subQuery)).selectThis().list();
Output
select u1_0.id, u1_0.email, u1_0.password, u1_0.username, u1_0.vip
from example_user u1_0
where exists (select o1_0.id from example_order o1_0 where o1_0.user_id = u1_0.id)
The subquery is created from the outer query, which is what correlates the two. Negate it with
.not() for not exists; swap exists for in and it becomes an in subquery.
Example 5 — Joining an aggregate as a derived table
Input — every product with its sales, without a correlated subquery per row.
Execution
JpaQuery<Product, Tuple> query = productDao.customQuery();
JpaSubQuery<OrderProduct, Tuple> sales = query.subQuery(OrderProduct.class, "op", Tuple.class);
sales.groupBy(new FieldList().addFields(Property.forName("op", "product.id")))
.select(new ColumnList()
.addColumns(Property.forName("op", "product.id").as("productId"))
.addColumns(Fields.sum("op", "amount", Integer.class).as("soldAmount"),
Fields.count("op", "id").as("orderAmount")));
query.joinSubQuery(sales, "s", Restrictions.eq(Property.forName("s", "productId"),
Property.forName("this", "id")))
.sort(JpaSort.desc(Property.forName("s", "soldAmount")))
.select(new ColumnList().addColumns(Product::getName, Product::getPrice)
.addColumns(Property.forName("s", "soldAmount").as("soldAmount")))
.setTransformer(Transformers.asCaseInsensitiveMap()).list();
Output
select p1_0.name, p1_0.price, s1_0.soldAmount, s1_0.orderAmount
from example_product p1_0
join (select op1_0.product_id c0, sum(op1_0.amount) c1, count(op1_0.id) c2
from example_order_product op1_0
group by c0) s1_0(productId, soldAmount, orderAmount)
on s1_0.productId = p1_0.id
order by 3 desc
Example 6 — Update by an expression, delete by a subquery
Input — shout a username; decrement a stock; drop the users who never ordered.
Execution
userDao.update().setField(User::getUsername, Fields.concat(Fields.upper(User::getUsername), "!"))
.filter(Restrictions.eq(User::getUsername, "Lee")).execute();
stockDao.update().setField(Stock::getAmount, Fields.minusValue(Stock::getAmount, 1L))
.filter(Restrictions.gt(Stock::getAmount, 0L)).execute();
JpaDelete<User> delete = userDao.delete();
JpaSubQuery<Order, Order> subQuery = delete.subQuery(Order.class)
.filter(Restrictions.eq(Order::getUser, User::getId));
delete.filter(Restrictions.exists(subQuery).not()).execute();
Output
update example_user u1_0 set username = (upper(u1_0.username)||?) where u1_0.username = ?
update example_stock s1_0 set amount = (s1_0.amount - cast(? as bigint)) where s1_0.amount > ?
delete from example_user u1_0
where not exists (select o1_0.id from example_order o1_0 where o1_0.user_id = u1_0.id)
The rest of the surface, in one table
| You want | Call |
|---|---|
| The entities | dao.query() |
| Columns into a VO | dao.query(Vo.class) |
| Any columns |
dao.customQuery() → Tuple
|
| …with a total count |
dao.page() · dao.customPage() → JpaPageResultSet
|
| Fetch an association |
fetch(Order::getUser) · leftFetch(User::getOrders).distinct()
|
CASE WHEN |
new IfExpression<>(attr).when(a, b).otherwise(c) |
| Date parts | Fields.year/month/day(...) |
| Any database function | Function.build("LOWER", String.class, Product::getName) |
| A result shape | Transformers.asBean/asMap/asCaseInsensitiveMap/asList/noop |
| Native SQL |
dao.queryForMap(sql, args) — keeps rowCount() and paginate()
|
7. Configuration
EasyJPA has no properties of its own — no spring.easyjpa.* namespace. What you configure is
the provider and, for three databases, the driver.
| What | Where | Value |
|---|---|---|
| Provider | @EnableJpaRepositories(repositoryFactoryBeanClass = …) |
HibernateEntityDaoFactoryBean (default) · EclipseLinkEntityDaoFactoryBean · StandardEntityDaoFactoryBean
|
| EclipseLink | spring.autoconfigure.exclude |
…orm.jpa.HibernateJpaAutoConfiguration |
| SQL Server | JDBC url |
calcBigDecimalPrecision=true — otherwise every BigDecimal binds as decimal(38,0) and 0.90 reads back as 1
|
| SQLite | entity id |
GenerationType.IDENTITY — AUTO needs a sequence table SQLite locks against |
| Oracle 23+ on Hibernate |
OracleDialect subclass |
DatabaseVersion.make(21) |
8. Comparison
Feature-level. No timings are claimed — nothing here is a benchmark.
| EasyJPA | Criteria API | Spring Data Specification
|
QueryDSL | |
|---|---|---|---|---|
| Type-safe attributes | method references | metamodel or strings | metamodel or strings | generated Q classes |
| Code generation step | none | none | none | annotation processor |
| Dynamic composition | ✅ | manual Predicate[]
|
✅ | ✅ |
| Multi-branch joins | one call each | track Join<?,?> by hand |
manual | ✅ |
| Correlated subqueries | built from the outer query | manual | awkward | ✅ |
Pagination count over group by
|
derived table, one statement | write it yourself | write it yourself | write it yourself |
| Derived-table join | joinSubQuery(...) |
manual | ❌ | limited |
| Native SQL, pagination kept | ✅ | ❌ | ❌ | separate API |
| Provider gaps discoverable at runtime |
JpaProvider flags |
❌ | ❌ | ❌ |
Provider and database coverage
The same 202 tests run on three providers against six databases.
| H2 2.3 | PostgreSQL 16 | MySQL 9.6 | SQL Server 16 | SQLite 3.49 | Oracle 23 | |
|---|---|---|---|---|---|---|
| Hibernate | ✅ | ✅ | ✅ | ⚠️ 1 | ⚠️ 3 | ⚠️ 4 |
| Criteria API alone | ✅ | ✅ | ✅ | ⚠️ 1 | ⚠️ 3 | ✅ |
| EclipseLink | ✅ | ✅ | ✅ | ✅ | ❌ no platform | ⚠️ 2 |
Each ⚠️ belongs to the database or to the provider on that database, never to a query EasyJPA built.
The README names every one of them.
9. Limitations & Trade-offs
-
Aliases are strings. A typo in
"op"is a runtime error, not a compile error. -
Not auto-configured. You name the factory bean yourself; there is no
@AutoConfiguration. -
The Criteria API is the ceiling. Window functions, CTEs and
UNIONneed native SQL. -
EclipseLink reaches less than Hibernate. No derived table, no right join, no subquery as a
selected column — all of it reported through
JpaProviderrather than failed over silently. - A fetched collection paginates in memory. Keep paginated fetches to to-one associations.
- No reactive support. Blocking JDBC only.
- Not a good fit for a project that writes mostly static queries — Spring Data method names are shorter for those.
10. Summary
-
One chain instead of ten lines.
query().filter().sort().select().list()replaces the wholeCriteriaBuilder/Root/Predicate[]ritual. - Method references throughout, so a renamed field is a compile error rather than a runtime surprise. No code generation, no annotation processor.
- Joins branch by themselves. A lambda carries its own entity, so a four-table, two-branch join is four calls that read in order.
- A pagination is one definition, two statements. The counting query is derived from the listing query and cannot drift from it.
-
A grouping pagination counts groups, through a derived table, in one statement,
havingincluded — the count most hand-written pagination gets wrong. - Subqueries correlate themselves because they are created from the query that uses them. Filter, column, comparison or nested — all the same way.
- Aggregates join once, as a derived table, instead of running a correlated subquery per row.
-
Provider gaps are a
boolean, not a stack trace.JpaProviders.getProvider().supportsXxx()answers before you build. - Tested where it matters — 202 tests, three providers, six databases, and every documented failure attributed to the database or the provider rather than hidden.
-
Zero configuration. No properties; name one factory bean and extend
EntityDao.
GitHub — paganini2008/easyjpa, MIT licensed.
Spring Boot 4 takes the 2.0.x line, Spring Boot 3 the 1.0.x line.
Top comments (0)