Advanced Spring Data JPA Techniques for Enterprise Applications
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),