Spring Data JPA Projections and DTOs

Introduction

This tutorial explains projections in Spring Data JPA: repository methods that return only part of an entity, in a shape the caller chooses. We will see why loading whole entities is often wasteful, then use the same Book entity through closed and open interface projections, a Java record used as a DTO, a dynamic projection that lets the caller pick the type, a JPQL constructor expression, and a projection for an aggregate query. For each, the output shows the SQL Hibernate actually ran, so we can see which columns were read.

Why not always return entities?

A repository that returns entities reads every mapped column, and the entities become managed: Hibernate keeps a snapshot of each one for dirty checking until the transaction ends. A list screen that shows titles and prices needs neither. With wide tables, large text columns or thousands of rows, the difference is real work for the database, the network and the garbage collector.

A projection is a type that describes only the data you want. Spring Data reads the return type of the repository method, and, where it can, changes the select to fetch only those columns. There are two kinds -
  • Interface projections: an interface with getters named after entity properties. Spring Data creates an implementation (a proxy) at runtime.
  • Class-based projections (DTOs): a class or record whose constructor parameters are named after entity properties. Spring Data creates instances by calling that constructor.

The example

One entity, Book, with six columns, and five small projection types. The repository returns the same data in each shape. Method names follow the derived-query rules from Spring Data JPA derived query methodsClick to read tutorial: Spring Data JPA Derived Query Methods Explained: words between find and By, such as Titles or Summaries, are only descriptive and are ignored by the parser, so they can be used to give methods with different return types different names.
Book.java - an entity with more columns than most screens need
//Book.java
package com.javabrahman.data.projection;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Book {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String title;
  private String author;
  private int publishedYear;
  private int pages;
  private int price;

  protected Book() {
  }

  public Book(String title, String author, int publishedYear, int pages, int price) {
    this.title = title;
    this.author = author;
    this.publishedYear = publishedYear;
    this.pages = pages;
    this.price = price;
  }

  public Long getId() { return id; }
  public String getTitle() { return title; }
  public String getAuthor() { return author; }
  public int getPublishedYear() { return publishedYear; }
  public int getPages() { return pages; }
  public int getPrice() { return price; }
}
TitleOnly.java - a closed interface projection
//TitleOnly.java
package com.javabrahman.data.projection;

public interface TitleOnly {
  String getTitle();
  int getPrice();
}
BookLabel.java - an open interface projection with a SpEL expression
//BookLabel.java
package com.javabrahman.data.projection;

import org.springframework.beans.factory.annotation.Value;

public interface BookLabel {
  @Value("#{target.title + ' by ' + target.author}")
  String getLabel();
}
BookSummary.java - a record used as a DTO projection
//BookSummary.java
package com.javabrahman.data.projection;

public record BookSummary(String title, String author) {
}
AuthorCount.java - a projection for an aggregate
//AuthorCount.java
package com.javabrahman.data.projection;

public interface AuthorCount {
  String getAuthor();
  long getBooks();
}
BookRepository.java - the same entity, returned in different shapes
//BookRepository.java
package com.javabrahman.data.projection;

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface BookRepository extends JpaRepository<Book, Long> {

  List<Book> findByAuthor(String author);                    // whole entities

  List<TitleOnly> findTitlesByAuthor(String author);         // closed interface projection

  List<BookLabel> findLabelsByPublishedYearLessThan(int year);   // open interface projection

  List<BookSummary> findSummariesByPriceLessThan(int price); // record (DTO) projection

  <T> List<T> findByPublishedYear(int year, Class<T> type);  // dynamic projection

  @Query("select new com.javabrahman.data.projection.BookSummary(b.title, b.author) from Book b where b.pages > :pages")
  List<BookSummary> findLongBooks(int pages);                // JPQL constructor expression

  @Query("select b.author as author, count(b) as books from Book b group by b.author order by b.author")
  List<AuthorCount> countPerAuthor();                        // aggregate into a projection
}
ProjectionApp.java - calling each method and showing the SQL
//ProjectionApp.java
package com.javabrahman.data.projection;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class ProjectionApp {

  @Bean
  CommandLineRunner demo(BookRepository books) {
    return args -> {
      books.save(new Book("Effective Java", "Joshua Bloch", 2018, 412, 45));
      books.save(new Book("Java Puzzlers", "Joshua Bloch", 2005, 312, 30));
      books.save(new Book("Clean Code", "Robert Martin", 2008, 464, 38));
      books.save(new Book("Clean Architecture", "Robert Martin", 2017, 432, 35));
      books.save(new Book("Refactoring", "Martin Fowler", 2018, 448, 50));

      System.out.println("== entities");
      books.findByAuthor("Joshua Bloch").forEach(b -> System.out.println("   " + b.getTitle()));

      System.out.println("== closed interface projection");
      books.findTitlesByAuthor("Joshua Bloch").forEach(t -> System.out.println("   " + t.getTitle() + ", " + t.getPrice()));

      System.out.println("== open interface projection");
      books.findLabelsByPublishedYearLessThan(2010).forEach(l -> System.out.println("   " + l.getLabel()));

      System.out.println("== record projection");
      books.findSummariesByPriceLessThan(40).forEach(s -> System.out.println("   " + s));

      System.out.println("== dynamic projection, same method, two shapes");
      books.findByPublishedYear(2018, TitleOnly.class).forEach(t -> System.out.println("   " + t.getTitle()));
      books.findByPublishedYear(2018, BookSummary.class).forEach(s -> System.out.println("   " + s));

      System.out.println("== JPQL constructor expression");
      books.findLongBooks(440).forEach(s -> System.out.println("   " + s));

      System.out.println("== aggregate projection");
      books.countPerAuthor().forEach(c -> System.out.println("   " + c.getAuthor() + ": " + c.getBooks()));
    };
  }

  public static void main(String[] args) {
    SpringApplication.run(ProjectionApp.class, args);
  }
}
 OUTPUT of the above code
