Architecting Efficient Data Access with Immutability

As Spring developers, we spend a significant amount of time optimizing the path between the database and the client. One of the most common performance pitfalls is the over-fetching of data—loading entire, complex JPA entities when all we need is a small subset of fields for a list view or a report.

This practice leads to wasted memory, unnecessary I/O, and slows down application performance. The solution lies in Query Projections, and with Java Records (available since Java 16), we can implement these projections in the cleanest, most modern way possible.

In this post, we’ll dive into how to leverage Java Records to create immutable, self-describing Data Transfer Objects (DTOs) directly from your Spring Data JPA queries, avoiding proxies, entity loading, and maximizing efficiency.

1. The Inefficiency of Over-Fetching

Imagine you have a detailed Product entity:

public class Product {
    private Long id;
    private String name;
    private String description; // Potentially very long text
    private BigDecimal price;
    private byte[] imageBytes; // Large blob
    private int stockQuantity;
    private List<Review> reviews; // Lazy loaded collection
    // ... many more fields
}

If you only need a list of product names and prices for a catalog view, running a standard repository.findAll() loads every field, every time, including the potentially massive description, imageBytes, and collection proxies, just to ignore them later.

2. Introducing Java Records for Projections

Before Java Records, creating a DTO projection meant writing a dedicated class with private final fields, a bulky constructor, and getters—all boilerplate.

Java Records simplify this boilerplate into a single line, making them the perfect candidates for projections:

  • Immutability: Records are inherently immutable, guaranteeing the data retrieved from the query remains consistent.
  • Conciseness: They eliminate boilerplate code, resulting in cleaner, more readable repositories.
  • Type Safety: They provide clear, strong typing for the projected data.

Defining the Record Projection

Let’s define a projection for our simple product catalog view.

// src/main/java/com/example/dto/ProductSummary.java

/**
 * An immutable DTO representing a summary view of a Product.
 * The component names (id, name, price) must match the query projection order.
 */
public record ProductSummary(
    Long id,
    String name,
    BigDecimal price
) {}

3. Implementing the Projection in Spring Data JPA

To tell JPA to construct this record instead of fetching the entire Product entity, we use a JPA Constructor Expression within the @Query annotation.

This is the most powerful technique because it allows JPA to select only the columns needed and use the database result set to directly instantiate the Java Record via its canonical constructor.

The Repository Implementation

The key is the SELECT new clause, which must include the fully qualified name of the Java Record and pass the entity fields in the exact order defined by the record’s components.

// src/main/java/com/example/repository/ProductRepository.java
import com.example.dto.ProductSummary;

public interface ProductRepository extends JpaRepository<Product, Long> {

    /**
     * Uses a JPA Constructor Expression to select only the necessary fields
     * and constructs the immutable ProductSummary Record directly.
     * This avoids loading the heavy Product entity into the Persistence Context.
     */
    @Query("""
        SELECT new com.example.dto.ProductSummary(p.id, p.name, p.price)
        FROM Product p
        WHERE p.active = true
        ORDER BY p.name
    """)
    List<ProductSummary> findActiveProductSummaries();

    // The component types must match exactly (e.g., String name -> String name)
}

Usage

Now, your service layer works exclusively with the lightweight, immutable ProductSummary objects, ensuring you only retrieve the data you need.

// src/main/java/com/example/service/ProductService.java

@Service
public class ProductService {
    
    private final ProductRepository productRepository;

    @Autowired
    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    public List<ProductSummary> getActiveCatalogView() {
        // This call executes the optimized SQL query and returns records directly.
        return productRepository.findActiveProductSummaries();
    }
}

4. Summary of Benefits

This approach offers substantial improvements for modern Spring applications:

BenefitDescription
PerformanceThe generated SQL query is highly efficient (SELECT p.id, p.name, p.price FROM product...). Fewer columns are transferred from the database, and the JPA engine skips entity management overhead (no dirty checking, no proxy generation).
ImmutabilityJava Records ensure that the projected data, once retrieved, cannot be modified, eliminating potential side effects and making code easier to reason about.
Clean CodeThe Repository method signature clearly documents the returned structure, and the Record definition instantly tells you what data to expect, with no tedious boilerplate getters/setters.
Type SafetyBy referencing the Record’s canonical constructor in the @Query, the compiler helps ensure the field types and order are correct, unlike interface-based (Dynamic) projections.

By embracing Java Records, you are not just adopting a new syntax; you are significantly improving the efficiency, safety, and readability of your Spring Data JPA architecture. Start replacing those old boilerplate DTOs with sleek, modern Records today!


Discover more from GhostProgrammer - Jeff Miller

Subscribe to get the latest posts sent to your email.

By Jeffery Miller

I am known for being able to quickly decipher difficult problems to assist development teams in producing a solution. I have been called upon to be the Team Lead for multiple large-scale projects. I have a keen interest in learning new technologies, always ready for a new challenge.