Skip to main content

Spring Data Projections — Practical Demo

Hands-on examples for Spring Data Projections. All examples use the Product/Customer domain to show how projections reduce over-fetching and prevent sensitive field leakage.

Prerequisites

Ensure you understand Spring Data Repositories and JPA Basics. See Spring Data Projections for the full theory.


Example 1: Closed Interface Projection

Load only id, name, and price — skip the heavy description and thumbnail columns entirely.

Product.java (entity)
@Entity
public class Product {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
private String description; // ← large text column
private byte[] thumbnailImage; // ← binary column — expensive
}
ProductSummary.java (closed projection)
public interface ProductSummary {    // ← interface — Spring generates a proxy implementing this
Long getId();
String getName();
BigDecimal getPrice();
// ← description and thumbnailImage are NOT declared here → NOT fetched from DB
}
ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {
List<ProductSummary> findAllProjectedBy(); // ← Spring infers projection from return type
Optional<ProductSummary> findProjectedById(Long id);
}

SQL generated (only declared columns):

SELECT p.id, p.name, p.price FROM products p
-- thumbnail_image NOT loaded — saves IO on large binary columns
ProductController.java
@GetMapping("/products")
public List<ProductSummary> listProducts() {
return productRepo.findAllProjectedBy(); // ← safe: no thumbnailImage in response
}
Key takeaway

The interface getter names (getId, getName, getPrice) match entity field names exactly. Spring Data derives column selection from these names at startup time.


Example 2: Nested Projection for Associations

Include selected fields from a related entity in a single query — eliminates N+1.

OrderSummary.java
public interface OrderSummary {
Long getId();
String getStatus();

CustomerInfo getCustomer(); // ← nested projection — one level deep

interface CustomerInfo { // ← inner interface for Customer fields
String getName();
String getEmail();
// ← customer.passwordHash is NOT included → not fetched → not exposed
}
}
OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findByStatus(String status);
}

SQL generated (single JOIN — no N+1):

SELECT o.id, o.status, c.name, c.email
FROM orders o
INNER JOIN customers c ON o.customer_id = c.id
WHERE o.status = ?
Demo
List<OrderSummary> pending = orderRepo.findByStatus("PENDING");
pending.forEach(o ->
System.out.println(o.getCustomer().getName() + " — " + o.getStatus())
);
// → "Alice — PENDING"
// → "Bob — PENDING"
// All loaded in ONE query. Customer password never exposed.

Example 3: DTO Projection with Java Records

Use a record for an immutable, serialization-friendly DTO loaded via a JPQL constructor expression.

ProductPriceInfo.java (record DTO)
public record ProductPriceInfo(Long id, String name, BigDecimal price) {}
// ↑ constructor parameter order must match SELECT order below
ProductRepository.java
public interface ProductRepository extends JpaRepository<Product, Long> {

@Query("SELECT new com.example.dto.ProductPriceInfo(p.id, p.name, p.price) FROM Product p")
List<ProductPriceInfo> findAllPriceInfo();

@Query("SELECT new com.example.dto.ProductPriceInfo(p.id, p.name, p.price)" +
" FROM Product p WHERE p.price < :maxPrice ORDER BY p.price ASC")
List<ProductPriceInfo> findAffordable(@Param("maxPrice") BigDecimal maxPrice);
}
Demo
List<ProductPriceInfo> affordable = productRepo.findAffordable(new BigDecimal("50.00"));
affordable.forEach(p ->
System.out.printf("%-30s $%.2f%n", p.name(), p.price())
);
// → "USB Hub $19.99"
// → "Screen Cleaner $9.99"

SQL generated:

SELECT p.id, p.name, p.price FROM products p WHERE p.price < ? ORDER BY p.price ASC
-- No description or thumbnail loaded
Key takeaway

DTO projections with records are not managed by the Hibernate session — they are plain Java objects. No dirty checking, no session overhead, fully immutable.


Example 4: Dynamic Projections

One repository method returns different shapes depending on the caller.

ProductRepository.java (dynamic)
public interface ProductRepository extends JpaRepository<Product, Long> {
<T> List<T> findByCategory(String category, Class<T> type); // ← generic
}
Demo
// Consumer A — needs only summary
List<ProductSummary> summaries =
productRepo.findByCategory("electronics", ProductSummary.class);

// Consumer B — needs full entity (e.g., for admin panel)
List<Product> full =
productRepo.findByCategory("electronics", Product.class);

// Summary query → SELECT id, name, price WHERE category = ?
// Full query → SELECT * WHERE category = ?

Exercises

  1. Easy: Add a getCategory() getter to ProductSummary and observe that the SQL now includes category in the SELECT clause.
  2. Medium: Create a CustomerListView projection interface with getId(), getName(), and a nested AddressView projection for getAddress() that includes getCity() and getCountry(). Verify that SELECT contains only those columns.
  3. Hard: Write a @DataJpaTest integration test that: (a) saves a Product with a large description, (b) calls findAllProjectedBy(), (c) asserts the result has the right name and price, and (d) uses a SQL logging interceptor to assert description does NOT appear in the generated SELECT statement.

Back to Topic

Return to Spring Data Projections for theory, projection type comparison table, interview questions, and further reading.