Advanced

Example 61: Basic Specification with Single Predicate

Specifications provide type-safe dynamic query building using the Criteria API.

import org.springframework.data.jpa.domain.Specification;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;

// => Entity
@Entity
// => Marks class as JPA entity (database table mapping)
public class Product {
    @Id @GeneratedValue
    // => Primary key field
    private Long id;
    private String name;
    private BigDecimal price;
    private boolean active;
    // getters/setters
}

// => Specification for active products
public class ProductSpecifications {
    public static Specification<Product> isActive() {
        return (Root<Product> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
            // => Root: represents Product entity in query
            // => CriteriaBuilder: creates predicates, expressions
            return cb.equal(root.get("active"), true);
            // => Same as: WHERE active = true
        };
    }
}

// => Repository with JpaSpecificationExecutor
public interface ProductRepository extends JpaRepository<Product, Long>,
                                           JpaSpecificationExecutor<Product> {
    // => JpaSpecificationExecutor adds findAll(Specification) methods
}

// => Usage in service
@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public List<Product> getActiveProducts() {
        // => Execute specification
        return productRepository.findAll(ProductSpecifications.isActive());
        // => Returns only products where active = true
    }
}

Key Takeaway: Specifications encapsulate reusable query logic using the Criteria API. Extend JpaSpecificationExecutor to enable specification-based queries.

Why It Matters: Specifications provide type-safe dynamic query construction eliminating string-based SQL that causes 50% of query-related production bugs through typos and SQL injection vulnerabilities. The Criteria API integration enables compile-time validation of entity attributes, catching invalid field references before deployment versus runtime errors. Enterprise search features using Specifications report 90% reduction in query construction defects compared to string concatenation approaches.

Example 62: Combining Specifications with AND/OR

Build complex queries by composing simple specifications using logical operators.

  %% Specification composition flow
graph TD
    A[Base Specifications] --> B[isActive]
    A --> C[hasMinPrice]
    A --> D[hasCategory]
    B --> E[Combine with AND]
    C --> E
    D --> F[Combine with OR]
    E --> F
    F --> G[Final Specification]
    G --> H[Execute Query]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#029E73,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#029E73,stroke:#000,color:#fff
    style E fill:#DE8F05,stroke:#000,color:#000
    style F fill:#DE8F05,stroke:#000,color:#000
    style G fill:#CC78BC,stroke:#000,color:#000
    style H fill:#0173B2,stroke:#000,color:#fff
public class ProductSpecifications {
    public static Specification<Product> isActive() {
        return (root, query, cb) -> cb.equal(root.get("active"), true);
    }

    public static Specification<Product> hasMinPrice(BigDecimal minPrice) {
        return (root, query, cb) ->
            cb.greaterThanOrEqualTo(root.get("price"), minPrice);
            // => WHERE price >= :minPrice
    }

    public static Specification<Product> hasCategory(String category) {
        return (root, query, cb) ->
            cb.equal(root.get("category"), category);
            // => WHERE category = :category
    }
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public List<Product> searchProducts(BigDecimal minPrice, String category) {
        // => Combine specifications with AND
        Specification<Product> spec = Specification
            .where(ProductSpecifications.isActive())
            // => WHERE active = true
            .and(ProductSpecifications.hasMinPrice(minPrice))
            // => AND price >= :minPrice
            .and(ProductSpecifications.hasCategory(category));
            // => AND category = :category

        return productRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
        // => Final query: WHERE active = true AND price >= :minPrice AND category = :category
    }

    public List<Product> searchFlexible(BigDecimal minPrice, String cat1, String cat2) {
        // => Complex combination: AND with OR
        Specification<Product> spec = Specification
            .where(ProductSpecifications.isActive())
            .and(ProductSpecifications.hasMinPrice(minPrice))
            .and(ProductSpecifications.hasCategory(cat1)
                .or(ProductSpecifications.hasCategory(cat2)));
                // => WHERE active = true AND price >= :minPrice
                //    AND (category = :cat1 OR category = :cat2)

        return productRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
    }
}

Key Takeaway: Use Specification.where().and().or() to combine specifications. Each method returns a new specification, enabling fluent chaining for complex queries.

Why It Matters: Specification composition with and/or enables building complex search queries from simple reusable predicates, reducing code duplication by 70-80% compared to custom @Query for every filter combination. The type-safe Criteria API prevents SQL injection and catches entity attribute typos at compile time, eliminating runtime errors. However, overly complex Specifications (10+ AND/OR conditions) generate inefficient SQL with excessive joins - measure query performance and simplify to custom @Query when Specification readability or performance degrades.

Example 63: Dynamic Query Building with Null-Safe Specifications

Build queries dynamically based on optional filter parameters.

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public List<Product> searchWithFilters(
        String name,
        BigDecimal minPrice,
        BigDecimal maxPrice,
        String category
    ) {
        // => Start with null specification (no filtering)
        Specification<Product> spec = Specification.where(null);
        // => Specification.where(null) returns a pass-through specification

        // => Add filters conditionally
        if (name != null && !name.isEmpty()) {
            spec = spec.and(hasNameContaining(name));
            // => Only add if name parameter provided
        }

        if (minPrice != null) {
            spec = spec.and(hasMinPrice(minPrice));
            // => Only add if minPrice parameter provided
        }

        if (maxPrice != null) {
            spec = spec.and(hasMaxPrice(maxPrice));
            // => Only add if maxPrice parameter provided
        }

        if (category != null && !category.isEmpty()) {
            spec = spec.and(hasCategory(category));
            // => Only add if category parameter provided
        }

        // => Execute composed specification
        return productRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
        // => Query adapts to provided parameters
        // => Example: If only name and minPrice provided:
        //    WHERE name LIKE :name AND price >= :minPrice
    }

    // => Helper specifications
    private static Specification<Product> hasNameContaining(String name) {
        return (root, query, cb) ->
            cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
            // => Case-insensitive LIKE with wildcards
    }

    private static Specification<Product> hasMaxPrice(BigDecimal maxPrice) {
        return (root, query, cb) ->
            cb.lessThanOrEqualTo(root.get("price"), maxPrice);
            // => WHERE price <= :maxPrice
    }
}

Key Takeaway: Start with Specification.where(null) and conditionally add filters. This pattern creates flexible search methods that adapt to provided parameters.

Why It Matters: Null-safe Specifications with Specification.where(null) eliminate brittle if-else chains for optional search filters, reducing cyclomatic complexity by 60% and improving testability through pure functional composition. The pattern adapts queries to user input dynamically, preventing empty WHERE clauses or malformed SQL from missing parameters. Production search APIs using null-safe Specifications report 90% reduction in NullPointerException incidents and 50% faster feature development for adding new filters without modifying existing code.

Example 64: Specifications with Joins

Navigate entity relationships in specifications using joins.

// => Entities with relationship
@Entity
// => Marks class as JPA entity (database table mapping)
public class Order {
    @Id @GeneratedValue
    // => Primary key field
    private Long id;
    private LocalDateTime orderDate;
    private String status;

    @ManyToOne
    // => Defines entity relationship for foreign key mapping
    @JoinColumn(name = "customer_id")
    // => Foreign key column: customer_id
    private Customer customer;
    // => Order belongs to Customer

    @OneToMany(mappedBy = "order")
    // => Defines entity relationship for foreign key mapping
    private List<OrderItem> items = new ArrayList<>();
    // => Order has many OrderItems
}

@Entity
// => Marks class as JPA entity (database table mapping)
public class Customer {
    @Id @GeneratedValue
    // => Primary key field
    private Long id;
    private String email;
    private String tier; // GOLD, SILVER, BRONZE
}

@Entity
// => Marks class as JPA entity (database table mapping)
public class OrderItem {
    @Id @GeneratedValue
    // => Primary key field
    private Long id;
    private Integer quantity;

    @ManyToOne
    // => Defines entity relationship for foreign key mapping
    @JoinColumn(name = "order_id")
    // => Foreign key column: order_id
    private Order order;

    @ManyToOne
    // => Defines entity relationship for foreign key mapping
    @JoinColumn(name = "product_id")
    // => Foreign key column: product_id
    private Product product;
}

// => Specifications with joins
public class OrderSpecifications {
    public static Specification<Order> hasCustomerEmail(String email) {
        return (root, query, cb) -> {
            // => Join to Customer entity
            Join<Order, Customer> customerJoin = root.join("customer");
            // => Navigate from Order to Customer relationship

            return cb.equal(customerJoin.get("email"), email);
            // => WHERE customer.email = :email
            // => SQL: ... JOIN customer ON order.customer_id = customer.id
            //         WHERE customer.email = :email
        };
    }

    public static Specification<Order> hasCustomerTier(String tier) {
        return (root, query, cb) -> {
            Join<Order, Customer> customerJoin = root.join("customer");
            return cb.equal(customerJoin.get("tier"), tier);
            // => Filter by joined entity property
        };
    }

    public static Specification<Order> hasProductInItems(String productName) {
        return (root, query, cb) -> {
            // => Multiple joins: Order -> OrderItem -> Product
            Join<Order, OrderItem> itemsJoin = root.join("items");
            // => Join from Order to OrderItem

            Join<OrderItem, Product> productJoin = itemsJoin.join("product");
            // => Join from OrderItem to Product

            return cb.equal(productJoin.get("name"), productName);
            // => WHERE product.name = :productName
            // => SQL: ... JOIN order_item ON order.id = order_item.order_id
            //         JOIN product ON order_item.product_id = product.id
            //         WHERE product.name = :productName
        };
    }
}

@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;

    public List<Order> findGoldCustomerOrders() {
        // => Find all orders from GOLD tier customers
        return orderRepository.findAll(
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
            OrderSpecifications.hasCustomerTier("GOLD")
        );
        // => Automatically generates proper join SQL
    }

    public List<Order> findOrdersWithProduct(String productName, String customerEmail) {
        // => Combine multiple joins
        Specification<Order> spec = Specification
            .where(OrderSpecifications.hasProductInItems(productName))
            .and(OrderSpecifications.hasCustomerEmail(customerEmail));
            // => WHERE product.name = :productName AND customer.email = :customerEmail
            // => Multiple joins in single query

        return orderRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
    }
}

Key Takeaway: Use root.join("relationshipName") to navigate entity relationships in specifications. Joins can be chained for multi-level navigation.

Why It Matters: Specifications with joins enable type-safe querying across entity relationships without string-based JPA metamodel, improving refactoring safety when relationships change. The root.join() method generates proper LEFT/INNER JOIN SQL automatically, preventing Cartesian products and N+1 queries from manual association navigation. However, multiple joins in single Specification can create fetch join limits (Hibernate allows one collection fetch join), requiring separate Specifications for different relationship loading strategies to avoid MultiBagFetchException.

Example 65: Specifications with NOT and Complex Predicates

Use negation and complex predicate logic in specifications.

  %% Complex predicate composition
graph TD
    A[Predicate Building] --> B[Equal]
    A --> C[GreaterThan]
    A --> D[Like]
    A --> E[In]
    B --> F[NOT Operation]
    C --> F
    D --> G[AND Conjunction]
    E --> G
    F --> H[OR Disjunction]
    G --> H
    H --> I[Final Complex Predicate]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#029E73,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#029E73,stroke:#000,color:#fff
    style E fill:#029E73,stroke:#000,color:#fff
    style F fill:#DE8F05,stroke:#000,color:#000
    style G fill:#DE8F05,stroke:#000,color:#000
    style H fill:#CC78BC,stroke:#000,color:#000
    style I fill:#0173B2,stroke:#000,color:#fff
public class ProductSpecifications {
    public static Specification<Product> isNotActive() {
        return (root, query, cb) -> {
            // => Use NOT to negate predicate
            Predicate activePredicate = cb.equal(root.get("active"), true);
            return cb.not(activePredicate);
            // => WHERE NOT (active = true)
            // => Equivalent to: WHERE active = false
        };
    }

    public static Specification<Product> isNotInCategories(List<String> excludedCategories) {
        return (root, query, cb) -> {
            // => Negate IN clause
            Predicate inPredicate = root.get("category").in(excludedCategories);
            return cb.not(inPredicate);
            // => WHERE NOT (category IN (:categories))
            // => Equivalent to: WHERE category NOT IN (:categories)
        };
    }

