Back to Blog

Advanced Spring Data JPA Techniques for Enterprise Applications

32 min read
Spring BootJPADatabasePerformance

Advanced Spring Data JPA Techniques for Enterprise Applications

Spring Data JPA has revolutionized how Java developers interact with relational databases by providing a powerful abstraction layer that significantly reduces boilerplate code. While the basics of Spring Data JPA are straightforward, enterprise applications often require more advanced techniques to handle complex data access patterns, optimize performance, and maintain code quality.

Custom Repositories and Query Methods

Beyond Basic Repositories

Spring Data JPA's repository interfaces provide many built-in methods, but enterprise applications often need custom query methods:

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // Find products by category with pagination
    Page<Product> findByCategoryOrderByNameAsc(String category, Pageable pageable);
    
    // Find products by price range
    List<Product> findByPriceBetween(BigDecimal minPrice, BigDecimal maxPrice);
    
    // Find products by multiple criteria
    List<Product> findByCategoryAndPriceGreaterThanAndAvailableTrue(
        String category, BigDecimal minPrice);
    
    // Custom query with JPQL
    @Query("SELECT p FROM Product p WHERE p.category = :category AND p.price > :price")
    List<Product> findProductsByCategoryAndPrice(
        @Param("category") String category, 
        @Param("price") BigDecimal price);
    
    // Native SQL query
    @Query(value = "SELECT * FROM products WHERE LOWER(name) LIKE LOWER(CONCAT('%', :keyword, '%'))", 
           nativeQuery = true)
    List<Product> searchProductsByName(@Param("keyword") String keyword);
}

Custom Repository Implementations

For complex queries or operations that can't be expressed through method names or annotations:

public interface ProductRepositoryCustom {
    List<Product> findProductsOnSale(String category, BigDecimal discountThreshold);
    Map<String, List<Product>> groupProductsByCategory();
}

public class ProductRepositoryImpl implements ProductRepositoryCustom {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Override
    public List<Product> findProductsOnSale(String category, BigDecimal discountThreshold) {
        String jpql = "SELECT p FROM Product p " +
                      "WHERE p.category = :category " +
                      "AND p.discountPercentage > :threshold " +
                      "AND p.available = true " +
                      "ORDER BY p.discountPercentage DESC";
                      
        return entityManager.createQuery(jpql, Product.class)
            .setParameter("category", category)
            .setParameter("threshold", discountThreshold)
            .getResultList();
    }
    
    @Override
    public Map<String, List<Product>> groupProductsByCategory() {
        List<Product> products = entityManager.createQuery(
            "SELECT p FROM Product p", Product.class).getResultList();
            
        return products.stream()
            .collect(Collectors.groupingBy(Product::getCategory));
    }
}

// Combined repository interface
public interface ProductRepository extends 
    JpaRepository<Product, Long>, 
    ProductRepositoryCustom {
    // Standard query methods here
}

Projections and DTOs

Interface-based Projections

Use projections to retrieve only the data you need:

public interface ProductSummary {
    Long getId();
    String getName();
    BigDecimal getPrice();
    
    // Computed attribute
    @Value("#{target.name + ' - ' + target.category}")
    String getDisplayName();
}

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<ProductSummary> findByCategory(String category);
    
    // Dynamic projections
    <T> List<T> findByCategory(String category, Class<T> type);
}

// Usage
List<ProductSummary> summaries = productRepository.findByCategory("Electronics");

// Dynamic projection
List<ProductNameOnly> names = productRepository.findByCategory("Electronics", ProductNameOnly.class);

Class-based DTOs with Constructor Expressions

For more complex projections:

@Data
@AllArgsConstructor
public class ProductDTO {
    private Long id;
    private String name;
    private BigDecimal price;
    private String category;
    private int stockLevel;
}

public interface ProductRepository extends JpaRepository<Product, Long> {
    @Query("SELECT new com.example.dto.ProductDTO(p.id, p.name, p.price, p.category, p.stockLevel) " +
           "FROM Product p WHERE p.category = :category")
    List<ProductDTO> findProductDTOsByCategory(@Param("category") String category);
}

