Spring Data JPA Projections: Interface, Record and Dynamic Projections (With the SQL They Generate)
Spring Data JPA projections, tested on Spring Boot 4.1.2 and Hibernate 7.4: closed and open interface projections, record DTOs, dynamic projections and @Query constructor expressions, with the real SQL Hibernate generated for each, the @Value trap that loads full rows, and three @Query traps that fail quietly.
You write repository.findByDepartmentName("Sales"), get back a list of Employee objects, and use two fields from each one: the first name and the last name. The database sent you seven columns per row, including a 2,000-character bio, and Hibernate turned every row into a managed entity it now has to keep track of. Spring Data JPA projections are how you say “I only want these fields” — and, unlike most advice on the topic, this post shows the exact SQL Hibernate generated for each variant, captured from a real run.
If you are meeting the topic for the first time: a projection is just a smaller return type for a repository method. By the end you will know the four kinds (closed interface, record DTO, dynamic, @Query constructor expression), the one innocent-looking annotation that silently switches the saving off, and three @Query traps that fail quietly.
Versions tested. Spring Boot 4.1.2 (4.1.0 went GA on June 10, 2026), Spring Data JPA 4.1.2, Hibernate ORM 7.4.13.Final, H2 2.4.240 in memory, JDK Temurin 25.0.5 LTS, JUnit via spring-boot-starter-data-jpa-test. Every SQL line and number below comes from a file in the companion repository’s output/ folder, regenerated by ./scripts/run-all.sh. H2 is not your production database: the shape of the SQL (which columns, how many statements) is what transfers; small dialect details such as fetch first ? rows only will differ.
A repository method returns whole rows, even when you need two columns
Start with the baseline. The domain is tiny: an Employee with seven columns and a lazy Department. The repository method below is a plain derived query — Spring Data builds the query from the method name. Its return type is the entity, so that is what you get. The full class is Employee.java, and the method is number 1 in EmployeeRepository.java.
// 1. Derived query, entity result (the baseline).
List<Employee> findByDepartmentName(String departmentName);
# entity result: findByDepartmentName("Sales")
select e1_0.id,e1_0.bio,e1_0.department_id,e1_0.email,e1_0.first_name,e1_0.last_name,e1_0.salary from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
columns selected: 7, entities now managed by the persistence context: 4
Seven columns, and the persistence context (Hibernate’s per-session cache of loaded entities) now holds four managed Employee objects. That second number matters: a managed entity is watched for changes, so Hibernate keeps a snapshot of it and compares at flush time. If you only wanted to display two fields, you paid for that tracking for nothing.
The diagram shows the whole argument of this post: the two green columns are all you needed, and a projection is how you stop paying for the other five plus the tracking.
Going deeper: how the numbers were captured
Every statement is recorded by a tiny Hibernate StatementInspector registered in application.properties, and the managed-entity count is read from Session.getStatistics().getEntityCount(). The test calls the repository, asserts exactly one statement was issued, and writes the SQL to the transcript, so a number in this post cannot drift from what ran. Files: SqlRecorder.java, application.properties, and the test ProjectionsTest.java.
public class SqlRecorder implements StatementInspector {
private static final List<String> CAPTURED = new CopyOnWriteArrayList<>();
@Override
public String inspect(String sql) {
CAPTURED.add(sql);
return sql;
}
public static void clear() { CAPTURED.clear(); }
public static List<String> captured() { return List.copyOf(CAPTURED); }
The inspector is Hibernate-instantiated, which is why the captured list has to be static. The property that wires it in is one line:
A closed interface projection: declare only the getters you want
The simplest projection is an interface whose getters match property names on the entity. Spring Data sees that every getter maps to an entity property — the docs call this a closed projection — so it knows in advance exactly which columns it needs. The interface is EmployeeSummary.java:
/** Closed interface projection: every getter matches an entity property, nothing computed. */
public interface EmployeeSummary {
String getFirstName();
String getLastName();
}
The repository method differs from the baseline only in its return type (number 2 in EmployeeRepository.java):
Same method-name query, same join, and a different SELECT list — from the same transcript:
# closed interface projection: findSummaryByDepartmentName("Sales")
select e1_0.first_name,e1_0.last_name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
columns selected: 2, entities now managed by the persistence context: 0
Two columns, zero managed entities. Notice the left join department is still there: it is needed for the where clause, and a projection only changes what is selected, not how rows are filtered.
Going deeper: what object you actually get back
There is no class called EmployeeSummaryImpl. Spring Data creates a JDK dynamic proxy that implements your interface and answers each getter from the row it read. The test checks this directly (09-paging-and-proxy.txt):
# what an interface projection really is at runtime
is a java.lang.reflect.Proxy: true
is an Employee: false
Practical consequences: the result is not an Employee, it has no identity relationship to the persistence context, and changing it saves nothing. If you need to update, load the entity.
Spring Data JPA reference: Projections, section on interface-based projections.
If a proxy feels too magical, use a class. Since Java 16 a record is the natural shape, and Spring Data JPA will call its canonical constructor. The whole type is EmployeeDto.java:
public record EmployeeDto(String firstName, String lastName) {}
The repository method is, again, only a different return type (number 5):
// 5. Record DTO projection.
List<EmployeeDto> findDtoByDepartmentName(String departmentName);
The SQL is identical in shape to the interface projection (transcript):
# record DTO projection: findDtoByDepartmentName("Sales")
select e1_0.first_name,e1_0.last_name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
columns selected: 2, entities now managed by the persistence context: 0
So which should you pick? Both select two columns. The difference is in what you hold afterwards:
Interface projection
Record DTO
Runtime object
JDK proxy
A real record instance
Nested projections
Supported
Not supported (per the Spring Data docs)
Computed values
Default methods
Compact constructor or methods on the record
Going deeper: multiple constructors and mapping to API types
From the Spring Data docs (not re-run here): if a DTO class has several constructors, mark the intended one with @PersistenceCreator so Spring Data knows which properties to select; it determines the selected fields from the constructor parameter names. A record has exactly one canonical constructor, which is one more reason to prefer it.
If your DTOs end up crossing the API boundary, see MapStruct with Spring Boot 4 and Java Records for mapping between record shapes.
Dynamic projections: let the caller choose the shape
Writing findSummaryByDepartmentName, findDtoByDepartmentName and so on for every shape gets noisy. A dynamic projection is one method with a generic return type and a Class argument (number 6 in EmployeeRepository.java):
// 6. Dynamic projection: the caller picks the shape.
<T> List<T> findByDepartmentName(String departmentName, Class<T> type);
The caller then picks: Employee.class for the entity, EmployeeSummary.class for the interface, EmployeeDto.class for the record. One method, three SELECT lists (04-dynamic-projection.txt):
Going deeper: what happens with a projection that does not match
Ask for an interface with a getter the entity does not have and you do not get null; you get an exception, which is the behaviour you want (08-pitfall-unknown-property.txt):
org.springframework.data.core.PropertyReferenceException
No property 'nickname' found for type 'Employee'
Note the exception class lives in org.springframework.data.core in this Spring Data generation. The Spring Data docs add one caveat for the generic Class<T> parameter: when its type matches the return type it cannot also be used as a query argument or in SpEL; declare it Class<?> if you need that.
The open projection trap: one @Value and you are back to full rows
Here is the surprise. Suppose you want a full name, so you add a computed getter using Spring’s expression language. It looks harmless, and it returns the right answer (EmployeeFullName.java):
Spring Data cannot look inside a SpEL string to know which properties it touches, so it treats the projection as open and falls back to loading the whole entity, then evaluating the expression against it. The proof is in 02-open-projection-trap.txt:
select e1_0.id,e1_0.bio,e1_0.department_id,e1_0.email,e1_0.first_name,e1_0.last_name,e1_0.salary from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
columns selected: 7, entities now managed by the persistence context: 4
getFullName() of first row: First1 Last1
Seven columns, four managed entities — exactly the baseline, with a projection-shaped return type that hides it. Nothing warns you.
The diagram is the rule of thumb: the question Spring Data asks is “can I name every property up front?”. @Value makes the answer no.
The fingerprint of this bug. A repository method whose return type is a projection, yet the SQL in your logs lists every column of the table. Search your projection interfaces for @Value first.
The fix is dull and works: keep the interface closed and put the computation in a Java default method. The two getters are real properties, so Spring Data can name them (EmployeeFullNameClosed.java):
/** Closed projection with a computed value: the computation is a plain default method, no SpEL. */
public interface EmployeeFullNameClosed {
String getFirstName();
String getLastName();
default String getFullName() {
return getFirstName() + " " + getLastName();
}
}
select e1_0.first_name,e1_0.last_name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
columns selected: 2, entities now managed by the persistence context: 0
getFullName() of first row: First1 Last1
Going deeper: when @Value is still fine
If the row is already loaded for another reason (you also need the entity), the open projection costs you nothing extra and the expression is convenient. The point is not that @Value is banned; it is that it cancels the column saving, so you should choose it knowingly. The Spring Data docs suggest keeping expressions simple and, for anything complex, calling a Spring bean from the expression, which keeps the same trade-off.
Nested projections: convenient, but the join is not trimmed
Interface projections can nest: an employee projection can expose a department projection. Here only department.name is asked for (EmployeeWithDepartment.java):
/** Nested closed projection: one property reaches into the department association. */
public interface EmployeeWithDepartment {
String getFirstName();
DepartmentName getDepartment();
interface DepartmentName {
String getName();
}
}
select e1_0.first_name,d1_0.id,d1_0.location,d1_0.name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
columns selected: 4 (1 from employee + 3 from department), entities now managed: 1
department name read from the projection: Sales
extra statements after reading it: 0
Four columns, not two: the whole department row (id, location, name) is selected, and Hibernate holds one managed Department entity. The good news is the last line — reading the nested getter caused zero extra statements, so there is no N+1. The Spring Data docs state the same limit: a projected property that resolves to a join selects the entire nested property.
Going deeper: when to avoid nesting
If the associated table is wide, nesting drags its columns along. For a precise two-column result across a join, a flat record with a @Query constructor expression (next section) gives you exact control. The department here has only three columns, so the extra cost is small; with a wide table it would not be.
@Query: aliases, constructor expressions and native queries
When the shape is not a simple subset of one entity — aggregates, joins, computed columns — write the query yourself. Three forms are in EmployeeRepository.java:
// 7. @Query returning the entity but declared as an interface projection (aliases required).
@Query("select e.firstName as firstName, e.lastName as lastName from Employee e where e.salary > :min")
List<EmployeeSummary> richerThan(@Param("min") java.math.BigDecimal min);
// 8. Constructor expression in JPQL.
@Query("select new com.ankurm.projections.DepartmentPay(d.name, count(e), sum(e.salary)) "
+ "from Employee e join e.department d group by d.name order by d.name")
List<DepartmentPay> payByDepartment();
// 9. Native query + interface projection (alias must equal the getter name).
@Query(value = "select first_name as firstName, last_name as lastName from employee where salary > :min", nativeQuery = true)
List<EmployeeSummary> nativeRicherThan(@Param("min") java.math.BigDecimal min);
The first returns an interface projection from a JPQL tuple, so each selected value needs an alias equal to the getter name. The second uses a JPQL constructor expression (select new ...) with the fully qualified class name, here the record DepartmentPay.java. The third is a native SQL query with the same alias rule. Real output (05-query-forms.txt):
# @Query with aliases, declared as an interface projection
select e1_0.first_name,e1_0.last_name from employee e1_0 where e1_0.salary>?
rows: 6, first getFirstName(): First6
# @Query with a constructor expression (select new ...DepartmentPay)
select d1_0.name,count(e1_0.id),sum(e1_0.salary) from employee e1_0 join department d1_0 on d1_0.id=e1_0.department_id group by d1_0.name order by d1_0.name
DepartmentPay[department=Engineering, headcount=4, totalSalary=218000.00]
DepartmentPay[department=HR, headcount=4, totalSalary=226000.00]
DepartmentPay[department=Sales, headcount=4, totalSalary=222000.00]
# native query with column aliases equal to the getter names
select first_name as firstName, last_name as lastName from employee where salary > ?
rows: 6, first getFirstName(): First6
The constructor expression is the one to reach for with group by: Hibernate calculated count and sum in the database and sent three rows, not twelve employees.
Three @Query traps that fail quietly
Trap 1: selecting the entity and declaring an interface projection.select e from Employee e returns whole entities and Spring Data applies the interface afterwards in Java. Method 11 in the repository does this, and 06-pitfall-entity-query.txt shows the cost:
select e1_0.id,e1_0.bio,e1_0.department_id,e1_0.email,e1_0.first_name,e1_0.last_name,e1_0.salary from employee e1_0 where e1_0.salary>?
columns selected: 7, entities now managed: 6, rows: 6
All seven columns, six managed entities. Nothing is saved.
Trap 2: a tuple select without aliases. Method 12 drops the as firstName aliases. It compiles, runs, returns six rows — and every value is null (07-pitfall-missing-aliases.txt):
select e1_0.first_name,e1_0.last_name from employee e1_0 where e1_0.salary>?
rows: 6
getFirstName() of first row: null
getLastName() of first row: null
The SQL is correct, but Spring Data has no names to match the tuple elements to the getters. No exception, so it surfaces as blank fields in the UI. Treat “projection fields are all null” as “check the aliases”.
The asymmetry to remember. For an interface projection, aliases are required. For a record DTO built with a constructor expression, the Spring Data docs say aliases must not be used. Same-looking queries, opposite rules.
The opposite surprise (a good one). Declare the same entity-selecting query (method 13) with a record return type and Spring Data rewrites it into a constructor expression for you (10-entity-query-as-dto-rewrite.txt):
select e1_0.first_name,e1_0.last_name from employee e1_0 where e1_0.salary>?
columns selected: 2, entities now managed: 0, rows: 6
Two columns and no managed entities from select e. This matches the docs’ note that Spring Data can rewrite entity-selecting queries for DTO projections, and the run above shows that this rewrite does not happen for interface projections (trap 1).
Going deeper: native queries and result-set mappings
For native queries, the docs say a DTO can be the return type directly when the result columns match the constructor arguments in order and type; otherwise define a @SqlResultSetMapping and reference it from @NativeQuery. That path was not run here, so treat it as documentation rather than verified behaviour.
Returning Page<EmployeeSummary> works, and the data query keeps its two-column SELECT. But a Page also needs the total, so Spring Data issues a second statement (09-paging-and-proxy.txt):
# Page<EmployeeSummary>, page size 2
select e1_0.first_name,e1_0.last_name from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=? fetch first ? rows only
select count(*) from employee e1_0 left join department d1_0 on d1_0.id=e1_0.department_id where d1_0.name=?
statements: 2, rows in page: 2, total elements: 4
If you do not need the total, return a Slice instead and skip the second query. That is not covered by a run here, so check it against your own SQL log.
Which one should you use?
The chooser condenses the post: pick the entity only when you will change it, and otherwise make sure the properties can be named up front. Everything else in the post is a variation of that one rule.
Should you do this everywhere? No. On a table with seven small columns and a few rows, the saving is invisible and projections add types to maintain. They pay off on wide tables, large text columns, hot read endpoints and list screens. Measure your own SQL log first; the figures here are column counts and managed-entity counts, not timings, because a real timing depends on your database and data. If you find yourself writing many hand-shaped read queries, it is also worth reading Spring Data JDBC vs Spring Data JPA.
No Comments yet!