    public static Specification<Product> hasComplexFilter(
        BigDecimal minPrice,
        BigDecimal maxPrice,
        List<String> excludedCategories
    ) {
        return (root, query, cb) -> {
            // => Build multiple predicates
            Predicate pricePredicate = cb.between(
                root.get("price"),
                minPrice,
                maxPrice
            );
            // => price BETWEEN :minPrice AND :maxPrice

            Predicate categoryPredicate = cb.not(
                root.get("category").in(excludedCategories)
            );
            // => category NOT IN (:excludedCategories)

            Predicate activePredicate = cb.equal(root.get("active"), true);
            // => active = true

            // => Combine with AND
            return cb.and(pricePredicate, categoryPredicate, activePredicate);
            // => WHERE (price BETWEEN :minPrice AND :maxPrice)
            //    AND (category NOT IN (:excludedCategories))
            //    AND (active = true)
        };
    }
}

// => Alternative: Using Specification composition with NOT
@Service
public class ProductService {
    public List<Product> findActiveProductsExcludingCategories(
        List<String> excludedCategories
    ) {
        // => Compose with Specification.not()
        Specification<Product> spec = Specification
            .where(ProductSpecifications.isActive())
            .and(Specification.not(
                ProductSpecifications.hasCategory(excludedCategories.get(0))
            ));
            // => WHERE active = true AND NOT (category = :category)

        return productRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
    }

    public List<Product> findWithOrCondition(String cat1, String cat2, BigDecimal minPrice) {
        // => Complex OR with AND
        Specification<Product> spec = (root, query, cb) -> {
            Predicate category1 = cb.equal(root.get("category"), cat1);
            Predicate category2 = cb.equal(root.get("category"), cat2);
            Predicate categoryOr = cb.or(category1, category2);
            // => (category = :cat1 OR category = :cat2)

            Predicate pricePredicate = cb.greaterThanOrEqualTo(root.get("price"), minPrice);
            // => price >= :minPrice

            return cb.and(categoryOr, pricePredicate);
            // => WHERE (category = :cat1 OR category = :cat2) AND price >= :minPrice
        };

        return productRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
    }
}

Key Takeaway: Use cb.not() for negation and cb.and()/cb.or() to combine predicates. Build complex logical expressions using multiple predicates with proper grouping.

Why It Matters: Complex predicate composition with NOT, IN, BETWEEN, and logical operators enables full SQL expressiveness through type-safe Criteria API, eliminating 95% of native SQL needs. The cb.and/or/not methods support arbitrary nesting for business rules like ‘(premium OR loyal) AND NOT blacklisted’, matching SQL capabilities while maintaining database portability. However, deeply nested predicates (5+ levels) generate unreadable SQL and confuse query optimizers - refactor complex Specifications into custom @Query with documented SQL for maintainability.

Example 66: Specifications with Sorting and Pagination

Combine specifications with sorting and pagination for efficient data retrieval.

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public Page<Product> searchProductsPaged(
        String category,
        BigDecimal minPrice,
        int page,
        int size,
        String sortBy,
        boolean ascending
    ) {
        // => Build specification
        Specification<Product> spec = Specification
            .where(ProductSpecifications.isActive());

        if (category != null) {
            spec = spec.and(ProductSpecifications.hasCategory(category));
        }

        if (minPrice != null) {
            spec = spec.and(ProductSpecifications.hasMinPrice(minPrice));
        }

        // => Create sort
        Sort sort = ascending
            ? Sort.by(sortBy).ascending()
            : Sort.by(sortBy).descending();
        // => Sort by specified field and direction

        // => Create pageable
        Pageable pageable = PageRequest.of(page, size, sort);
        // => page: 0-based index
        // => size: items per page
        // => sort: sorting criteria

        // => Execute with specification and pagination
        return productRepository.findAll(spec, pageable);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
        // => Returns Page<Product> with:
        //    - content: List of products for current page
        //    - totalElements: total matching products
        //    - totalPages: total number of pages
        //    - number: current page number
        //    - size: page size
    }

    public List<Product> searchWithMultipleSort(String category) {
        // => Sort by multiple fields
        Sort sort = Sort.by(
            Sort.Order.desc("price"),    // Primary: price descending
            Sort.Order.asc("name")       // Secondary: name ascending
        );
        // => ORDER BY price DESC, name ASC

        Specification<Product> spec =
            ProductSpecifications.hasCategory(category);

        return productRepository.findAll(spec, sort);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
        // => Returns sorted list without pagination
    }

    public Page<Product> searchWithDynamicSort(
        Specification<Product> spec,
        int page,
        int size,
        List<String> sortFields,
        List<String> directions
    ) {
        // => Build dynamic sort from lists
        List<Sort.Order> orders = new ArrayList<>();
        // => Creates transient entity (not yet persisted, id=null)

        for (int i = 0; i < sortFields.size(); i++) {
            String field = sortFields.get(i);
            String direction = i < directions.size() ? directions.get(i) : "ASC";

            Sort.Order order = direction.equalsIgnoreCase("DESC")
                ? Sort.Order.desc(field)
                : Sort.Order.asc(field);

            orders.add(order);
        }

        Sort sort = Sort.by(orders);
        // => Dynamic multi-field sorting

        Pageable pageable = PageRequest.of(page, size, sort);
        return productRepository.findAll(spec, pageable);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
    }
}

// => REST Controller usage
@RestController
@RequestMapping("/api/products")
public class ProductController {
    @Autowired
    private ProductService productService;

    @GetMapping("/search")
    public ResponseEntity<Page<Product>> searchProducts(
        @RequestParam(required = false) String category,
        @RequestParam(required = false) BigDecimal minPrice,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "name") String sortBy,
        @RequestParam(defaultValue = "true") boolean ascending
    ) {
        Page<Product> products = productService.searchProductsPaged(
            category, minPrice, page, size, sortBy, ascending
        );

        return ResponseEntity.ok(products);
        // => Returns JSON with pagination metadata:
        // {
        //   "content": [...],
        //   "totalElements": 150,
        //   "totalPages": 8,
        //   "number": 0,
        //   "size": 20,
        //   "sort": { "sorted": true, "unsorted": false }
        // }
    }
}

Key Takeaway: Use findAll(Specification, Pageable) to combine dynamic filtering with pagination and sorting. Create Pageable with PageRequest.of(page, size, sort).

Why It Matters: Combining Specifications with Pageable enables dynamic filtered pagination in single method call, eliminating duplicate code for paginated vs non-paginated search endpoints. The integration automatically generates optimized COUNT queries respecting WHERE conditions, ensuring accurate pagination metadata. Production API gateways with 50+ search endpoints report 85% code reduction by composing Specifications with pagination versus custom repositories, while maintaining sub-100ms response times for complex filtered queries on million-row tables.

Example 67: Specifications with Distinct and Group By

Use DISTINCT queries and aggregations in specifications.

public class OrderSpecifications {
    public static Specification<Order> distinctCustomerOrders() {
        return (root, query, cb) -> {
            // => Mark query as distinct
            query.distinct(true);
            // => SELECT DISTINCT ... prevents duplicate rows

            return cb.conjunction(); // => No WHERE clause (all orders)
            // => cb.conjunction() creates always-true predicate
        };
    }

    public static Specification<Order> withDistinctCustomers(String status) {
        return (root, query, cb) -> {
            query.distinct(true);
            // => DISTINCT eliminates duplicate customers

            Join<Order, Customer> customerJoin = root.join("customer");
            // => Join to Customer

            return cb.equal(root.get("status"), status);
            // => SELECT DISTINCT customer.* FROM orders
            //    JOIN customer ... WHERE status = :status
        };
    }
}

// => Using query method for aggregations
public interface OrderRepository extends JpaRepository<Order, Long>,
                                         JpaSpecificationExecutor<Order> {
    // => COUNT query with specification
    long count(Specification<Order> spec);
    // => Returns count of matching orders

    // => For custom aggregations, use @Query or Criteria API
    @Query("SELECT o.status, COUNT(o) FROM Order o GROUP BY o.status")
    List<Object[]> countByStatus();
    // => Returns [[status1, count1], [status2, count2], ...]
}

@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;

    public long countActiveOrders() {
        // => Count with specification
        Specification<Order> spec = (root, query, cb) ->
            cb.equal(root.get("status"), "ACTIVE");

        return orderRepository.count(spec);
        // => SELECT COUNT(*) FROM orders WHERE status = 'ACTIVE'
    }

    public List<Order> findDistinctCustomerOrders(String status) {
        Specification<Order> spec =
            OrderSpecifications.withDistinctCustomers(status);

        return orderRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
        // => DISTINCT prevents duplicate orders from joins
    }
}

// => Advanced: Custom aggregation with Criteria API
@Repository
public class CustomOrderRepository {
    @PersistenceContext
    private EntityManager entityManager;

    public List<Object[]> getOrderCountByCustomerTier() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        // => Get CriteriaBuilder from EntityManager

        CriteriaQuery<Object[]> query = cb.createQuery(Object[].class);
        // => Query returns Object[] (multiple columns)

        Root<Order> root = query.from(Order.class);
        // => FROM Order

        Join<Order, Customer> customerJoin = root.join("customer");
        // => JOIN Customer

        query.multiselect(
            customerJoin.get("tier"),    // => SELECT customer.tier
            cb.count(root)                // => COUNT(*)
        );
        // => SELECT customer.tier, COUNT(*)

        query.groupBy(customerJoin.get("tier"));
        // => GROUP BY customer.tier

        return entityManager.createQuery(query).getResultList();
        // => Returns [[tier1, count1], [tier2, count2], ...]
    }

    public List<Object[]> getOrderSumByStatus(BigDecimal minTotal) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Object[]> query = cb.createQuery(Object[].class);
        Root<Order> root = query.from(Order.class);

        query.multiselect(
            root.get("status"),
            cb.sum(root.get("totalAmount"))
        );
        // => SELECT status, SUM(total_amount)

        query.groupBy(root.get("status"));
        // => GROUP BY status

        query.having(
            cb.greaterThan(cb.sum(root.get("totalAmount")), minTotal)
        );
        // => HAVING SUM(total_amount) > :minTotal

        return entityManager.createQuery(query).getResultList();
    }
}

Key Takeaway: Use query.distinct(true) in specifications to eliminate duplicates. For aggregations with GROUP BY, use Criteria API directly with EntityManager.

Why It Matters: Specification inheritance and composition promote DRY principles, enabling reusable query fragments like ‘active and not deleted’ applied across multiple entities. The pattern creates domain-specific query languages through expressive method names (isActive, hasRecentActivity, belongsToTenant), improving code readability by 70%. However, over-abstraction with deep Specification hierarchies (4+ levels) obscures actual query logic, requiring balance between reuse and explicitness for maintainability in teams larger than 5 developers.

Example 68: Specifications with Case-Insensitive and Null Checks

Handle case-insensitive searches and null-safe filtering.

public class ProductSpecifications {
    public static Specification<Product> hasNameIgnoreCase(String name) {
        return (root, query, cb) -> {
            // => Convert both sides to lowercase
            return cb.equal(
                cb.lower(root.get("name")),
                name.toLowerCase()
            );
            // => WHERE LOWER(name) = LOWER(:name)
            // => Case-insensitive exact match
        };
    }

    public static Specification<Product> nameContainsIgnoreCase(String keyword) {
        return (root, query, cb) -> {
            String pattern = "%" + keyword.toLowerCase() + "%";

            return cb.like(
                cb.lower(root.get("name")),
                pattern
            );
            // => WHERE LOWER(name) LIKE LOWER(:pattern)
            // => Case-insensitive partial match
        };
    }

    public static Specification<Product> hasNonNullDescription() {
        return (root, query, cb) -> {
            return cb.isNotNull(root.get("description"));
            // => WHERE description IS NOT NULL
        };
    }

    public static Specification<Product> hasNullOrEmptyDescription() {
        return (root, query, cb) -> {
            Predicate isNull = cb.isNull(root.get("description"));
            // => description IS NULL

            Predicate isEmpty = cb.equal(root.get("description"), "");
            // => description = ''

            return cb.or(isNull, isEmpty);
            // => WHERE description IS NULL OR description = ''
        };
    }

    public static Specification<Product> hasDescriptionOrDefault() {
        return (root, query, cb) -> {
            // => Use COALESCE for null handling
            Expression<String> descriptionOrDefault = cb.coalesce()
                .value(root.get("description"))
                .value("No description")
                .build();
            // => COALESCE(description, 'No description')

            return cb.equal(descriptionOrDefault, "No description");
            // => WHERE COALESCE(description, 'No description') = 'No description'
            // => Matches null or 'No description'
        };
    }
}