Specifications and Dynamic Queries

Using JPA Criteria API with Specifications

For dynamic query building:

public interface ProductRepository extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
    // Standard methods plus specification support
}

// Product filter class
@Data
public class ProductFilter {
    private String name;
    private String category;
    private BigDecimal minPrice;
    private BigDecimal maxPrice;
    private Boolean available;
}

// Specification builder
public class ProductSpecifications {
    
    public static Specification<Product> withFilter(ProductFilter filter) {
        return Specification
            .where(nameContains(filter.getName()))
            .and(categoryEquals(filter.getCategory()))
            .and(priceGreaterThanOrEqual(filter.getMinPrice()))
            .and(priceLessThanOrEqual(filter.getMaxPrice()))
            .and(isAvailable(filter.getAvailable()));
    }
    
    public static Specification<Product> nameContains(String name) {
        return (root, query, cb) -> {
            if (name == null || name.isEmpty()) {
                return cb.conjunction();
            }
            return cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
        };
    }
    
    public static Specification<Product> categoryEquals(String category) {
        return (root, query, cb) -> {
            if (category == null || category.isEmpty()) {
                return cb.conjunction();
            }
            return cb.equal(root.get("category"), category);
        };
    }
    
    public static Specification<Product> priceGreaterThanOrEqual(BigDecimal price) {
        return (root, query, cb) -> {
            if (price == null) {
                return cb.conjunction();
            }
            return cb.greaterThanOrEqualTo(root.get("price"), price);
        };
    }
    
    public static Specification<Product> priceLessThanOrEqual(BigDecimal price) {
        return (root, query, cb) -> {
            if (price == null) {
                return cb.conjunction();
            }
            return cb.lessThanOrEqualTo(root.get("price"), price);
        };
    }
    
    public static Specification<Product> isAvailable(Boolean available) {
        return (root, query, cb) -> {
            if (available == null) {
                return cb.conjunction();
            }
            return cb.equal(root.get("available"), available);
        };
    }
}

// Service using specifications
@Service
public class ProductService {
    
    private final ProductRepository productRepository;
    
    // Constructor
    
    public Page<Product> findProducts(ProductFilter filter, Pageable pageable) {
        Specification<Product> spec = ProductSpecifications.withFilter(filter);
        return productRepository.findAll(spec, pageable);
    }
}

QueryDSL Integration

For type-safe queries:

// Add QueryDSL dependencies and plugin to generate Q classes

public interface ProductRepository extends 
    JpaRepository<Product, Long>, 
    QuerydslPredicateExecutor<Product> {
    // Standard methods plus QueryDSL support
}

@Service
public class ProductService {
    
    private final ProductRepository productRepository;
    
    // Constructor
    
    public List<Product> findProductsWithQueryDSL(ProductFilter filter) {
        QProduct product = QProduct.product;
        
        BooleanBuilder predicate = new BooleanBuilder();
        
        if (filter.getName() != null && !filter.getName().isEmpty()) {
            predicate.and(product.name.containsIgnoreCase(filter.getName()));
        }
        
        if (filter.getCategory() != null && !filter.getCategory().isEmpty()) {
            predicate.and(product.category.eq(filter.getCategory()));
        }
        
        if (filter.getMinPrice() != null) {
            predicate.and(product.price.goe(filter.getMinPrice()));
        }
        
        if (filter.getMaxPrice() != null) {
            predicate.and(product.price.loe(filter.getMaxPrice()));
        }
        
        if (filter.getAvailable() != null) {
            predicate.and(product.available.eq(filter.getAvailable()));
        }
        
        return (List<Product>) productRepository.findAll(predicate);
    }
}

Auditing and Versioning

JPA Auditing

Track entity changes automatically:

