Projections
Read only the columns an endpoint needs with interface and record projections, and avoid the projection types that load whole entities anyway.
interface BookSummary { Long getId(); String getTitle(); }
record BookRow(Long id, String title) {}
List<BookSummary> findByAuthorId(Long authorId);
<T> List<T> findByStatus(Status status, Class<T> type);A repository method does not have to return entities. A projection is a smaller type, and Spring Data selects only the columns it needs. For read endpoints this means less data from the database, no dirty checking, and no lazy associations waiting to fail.
Interface projections
Declare an interface with getters named after entity properties and use it as the return type: List<BookSummary> findByAuthorId(Long id). Spring Data builds a query that selects just those columns and returns proxies backed by the row. Nested interfaces follow associations: a getAuthor() returning an AuthorName interface with getLastName().
Record projections
A record whose component names match entity properties works the same way for derived queries: Spring Data reads the constructor parameters and selects those columns. Records are plain values, so they serialise to JSON without surprises and can be returned straight from a controller. For @Query methods, write a constructor expression or use an interface projection with column aliases.
Dynamic projections
Add a Class<T> parameter and the caller chooses the shape: <T> List<T> findByStatus(Status s, Class<T> type). One method serves the list view and the detail view.
Projections that are not what they seem
An open projection uses @Value with a SpEL expression to compute a value, for example @Value("#{target.firstName + ' ' + target.lastName}"). Because Spring Data cannot know which columns the expression touches, it loads the full entity. It is convenient, but it saves nothing. Do the formatting in Java instead, in a default method on the interface.
Nested interface projections over collections, or over associations in an otherwise flat projection, also fall back to loading entities in some cases. Check the SQL in the log when you add one; if it selects every column, use a @Query with a constructor expression or a native query with aliases.
When to project
| Need | Use |
|---|---|
| Change data and save it | The entity |
| List screen with a few columns | Record or interface projection |
| Aggregates, joins over many tables | @Query with a record, or native SQL |
Projections are read-only. If an endpoint loads data and then modifies it, load the entity. Mixing the two leads to code that reads a projection, builds an entity by hand and calls save, which overwrites every column it did not know about.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.repository.Repository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
class Author {
private Long id;
private String firstName;
private String lastName;
protected Author() { }
}
class Book {
private Long id;
private String title;
private String description;
private BigDecimal price;
(fetch = FetchType.LAZY)
private Author author;
protected Book() { }
}
record BookRow(Long id, String title, BigDecimal price) { }
interface BookWithAuthor {
String getTitle();
AuthorName getAuthor();
interface AuthorName {
String getFirstName();
String getLastName();
default String getDisplayName() {
return getFirstName() + " " + getLastName();
}
}
}
interface BookRepository extends Repository<Book, Long> {
List<BookRow> findByAuthorIdOrderByTitle(Long authorId);
List<BookWithAuthor> findByPriceLessThan(BigDecimal max);
}
public class AuthorBooksController {
private final BookRepository books;
public AuthorBooksController(BookRepository books) {
this.books = books;
}
("/api/authors/{id}/books")
public List<BookRow> books( Long id) {
return books.findByAuthorIdOrderByTitle(id);
}
}When to use it
- Return a lightweight list of books for an author page without loading descriptions or cover data.
- Serve both a summary list and a detailed export from one repository method.
- Build a sales-by-region report as plain records instead of loading every sale entity.
More examples
One method, several shapes
The list view never reads the potentially large body column, while the export uses the same query with the detail record. Passing the entity class itself also works and returns managed entities, which is the one shape you would choose when the caller intends to change them.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.Instant;
import java.util.List;
import org.springframework.data.repository.Repository;
import org.springframework.stereotype.Service;
@Entity
class Ticket {
@Id
private Long id;
private String subject;
private String body;
private String status;
private Instant openedAt;
protected Ticket() { }
}
record TicketListItem(Long id, String subject, Instant openedAt) { }
record TicketDetail(Long id, String subject, String body, String status, Instant openedAt) { }
interface TicketRepository extends Repository<Ticket, Long> {
<T> List<T> findByStatusOrderByOpenedAtDesc(String status, Class<T> type);
}
@Service
public class TicketQueries {
private final TicketRepository tickets;
public TicketQueries(TicketRepository tickets) { this.tickets = tickets; }
public List<TicketListItem> openList() {
return tickets.findByStatusOrderByOpenedAtDesc("OPEN", TicketListItem.class);
}
public List<TicketDetail> openDetails() {
return tickets.findByStatusOrderByOpenedAtDesc("OPEN", TicketDetail.class);
}
}Aliases for a native query projection
Each column alias matches a getter on the interface, which is how Spring Data maps a native row onto it. Keep the aliases in the same case as the getters (PostgreSQL folds unquoted aliases to lower case, so quote a mixed-case alias such as "regionName"), and mind the types: count(*) arrives as a Long and sum over numeric as a BigDecimal on PostgreSQL.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.jpa.repository.NativeQuery;
import org.springframework.data.repository.Repository;
@Entity
class Sale {
@Id
private Long id;
private String region;
private BigDecimal amount;
protected Sale() { }
}
interface RegionTotal {
String getRegion();
BigDecimal getTotal();
Long getSales();
}
public interface SaleRepository extends Repository<Sale, Long> {
@NativeQuery("select region as region, sum(amount) as total, count(*) as sales from sale group by region")
List<RegionTotal> totalsByRegion();
}Formatting in a default method
This stays a closed projection: the query selects first name, last name and email, and the full name is built in Java. The same value written as @Value("#{target.firstName + ' ' + target.lastName}") would load the whole row, including the notes column, for every customer.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.util.Optional;
import org.springframework.data.repository.Repository;
@Entity
class Customer {
@Id
private Long id;
private String firstName;
private String lastName;
private String email;
private String notes;
protected Customer() { }
}
interface CustomerCard {
String getFirstName();
String getLastName();
String getEmail();
default String getFullName() {
return getFirstName() + " " + getLastName();
}
}
public interface CustomerRepository extends Repository<Customer, Long> {
Optional<CustomerCard> findCardById(Long id);
}
Discussion