Spring Data JPA¶
Overview¶
Spring Data JPA simplifies data access by reducing boilerplate code through repository abstractions.
Entity Mapping¶
Basic Entity¶
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_number", unique = true, nullable = false, length = 50)
private String orderNumber;
@Column(precision = 10, scale = 2)
private BigDecimal totalAmount;
@Enumerated(EnumType.STRING)
@Column(length = 20)
private OrderStatus status;
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@Version
private Long version; // Optimistic locking
// Getters and setters
}
public enum OrderStatus {
PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
ID Generation Strategies¶
// Auto (provider chooses)
@GeneratedValue(strategy = GenerationType.AUTO)
// Identity (auto-increment column)
@GeneratedValue(strategy = GenerationType.IDENTITY)
// Sequence (database sequence)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "ORDER_SEQ", allocationSize = 50)
// Table (simulated sequence)
@GeneratedValue(strategy = GenerationType.TABLE, generator = "order_gen")
@TableGenerator(name = "order_gen", table = "id_generator",
pkColumnName = "gen_name", valueColumnName = "gen_value", allocationSize = 50)
// UUID
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
Relationships¶
One-to-Many / Many-to-One¶
@Entity
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;
// Helper method to maintain bidirectional relationship
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
}
@Entity
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
private String productId;
private int quantity;
private BigDecimal price;
}
Many-to-Many¶
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany
@JoinTable(
name = "product_categories",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id")
)
private Set<Category> categories = new HashSet<>();
}
@Entity
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany(mappedBy = "categories")
private Set<Product> products = new HashSet<>();
}
One-to-One¶
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL,
fetch = FetchType.LAZY, optional = false)
private UserProfile profile;
}
@Entity
public class UserProfile {
@Id
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@MapsId // Share primary key with User
@JoinColumn(name = "user_id")
private User user;
}
Repository Interface¶
Repository Hierarchy¶
Basic Repository¶
public interface OrderRepository extends JpaRepository<Order, Long> {
// Query derivation from method name
List<Order> findByStatus(OrderStatus status);
List<Order> findByCustomerId(Long customerId);
List<Order> findByStatusAndCreatedAtAfter(OrderStatus status, LocalDateTime after);
Optional<Order> findByOrderNumber(String orderNumber);
boolean existsByOrderNumber(String orderNumber);
long countByStatus(OrderStatus status);
void deleteByStatus(OrderStatus status);
// Pagination and sorting
Page<Order> findByCustomerId(Long customerId, Pageable pageable);
Slice<Order> findByStatus(OrderStatus status, Pageable pageable);
List<Order> findByCustomerIdOrderByCreatedAtDesc(Long customerId);
}
Query Method Keywords¶
Custom Queries¶
@Query Annotation¶
public interface OrderRepository extends JpaRepository<Order, Long> {
// JPQL query
@Query("SELECT o FROM Order o WHERE o.customer.email = :email")
List<Order> findByCustomerEmail(@Param("email") String email);
// Native SQL query
@Query(value = "SELECT * FROM orders WHERE total_amount > :amount",
nativeQuery = true)
List<Order> findHighValueOrders(@Param("amount") BigDecimal amount);
// With pagination
@Query("SELECT o FROM Order o WHERE o.status = :status")
Page<Order> findByStatusWithPaging(@Param("status") OrderStatus status,
Pageable pageable);
// Projection
@Query("SELECT o.orderNumber, o.totalAmount FROM Order o WHERE o.customer.id = :customerId")
List<Object[]> findOrderSummary(@Param("customerId") Long customerId);
// Update query
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id = :id")
int updateStatus(@Param("id") Long id, @Param("status") OrderStatus status);
// Delete query
@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status AND o.createdAt < :before")
int deleteOldOrders(@Param("status") OrderStatus status,
@Param("before") LocalDateTime before);
// Join fetch (prevent N+1)
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);
@Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.items WHERE o.customer.id = :customerId")
List<Order> findByCustomerIdWithItems(@Param("customerId") Long customerId);
}
Named Queries¶
@Entity
@NamedQueries({
@NamedQuery(
name = "Order.findByStatusAndDateRange",
query = "SELECT o FROM Order o WHERE o.status = :status " +
"AND o.createdAt BETWEEN :start AND :end"
),
@NamedQuery(
name = "Order.countByCustomer",
query = "SELECT COUNT(o) FROM Order o WHERE o.customer.id = :customerId"
)
})
public class Order {
// ...
}
// Repository uses named query by convention: Entity.methodName
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByStatusAndDateRange(OrderStatus status,
LocalDateTime start,
LocalDateTime end);
}
Projections¶
Interface Projection¶
// Closed projection (specific fields)
public interface OrderSummary {
String getOrderNumber();
BigDecimal getTotalAmount();
OrderStatus getStatus();
// Nested projection
CustomerInfo getCustomer();
interface CustomerInfo {
String getName();
String getEmail();
}
}
public interface OrderRepository extends JpaRepository<Order, Long> {
List<OrderSummary> findByStatus(OrderStatus status);
@Query("SELECT o FROM Order o WHERE o.customer.id = :customerId")
List<OrderSummary> findSummaryByCustomerId(@Param("customerId") Long customerId);
}
Class-Based Projection (DTO)¶
public record OrderDTO(
Long id,
String orderNumber,
BigDecimal totalAmount,
String customerName
) {}
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT new com.example.dto.OrderDTO(o.id, o.orderNumber, " +
"o.totalAmount, o.customer.name) FROM Order o WHERE o.status = :status")
List<OrderDTO> findDTOByStatus(@Param("status") OrderStatus status);
}
Dynamic Projection¶
public interface OrderRepository extends JpaRepository<Order, Long> {
<T> List<T> findByStatus(OrderStatus status, Class<T> type);
<T> Optional<T> findById(Long id, Class<T> type);
}
// Usage
List<OrderSummary> summaries = orderRepository.findByStatus(PENDING, OrderSummary.class);
List<OrderDTO> dtos = orderRepository.findByStatus(PENDING, OrderDTO.class);
Pagination and Sorting¶
Pageable¶
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
Slice<Order> findByCustomerId(Long customerId, Pageable pageable);
}
// Usage
Pageable pageable = PageRequest.of(
0, // Page number (0-indexed)
20, // Page size
Sort.by("createdAt").descending()
);
Page<Order> page = orderRepository.findByStatus(OrderStatus.PENDING, pageable);
// Page info
page.getContent(); // List<Order>
page.getTotalElements(); // Total count
page.getTotalPages(); // Total pages
page.getNumber(); // Current page
page.getSize(); // Page size
page.hasNext(); // Has more pages
page.hasPrevious(); // Has previous page
Sorting¶
// Simple sort
Sort sort = Sort.by("createdAt").descending();
// Multiple fields
Sort sort = Sort.by(
Sort.Order.desc("status"),
Sort.Order.asc("createdAt")
);
// Type-safe sort with JpaSort
Sort sort = JpaSort.unsafe("LENGTH(orderNumber)");
// In repository method
List<Order> findByCustomerId(Long customerId, Sort sort);
// Usage
orderRepository.findByCustomerId(123L, Sort.by("totalAmount").descending());
Slice vs Page¶
Specifications (Dynamic Queries)¶
@Repository
public interface OrderRepository extends JpaRepository<Order, Long>,
JpaSpecificationExecutor<Order> {
}
// Specification class
public class OrderSpecifications {
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) ->
status == null ? null : cb.equal(root.get("status"), status);
}
public static Specification<Order> createdAfter(LocalDateTime date) {
return (root, query, cb) ->
date == null ? null : cb.greaterThan(root.get("createdAt"), date);
}
public static Specification<Order> totalAmountGreaterThan(BigDecimal amount) {
return (root, query, cb) ->
amount == null ? null : cb.greaterThan(root.get("totalAmount"), amount);
}
public static Specification<Order> customerNameLike(String name) {
return (root, query, cb) -> {
if (name == null) return null;
Join<Order, Customer> customer = root.join("customer");
return cb.like(cb.lower(customer.get("name")),
"%" + name.toLowerCase() + "%");
};
}
}
// Usage
Specification<Order> spec = Specification
.where(OrderSpecifications.hasStatus(OrderStatus.PENDING))
.and(OrderSpecifications.createdAfter(LocalDateTime.now().minusDays(7)))
.and(OrderSpecifications.totalAmountGreaterThan(new BigDecimal("100")));
List<Order> orders = orderRepository.findAll(spec);
Page<Order> pagedOrders = orderRepository.findAll(spec, pageable);
long count = orderRepository.count(spec);
Auditing¶
Enable Auditing¶
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JpaConfig {
@Bean
public AuditorAware<String> auditorProvider() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.map(Authentication::getName);
}
}
Auditable Entity¶
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
@CreatedBy
@Column(updatable = false)
private String createdBy;
@LastModifiedBy
private String updatedBy;
}
@Entity
public class Order extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// other fields
}
Transaction Management¶
@Transactional¶
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
private final PaymentService paymentService;
@Transactional // Read-write transaction
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
// ... set fields
inventoryService.reserve(order.getItems()); // Same transaction
paymentService.authorize(order.getPayment()); // Same transaction
return orderRepository.save(order);
}
@Transactional(readOnly = true) // Optimized for reads
public Order getOrder(Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditOrder(Order order) {
// Runs in separate transaction
}
@Transactional(timeout = 30) // Timeout in seconds
public void processLargeOrder(Order order) {
// Long-running operation
}
@Transactional(rollbackFor = BusinessException.class)
public void businessOperation() throws BusinessException {
// Rolls back for BusinessException (checked exception)
}
@Transactional(noRollbackFor = WarningException.class)
public void operationWithWarning() throws WarningException {
// Does NOT rollback for WarningException
}
}
Propagation Types¶
Entity Lifecycle Callbacks¶
@Entity
@EntityListeners(OrderEntityListener.class)
public class Order {
@PrePersist
public void prePersist() {
if (orderNumber == null) {
orderNumber = generateOrderNumber();
}
}
@PostPersist
public void postPersist() {
// After entity is persisted
}
@PreUpdate
public void preUpdate() {
// Before entity is updated
}
@PostUpdate
public void postUpdate() {
// After entity is updated
}
@PreRemove
public void preRemove() {
// Before entity is deleted
}
@PostRemove
public void postRemove() {
// After entity is deleted
}
@PostLoad
public void postLoad() {
// After entity is loaded
}
}
// External listener
public class OrderEntityListener {
@PostPersist
public void afterPersist(Order order) {
// Can inject beans via ObjectFactory
}
}
N+1 Problem and Solutions¶
The Problem¶
// This causes N+1 queries
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
order.getItems().size(); // N queries (one per order)
}
Solutions¶
// 1. JOIN FETCH in query
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
List<Order> findByStatusWithItems(@Param("status") OrderStatus status);
// 2. @EntityGraph
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(OrderStatus status);
// Named entity graph
@Entity
@NamedEntityGraph(
name = "Order.withItemsAndCustomer",
attributeNodes = {
@NamedAttributeNode("items"),
@NamedAttributeNode("customer")
}
)
public class Order { }
@EntityGraph(value = "Order.withItemsAndCustomer")
List<Order> findByStatus(OrderStatus status);
// 3. Batch fetching (in entity)
@OneToMany(mappedBy = "order")
@BatchSize(size = 50)
private List<OrderItem> items;
// 4. Subselect fetching
@OneToMany(mappedBy = "order")
@Fetch(FetchMode.SUBSELECT)
private List<OrderItem> items;
Performance Tips¶
// 1. Use projections for read-only data
List<OrderSummary> summaries = orderRepository.findProjectedByStatus(status);
// 2. Use readOnly transactions
@Transactional(readOnly = true)
public List<Order> getOrders() { }
// 3. Batch inserts/updates
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
// 4. Use saveAll for bulk
orderRepository.saveAll(orders); // Uses batching
// 5. Avoid fetching entities for updates
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.id IN :ids")
int updateStatusBatch(@Param("ids") List<Long> ids, @Param("status") OrderStatus status);
// 6. Use pagination
Page<Order> orders = orderRepository.findAll(PageRequest.of(0, 100));
// 7. Index frequently queried columns
@Table(indexes = {
@Index(name = "idx_order_status", columnList = "status"),
@Index(name = "idx_order_customer", columnList = "customer_id")
})
Common Interview Questions¶
- JpaRepository vs CrudRepository?
- JpaRepository: JPA-specific, flush, batch delete, getReference
-
CrudRepository: Basic CRUD, database-agnostic
-
How to handle N+1 problem?
-
JOIN FETCH, @EntityGraph, @BatchSize, @Fetch(SUBSELECT)
-
Lazy vs Eager loading?
- Lazy: Load on access (default for collections)
-
Eager: Load immediately (default for single associations)
-
@Transactional best practices?
- Use readOnly for queries
- Keep transactions short
-
Understand propagation
-
Optimistic vs Pessimistic locking?
- Optimistic: @Version, no DB lock, check on update
- Pessimistic: DB lock, @Lock annotation
- *