@Configuration
@EnableJpaAuditing
public class JpaConfig {
    
    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> Optional.ofNullable(SecurityContextHolder.getContext())
            .map(SecurityContext::getAuthentication)
            .filter(Authentication::isAuthenticated)
            .map(Authentication::getName);
    }
}

@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Data
public abstract class Auditable {
    
    @CreatedBy
    @Column(nullable = false, updatable = false)
    private String createdBy;
    
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdDate;
    
    @LastModifiedBy
    @Column(nullable = false)
    private String lastModifiedBy;
    
    @LastModifiedDate
    @Column(nullable = false)
    private Instant lastModifiedDate;
}

@Entity
@Table(name = "products")
@Data
@EqualsAndHashCode(callSuper = true)
public class Product extends Auditable {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private BigDecimal price;
    
    private String category;
    
    private boolean available = true;
    
    @Version
    private Long version;
}

Envers for Historical Data

Track historical changes to entities:

// Add Spring Data Envers dependency

@Configuration
@EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class)
public class EnversConfig {
    // Configuration for Envers
}

@Entity
@Table(name = "products")
@Audited
@Data
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private BigDecimal price;
    
    private String category;
    
    private boolean available = true;
    
    @NotAudited
    @OneToMany(mappedBy = "product")
    private List<Review> reviews;
}

public interface ProductRepository extends 
    JpaRepository<Product, Long>, 
    RevisionRepository<Product, Long, Integer> {
    // Standard methods plus Envers support
}

@Service
public class ProductService {
    
    private final ProductRepository productRepository;
    
    // Constructor
    
    public List<Revision<Integer, Product>> getProductHistory(Long productId) {
        return productRepository.findRevisions(productId).getContent();
    }
    
    public Product getProductAtRevision(Long productId, Integer revisionNumber) {
        return productRepository.findRevision(productId, revisionNumber)
            .orElseThrow(() -> new EntityNotFoundException("Revision not found"));
    }
}

Performance Optimization

Fetch Strategies

Optimize data loading with appropriate fetch strategies:

@Entity
@Table(name = "products")
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // Other fields
    
    // Lazy loading for collections (default)
    @OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
    private List<Review> reviews;
    
    // Eager loading for frequently accessed associations
    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "category_id")
    private Category category;
    
    // Batch fetching for collections
    @BatchSize(size = 20)
    @OneToMany(mappedBy = "product")
    private List<ProductAttribute> attributes;
}

Entity Graphs

Define fetch plans for specific use cases:

@Entity
@Table(name = "products")
@NamedEntityGraph(
    name = "Product.withCategoryAndAttributes",
    attributeNodes = {
        @NamedAttributeNode("category"),
        @NamedAttributeNode("attributes")
    }
)
public class Product {
    // Entity definition
}

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // Use named entity graph
    @EntityGraph(value = "Product.withCategoryAndAttributes")
    List<Product> findByCategory(String category);
    
    // Dynamic entity graph
    @EntityGraph(attributePaths = {"category", "attributes"})
    Optional<Product> findById(Long id);
    
    // Different entity graph for different use case
    @EntityGraph(attributePaths = {"reviews", "reviews.user"})
    Optional<Product> findWithReviewsById(Long id);
}

Query Optimization

Optimize queries for better performance:

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    // Use countQuery for optimized count in pagination
    @Query(value = "SELECT p FROM Product p WHERE p.category = :category",
           countQuery = "SELECT COUNT(p.id) FROM Product p WHERE p.category = :category")
    Page<Product> findByCategory(@Param("category") String category, Pageable pageable);
    
    // Use joins for efficient data loading
    @Query("SELECT p FROM Product p JOIN FETCH p.category WHERE p.id = :id")
    Optional<Product> findByIdWithCategory(@Param("id") Long id);
    
    // Use EXISTS for existence checks
    @Query("SELECT CASE WHEN COUNT(p) > 0 THEN true ELSE false END FROM Product p WHERE p.name = :name")
    boolean existsByName(@Param("name") String name);
    
    // Use native queries for complex operations
    @Query(value = "SELECT p.* FROM products p " +
                  "JOIN product_sales ps ON p.id = ps.product_id " +
                  "WHERE ps.sale_date >= :startDate " +
                  "GROUP BY p.id " +
                  "ORDER BY SUM(ps.quantity) DESC " +
                  "LIMIT 10",
           nativeQuery = true)
    List<Product> findTopSellingProducts(@Param("startDate") Date startDate);
}