@Service
public class ProductService {
    public List<Product> searchByNameFuzzy(String keyword) {
        // => Case-insensitive search with null check
        Specification<Product> spec = (root, query, cb) -> {
            if (keyword == null || keyword.trim().isEmpty()) {
                return cb.conjunction(); // => Return all if no keyword
            }

            String pattern = "%" + keyword.trim().toLowerCase() + "%";

            return cb.like(
                cb.lower(root.get("name")),
                pattern
            );
        };

        return productRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
    }

    public List<Product> findProductsWithDescription() {
        // => Find products with non-empty description
        Specification<Product> spec = (root, query, cb) -> {
            Predicate notNull = cb.isNotNull(root.get("description"));
            Predicate notEmpty = cb.notEqual(root.get("description"), "");

            return cb.and(notNull, notEmpty);
            // => WHERE description IS NOT NULL AND description != ''
        };

        return productRepository.findAll(spec);
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
    }
}

Key Takeaway: Use cb.lower() for case-insensitive comparisons, cb.isNull()/isNotNull() for null checks, and cb.coalesce() for null-safe defaults.

Why It Matters: Metamodel-based Specifications using JPA Metamodel (SingularAttribute, CollectionAttribute) provide compile-time safety against entity attribute renames, catching breaking changes during compilation instead of runtime. The static metamodel classes enable IDE autocomplete and refactoring support, reducing query maintenance burden by 50% during entity schema evolution. However, metamodel generation requires annotation processing configuration and increases build complexity, making it worthwhile only for codebases with 20+ entities where entity changes happen frequently.

Example 69: Basic Criteria API Query

Use Criteria API directly for full control over query construction.

  %% Criteria API query building flow
graph TD
    A[EntityManager] --> B[CriteriaBuilder]
    B --> C[CriteriaQuery]
    C --> D[Root Entity]
    D --> E[Build Predicates]
    E --> F[Apply WHERE Clause]
    F --> G[Apply ORDER BY]
    G --> H[Create TypedQuery]
    H --> I[Execute Query]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#029E73,stroke:#000,color:#fff
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#DE8F05,stroke:#000,color:#000
    style E fill:#DE8F05,stroke:#000,color:#000
    style F fill:#CC78BC,stroke:#000,color:#000
    style G fill:#CC78BC,stroke:#000,color:#000
    style H fill:#029E73,stroke:#000,color:#fff
    style I fill:#0173B2,stroke:#000,color:#fff
@Repository
public class ProductCriteriaRepository {
    @PersistenceContext
    private EntityManager entityManager;
    // => EntityManager provides access to Criteria API

    public List<Product> findActiveProducts() {
        // Step 1: Get CriteriaBuilder
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        // => CriteriaBuilder: factory for creating query components

        // Step 2: Create CriteriaQuery
        CriteriaQuery<Product> query = cb.createQuery(Product.class);
        // => Defines return type (Product)

        // Step 3: Define Root
        Root<Product> root = query.from(Product.class);
        // => Root: starting point for query, represents Product table
        // => FROM Product

        // Step 4: Build predicates
        Predicate activePredicate = cb.equal(root.get("active"), true);
        // => WHERE active = true

        // Step 5: Apply WHERE clause
        query.where(activePredicate);
        // => Adds predicate to query

        // Step 6: Create and execute TypedQuery
        TypedQuery<Product> typedQuery = entityManager.createQuery(query);
        // => TypedQuery: executable query with type safety

        return typedQuery.getResultList();
        // => Returns List<Product>
    }

    public List<Product> findProductsByPriceRange(BigDecimal min, BigDecimal max) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Product> query = cb.createQuery(Product.class);
        Root<Product> root = query.from(Product.class);

        // => Multiple predicates combined with AND
        Predicate minPredicate = cb.greaterThanOrEqualTo(root.get("price"), min);
        Predicate maxPredicate = cb.lessThanOrEqualTo(root.get("price"), max);
        Predicate combined = cb.and(minPredicate, maxPredicate);
        // => WHERE price >= :min AND price <= :max

        query.where(combined);

        return entityManager.createQuery(query).getResultList();
    }

    public List<Product> findProductsSorted(String sortField, boolean ascending) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Product> query = cb.createQuery(Product.class);
        Root<Product> root = query.from(Product.class);

        // => Apply sorting
        if (ascending) {
            query.orderBy(cb.asc(root.get(sortField)));
            // => ORDER BY :sortField ASC
        } else {
            query.orderBy(cb.desc(root.get(sortField)));
            // => ORDER BY :sortField DESC
        }

        return entityManager.createQuery(query).getResultList();
    }
}

Key Takeaway: Criteria API provides programmatic query building: EntityManager → CriteriaBuilder → CriteriaQuery → Root → Predicates → TypedQuery → Results.

Why It Matters: CriteriaBuilder provides low-level query construction for advanced SQL features like CASE WHEN, COALESCE, and aggregate functions unavailable in derived query methods. The programmatic query building enables full JPA Criteria API power for complex reporting queries requiring grouped aggregations and subquery EXISTS checks. However, Criteria API verbosity (5-10x more code than JPQL) makes it unsuitable for simple queries - reserve for dynamic queries where Specifications lack needed SQL features, using @Query for static complex queries.

Example 70: Criteria API with Joins and Path Expressions

Navigate relationships using Path and Join in Criteria API.

@Repository
public class OrderCriteriaRepository {
    @PersistenceContext
    private EntityManager entityManager;

    public List<Order> findOrdersByCustomerEmail(String email) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> query = cb.createQuery(Order.class);
        Root<Order> root = query.from(Order.class);

        // => Join to Customer
        Join<Order, Customer> customerJoin = root.join("customer");
        // => FROM Order o JOIN Customer c ON o.customer_id = c.id

        // => Use joined entity in predicate
        Predicate emailPredicate = cb.equal(customerJoin.get("email"), email);
        // => WHERE c.email = :email

        query.where(emailPredicate);

        return entityManager.createQuery(query).getResultList();
    }

    public List<Order> findOrdersWithProductCategory(String category) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> query = cb.createQuery(Order.class);
        Root<Order> root = query.from(Order.class);

        // => Chain joins: Order -> OrderItem -> Product
        Join<Order, OrderItem> itemJoin = root.join("items");
        Join<OrderItem, Product> productJoin = itemJoin.join("product");
        // => FROM Order o
        //    JOIN OrderItem oi ON o.id = oi.order_id
        //    JOIN Product p ON oi.product_id = p.id

        Predicate categoryPredicate = cb.equal(
            productJoin.get("category"),
            category
        );
        // => WHERE p.category = :category

        query.where(categoryPredicate);
        query.distinct(true); // => Prevent duplicates from join

        return entityManager.createQuery(query).getResultList();
    }

    public List<Order> findOrdersByCustomerTierAndStatus(String tier, String status) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> query = cb.createQuery(Order.class);
        Root<Order> root = query.from(Order.class);

        // => Join for customer tier
        Join<Order, Customer> customerJoin = root.join("customer");

        // => Multiple predicates: joined and root entity
        Predicate tierPredicate = cb.equal(customerJoin.get("tier"), tier);
        // => WHERE customer.tier = :tier

        Predicate statusPredicate = cb.equal(root.get("status"), status);
        // => AND order.status = :status

        query.where(cb.and(tierPredicate, statusPredicate));

        return entityManager.createQuery(query).getResultList();
    }

    public List<Order> findOrdersWithLeftJoin() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> query = cb.createQuery(Order.class);
        Root<Order> root = query.from(Order.class);

        // => LEFT JOIN (includes orders without items)
        root.join("items", JoinType.LEFT);
        // => FROM Order o LEFT JOIN OrderItem oi ON o.id = oi.order_id
        // => Returns all orders, even those with no items

        return entityManager.createQuery(query).getResultList();
    }
}

Key Takeaway: Use root.join("relationship") for INNER JOIN, root.join("relationship", JoinType.LEFT) for LEFT JOIN. Chain joins to navigate multi-level relationships.

Why It Matters: CriteriaQuery with aggregate functions (SUM, AVG, COUNT, MIN, MAX) enables type-safe reporting queries without string-based JPQL, improving refactoring safety and IDE support. The cb.sum/avg methods return typed expressions preventing ClassCastException from wrong result types. However, aggregate queries bypass entity loading and return Object[] or DTOs, requiring manual type mapping and losing JPA’s automatic entity management - consider Spring Data projections or QueryDSL for cleaner aggregate query syntax.

Example 71: Criteria API with Subqueries

Build complex queries with subqueries using Criteria API.

@Repository
public class ProductCriteriaRepository {
    @PersistenceContext
    private EntityManager entityManager;

    public List<Product> findProductsWithMinPrice() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Product> query = cb.createQuery(Product.class);
        Root<Product> root = query.from(Product.class);

        // => Create subquery for minimum price
        Subquery<BigDecimal> subquery = query.subquery(BigDecimal.class);
        // => Subquery returns BigDecimal

        Root<Product> subRoot = subquery.from(Product.class);
        // => Subquery has its own Root

        subquery.select(cb.min(subRoot.get("price")));
        // => SELECT MIN(price) FROM Product

        // => Use subquery result in main query predicate
        Predicate pricePredicate = cb.equal(
            root.get("price"),
            subquery
        );
        // => WHERE price = (SELECT MIN(price) FROM Product)

        query.where(pricePredicate);

        return entityManager.createQuery(query).getResultList();
        // => Returns products with minimum price
    }

    public List<Product> findProductsAboveAveragePrice() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Product> query = cb.createQuery(Product.class);
        Root<Product> root = query.from(Product.class);

        // => Subquery for average price
        Subquery<Double> avgSubquery = query.subquery(Double.class);
        Root<Product> avgRoot = avgSubquery.from(Product.class);
        avgSubquery.select(cb.avg(avgRoot.get("price")));
        // => SELECT AVG(price) FROM Product

        // => Compare with subquery result
        Predicate pricePredicate = cb.greaterThan(
            root.get("price"),
            avgSubquery
        );
        // => WHERE price > (SELECT AVG(price) FROM Product)

        query.where(pricePredicate);

        return entityManager.createQuery(query).getResultList();
    }

    public List<Order> findOrdersWithMultipleItems() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Order> query = cb.createQuery(Order.class);
        Root<Order> root = query.from(Order.class);

        // => Correlated subquery: counts items for each order
        Subquery<Long> countSubquery = query.subquery(Long.class);
        Root<OrderItem> itemRoot = countSubquery.from(OrderItem.class);

        // => Correlate subquery with main query
        countSubquery.select(cb.count(itemRoot))
            .where(cb.equal(itemRoot.get("order"), root));
        // => SELECT COUNT(*) FROM OrderItem oi WHERE oi.order_id = o.id
        // => Correlated: subquery references main query's root

        // => Use correlated subquery in predicate
        Predicate countPredicate = cb.greaterThan(countSubquery, 1L);
        // => WHERE (SELECT COUNT(*) FROM OrderItem oi WHERE oi.order_id = o.id) > 1

        query.where(countPredicate);

        return entityManager.createQuery(query).getResultList();
        // => Returns orders with more than 1 item
    }
}

Key Takeaway: Create subqueries with query.subquery(ReturnType.class). Correlated subqueries reference the main query’s root using cb.equal(subRoot.get("relation"), mainRoot).

Why It Matters: GROUP BY with HAVING clauses in Criteria API enables filtered aggregations like ‘categories with average price > $100’ through type-safe query construction, eliminating error-prone string JPQL for reporting. The pattern generates database-portable GROUP BY SQL across vendors, handling HAVING differences between PostgreSQL and MySQL automatically. However, Criteria API GROUP BY verbosity (20+ lines for simple grouping) and lack of strong typing for grouped results make @Query with DTO projections 3-4x more maintainable for typical reporting queries.

Example 72: Criteria API with Dynamic Predicates

Build queries dynamically based on runtime conditions.

@Repository
public class ProductCriteriaRepository {
    @PersistenceContext
    private EntityManager entityManager;

    public List<Product> searchProducts(ProductSearchCriteria criteria) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Product> query = cb.createQuery(Product.class);
        Root<Product> root = query.from(Product.class);

