JPQL, Criteria API & Queries

The Metamodel & Type Safety

18 min Lesson 7 of 13

The Metamodel & Type Safety

In the previous lesson you built dynamic Predicate lists with the Criteria API using raw string literals such as root.get("price"). That works — but it breaks at runtime, not compile time, when you mistype an attribute name or change a field name during a refactor. The JPA Static Metamodel is the solution: it generates a companion class for each entity that exposes every persistent attribute as a strongly-typed constant, turning runtime surprises into compile-time errors.

What the Static Metamodel Is

The JPA specification (§6.2) defines a static metamodel as a set of generated classes, one per entity, that live in the same package as the entity and are named with a trailing underscore. For an entity Product in package com.shop.domain, the metamodel class is com.shop.domain.Product_. It contains one public static final field per persistent attribute:

// Auto-generated — do not edit by hand package com.shop.domain; import jakarta.persistence.metamodel.SingularAttribute; import jakarta.persistence.metamodel.ListAttribute; import jakarta.persistence.metamodel.StaticMetamodel; @StaticMetamodel(Product.class) public abstract class Product_ { public static volatile SingularAttribute<Product, Long> id; public static volatile SingularAttribute<Product, String> name; public static volatile SingularAttribute<Product, Double> price; public static volatile SingularAttribute<Product, Boolean> active; public static volatile SingularAttribute<Product, Category> category; public static volatile ListAttribute<Product, OrderLine> orderLines; }

The fields are typed with the JPA metamodel types: SingularAttribute<Owner, FieldType>, ListAttribute<Owner, ElementType>, SetAttribute, MapAttribute, etc. You pass them directly to the Criteria API methods that accept SingularAttribute — no strings needed.

Generating the Metamodel

The metamodel is produced by a JPA annotation processor at compile time. With Hibernate 6 and Maven, add the processor to your build:

<!-- pom.xml --> <dependency> <groupId>org.hibernate.orm</groupId> <artifactId>hibernate-jpamodelgen</artifactId> <version>6.4.4.Final</version> <scope>provided</scope> <!-- compile-only, not packaged --> </dependency>

With Spring Boot and Gradle:

// build.gradle (Groovy DSL) dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' annotationProcessor 'org.hibernate.orm:hibernate-jpamodelgen:6.4.4.Final' }

After a build (mvn compile or gradle compileJava) the *_ classes appear in target/generated-sources/annotations (Maven) or build/generated/sources/annotationProcessor (Gradle). Your IDE needs to include those directories as source roots — IntelliJ IDEA does this automatically when the annotation processor is on the compile classpath.

No entity change needed. You do not add anything to your entity class. The annotation processor reads @Entity, @Column, @OneToMany, etc. from the bytecode and generates the metamodel class automatically. Re-run the build whenever you add or rename a field.

Using the Metamodel in Criteria Queries

Compare the two styles side by side. Without the metamodel:

// String-based — no compile-time safety Root<Product> root = cq.from(Product.class); Predicate cheap = cb.lessThan(root.<Double>get("price"), 100.0); // typo? found at runtime

With the metamodel:

import com.shop.domain.Product_; import jakarta.persistence.*; import jakarta.persistence.criteria.*; // inside a repository or service public List<Product> findCheapActive(double maxPrice) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Product> cq = cb.createQuery(Product.class); Root<Product> root = cq.from(Product.class); Predicate pricePred = cb.lessThan(root.get(Product_.price), maxPrice); Predicate activePred = cb.isTrue(root.get(Product_.active)); cq.select(root) .where(cb.and(pricePred, activePred)) .orderBy(cb.asc(root.get(Product_.name))); return em.createQuery(cq).getResultList(); }

Product_.price is a SingularAttribute<Product, Double>. The compiler verifies that cb.lessThan receives a Double path and a Double value — it will refuse to compile if you mix types.

Type-Safe Joins

Joins benefit even more. The metamodel overload of Root.join() returns a Join<Product, Category> with the correct generic types, so you can navigate into the joined entity with full type checking:

import com.shop.domain.Product_; import com.shop.domain.Category_; Root<Product> product = cq.from(Product.class); Join<Product, Category> cat = product.join(Product_.category, JoinType.INNER); // Category_.name is SingularAttribute<Category, String> Predicate catPred = cb.equal(cat.get(Category_.name), "Electronics"); cq.select(product).where(catPred);

If you later rename the name field in Category to displayName, the metamodel is regenerated and Category_.name no longer exists — the build fails immediately, pointing you to every query that needs updating.

Dynamic Queries with the Metamodel

The real payoff of the metamodel is building dynamic filter methods where the attribute itself is a parameter. This pattern lets you write a single generic helper that works with any attribute of any entity:

public <E, F extends Comparable<F>> List<E> findByRange( Class<E> entityClass, SingularAttribute<E, F> attribute, F from, F to) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<E> cq = cb.createQuery(entityClass); Root<E> root = cq.from(entityClass); cq.select(root).where( cb.between(root.get(attribute), from, to) ); return em.createQuery(cq).getResultList(); } // Usage — fully type-safe at the call site List<Product> midRange = findByRange(Product.class, Product_.price, 50.0, 200.0); List<Order> recent = findByRange(Order.class, Order_.placedAt, LocalDateTime.now().minusDays(7), LocalDateTime.now());
Combine with Spring Data Specifications. Spring Data JPA's Specification<T> interface wraps a Criteria predicate builder. Using the metamodel inside your Specification implementations gives you both the composability of Specifications and the compile-time safety of the metamodel — the ideal combination for production filter/search APIs.

The Dynamic Metamodel

JPA also provides a dynamic metamodel accessible at runtime via EntityManager.getMetamodel(). This is useful for generic frameworks or admin tools that must introspect entity attributes without knowing the entity class at compile time:

Metamodel mm = em.getMetamodel(); EntityType<Product> productType = mm.entity(Product.class); // Retrieve an attribute by name — runtime, but still typed SingularAttribute<? super Product, ?> attr = productType.getSingularAttribute("price"); System.out.println(attr.getJavaType()); // class java.lang.Double

For normal application queries, prefer the static metamodel. The dynamic one is a diagnostic and framework-building tool.

Performance Notes

The metamodel itself adds zero runtime overhead — the *_ classes are populated by the JPA provider during EntityManagerFactory initialization (once per application startup). Using Product_.price instead of "price" in a Criteria query produces identical SQL. The benefit is entirely at the developer level: fewer bugs, safer refactoring, better IDE support (find usages, rename refactoring).

Keep generated sources in sync. If you rename an entity field and forget to rebuild before committing, the stale *_ class in version control will still reference the old name. The fix is simple: add the target/generated-sources (Maven) or build/generated (Gradle) directory to .gitignore so generated files are never committed, and rely on the build to regenerate them fresh on every machine.

Summary

The JPA static metamodel bridges the gap between the flexibility of dynamic Criteria queries and the safety of a statically-typed language. Add hibernate-jpamodelgen as an annotation processor, rebuild, and every entity gets a companion Entity_ class. Replace every string literal in your Criteria code with the corresponding Entity_.attribute constant. The compiler then enforces attribute names and types, your IDE can navigate and rename them, and your queries survive refactoring without hiding bugs until runtime.