Caching

Implement caching for frequently accessed data:

@Configuration
@EnableCaching
public class CachingConfig {
    
    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();
        cacheManager.setCaches(Arrays.asList(
            new ConcurrentMapCache("products"),
            new ConcurrentMapCache("categories")
        ));
        return cacheManager;
    }
}

@Entity
@Table(name = "products")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
    // Entity definition
}

@Service
public class ProductService {
    
    private final ProductRepository productRepository;
    
    // Constructor
    
    @Cacheable(value = "products", key = "#id")
    public Product getProduct(Long id) {
        return productRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Product not found"));
    }
    
    @CacheEvict(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        return productRepository.save(product);
    }
    
    @CacheEvict(value = "products", allEntries = true)
    public void refreshProductCache() {
        // Method to refresh the entire cache
    }
}

Transactions and Locking

Transaction Management

Configure transaction behavior for different operations:

@Service
public class OrderService {
    
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final PaymentService paymentService;
    
    // Constructor
    
    @Transactional
    public Order createOrder(OrderRequest request) {
        // Create order
        Order order = new Order();
        order.setCustomerId(request.getCustomerId());
        order.setOrderDate(Instant.now());
        order.setStatus(OrderStatus.PENDING);
        
        // Add order items
        List<OrderItem> items = request.getItems().stream()
            .map(item -> {
                Product product = productRepository.findById(item.getProductId())
                    .orElseThrow(() -> new EntityNotFoundException("Product not found"));
                    
                // Check stock
                if (product.getStockLevel() < item.getQuantity()) {
                    throw new InsufficientStockException("Not enough stock for product: " + product.getName());
                }
                
                // Update stock
                product.setStockLevel(product.getStockLevel() - item.getQuantity());
                productRepository.save(product);
                
                // Create order item
                OrderItem orderItem = new OrderItem();
                orderItem.setOrder(order);
                orderItem.setProduct(product);
                orderItem.setQuantity(item.getQuantity());
                orderItem.setPrice(product.getPrice());
                
                return orderItem;
            })
            .collect(Collectors.toList());
            
        order.setItems(items);
        order.setTotalAmount(calculateTotalAmount(items));
        
        // Save order
        Order savedOrder = orderRepository.save(order);
        
        // Process payment
        PaymentResult paymentResult = paymentService.processPayment(
            request.getPaymentDetails(), savedOrder.getTotalAmount());
            
        if (paymentResult.isSuccess()) {
            savedOrder.setStatus(OrderStatus.PAID);
            return orderRepository.save(savedOrder);
        } else {
            throw new PaymentFailedException("Payment failed: " + paymentResult.getErrorMessage());
        }
    }
    
    private BigDecimal calculateTotalAmount(List<OrderItem> items) {
        return items.stream()
            .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
    
    @Transactional(readOnly = true)
    public List<Order> getCustomerOrders(Long customerId) {
        return orderRepository.findByCustomerId(customerId);
    }
    
    @Transactional(timeout = 30)
    public void processLargeOrder(OrderRequest request) {
        // Process large order with extended timeout
    }
    
    @Transactional(noRollbackFor = InventoryWarningException.class)
    public Order createOrderWithLowInventoryWarning(OrderRequest request) {
        // Create order even if inventory is low
    }
}

Optimistic Locking

Prevent concurrent modifications with optimistic locking:

@Entity
@Table(name = "products")
public class Product {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false)
    private String name;
    
    @Column(nullable = false)
    private BigDecimal price;
    
    private int stockLevel;
    
    @Version
    private Long version;
}

