Projections

Read only the columns an endpoint needs with interface and record projections, and avoid the projection types that load whole entities anyway.

Syntaxinterface 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

NeedUse
Change data and save itThe entity
List screen with a few columnsRecord 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

Example · java
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;

@Entity
class Author {
    @Id
    private Long id;
    private String firstName;
    private String lastName;

    protected Author() { }
}

@Entity
class Book {
    @Id
    private Long id;
    private String title;
    private String description;
    private BigDecimal price;
    @ManyToOne(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);
}

@RestController
public class AuthorBooksController {
    private final BookRepository books;

    public AuthorBooksController(BookRepository books) {
        this.books = books;
    }

    @GetMapping("/api/authors/{id}/books")
    public List<BookRow> books(@PathVariable 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.

Example · java
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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.