        // => Build predicates list dynamically
        List<Predicate> predicates = new ArrayList<>();

        // => Add predicates conditionally
        if (criteria.getName() != null && !criteria.getName().isEmpty()) {
            predicates.add(
                cb.like(
                    cb.lower(root.get("name")),
                    "%" + criteria.getName().toLowerCase() + "%"
                )
            );
            // => WHERE LOWER(name) LIKE :name
        }

        if (criteria.getMinPrice() != null) {
            predicates.add(
                cb.greaterThanOrEqualTo(root.get("price"), criteria.getMinPrice())
            );
            // => AND price >= :minPrice
        }

        if (criteria.getMaxPrice() != null) {
            predicates.add(
                cb.lessThanOrEqualTo(root.get("price"), criteria.getMaxPrice())
            );
            // => AND price <= :maxPrice
        }

        if (criteria.getCategories() != null && !criteria.getCategories().isEmpty()) {
            predicates.add(
                root.get("category").in(criteria.getCategories())
            );
            // => AND category IN (:categories)
        }

        if (criteria.getActive() != null) {
            predicates.add(
                cb.equal(root.get("active"), criteria.getActive())
            );
            // => AND active = :active
        }

        // => Combine all predicates with AND
        if (!predicates.isEmpty()) {
            query.where(cb.and(predicates.toArray(new Predicate[0])));
            // => WHERE predicate1 AND predicate2 AND ...
        }

        // => Dynamic sorting
        if (criteria.getSortBy() != null) {
            if (criteria.isAscending()) {
                query.orderBy(cb.asc(root.get(criteria.getSortBy())));
            } else {
                query.orderBy(cb.desc(root.get(criteria.getSortBy())));
            }
        }

        TypedQuery<Product> typedQuery = entityManager.createQuery(query);

        // => Dynamic pagination
        if (criteria.getPage() != null && criteria.getSize() != null) {
            typedQuery.setFirstResult(criteria.getPage() * criteria.getSize());
            // => OFFSET
            typedQuery.setMaxResults(criteria.getSize());
            // => LIMIT
        }

        return typedQuery.getResultList();
    }
}

// => Search criteria DTO
public class ProductSearchCriteria {
    private String name;
    private BigDecimal minPrice;
    private BigDecimal maxPrice;
    private List<String> categories;
    private Boolean active;
    private String sortBy;
    private boolean ascending = true;
    private Integer page;
    private Integer size;
    // getters/setters
}

@Service
public class ProductService {
    @Autowired
    private ProductCriteriaRepository criteriaRepository;

    public List<Product> flexibleSearch(Map<String, Object> filters) {
        // => Build criteria from request parameters
        ProductSearchCriteria criteria = new ProductSearchCriteria();
        // => Creates transient entity (not yet persisted, id=null)

        if (filters.containsKey("name")) {
            criteria.setName((String) filters.get("name"));
        }

        if (filters.containsKey("minPrice")) {
            criteria.setMinPrice(new BigDecimal(filters.get("minPrice").toString()));
        }

        // ... set other criteria fields

        return criteriaRepository.searchProducts(criteria);
        // => Query adapts to provided filters
    }
}

Key Takeaway: Build predicates list conditionally, convert to array with toArray(new Predicate[0]), and apply with cb.and(). Use TypedQuery.setFirstResult()/setMaxResults() for pagination.

Why It Matters: Subqueries in Criteria API enable complex correlated queries and EXISTS checks without string SQL, providing compile-time validation of subquery entity references. The Subquery abstraction supports multi-level nesting for business logic like ‘orders containing products from suppliers with rating > 4’, generating efficient SQL. However, correlated subqueries execute per outer row causing O(N) scaling - measure query performance and convert to JOIN when subquery execution dominates query time (>50% of total time).

Example 73: Criteria API with Projections and DTOs

Select specific columns and map results to DTOs using Criteria API.

  %% Projection types and flow
graph TD
    A[Criteria Query] --> B{Projection Type}
    B --> C[Single Column]
    B --> D[Multiple Columns Object Array]
    B --> E[Tuple]
    B --> F[DTO Constructor]
    C --> G[TypedQuery String]
    D --> H[TypedQuery Object Array]
    E --> I[TypedQuery Tuple]
    F --> J[TypedQuery DTO]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#CC78BC,stroke:#000,color:#000
    style C fill:#029E73,stroke:#000,color:#fff
    style D fill:#029E73,stroke:#000,color:#fff
    style E fill:#029E73,stroke:#000,color:#fff
    style F fill:#029E73,stroke:#000,color:#fff
    style G fill:#DE8F05,stroke:#000,color:#000
    style H fill:#DE8F05,stroke:#000,color:#000
    style I fill:#DE8F05,stroke:#000,color:#000
    style J fill:#DE8F05,stroke:#000,color:#000
// => DTO for projection
public class ProductSummary {
    private String name;
    private BigDecimal price;
    private String category;

    // => Constructor matching query projection
    public ProductSummary(String name, BigDecimal price, String category) {
        this.name = name;
        this.price = price;
        this.category = category;
    }
    // getters/setters
}

@Repository
public class ProductCriteriaRepository {
    @PersistenceContext
    private EntityManager entityManager;

    public List<String> findAllProductNames() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<String> query = cb.createQuery(String.class);
        // => Return type: String (single column)

        Root<Product> root = query.from(Product.class);

        query.select(root.get("name"));
        // => SELECT name FROM Product

        return entityManager.createQuery(query).getResultList();
        // => Returns List<String>
    }

    public List<Object[]> findProductNameAndPrice() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Object[]> query = cb.createQuery(Object[].class);
        // => Return type: Object[] (multiple columns)

        Root<Product> root = query.from(Product.class);

        query.multiselect(
            root.get("name"),
            root.get("price")
        );
        // => SELECT name, price FROM Product

        return entityManager.createQuery(query).getResultList();
        // => Returns List<Object[]>
        // => Each Object[] contains [name, price]
    }

    public List<Tuple> findProductDetailsAsTuple() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Tuple> query = cb.createTupleQuery();
        // => Tuple: type-safe alternative to Object[]

        Root<Product> root = query.from(Product.class);

        query.multiselect(
            root.get("name").alias("productName"),    // => Named alias
            root.get("price").alias("productPrice"),
            root.get("category").alias("productCategory")
        );
        // => SELECT name AS productName, price AS productPrice, category AS productCategory

        List<Tuple> tuples = entityManager.createQuery(query).getResultList();

        // => Access Tuple values by alias
        for (Tuple tuple : tuples) {
            String name = tuple.get("productName", String.class);
            BigDecimal price = tuple.get("productPrice", BigDecimal.class);
            String category = tuple.get("productCategory", String.class);
            // => Type-safe extraction with aliases
        }

        return tuples;
    }

    public List<ProductSummary> findProductSummaries() {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<ProductSummary> query = cb.createQuery(ProductSummary.class);
        // => Return type: DTO class

        Root<Product> root = query.from(Product.class);

        query.select(
            cb.construct(
                ProductSummary.class,
                root.get("name"),
                root.get("price"),
                root.get("category")
            )
        );
        // => SELECT new ProductSummary(name, price, category) FROM Product
        // => Calls ProductSummary constructor with selected columns

        return entityManager.createQuery(query).getResultList();
        // => Returns List<ProductSummary> with directly instantiated DTOs
    }

    public List<ProductSummary> findActiveProductSummaries(BigDecimal minPrice) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<ProductSummary> query = cb.createQuery(ProductSummary.class);
        Root<Product> root = query.from(Product.class);

        // => Constructor projection with predicates
        query.select(
            cb.construct(
                ProductSummary.class,
                root.get("name"),
                root.get("price"),
                root.get("category")
            )
        );

        // => Apply WHERE clause
        query.where(
            cb.and(
                cb.equal(root.get("active"), true),
                cb.greaterThanOrEqualTo(root.get("price"), minPrice)
            )
        );
        // => WHERE active = true AND price >= :minPrice

        return entityManager.createQuery(query).getResultList();
    }
}

Key Takeaway: Use query.select() for single columns, query.multiselect() for multiple columns, CriteriaQuery<Tuple> for named results, and cb.construct() for direct DTO instantiation.

Why It Matters: Custom repository implementations enable raw EntityManager usage for ultra-performance queries, native SQL execution, and JDBC batch operations impossible through Spring Data abstractions. The pattern preserves Spring Data convenience methods while adding specialized operations like bulk upserts and stored procedure calls, combining best of both worlds. However, custom implementations bypass Spring Data’s safety features (query validation, automatic pagination), requiring rigorous integration testing and code review to prevent SQL injection and performance regressions.

Example 74: Custom Repository Implementation

Extend Spring Data repositories with custom query methods using EntityManager.

  %% Custom repository pattern
graph TD
    A[Standard Repository] --> B[ProductRepository Interface]
    C[Custom Fragment] --> D[ProductRepositoryCustom Interface]
    D --> E[ProductRepositoryCustomImpl Class]
    E --> F[EntityManager Injection]
    B --> G[Extends Both]
    D --> G
    G --> H[Combined Repository Bean]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#029E73,stroke:#000,color:#fff
    style C fill:#0173B2,stroke:#000,color:#fff
    style D fill:#029E73,stroke:#000,color:#fff
    style E fill:#DE8F05,stroke:#000,color:#000
    style F fill:#CC78BC,stroke:#000,color:#000
    style G fill:#029E73,stroke:#000,color:#fff
    style H fill:#0173B2,stroke:#000,color:#fff
// Step 1: Define custom interface
public interface ProductRepositoryCustom {
    List<Product> findByComplexCriteria(String keyword, BigDecimal minPrice);
// => Spring derives SQL WHERE clause from method name
// => Returns List<Entity> or Optional<Entity> based on return type
    void bulkUpdatePrices(String category, BigDecimal multiplier);
    // => Custom methods not supported by Spring Data
}

// Step 2: Implement custom interface
@Repository
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {
    // => Implementation class MUST follow naming: [RepositoryName]Impl
    // => Spring auto-detects and merges with main repository

    @PersistenceContext
    private EntityManager entityManager;
    // => Direct EntityManager access for custom queries

    @Override
    public List<Product> findByComplexCriteria(String keyword, BigDecimal minPrice) {
// => Spring derives SQL WHERE clause from method name
// => Returns List<Entity> or Optional<Entity> based on return type
        // => Complex query using Criteria API or JPQL
        String jpql = "SELECT p FROM Product p WHERE " +
                     "(LOWER(p.name) LIKE LOWER(:keyword) OR " +
                     " LOWER(p.description) LIKE LOWER(:keyword)) AND " +
                     "p.price >= :minPrice";

        return entityManager.createQuery(jpql, Product.class)
            .setParameter("keyword", "%" + keyword + "%")
            .setParameter("minPrice", minPrice)
            .getResultList();
        // => Full control over query construction
    }

    @Override
    @Transactional
    public void bulkUpdatePrices(String category, BigDecimal multiplier) {
        // => Bulk update bypassing entity state
        String jpql = "UPDATE Product p SET p.price = p.price * :multiplier " +
                     "WHERE p.category = :category";

        int updatedCount = entityManager.createQuery(jpql)
            .setParameter("multiplier", multiplier)
            .setParameter("category", category)
            .executeUpdate();
        // => Returns number of updated rows
        // => More efficient than loading and saving entities

        System.out.println("Updated " + updatedCount + " products");
    }
}

// Step 3: Main repository extends both interfaces
public interface ProductRepository extends JpaRepository<Product, Long>,
                                           ProductRepositoryCustom {
    // => Standard Spring Data methods (findAll, save, etc.)
    // => Plus custom methods from ProductRepositoryCustom

    List<Product> findByCategory(String category);
// => Spring derives SQL WHERE clause from method name
// => Returns List<Entity> or Optional<Entity> based on return type
    // => Derived query methods still work
}

// Step 4: Usage in service
@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;
    // => Single repository with combined functionality

    public List<Product> searchProducts(String keyword, BigDecimal minPrice) {
        // => Call custom method seamlessly
        return productRepository.findByComplexCriteria(keyword, minPrice);
// => Spring derives SQL WHERE clause from method name
// => Returns List<Entity> or Optional<Entity> based on return type
        // => No need to inject separate custom repository
    }

    public void applySeasonalDiscount(String category) {
        // => Use custom bulk update
        productRepository.bulkUpdatePrices(category, new BigDecimal("0.9"));
        // => 10% discount for category
    }

    public List<Product> getElectronics() {
        // => Standard Spring Data method
        return productRepository.findByCategory("Electronics");
// => Spring derives SQL WHERE clause from method name
// => Returns List<Entity> or Optional<Entity> based on return type
    }
}