@Service
public class InventoryService {
    
    private final ProductRepository productRepository;
    
    // Constructor
    
    @Transactional
    public void updateStock(Long productId, int quantity) {
        try {
            Product product = productRepository.findById(productId)
                .orElseThrow(() -> new EntityNotFoundException("Product not found"));
                
            product.setStockLevel(product.getStockLevel() + quantity);
            productRepository.save(product);
        } catch (ObjectOptimisticLockingFailureException e) {
            // Handle concurrent modification
            throw new ConcurrentModificationException("Product was updated by another transaction");
        }
    }
}

Pessimistic Locking

Use pessimistic locking for critical operations:

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdWithPessimisticLock(@Param("id") Long id);
    
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT p FROM Product p WHERE p.category = :category")
    List<Product> findByCategoryWithPessimisticReadLock(@Param("category") String category);
}

@Service
public class InventoryService {
    
    private final ProductRepository productRepository;
    
    // Constructor
    
    @Transactional
    public void reserveStock(Long productId, int quantity) {
        Product product = productRepository.findByIdWithPessimisticLock(productId)
            .orElseThrow(() -> new EntityNotFoundException("Product not found"));
            
        if (product.getStockLevel() < quantity) {
            throw new InsufficientStockException("Not enough stock available");
        }
        
        product.setStockLevel(product.getStockLevel() - quantity);
        productRepository.save(product);
    }
}

Batch Processing

Batch Inserts and Updates

Optimize bulk operations:

@Service
public class ProductImportService {
    
    private final EntityManager entityManager;
    
    // Constructor
    
    @Transactional
    public void importProducts(List<ProductDTO> productDTOs) {
        int batchSize = 50;
        int i = 0;
        
        for (ProductDTO dto : productDTOs) {
            Product product = new Product();
            product.setName(dto.getName());
            product.setPrice(dto.getPrice());
            product.setCategory(dto.getCategory());
            product.setStockLevel(dto.getStockLevel());
            
            entityManager.persist(product);
            
            // Flush and clear the persistence context periodically
            if (i % batchSize == 0) {
                entityManager.flush();
                entityManager.clear();
            }
            i++;
        }
    }
}

Spring Batch Integration

For complex batch processing:

@Configuration
@EnableBatchProcessing
public class BatchConfig {
    
    @Autowired
    private JobBuilderFactory jobBuilderFactory;
    
    @Autowired
    private StepBuilderFactory stepBuilderFactory;
    
    @Bean
    public Job importProductsJob(Step importProductsStep) {
        return jobBuilderFactory.get("importProductsJob")
            .incrementer(new RunIdIncrementer())
            .flow(importProductsStep)
            .end()
            .build();
    }
    
    @Bean
    public Step importProductsStep(
            ItemReader<ProductDTO> reader,
            ItemProcessor<ProductDTO, Product> processor,
            ItemWriter<Product> writer) {
        return stepBuilderFactory.get("importProductsStep")
            .<ProductDTO, Product>chunk(100)
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .build();
    }
    
    @Bean
    public FlatFileItemReader<ProductDTO> reader() {
        return new FlatFileItemReaderBuilder<ProductDTO>()
            .name("productItemReader")
            .resource(new ClassPathResource("products.csv"))
            .delimited()
            .names("name", "price", "category", "stockLevel")
            .fieldSetMapper(new BeanWrapperFieldSetMapper<ProductDTO>() {{
                setTargetType(ProductDTO.class);
            }})
            .build();
    }
    
    @Bean
    public ItemProcessor<ProductDTO, Product> processor() {
        return dto -> {
            Product product = new Product();
            product.setName(dto.getName());
            product.setPrice(new BigDecimal(dto.getPrice()));
            product.setCategory(dto.getCategory());
            product.setStockLevel(Integer.parseInt(dto.getStockLevel()));
            return product;
        };
    }
    
