When building a list API, have you ever fetched an entire entity with findAll() when all the response needs is two columns, id and name? And then manually copied the data into a DTO afterward—you see this kind of code all the time.

With Spring Data JPA projections, you can extract only the columns you need at the SELECT stage and map them directly to a DTO or interface. This time, we’ll actually run and compare three approaches—interface projection, class (constructor) projection, and dynamic projection—and sort out which one to choose and when.

Why fetching an entire entity is wasteful

Suppose the User entity has 20 columns and also has a relationship with Department. Even if all you want to display on a list screen is the name and department name, findAll() basically fetches every column. If the relationship is EAGER, it even runs a JOIN or additional queries. This is over-fetching.

On top of that, when you write code to copy the fetched entity into a UserListDto, the conversion logic piles up. You’re throwing away most of the columns you went to the trouble of fetching, and paying the cost of the copy-over on top of that.

Projection is an approach that “narrows at the point of SELECT” rather than “narrows after fetching.” Because the SELECT clause of the issued SQL contains only the properties you declared, you can reduce both the transfer volume and the copy-over code.

Sample entities and repository

Let’s prepare the entities we’ll use in common from here on.

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String firstName;
    private String lastName;
    private String email;

    @ManyToOne(fetch = FetchType.LAZY)
    private Department department;
    // getters/setters omitted
}

@Entity
public class Department {
    @Id @GeneratedValue
    private Long id;
    private String name;
}

Since you can’t tell whether the projection is working without looking at the issued SQL, be sure to enable logging.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Confirming in the log whether “the SELECT columns are really narrowed down.” This habit is the single most important thing when using projections. For how to write query methods themselves, refer to the Spring Data JPA query methods article as well.

Interface projection (closed projection)

The easiest one is interface projection. You create an interface that declares only the getters for the properties you want to fetch.

public interface UserSummary {
    Long getId();
    String getFirstName();
    String getLastName();
}

Then you just make the return type of the repository method this interface.

public interface UserRepository extends JpaRepository<User, Long> {
    List<UserSummary> findByLastName(String lastName);
}

With this, the SELECT clause of the issued SQL is narrowed to the three columns id, first_name, last_name.

select u.id, u.first_name, u.last_name from users u where u.last_name=?

A projection that references nothing beyond the declared properties is called a closed projection. Spring Data internally generates a proxy and returns it as a read-only view. There are no setters, and its appeal is the ease of being able to use it directly in a response.

Open projection (@Value / SpEL) and its pitfall

When you want a derived value that combines multiple properties, you can use @Value and SpEL.

public interface UserNameView {
    @Value("#{target.firstName + ' ' + target.lastName}")
    String getFullName();
}

With this, getFullName() returns the full name. It’s convenient, but there’s a pitfall. Because SpEL expressions are evaluated at runtime, Spring Data can’t statically determine which columns are needed. As a result, all columns of the entity are fetched, and the SELECT optimization of closed projection is disabled.

In other words, the moment you use open projection, the original benefit of projection—“narrowing the columns”—disappears. If you want to do the derivation on the application side and fetch only the necessary columns from the DB, it’s more straightforward to fetch the raw values with a closed projection and assemble them in the service layer.

Class-based (constructor) projection and record classes

If you want a tangible DTO rather than an interface, use class-based projection. When you match the constructor’s parameter names with the entity’s property names, Spring Data maps them accordingly. For Java 16 and later, record is ideal.

public record UserDto(Long id, String firstName, String lastName) {}
public interface UserRepository extends JpaRepository<User, Long> {
    List<UserDto> findByEmail(String email);
}

The issued SQL is narrowed to three columns, and what comes back is an immutable UserDto. Unlike interface projection, it’s a real object rather than a proxy, so you can pass it directly into a response, and it’s easy to handle in tests too.

However, keep in mind that class projection can’t use the nested projection described later, and the correspondence between constructor arguments and columns is fixed.

Reusing one method for multiple return types with dynamic projection

When the columns needed differ per screen, you’ll be tempted to create several findByDepartment methods, one for each return type. Dynamic projection avoids this. You pass a Class<T> to the method.

public interface UserRepository extends JpaRepository<User, Long> {
    <T> List<T> findByDepartmentName(String name, Class<T> type);
}

You switch the return type at the call site.

List<UserSummary> summaries = repo.findByDepartmentName("Sales", UserSummary.class);
List<UserDto> dtos = repo.findByDepartmentName("Sales", UserDto.class);
List<User> entities = repo.findByDepartmentName("Sales", User.class);

The SELECT columns also change according to the type you pass. When you want purpose-specific DTOs under the same search condition, this dramatically curbs the proliferation of methods.

You can also project properties of related entities. You return a nested projection interface for the relation inside the interface.

public interface UserWithDept {
    String getFirstName();
    DeptView getDepartment();

    interface DeptView {
        String getName();
    }
}

With this you can fetch the department name together as well, but there’s a caveat. When you nest, a JOIN or additional queries are issued, and the columns may not be narrowed as cleanly as with closed projection. In particular, nesting a collection relationship (@OneToMany) tends to produce N+1-like behavior where a query flies off for each element.

Once you get into deep hierarchies or aggregations, it’s safer to shift to the @Query constructor expression described next rather than forcing it with nested projection. For a detailed discussion of N+1, check out the JPA performance optimization article as well.

Combining @Query / native queries with projection

You can also project with JPQL. The key is to use a constructor expression (the new operator) and write the DTO with its fully qualified name.

@Query("select new com.example.UserDto(u.id, u.firstName, u.lastName) " +
       "from User u where u.department.name = :dept")
List<UserDto> findSummaries(@Param("dept") String dept);

When combining interface projection with @Query, you need to match the SELECT alias names to the interface’s property names.

Native queries are even stricter. SpEL (open projection) doesn’t work, and the mapping between column names and property names becomes exact. A common mistake is forgetting to add a column alias as shown below, resulting in a null value.

// If it stays as first_name, it tends not to be set into firstName and becomes null
@Query(value = "select u.id, u.first_name as firstName, u.last_name as lastName " +
               "from users u", nativeQuery = true)
List<UserSummary> findNative();

If you use interface projection with a native query, always add an alias that aligns to the property name, like as firstName.

Comparing the three approaches and selection criteria

Let’s wrap up.

AspectInterface projectionClass projectionDynamic projection
Narrowing SELECT columnsYes if closed (No if open)YesDepends on the type passed
Nature of return valueProxy (read-only)Real DTO/recordDepends on the type
Use with @QueryRequires alias matchingGood fit with constructor expressionCan be combined
Nesting supportYesNoDepends on the type

Here’s how to think about choosing, so you won’t get lost. If you have a simple list and just want to narrow the columns, use closed interface projection. If you want a tangible object for the response or tests, use class (record) projection. If the columns change per screen, build around dynamic projection. Shift complex JOINs and aggregations to the constructor expression of @Query, and leave only the involved post-fetch conversions to a mapping library such as MapStruct—this division of roles is clean.

Summary

The essence of projection is a shift in mindset from “narrowing after fetching” to “narrowing at SELECT.” Start by trying closed interface projection, and check in the log whether the columns of the issued SQL have really been reduced. As long as you grasp the points where open projection or nesting disables the optimization, you can significantly reduce wasteful fetching in list and search APIs. For paging, take a look at the pagination article as well.