Hibernate: drop table if exists book cascade
Hibernate: create table book (pages integer not null, price integer not null, published_year integer not null, id bigint generated by default as identity, author varchar(255), title varchar(255), primary key (id))
Hibernate: insert into book (author,pages,price,published_year,title,id) values (?,?,?,?,?,default)
Hibernate: insert into book (author,pages,price,published_year,title,id) values (?,?,?,?,?,default)
Hibernate: insert into book (author,pages,price,published_year,title,id) values (?,?,?,?,?,default)
Hibernate: insert into book (author,pages,price,published_year,title,id) values (?,?,?,?,?,default)
Hibernate: insert into book (author,pages,price,published_year,title,id) values (?,?,?,?,?,default)
== entities
Hibernate: select b1_0.id,b1_0.author,b1_0.pages,b1_0.price,b1_0.published_year,b1_0.title from book b1_0 where b1_0.author=?
   Effective Java
   Java Puzzlers
== closed interface projection
Hibernate: select b1_0.title,b1_0.price from book b1_0 where b1_0.author=?
   Effective Java, 45
   Java Puzzlers, 30
== open interface projection
Hibernate: select b1_0.id,b1_0.author,b1_0.pages,b1_0.price,b1_0.published_year,b1_0.title from book b1_0 where b1_0.published_year<?
   Java Puzzlers by Joshua Bloch
   Clean Code by Robert Martin
== record projection
Hibernate: select b1_0.title,b1_0.author from book b1_0 where b1_0.price<?
   BookSummary[title=Java Puzzlers, author=Joshua Bloch]
   BookSummary[title=Clean Code, author=Robert Martin]
   BookSummary[title=Clean Architecture, author=Robert Martin]
== dynamic projection, same method, two shapes
Hibernate: select b1_0.title,b1_0.price from book b1_0 where b1_0.published_year=?
   Effective Java
   Refactoring
Hibernate: select b1_0.title,b1_0.author from book b1_0 where b1_0.published_year=?
   BookSummary[title=Effective Java, author=Joshua Bloch]
   BookSummary[title=Refactoring, author=Martin Fowler]
== JPQL constructor expression
Hibernate: select b1_0.title,b1_0.author from book b1_0 where b1_0.pages>?
   BookSummary[title=Clean Code, author=Robert Martin]
   BookSummary[title=Refactoring, author=Martin Fowler]
== aggregate projection
Hibernate: select b1_0.author,count(b1_0.id) from book b1_0 group by b1_0.author order by b1_0.author
   Joshua Bloch: 2
   Martin Fowler: 1
   Robert Martin: 2
Hibernate: drop table if exists book cascade
(The program runs with spring.jpa.show-sql=true, so each query is printed before its results. The first lines are Hibernate creating the table and inserting the five books.)

Explanation of the output: interface projections

  • Entities: findByAuthor() selected all six columns, id, author, pages, price, published_year and title, though the caller printed only titles.
  • Closed interface projection: TitleOnly declares only getTitle() and getPrice(), and the SQL became select b1_0.title,b1_0.price. It is called closed because every getter maps directly to a property, so Spring Data knows exactly which columns are needed.
  • Open interface projection: BookLabel computes its value with a SpEL expression in @Value, where target is the entity. Spring Data cannot know which properties an expression will use, so the SQL selected all columns, and the expression ran on a full entity. An open projection is convenient for formatting, but saves nothing in the database. A default method in the interface, combining closed getters, keeps the query narrow.

Class-based projections: records as DTOs

  • Record projection: findSummariesByPriceLessThan() returns BookSummary records. The SQL is select b1_0.title,b1_0.author, from the record's component names, and each row became a record through its constructor. A record is a real object, not a proxy: it can be returned from a REST controller, compared with equals(), and printed, as the output shows.
  • Dynamic projection: findByPublishedYear(int, Class<T>) takes the projection type as a parameter. The output shows the same method called twice, with two different select lists: title and price for TitleOnly, title and author for BookSummary. This saves writing one method per shape.
  • JPQL constructor expression: select new com.javabrahman.data.projection.BookSummary(b.title, b.author) creates the DTO in the query itself. It needs the fully qualified class name, and is the way to build DTOs from joins or computed values in a @Query.
  • Aggregate projection: countPerAuthor() returns a value no entity has, a count per author. The aliases in the query (as author, as books) match the getters of AuthorCount, which is how Spring Data maps them. The same works with native queries, where the column aliases must match the getters.

Choosing a projection

  • Need to change the data? Load entities. Projections are read-only: they are not managed, and changing them writes nothing.
  • Read-only list or API response? Use a record DTO. It is a plain object, works everywhere, and selects only its columns.
  • Several shapes of the same query? Use a dynamic projection with a Class<T> parameter.
  • Aggregates, joins, computed values? Use a @Query with aliases and an interface, or a constructor expression with a record.
  • Projections also avoid the N+1 problemClick to read tutorial: JPA Lazy Loading and the N+1 Problem in Spring Boot for read screens, because there are no lazy relationships to trigger.

Summary

In this tutorial we saw that returning entities reads every column and creates managed objects, and that a projection type in the return of a repository method lets Spring Data select only what is needed. The output showed a closed interface projection selecting two columns, an open projection with SpEL selecting all six, a record DTO built through its constructor, a dynamic projection producing two different select lists from one method, a JPQL constructor expression, and a count per author mapped by aliases. The next tutorial shows how to record who created and changed an entity, and when: Spring Data JPA auditingClick to read tutorial: Spring Data JPA Auditing: @CreatedDate and @CreatedBy.