    @Bean
    public JpaItemWriter<Product> writer(EntityManagerFactory entityManagerFactory) {
        JpaItemWriter<Product> writer = new JpaItemWriter<>();
        writer.setEntityManagerFactory(entityManagerFactory);
        return writer;
    }
}

Testing

Repository Testing

Test repositories with TestEntityManager:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest {
    
    @Autowired
    private TestEntityManager entityManager;
    
    @Autowired
    private ProductRepository productRepository;
    
    @Test
    void findByCategoryShouldReturnProducts() {
        // Given
        Product product1 = new Product();
        product1.setName("Laptop");
        product1.setPrice(new BigDecimal("999.99"));
        product1.setCategory("Electronics");
        entityManager.persist(product1);
        
        Product product2 = new Product();
        product2.setName("Smartphone");
        product2.setPrice(new BigDecimal("499.99"));
        product2.setCategory("Electronics");
        entityManager.persist(product2);
        
        Product product3 = new Product();
        product3.setName("Book");
        product3.setPrice(new BigDecimal("19.99"));
        product3.setCategory("Books");
        entityManager.persist(product3);
        
        entityManager.flush();
        
        // When
        List<Product> products = productRepository.findByCategory("Electronics");
        
        // Then
        assertThat(products).hasSize(2);
        assertThat(products).extracting(Product::getName).containsExactlyInAnyOrder("Laptop", "Smartphone");
    }
    
    @Test
    void findProductsOnSaleShouldReturnDiscountedProducts() {
        // Given
        Product product1 = new Product();
        product1.setName("Laptop");
        product1.setPrice(new BigDecimal("999.99"));
        product1.setCategory("Electronics");
        product1.setDiscountPercentage(new BigDecimal("10.0"));
        entityManager.persist(product1);
        
        Product product2 = new Product();
        product2.setName("Smartphone");
        product2.setPrice(new BigDecimal("499.99"));
        product2.setCategory("Electronics");
        product2.setDiscountPercentage(new BigDecimal("5.0"));
        entityManager.persist(product2);
        
        entityManager.flush();
        
        // When
        List<Product> products = productRepository.findProductsOnSale(
            "Electronics", new BigDecimal("7.0"));
        
        // Then
        assertThat(products).hasSize(1);
        assertThat(products.get(0).getName()).isEqualTo("Laptop");
    }
}

Service Layer Testing

Test service layer with mocked repositories:

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {
    
    @Mock
    private ProductRepository productRepository;
    
    @InjectMocks
    private ProductService productService;
    
    @Test
    void getProductShouldReturnProduct() {
        // Given
        Product product = new Product();
        product.setId(1L);
        product.setName("Test Product");
        
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));
        
        // When
        Product result = productService.getProduct(1L);
        
        // Then
        assertThat(result).isNotNull();
        assertThat(result.getName()).isEqualTo("Test Product");
        verify(productRepository).findById(1L);
    }
    
    @Test
    void getProductShouldThrowExceptionWhenNotFound() {
        // Given
        when(productRepository.findById(1L)).thenReturn(Optional.empty());
        
        // When/Then
        assertThrows(EntityNotFoundException.class, () -> {
            productService.getProduct(1L);
        });
        verify(productRepository).findById(1L);
    }
    
    @Test
    void findProductsWithFilterShouldUseSpecifications() {
        // Given
        ProductFilter filter = new ProductFilter();
        filter.setCategory("Electronics");
        filter.setMinPrice(new BigDecimal("100.0"));
        
        Pageable pageable = PageRequest.of(0, 10);
        Page<Product> expectedPage = new PageImpl<>(List.of(new Product()));
        
        ArgumentCaptor<Specification<Product>> specCaptor = ArgumentCaptor.forClass(Specification.class);
        when(productRepository.findAll(specCaptor.capture(), eq(pageable))).thenReturn(expectedPage);
        
        // When
        Page<Product> result = productService.findProducts(filter, pageable);
        
        // Then
        assertThat(result).isEqualTo(expectedPage);
        verify(productRepository).findAll(any(Specification.class),