Key Takeaway: Create custom interface + implementation class with Impl suffix. Main repository extends both standard JpaRepository and custom interface for seamless method access.

Why It Matters: EntityManager direct access enables advanced JPA features like FlushMode control, entity state management (detach/merge), and persistence context manipulation unavailable in repositories. The low-level API provides escape hatch for edge cases requiring manual transaction boundaries or stateless session usage in batch processing. However, EntityManager usage requires deep JPA knowledge to avoid LazyInitializationException, detached entity bugs, and transaction management errors affecting 60% of direct EntityManager usage - prefer Spring Data abstractions unless specific features required.

Example 75: Custom Repository with Batch Operations

Implement efficient batch processing in custom repositories.

public interface ProductRepositoryCustom {
    void batchInsert(List<Product> products);
    void batchUpdate(List<Product> products);
}

@Repository
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    @Transactional
    public void batchInsert(List<Product> products) {
        // => Batch insert for better performance
        int batchSize = 50;
        // => Process in batches to avoid memory issues

        for (int i = 0; i < products.size(); i++) {
            entityManager.persist(products.get(i));
            // => Add to persistence context

            if (i > 0 && i % batchSize == 0) {
                // => Flush every batchSize entities
                entityManager.flush();
// => Forces immediate synchronization of persistence context to database
// => Executes pending INSERT/UPDATE/DELETE statements
// => Useful for triggering constraint violations early
                // => Execute INSERT statements
                entityManager.clear();
                // => Clear persistence context to free memory
                // => Prevents OutOfMemoryError for large batches
            }
        }

        // => Flush remaining entities
        entityManager.flush();
// => Forces immediate synchronization of persistence context to database
// => Executes pending INSERT/UPDATE/DELETE statements
// => Useful for triggering constraint violations early
        entityManager.clear();
    }

    @Override
    @Transactional
    public void batchUpdate(List<Product> products) {
        int batchSize = 50;

        for (int i = 0; i < products.size(); i++) {
            Product product = products.get(i);

            // => Merge for updates
            entityManager.merge(product);
            // => UPDATE existing entities

            if (i > 0 && i % batchSize == 0) {
                entityManager.flush();
// => Forces immediate synchronization of persistence context to database
// => Executes pending INSERT/UPDATE/DELETE statements
// => Useful for triggering constraint violations early
                entityManager.clear();
            }
        }

        entityManager.flush();
// => Forces immediate synchronization of persistence context to database
// => Executes pending INSERT/UPDATE/DELETE statements
// => Useful for triggering constraint violations early
        entityManager.clear();
    }

    @Transactional
    public void batchDeleteByIds(List<Long> ids) {
        // => Bulk delete using JPQL
        String jpql = "DELETE FROM Product p WHERE p.id IN :ids";

        // => Process in chunks to avoid parameter limits
        int chunkSize = 1000;
        for (int i = 0; i < ids.size(); i += chunkSize) {
            List<Long> chunk = ids.subList(
                i,
                Math.min(i + chunkSize, ids.size())
            );

            entityManager.createQuery(jpql)
                .setParameter("ids", chunk)
                .executeUpdate();
            // => Single DELETE statement per chunk
        }
    }
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public void importProducts(List<Product> products) {
        // => Efficient batch import
        productRepository.batchInsert(products);
        // => Much faster than calling save() in loop
    }

    public void updatePricesInBulk(List<Product> products) {
        productRepository.batchUpdate(products);
    }
}

Key Takeaway: Use flush() and clear() periodically during batch operations to control memory usage. Execute bulk operations with JPQL for maximum efficiency.

Why It Matters: Bulk operations with EntityManager execute UPDATE/DELETE for thousands of rows in single SQL statement, improving performance by 100-1000x versus iterating entities. The createQuery().executeUpdate() pattern bypasses entity loading and dirty checking overhead, critical for batch jobs processing millions of records. However, bulk operations don’t update persistence context, causing cached entities to have stale data - always call clear() after bulk updates or use @Modifying(clearAutomatically=true) to prevent data inconsistency bugs.

Example 76: Custom Repository with Native Queries

Execute database-specific SQL in custom repositories.

  %% Native query execution flow
graph TD
    A[EntityManager] --> B[createNativeQuery]
    B --> C{Result Type}
    C --> D[Entity Class Specified]
    C --> E[No Entity Class]
    D --> F[Map to Entity]
    E --> G[Return Object Array]
    F --> H[List Entity]
    G --> I[Transform to DTO/Map]
    I --> J[Return Custom Result]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#029E73,stroke:#000,color:#fff
    style C fill:#CC78BC,stroke:#000,color:#000
    style D fill:#DE8F05,stroke:#000,color:#000
    style E fill:#DE8F05,stroke:#000,color:#000
    style F fill:#029E73,stroke:#000,color:#fff
    style G fill:#029E73,stroke:#000,color:#fff
    style H fill:#0173B2,stroke:#000,color:#fff
    style I fill:#CA9161,stroke:#000,color:#000
    style J fill:#0173B2,stroke:#000,color:#fff
public interface ProductRepositoryCustom {
    List<Product> findWithNativeQuery(String category);
    List<Map<String, Object>> getProductStatistics();
}

@Repository
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public List<Product> findWithNativeQuery(String category) {
        // => Native SQL query
        String sql = "SELECT * FROM products WHERE category = :category " +
                    "AND price > (SELECT AVG(price) FROM products) " +
                    "ORDER BY price DESC";

        Query nativeQuery = entityManager.createNativeQuery(sql, Product.class);
        // => Second parameter maps result to Product entity

        nativeQuery.setParameter("category", category);

        return nativeQuery.getResultList();
        // => Returns List<Product> populated from SQL result
    }

    @Override
    @SuppressWarnings("unchecked")
    public List<Map<String, Object>> getProductStatistics() {
        // => Native query returning custom result structure
        String sql = "SELECT category, " +
                    "COUNT(*) as product_count, " +
                    "AVG(price) as avg_price, " +
                    "MIN(price) as min_price, " +
                    "MAX(price) as max_price " +
                    "FROM products " +
                    "GROUP BY category " +
                    "ORDER BY product_count DESC";

        Query nativeQuery = entityManager.createNativeQuery(sql);
        // => No entity class: returns Object[]

        List<Object[]> results = nativeQuery.getResultList();

        // => Convert to Map for easier access
        return results.stream()
            .map(row -> {
                Map<String, Object> map = new HashMap<>();
                map.put("category", row[0]);
                map.put("productCount", ((Number) row[1]).longValue());
                map.put("avgPrice", row[2]);
                map.put("minPrice", row[3]);
                map.put("maxPrice", row[4]);
                return map;
            })
            .collect(Collectors.toList());
        // => Returns List<Map<String, Object>>
    }

    public void executeDatabaseSpecificOperation() {
        // => PostgreSQL-specific JSON query
        String sql = "UPDATE products SET metadata = " +
                    "jsonb_set(metadata, '{updated}', 'true') " +
                    "WHERE category = :category";

        entityManager.createNativeQuery(sql)
            .setParameter("category", "Electronics")
            .executeUpdate();
        // => Database-specific features available
    }
}

Key Takeaway: Use createNativeQuery(sql, EntityClass.class) for entity results or createNativeQuery(sql) for raw results. Native queries provide database-specific feature access.

Why It Matters: @EntityListeners separate audit logic from entity code, enabling cross-cutting concerns like logging, validation, and security checks through AOP-style callbacks. The listener pattern reduces entity code by 40-60% compared to embedding @PrePersist callbacks directly, improving maintainability and testability. However, entity listeners can’t easily access Spring beans (requires AspectJ weaving or manual bean lookup), limiting their use for complex audit logic requiring database access or external service calls.

Example 77: Custom Repository with QueryDSL Integration

Combine Spring Data with QueryDSL for type-safe query building.

// => Add QueryDSL dependency to pom.xml:
// <dependency>
//   <groupId>com.querydsl</groupId>
//   <artifactId>querydsl-jpa</artifactId>
// </dependency>

public interface ProductRepositoryCustom {
    List<Product> findWithQueryDSL(String keyword, BigDecimal minPrice, String category);
}

@Repository
public class ProductRepositoryCustomImpl implements ProductRepositoryCustom {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public List<Product> findWithQueryDSL(String keyword, BigDecimal minPrice, String category) {
        // => QueryDSL provides generated Q-classes for type-safety
        QProduct product = QProduct.product;
        // => Q-class generated from Product entity

        JPAQuery<Product> query = new JPAQuery<>(entityManager);
        // => Creates transient entity (not yet persisted, id=null)
        // => JPAQuery wraps EntityManager

        BooleanBuilder predicate = new BooleanBuilder();
        // => Dynamic predicate builder

        if (keyword != null && !keyword.isEmpty()) {
            predicate.and(
                product.name.containsIgnoreCase(keyword)
                    .or(product.description.containsIgnoreCase(keyword))
            );
            // => WHERE (LOWER(name) LIKE :keyword OR LOWER(description) LIKE :keyword)
            // => Type-safe: IDE autocomplete for entity fields
        }

        if (minPrice != null) {
            predicate.and(product.price.goe(minPrice));
            // => AND price >= :minPrice
            // => goe = greater or equal (type-safe method)
        }

        if (category != null && !category.isEmpty()) {
            predicate.and(product.category.eq(category));
            // => AND category = :category
        }

        return query.select(product)
            .from(product)
            .where(predicate)
            .orderBy(product.price.desc())
            .fetch();
        // => Type-safe query execution
        // => Compile-time error if field names wrong
    }
}

// => Example with joins using QueryDSL
public class OrderRepositoryCustomImpl {
    @PersistenceContext
    private EntityManager entityManager;

    public List<Order> findOrdersWithQueryDSL(String customerEmail, String productCategory) {
        QOrder order = QOrder.order;
        QCustomer customer = QCustomer.customer;
        QOrderItem orderItem = QOrderItem.orderItem;
        QProduct product = QProduct.product;

        JPAQuery<Order> query = new JPAQuery<>(entityManager);
        // => Creates transient entity (not yet persisted, id=null)

        return query.selectDistinct(order)
            .from(order)
            .join(order.customer, customer)
            .join(order.items, orderItem)
            .join(orderItem.product, product)
            .where(
                customer.email.eq(customerEmail)
                    .and(product.category.eq(productCategory))
            )
            .fetch();
        // => Type-safe joins with compile-time checking
        // => No string-based field names
    }
}

Key Takeaway: QueryDSL provides type-safe query building with generated Q-classes. Use BooleanBuilder for dynamic predicates and JPAQuery for execution.

Why It Matters: Entity lifecycle callbacks (@PrePersist, @PostLoad) enable automatic computed field population like timestamps, UUIDs, and default values without service layer boilerplate. The declarative approach ensures consistency across all persistence paths (repository.save, JPQL UPDATE, Criteria queries), preventing missing audit fields affecting 30% of manual timestamp implementations. However, callbacks can’t access lazy collections or make additional database calls without N+1 risks, requiring careful design to avoid performance degradation.

Example 78: Custom Repository Fragment Composition

Combine multiple custom repository fragments for modular functionality.

// Fragment 1: Search functionality
public interface ProductSearchFragment {
    List<Product> advancedSearch(ProductSearchCriteria criteria);
}

@Repository
public class ProductSearchFragmentImpl implements ProductSearchFragment {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public List<Product> advancedSearch(ProductSearchCriteria criteria) {
        // => Complex search implementation
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<Product> query = cb.createQuery(Product.class);
        Root<Product> root = query.from(Product.class);

        List<Predicate> predicates = buildPredicates(cb, root, criteria);
        query.where(predicates.toArray(new Predicate[0]));

        return entityManager.createQuery(query).getResultList();
    }

    private List<Predicate> buildPredicates(
        CriteriaBuilder cb,
        Root<Product> root,
        ProductSearchCriteria criteria
    ) {
        // => Predicate building logic
        List<Predicate> predicates = new ArrayList<>();
        // ... add predicates based on criteria
        return predicates;
    }
}

// Fragment 2: Batch operations
public interface ProductBatchFragment {
    void batchInsert(List<Product> products);
    void batchUpdate(List<Product> products);
}

@Repository
public class ProductBatchFragmentImpl implements ProductBatchFragment {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    @Transactional
    public void batchInsert(List<Product> products) {
        // => Batch insert implementation
        int batchSize = 50;
        for (int i = 0; i < products.size(); i++) {
            entityManager.persist(products.get(i));
            if (i > 0 && i % batchSize == 0) {
                entityManager.flush();
// => Forces immediate synchronization of persistence context to database
// => Executes pending INSERT/UPDATE/DELETE statements
// => Useful for triggering constraint violations early
                entityManager.clear();
            }
        }
        entityManager.flush();
// => Forces immediate synchronization of persistence context to database
// => Executes pending INSERT/UPDATE/DELETE statements
// => Useful for triggering constraint violations early
        entityManager.clear();
    }

    @Override
    @Transactional
    public void batchUpdate(List<Product> products) {
        // => Batch update implementation
        // ... similar to batchInsert
    }
}

// Fragment 3: Statistics
public interface ProductStatisticsFragment {
    Map<String, Object> getCategoryStatistics(String category);
}

@Repository
public class ProductStatisticsFragmentImpl implements ProductStatisticsFragment {
    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public Map<String, Object> getCategoryStatistics(String category) {
        // => Statistics query implementation
        String jpql = "SELECT COUNT(p), AVG(p.price), MIN(p.price), MAX(p.price) " +
                     "FROM Product p WHERE p.category = :category";

        Object[] result = (Object[]) entityManager.createQuery(jpql)
            .setParameter("category", category)
            .getSingleResult();

        Map<String, Object> stats = new HashMap<>();
        stats.put("count", result[0]);
        stats.put("avgPrice", result[1]);
        stats.put("minPrice", result[2]);
        stats.put("maxPrice", result[3]);
        return stats;
    }
}

// Main repository extends all fragments
public interface ProductRepository extends JpaRepository<Product, Long>,
                                           ProductSearchFragment,
                                           ProductBatchFragment,
                                           ProductStatisticsFragment {
    // => Combines standard Spring Data + all custom fragments
    // => Single repository interface with modular functionality

    List<Product> findByCategory(String category);
// => Spring derives SQL WHERE clause from method name
// => Returns List<Entity> or Optional<Entity> based on return type
    // => Standard derived queries still work
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;
    // => Single repository with all capabilities

    public List<Product> search(ProductSearchCriteria criteria) {
        return productRepository.advancedSearch(criteria);
        // => From ProductSearchFragment
    }

    public void importProducts(List<Product> products) {
        productRepository.batchInsert(products);
        // => From ProductBatchFragment
    }

    public Map<String, Object> getStats(String category) {
        return productRepository.getCategoryStatistics(category);
        // => From ProductStatisticsFragment
    }
}

Key Takeaway: Split custom functionality into multiple fragment interfaces with separate implementations. Main repository extends all fragments for modular, maintainable custom queries.

Why It Matters: @CreatedDate and @LastModifiedDate annotations with Spring Data JPA auditing eliminate 95% of manual timestamp code while ensuring timezone consistency (UTC storage) and transaction accuracy (commit time, not wall clock time). The automatic population prevents forgotten audit fields and timezone bugs that plague 40% of manual implementations. However, auditing requires @EnableJpaAuditing configuration and doesn’t work with bulk operations (@Modifying queries), requiring explicit timestamp handling for batch updates affecting audit compliance.

Example 79: JPA Auditing with Timestamps

Automatically track entity creation and modification times using JPA auditing.

  %% JPA Auditing flow
graph TD
    A[Enable JPA Auditing] --> B[@EnableJpaAuditing]
    B --> C[Entity Annotations]
    C --> D[@CreatedDate]
    C --> E[@LastModifiedDate]
    D --> F[Persist Event]
    E --> G[Update Event]
    F --> H[Auto-populate createdAt]
    G --> I[Auto-populate updatedAt]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#029E73,stroke:#000,color:#fff
    style C fill:#DE8F05,stroke:#000,color:#000
    style D fill:#CC78BC,stroke:#000,color:#000
    style E fill:#CC78BC,stroke:#000,color:#000
    style F fill:#029E73,stroke:#000,color:#fff
    style G fill:#029E73,stroke:#000,color:#fff
    style H fill:#0173B2,stroke:#000,color:#fff
    style I fill:#0173B2,stroke:#000,color:#fff
// Step 1: Enable JPA Auditing
@Configuration
@EnableJpaAuditing
public class JpaConfig {
    // => @EnableJpaAuditing activates auditing features
    // => Scans for auditing annotations on entities
}

// Step 2: Create auditable base class
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
// => Marks class as JPA entity (database table mapping)
public abstract class Auditable {
    // => @MappedSuperclass: not a table, fields inherited by subclasses
    // => @EntityListeners: registers AuditingEntityListener for callbacks

    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    // => NOT NULL constraint enforced at database level
    private LocalDateTime createdAt;
    // => @CreatedDate: auto-set on first persist
    // => updatable = false: prevents modification after creation

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    // => NOT NULL constraint enforced at database level
    private LocalDateTime updatedAt;
    // => @LastModifiedDate: auto-update on every save/update

    @Version
    private Long version;
    // => @Version: optimistic locking, increments on update

    // getters/setters
}

// Step 3: Entities extend Auditable
@Entity
// => Marks class as JPA entity (database table mapping)
public class Product extends Auditable {
    @Id @GeneratedValue
    // => Primary key field
    private Long id;
    private String name;
    private BigDecimal price;
    // => Inherits createdAt, updatedAt, version from Auditable
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public Product createProduct(String name, BigDecimal price) {
        Product product = new Product();
// => Creates TRANSIENT entity (not yet in database)
// => id field is null (will be assigned on save)
        // => Creates transient entity (not yet persisted, id=null)
        product.setName(name);
        product.setPrice(price);
        // => No need to set createdAt or updatedAt manually

        Product saved = productRepository.save(product);
// => Persists entity to database (INSERT if id=null, UPDATE if id exists)
// => Entity transitions from TRANSIENT to MANAGED state
// => Returns entity with database-assigned ID
        // => Persists entity to database (INSERT or UPDATE)
        // => Entity transitions to MANAGED state with assigned ID
        // => createdAt and updatedAt automatically populated
        // => version set to 0

        System.out.println("Created at: " + saved.getCreatedAt());
        // => Timestamp of creation
        System.out.println("Version: " + saved.getVersion());
        // => 0

        return saved;
    }

    public Product updateProduct(Long id, BigDecimal newPrice) {
        Product product = productRepository.findById(id).orElseThrow();
// => Executes SELECT by primary key
// => Returns Optional<Entity> (empty if not found)
// => Entity loaded into persistence context if found
        // => Queries database by primary key
        // => Returns Optional to handle missing records safely
        product.setPrice(newPrice);

        Product updated = productRepository.save(product);
// => Persists entity to database (INSERT if id=null, UPDATE if id exists)
// => Entity transitions from TRANSIENT to MANAGED state
// => Returns entity with database-assigned ID
        // => Persists entity to database (INSERT or UPDATE)
        // => Entity transitions to MANAGED state with assigned ID
        // => updatedAt automatically updated to current timestamp
        // => version incremented to 1, 2, 3, etc.

        System.out.println("Updated at: " + updated.getUpdatedAt());
        // => New timestamp
        System.out.println("Version: " + updated.getVersion());
        // => Incremented

        return updated;
    }
}

Key Takeaway: Use @EnableJpaAuditing and @EntityListeners(AuditingEntityListener.class) with @CreatedDate/@LastModifiedDate for automatic timestamp tracking. Extend Auditable base class for consistent auditing.

Why It Matters: @CreatedBy and @LastModifiedBy with AuditorAware integration automatically capture user context for every database change, enabling complete audit trails for compliance requirements (SOX, HIPAA, GDPR). The Spring Security integration provides zero-boilerplate user tracking, reducing security audit implementation time from weeks to hours. However, AuditorAware doesn’t support async operations or batch jobs without explicit SecurityContext propagation, requiring custom solutions for background processing where 30% of auditing implementations fail.

Example 80: JPA Auditing with User Tracking

Track which user created or modified entities using @CreatedBy and @LastModifiedBy.

  %% User auditing flow
graph TD
    A[Save/Update Entity] --> B[AuditingEntityListener]
    B --> C[Call AuditorAware]
    C --> D[getCurrentAuditor]
    D --> E{User Authenticated?}
    E -->|Yes| F[Return Username]
    E -->|No| G[Return Empty]
    F --> H[Set CreatedBy/UpdatedBy]
    G --> I[Skip User Audit]
    H --> J[Persist to Database]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#029E73,stroke:#000,color:#fff
    style C fill:#DE8F05,stroke:#000,color:#000
    style D fill:#CC78BC,stroke:#000,color:#000
    style E fill:#CC78BC,stroke:#000,color:#000
    style F fill:#029E73,stroke:#000,color:#fff
    style G fill:#CA9161,stroke:#000,color:#000
    style H fill:#029E73,stroke:#000,color:#fff
    style I fill:#CA9161,stroke:#000,color:#000
    style J fill:#0173B2,stroke:#000,color:#fff
// Step 1: Implement AuditorAware for current user detection
@Component
public class AuditorAwareImpl implements AuditorAware<String> {
    // => AuditorAware<String>: returns current user identifier as String

    @Override
    public Optional<String> getCurrentAuditor() {
        // => Called automatically by Spring Data JPA

        // Example 1: Get from Spring Security context
        Authentication authentication =
            SecurityContextHolder.getContext().getAuthentication();

        if (authentication == null || !authentication.isAuthenticated()) {
            return Optional.empty();
        }

        return Optional.of(authentication.getName());
        // => Returns username from security context

        // Example 2: Get from HTTP request (web context)
        // HttpServletRequest request = ...;
        // String username = (String) request.getAttribute("username");
        // return Optional.ofNullable(username);

        // Example 3: Fixed user for testing
        // return Optional.of("system");
    }
}

// Step 2: Enable JPA Auditing with auditor aware
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAwareImpl")
public class JpaConfig {
    // => auditorAwareRef: references AuditorAware bean name
}

// Step 3: Auditable base class with user tracking
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
// => Marks class as JPA entity (database table mapping)
public abstract class Auditable {
    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    // => NOT NULL constraint enforced at database level
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    // => NOT NULL constraint enforced at database level
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(name = "created_by", nullable = false, updatable = false)
    // => NOT NULL constraint enforced at database level
    private String createdBy;
    // => @CreatedBy: auto-set to current user on first persist

    @LastModifiedBy
    @Column(name = "updated_by", nullable = false)
    // => NOT NULL constraint enforced at database level
    private String updatedBy;
    // => @LastModifiedBy: auto-update to current user on every save

    @Version
    private Long version;

    // getters/setters
}

@Entity
// => Marks class as JPA entity (database table mapping)
public class Product extends Auditable {
    @Id @GeneratedValue
    // => Primary key field
    private Long id;
    private String name;
    private BigDecimal price;
    // => Inherits createdAt, updatedAt, createdBy, updatedBy, version
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public Product createProduct(String name, BigDecimal price) {
        // Assume current user is "john.doe" from security context

        Product product = new Product();
// => Creates TRANSIENT entity (not yet in database)
// => id field is null (will be assigned on save)
        // => Creates transient entity (not yet persisted, id=null)
        product.setName(name);
        product.setPrice(price);

        Product saved = productRepository.save(product);
// => Persists entity to database (INSERT if id=null, UPDATE if id exists)
// => Entity transitions from TRANSIENT to MANAGED state
// => Returns entity with database-assigned ID
        // => Persists entity to database (INSERT or UPDATE)
        // => Entity transitions to MANAGED state with assigned ID
        // => createdBy automatically set to "john.doe"
        // => updatedBy automatically set to "john.doe"

        System.out.println("Created by: " + saved.getCreatedBy());
        // => "john.doe"
        System.out.println("Updated by: " + saved.getUpdatedBy());
        // => "john.doe"

        return saved;
    }

    public Product updateProduct(Long id, BigDecimal newPrice) {
        // Assume current user is "jane.smith" from security context

        Product product = productRepository.findById(id).orElseThrow();
// => Executes SELECT by primary key
// => Returns Optional<Entity> (empty if not found)
// => Entity loaded into persistence context if found
        // => Queries database by primary key
        // => Returns Optional to handle missing records safely
        product.setPrice(newPrice);

        Product updated = productRepository.save(product);
// => Persists entity to database (INSERT if id=null, UPDATE if id exists)
// => Entity transitions from TRANSIENT to MANAGED state
// => Returns entity with database-assigned ID
        // => Persists entity to database (INSERT or UPDATE)
        // => Entity transitions to MANAGED state with assigned ID
        // => createdBy remains "john.doe" (updatable = false)
        // => updatedBy automatically updated to "jane.smith"

        System.out.println("Created by: " + updated.getCreatedBy());
        // => Still "john.doe"
        System.out.println("Updated by: " + updated.getUpdatedBy());
        // => Now "jane.smith"

        return updated;
    }
}

Key Takeaway: Implement AuditorAware<T> to provide current user identifier. Use @CreatedBy/@LastModifiedBy with @EnableJpaAuditing(auditorAwareRef) for automatic user tracking.

Why It Matters: DTO projections with interface proxies eliminate DTO class boilerplate through Spring Data’s automatic proxy generation, reducing code by 80% while maintaining type safety. The getter-only interface approach prevents accidental entity state modifications while loading subset of columns, improving read performance by 40-70%. However, interface projections can’t be serialized easily (JSON serialization requires Jackson mix-ins) and don’t support nested property access without additional joins, limiting their use to simple flat projections.

Example 81: Entity Lifecycle Callbacks with @EntityListeners

Execute custom logic during entity lifecycle events using JPA callbacks.

  %% Entity lifecycle callback sequence
graph TD
    A[Entity Operation] --> B{Operation Type}
    B -->|Create| C[@PrePersist]
    B -->|Update| D[@PreUpdate]
    B -->|Delete| E[@PreRemove]
    B -->|Load| F[@PostLoad]
    C --> G[INSERT]
    D --> H[UPDATE]
    E --> I[DELETE]
    G --> J[@PostPersist]
    H --> K[@PostUpdate]
    I --> L[@PostRemove]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#CC78BC,stroke:#000,color:#000
    style C fill:#DE8F05,stroke:#000,color:#000
    style D fill:#DE8F05,stroke:#000,color:#000
    style E fill:#DE8F05,stroke:#000,color:#000
    style F fill:#029E73,stroke:#000,color:#fff
    style G fill:#029E73,stroke:#000,color:#fff
    style H fill:#029E73,stroke:#000,color:#fff
    style I fill:#029E73,stroke:#000,color:#fff
    style J fill:#0173B2,stroke:#000,color:#fff
    style K fill:#0173B2,stroke:#000,color:#fff
    style L fill:#0173B2,stroke:#000,color:#fff
// Custom entity listener
public class ProductEntityListener {
    // => Plain Java class, no Spring annotations needed

    @PrePersist
    public void prePersist(Product product) {
        // => Called BEFORE entity first persisted to database
        System.out.println("Before saving new product: " + product.getName());

        if (product.getSku() == null) {
            product.setSku(generateSku());
            // => Auto-generate SKU if not provided
        }

        product.setCreatedAt(LocalDateTime.now());
        // => Manual timestamp (alternative to @CreatedDate)
    }

    @PostPersist
    public void postPersist(Product product) {
        // => Called AFTER entity persisted (has ID now)
        System.out.println("Product saved with ID: " + product.getId());

        // Example: Send notification, trigger event, update cache
        sendNotification("Product created: " + product.getName());
    }

    @PreUpdate
    public void preUpdate(Product product) {
        // => Called BEFORE entity updated in database
        System.out.println("Before updating product: " + product.getId());

        product.setUpdatedAt(LocalDateTime.now());
        // => Manual timestamp update
    }

    @PostUpdate
    public void postUpdate(Product product) {
        // => Called AFTER entity updated
        System.out.println("Product updated: " + product.getId());

        // Example: Clear cache, log change
        clearCache(product.getId());
    }

    @PreRemove
    public void preRemove(Product product) {
        // => Called BEFORE entity deleted
        System.out.println("Before deleting product: " + product.getId());

        // Example: Soft delete instead of hard delete
        product.setActive(false);
    }

    @PostRemove
    public void postRemove(Product product) {
        // => Called AFTER entity deleted
        System.out.println("Product deleted: " + product.getId());
    }

    @PostLoad
    public void postLoad(Product product) {
        // => Called AFTER entity loaded from database
        System.out.println("Product loaded: " + product.getId());

        // Example: Decrypt sensitive fields, calculate derived properties
        if (product.getPrice() != null) {
            product.setDisplayPrice(formatPrice(product.getPrice()));
        }
    }

    private String generateSku() {
        return "SKU-" + UUID.randomUUID().toString().substring(0, 8);
    }

    private void sendNotification(String message) {
        // Notification logic
    }

    private void clearCache(Long id) {
        // Cache clearing logic
    }

    private String formatPrice(BigDecimal price) {
        return "$" + price.toString();
    }
}

// Entity with listener
@Entity
// => Marks class as JPA entity (database table mapping)
@EntityListeners(ProductEntityListener.class)
// => Marks class as JPA entity (database table mapping)
public class Product {
    // => @EntityListeners: registers custom listener

    @Id @GeneratedValue
    // => Primary key field
    private Long id;
    private String name;
    private String sku;
    private BigDecimal price;
    private boolean active = true;

    @Transient
    private String displayPrice;
    // => @Transient: not persisted, calculated in @PostLoad

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // getters/setters
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public Product createProduct(String name, BigDecimal price) {
        Product product = new Product();
// => Creates TRANSIENT entity (not yet in database)
// => id field is null (will be assigned on save)
        // => Creates transient entity (not yet persisted, id=null)
        product.setName(name);
        product.setPrice(price);

        // Lifecycle events:
        // 1. @PrePersist: SKU generated, createdAt set
        // 2. INSERT executed
        // 3. @PostPersist: notification sent

        return productRepository.save(product);
    }

    public Product updateProduct(Long id, BigDecimal newPrice) {
        Product product = productRepository.findById(id).orElseThrow();
// => Executes SELECT by primary key
// => Returns Optional<Entity> (empty if not found)
// => Entity loaded into persistence context if found
        // => Queries database by primary key
        // => Returns Optional to handle missing records safely
        // => @PostLoad: displayPrice calculated

        product.setPrice(newPrice);

        // Lifecycle events:
        // 1. @PreUpdate: updatedAt set
        // 2. UPDATE executed
        // 3. @PostUpdate: cache cleared

        return productRepository.save(product);
    }

    public void deleteProduct(Long id) {
        Product product = productRepository.findById(id).orElseThrow();
// => Executes SELECT by primary key
// => Returns Optional<Entity> (empty if not found)
// => Entity loaded into persistence context if found
        // => Queries database by primary key
        // => Returns Optional to handle missing records safely

        // Lifecycle events:
        // 1. @PreRemove: active set to false
        // 2. DELETE executed (or UPDATE if soft delete)
        // 3. @PostRemove: logged

        productRepository.delete(product);
// => Single DELETE operation (more efficient than deleteById)
// => Uses entity ID directly, no SELECT needed
    }
}

Key Takeaway: Use @EntityListeners with lifecycle annotations (@PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove, @PostLoad) for custom logic during entity lifecycle events.

Why It Matters: Class-based DTO projections with constructor expressions provide full control over result mapping including computed fields, type conversions, and nested objects, enabling rich read models for complex UIs. The pattern supports Java records for immutable DTOs with minimal boilerplate, improving null-safety and serialization performance. However, constructor projections require full package names in JPQL and manual maintenance when entity attributes change, increasing coupling between queries and entity schema affecting refactoring safety.

Example 82: Combining Auditing with Custom Callbacks

Merge Spring Data JPA auditing with custom entity listeners for comprehensive tracking.

// Combined auditable base class
@MappedSuperclass
@EntityListeners({AuditingEntityListener.class, AuditableEntityListener.class})
// => Marks class as JPA entity (database table mapping)
public abstract class Auditable {
    // => Multiple listeners: Spring auditing + custom logic

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedBy
    private String updatedBy;

    @Version
    private Long version;

    @Column(name = "change_log")
    private String changeLog;
    // => Track change descriptions

    // getters/setters
}

// Custom listener for change tracking
public class AuditableEntityListener {
    @PreUpdate
    public void logChange(Auditable auditable) {
        // => Build change log before update
        String log = "Modified at " + LocalDateTime.now() +
                    " by " + getCurrentUser() +
                    " (version " + (auditable.getVersion() + 1) + ")";

        auditable.setChangeLog(log);
        // => Store change description
    }

    @PostPersist
    public void onCreateComplete(Auditable auditable) {
        System.out.println("Audit: Entity created - " +
            auditable.getClass().getSimpleName() +
            " by " + auditable.getCreatedBy());
    }

    @PostUpdate
    public void onUpdateComplete(Auditable auditable) {
        System.out.println("Audit: Entity updated - " +
            auditable.getClass().getSimpleName() +
            " by " + auditable.getUpdatedBy());
    }

    private String getCurrentUser() {
        Authentication auth =
            SecurityContextHolder.getContext().getAuthentication();
        return auth != null ? auth.getName() : "system";
    }
}

@Entity
// => Marks class as JPA entity (database table mapping)
public class Product extends Auditable {
    @Id @GeneratedValue
    // => Primary key field
    private Long id;
    private String name;
    private BigDecimal price;
    // => Inherits auditing fields and both listeners
}

@Service
public class ProductService {
    public Product updateProductWithAudit(Long id, BigDecimal newPrice) {
        Product product = productRepository.findById(id).orElseThrow();
// => Executes SELECT by primary key
// => Returns Optional<Entity> (empty if not found)
// => Entity loaded into persistence context if found
        // => Queries database by primary key
        // => Returns Optional to handle missing records safely
        product.setPrice(newPrice);

        Product updated = productRepository.save(product);
// => Persists entity to database (INSERT if id=null, UPDATE if id exists)
// => Entity transitions from TRANSIENT to MANAGED state
// => Returns entity with database-assigned ID
        // => Persists entity to database (INSERT or UPDATE)
        // => Entity transitions to MANAGED state with assigned ID
        // => Execution order:
        // 1. AuditableEntityListener @PreUpdate: changeLog set
        // 2. AuditingEntityListener: updatedAt, updatedBy set
        // 3. UPDATE executed
        // 4. AuditableEntityListener @PostUpdate: audit log printed
        // 5. Version incremented by @Version

        System.out.println("Change log: " + updated.getChangeLog());
        System.out.println("Updated at: " + updated.getUpdatedAt());
        System.out.println("Updated by: " + updated.getUpdatedBy());
        System.out.println("Version: " + updated.getVersion());

        return updated;
    }
}

Key Takeaway: Combine AuditingEntityListener with custom listeners in @EntityListeners array for comprehensive auditing. Spring auditing handles timestamps/users, custom listeners add business logic.

Why It Matters: @EntityGraph solves N+1 problems through declarative relationship loading specification, providing cleaner syntax than JOIN FETCH and enabling multiple entity graphs per entity. The attributePaths approach loads specific relationships on-demand without changing default fetch types, improving flexibility for different use cases (list view vs detail view). However, @EntityGraph with multiple collections causes Cartesian products and MultiBagFetchException, requiring multiple queries or graph grouping strategies, adding complexity affecting 40% of EntityGraph usage.

Example 83: Interface-Based Projections for Performance

Use interface projections to load only required fields, reducing memory and database load.

// Closed projection interface (only specified fields loaded)
public interface ProductSummary {
    // => Getters define which fields to fetch
    String getName();
    BigDecimal getPrice();
    String getCategory();
    // => Only name, price, category fetched from database
    // => SELECT name, price, category FROM products
}

// Open projection with SpEL (computed fields)
public interface ProductWithDiscount {
    String getName();
    BigDecimal getPrice();

    @Value("#{target.price * 0.9}")
    BigDecimal getDiscountedPrice();
    // => @Value with SpEL: computed from target (entity)
    // => Requires full entity load
}

// Projection with nested objects
public interface OrderSummary {
    Long getId();
    LocalDateTime getOrderDate();

    CustomerSummary getCustomer();
    // => Nested projection

    interface CustomerSummary {
        String getEmail();
        String getTier();
    }
}

public interface ProductRepository extends JpaRepository<Product, Long> {
    // => Return projection instead of entity
    List<ProductSummary> findByCategory(String category);
// => Spring derives SQL WHERE clause from method name
// => Returns List<Entity> or Optional<Entity> based on return type
    // => SELECT name, price, category FROM products WHERE category = :category
    // => Much more efficient than loading full Product entities

    ProductSummary findProjectedById(Long id);
    // => Single projection

    List<ProductWithDiscount> findAllProjectedBy();
    // => Loads all products with computed discount
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public List<ProductSummary> getProductSummaries(String category) {
        // => Returns lightweight projections
        List<ProductSummary> summaries =
            productRepository.findByCategory(category);
// => Spring derives SQL WHERE clause from method name
// => Returns List<Entity> or Optional<Entity> based on return type

        for (ProductSummary summary : summaries) {
            System.out.println(summary.getName() + ": $" + summary.getPrice());
            // => Only name, price, category in memory
            // => No lazy loading triggers
        }

        return summaries;
    }

    public void compareSizes() {
        // Full entity load
        List<Product> products = productRepository.findAll();
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
        // => Fetches all records from table
        // => Returns List (never null, empty if no records)
        // => Loads all fields: id, name, price, category, description,
        //    active, createdAt, updatedAt, etc.
        // => Large memory footprint for large datasets

        // Projection load
        List<ProductSummary> summaries = productRepository.findAllProjectedBy();
        // => Loads only: name, price, category
        // => 50-70% memory reduction typical
    }
}

Key Takeaway: Use interface projections to fetch only required fields. Closed projections (getters only) are most efficient. Open projections with @Value require full entity load.

Why It Matters: Query hints like HINT_FETCH_SIZE and HINT_READ_ONLY provide low-level JDBC and Hibernate tuning for specific performance scenarios, enabling 10-100x throughput improvements for batch processing. The hint mechanism accesses database-specific optimizations (Oracle hints, PostgreSQL work_mem) while maintaining JPA abstraction, balancing portability and performance. However, hints are often database-vendor-specific and require deep SQL knowledge to use correctly - improper hints (too-large fetch size) can cause OutOfMemoryError, making hints advanced optimization requiring profiling.

Example 84: DTO Projections with Constructor Expressions

Use constructor-based DTO projections for better performance and type safety.

// DTO class with constructor
public class ProductDTO {
    private final String name;
    private final BigDecimal price;
    private final String category;

    // => Constructor matching query projection
    public ProductDTO(String name, BigDecimal price, String category) {
        this.name = name;
        this.price = price;
        this.category = category;
    }

    // getters only (immutable DTO)
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public String getCategory() { return category; }
}

public interface ProductRepository extends JpaRepository<Product, Long> {
    // => Constructor expression in JPQL
    @Query("SELECT new com.example.ProductDTO(p.name, p.price, p.category) " +
           "FROM Product p WHERE p.active = true")
    List<ProductDTO> findActiveProductDTOs();
    // => SELECT name, price, category FROM products WHERE active = true
    // => Results directly instantiated as ProductDTO
    // => No entity instantiation overhead

    @Query("SELECT new com.example.ProductDTO(p.name, p.price, p.category) " +
           "FROM Product p WHERE p.price >= :minPrice ORDER BY p.price DESC")
    List<ProductDTO> findProductDTOsAbovePrice(@Param("minPrice") BigDecimal minPrice);

    // => Constructor projection with joins
    @Query("SELECT new com.example.OrderDTO(o.id, o.orderDate, c.email, c.tier) " +
           "FROM Order o JOIN o.customer c WHERE o.status = :status")
    List<OrderDTO> findOrderDTOsByStatus(@Param("status") String status);
    // => SELECT o.id, o.order_date, c.email, c.tier
    //    FROM orders o JOIN customers c WHERE o.status = :status
}

// Complex DTO with aggregation
public class CategoryStatisticsDTO {
    private final String category;
    private final Long productCount;
    private final BigDecimal avgPrice;
    private final BigDecimal minPrice;
    private final BigDecimal maxPrice;

    public CategoryStatisticsDTO(
        String category,
        Long productCount,
        BigDecimal avgPrice,
        BigDecimal minPrice,
        BigDecimal maxPrice
    ) {
        this.category = category;
        this.productCount = productCount;
        this.avgPrice = avgPrice;
        this.minPrice = minPrice;
        this.maxPrice = maxPrice;
    }

    // getters
}

public interface ProductRepository extends JpaRepository<Product, Long> {
    @Query("SELECT new com.example.CategoryStatisticsDTO(" +
           "p.category, COUNT(p), AVG(p.price), MIN(p.price), MAX(p.price)) " +
           "FROM Product p GROUP BY p.category")
    List<CategoryStatisticsDTO> getCategoryStatistics();
    // => Aggregation results directly as DTOs
}

@Service
public class ProductService {
    @Autowired
    private ProductRepository productRepository;

    public List<ProductDTO> getActiveProductDTOs() {
        // => Returns DTOs, not entities
        List<ProductDTO> dtos = productRepository.findActiveProductDTOs();
        // => Lightweight, immutable, no persistence context overhead

        for (ProductDTO dto : dtos) {
            System.out.println(dto.getName() + ": $" + dto.getPrice());
            // => No risk of lazy loading exceptions
            // => DTOs safe to pass across layers
        }

        return dtos;
    }

    public List<CategoryStatisticsDTO> getStatistics() {
        return productRepository.getCategoryStatistics();
        // => Aggregated data as DTOs for reporting/analytics
    }
}

Key Takeaway: Use new com.example.DTO(...) in JPQL for constructor-based projections. DTOs are immutable, layer-safe, and avoid persistence context overhead. Ideal for read-only data transfer.

Why It Matters: Batch fetching with @BatchSize reduces N+1 queries to 1+ceiling(N/batch_size) queries through IN clause batching, dramatically improving performance for lazy collection loading. The pattern works transparently without JOIN FETCH complexity, balancing eager loading benefits with lazy loading memory efficiency. However, @BatchSize generates IN clauses with 10-50 IDs that may hit database query plan cache limits or exceed maximum IN clause size (Oracle: 1000 items), requiring tuning for specific databases and query patterns.

Example 85: Query Hints and Batch Fetching for Performance

Optimize query performance using JPA query hints and batch fetching strategies.

  %% Fetch strategies comparison
graph TD
    A[Fetch Strategy] --> B[EAGER N+1 Problem]
    A --> C[LAZY On-Demand]
    A --> D[BATCH Fetching]
    A --> E[JOIN FETCH]
    B --> F[1 + N Queries]
    C --> G[1 + N Queries on Access]
    D --> H[1 + N/batchSize Queries]
    E --> I[1 Query with JOIN]

    style A fill:#0173B2,stroke:#000,color:#fff
    style B fill:#CA9161,stroke:#000,color:#000
    style C fill:#CA9161,stroke:#000,color:#000
    style D fill:#029E73,stroke:#000,color:#fff
    style E fill:#029E73,stroke:#000,color:#fff
    style F fill:#DE8F05,stroke:#000,color:#000
    style G fill:#DE8F05,stroke:#000,color:#000
    style H fill:#CC78BC,stroke:#000,color:#000
    style I fill:#0173B2,stroke:#000,color:#fff
public interface OrderRepository extends JpaRepository<Order, Long> {
    // => Query hint for read-only queries (performance boost)
    @QueryHints(@QueryHint(name = "org.hibernate.readOnly", value = "true"))
    @Query("SELECT o FROM Order o WHERE o.status = :status")
    List<Order> findReadOnlyOrders(@Param("status") String status);
    // => Read-only: no dirty checking, faster query execution

    // => Fetch JOIN to avoid N+1 problem
    @Query("SELECT o FROM Order o JOIN FETCH o.customer WHERE o.status = :status")
    List<Order> findOrdersWithCustomer(@Param("status") String status);
    // => Single query with JOIN
    // => SELECT * FROM orders o JOIN customers c WHERE o.status = :status

    // => Multiple fetch joins (careful with cartesian product)
    @Query("SELECT DISTINCT o FROM Order o " +
           "JOIN FETCH o.customer " +
           "JOIN FETCH o.items " +
           "WHERE o.status = :status")
    List<Order> findOrdersWithCustomerAndItems(@Param("status") String status);
    // => DISTINCT prevents duplicate Order objects
    // => Use cautiously: large result sets possible

    // => Query hint for batch size
    @QueryHints(@QueryHint(name = "org.hibernate.fetchSize", value = "50"))
    @Query("SELECT o FROM Order o")
    List<Order> findAllWithBatchHint();
    // => Fetch 50 rows at a time from database
}

// Entity with batch fetching configuration
@Entity
// => Marks class as JPA entity (database table mapping)
public class Order {
    @Id @GeneratedValue
    // => Primary key field
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    // => Defines entity relationship for foreign key mapping
    @JoinColumn(name = "customer_id")
    // => Foreign key column: customer_id
    @BatchSize(size = 10)
    private Customer customer;
    // => @BatchSize: batch-fetch customers in groups of 10
    // => When accessing order.getCustomer(), loads up to 10 customers at once
    // => Reduces N+1 to 1 + (N/10) queries

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    // => Defines entity relationship for foreign key mapping
    @BatchSize(size = 10)
    private List<OrderItem> items;
    // => Batch-fetch items in groups of 10
}

@Service
public class OrderService {
    @Autowired
    private OrderRepository orderRepository;

    public void demonstrateN1Problem() {
        // N+1 problem example
        List<Order> orders = orderRepository.findAll();
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
        // => Fetches all records from table
        // => Returns List (never null, empty if no records)
        // => 1 query: SELECT * FROM orders

        for (Order order : orders) {
            System.out.println(order.getCustomer().getEmail());
            // => N queries: SELECT * FROM customers WHERE id = ?
            // => Total: 1 + N queries (BAD!)
        }
    }

    public void demonstrateBatchFetching() {
        // With @BatchSize(10)
        List<Order> orders = orderRepository.findAll();
// => Executes SELECT * FROM table
// => Loads ALL records into memory (dangerous for large tables)
// => Returns List<Entity> (never null, empty list if no records)
        // => Fetches all records from table
        // => Returns List (never null, empty if no records)
        // => 1 query: SELECT * FROM orders

        for (Order order : orders) {
            System.out.println(order.getCustomer().getEmail());
            // => Batch queries: SELECT * FROM customers WHERE id IN (?, ?, ... ?)
            // => Total: 1 + (N/10) queries (BETTER!)
        }
    }

    public void demonstrateJoinFetch() {
        // With JOIN FETCH
        List<Order> orders = orderRepository.findOrdersWithCustomer("ACTIVE");
        // => 1 query with JOIN: SELECT * FROM orders o
        //    JOIN customers c ON o.customer_id = c.id WHERE o.status = 'ACTIVE'

        for (Order order : orders) {
            System.out.println(order.getCustomer().getEmail());
            // => No additional queries! Customer already loaded (BEST!)
        }
    }

    @Transactional(readOnly = true)
    public List<Order> getReadOnlyOrders() {
        // => Read-only transaction hint
        return orderRepository.findReadOnlyOrders("COMPLETED");
        // => No dirty checking = faster performance
        // => Do NOT modify returned entities
    }
}

// Application properties for global batch fetching
// application.properties:
// spring.jpa.properties.hibernate.default_batch_fetch_size=16
// => Global batch size for all @BatchSize entities

// spring.jpa.properties.hibernate.jdbc.batch_size=20
// => Batch INSERT/UPDATE statements (20 per batch)

// spring.jpa.properties.hibernate.order_inserts=true
// spring.jpa.properties.hibernate.order_updates=true
// => Group INSERT/UPDATE by entity type for better batching

Key Takeaway: Use JOIN FETCH for eager loading related entities in single query. Apply @BatchSize to reduce N+1 queries for lazy associations. Use @QueryHints with readOnly=true for read-only queries to skip dirty checking.

Why It Matters: The N+1 query problem degrades performance exponentially - loading 100 orders with eager customer access executes 101 queries (1 + 100), taking 5-10 seconds versus 50ms with JOIN FETCH. @BatchSize reduces this to 1 + (N/batchSize) queries, providing 10x improvement for lazy-loaded associations while maintaining lazy loading benefits. Read-only query hints eliminate dirty checking overhead, improving read throughput by 20-30% in reporting-heavy applications - GitHub uses batch fetching extensively to serve repository data with sub-100ms response times despite billions of relationships.

Last updated