Spring Data JPA Repositories and Query Patterns
This guide focuses on day-to-day Spring Data JPA usage: repository design, query styles, pagination, and composable search patterns.
What Is Spring Data JPA?โ
Spring Data JPA is part of the larger Spring Data project. It provides a repository abstraction on top of JPA that eliminates the need to write boilerplate data access code.
Important Distinction: Spring Data JPA is not a JPA provider.
- JPA is the specification for object-relational mapping in Java.
- Hibernate is the default JPA implementation (provider) that handles the actual database operations.
- Spring Data JPA sits on top of the JPA provider. It hides the
EntityManagercomplexity and provides a clean interface for data access.
Instead of manually writing DAO (Data Access Object) implementations, you define interfaces. At runtime, Spring creates a proxy implementation of these interfaces automatically.
Why Use Spring Data JPA?โ
Problems It Solvesโ
| Problem | How Spring Data JPA Fixes It |
|---|---|
| Repetitive CRUD boilerplate | Auto-generated proxy implementations at runtime |
| Manual query writing for simple operations | Query derivation from method names |
| Complex pagination and sorting logic | Built-in Pageable and Sort support |
Tedious EntityManager management | Automatic session and transaction handling |
| Verbose DAO layer | Single interface replaces the DAO class |
| Migration workflow consistency | Works with Flyway/Liquibase |
Core Benefitsโ
- Zero boilerplate for CRUD and common list operations.
- Derived queries from method names.
- Custom JPQL/native queries via annotations.
- First-class pagination and sorting support.
- Auditing integration.
- Tight Spring Boot integration.
Defining Entitiesโ
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 50)
private String username;
@Column(nullable = false)
private String email;
@Enumerated(EnumType.STRING)
private UserStatus status;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
Key Annotationsโ
| Annotation | Purpose |
|---|---|
@Entity | Marks class as JPA entity |
@Table | Defines DB table mapping |
@Id | Primary key field |
@GeneratedValue | Key generation strategy |
@Column | Column constraints and options |
@Enumerated | Enum persistence strategy |
@Temporal | Date/Calendar precision mapping |
@CreatedDate / @LastModifiedDate | Auditing timestamps |
@OneToMany / @ManyToOne / @ManyToMany | Relationship mapping |
Repository Hierarchyโ
Spring Data JPA provides a specific hierarchy of interfaces, each adding more specialized functionality:
Repository (Marker interface, no methods)
-> CrudRepository (Adds basic CRUD operations like save, findById, delete)
-> PagingAndSortingRepository (Adds findAll(Pageable) and findAll(Sort))
-> JpaRepository (Adds JPA-specific methods like flush(), batch deletes)
Use JpaRepository in most projects for richer APIs (e.g., managing the persistence context via flush, batch operations, and pagination integration).
Setup and Creating Repositoriesโ
Dependencies & Configurationโ
In a Spring Boot application, you only need to include the starter dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
Spring Boot automatically configures the database connection based on your application.properties (or application.yml) and scans for interfaces extending a Spring Data repository.
Defining the Interfaceโ
public interface UserRepository extends JpaRepository<User, Long> {
}
By just declaring this interface, Spring injects a proxy implementation into your application context. You instantly gain access to methods like:
save,saveAllfindById,findAll,count,existsByIddeleteById,deleteAllfindAll(Pageable),findAll(Sort)
Query Derivationโ
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
List<User> findByStatusAndEmailContaining(UserStatus status, String email);
List<User> findByAgeGreaterThanOrderByLastNameAsc(int age);
long countByStatus(UserStatus status);
void deleteByStatus(UserStatus status);
}
Useful Naming Keywordsโ
| Keyword | Example | SQL Fragment |
|---|---|---|
And | findByFirstNameAndLastName | WHERE first_name = ? AND last_name = ? |
Or | findByFirstNameOrLastName | WHERE first_name = ? OR last_name = ? |
Between | findByAgeBetween | WHERE age BETWEEN ? AND ? |
LessThan / GreaterThan | findByAgeLessThan | WHERE age < ? |
Like / Containing | findByNameContaining | WHERE name LIKE %?% |
In | findByStatusIn | WHERE status IN (?) |
OrderBy | findByOrderByNameAsc | ORDER BY name ASC |
IsNull / IsNotNull | findByEmailIsNull | WHERE email IS NULL |
True / False | findByActiveTrue | WHERE active = true |
Top / First | findTop5ByOrderByCreatedAtDesc | LIMIT 5 |
Custom Queriesโ
JPQL with @Queryโ
@Query("SELECT u FROM User u WHERE u.status = :status ORDER BY u.createdAt DESC")
List<User> findActiveUsers(@Param("status") UserStatus status);
Native SQLโ
@Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
Optional<User> findByEmailNative(@Param("email") String email);
Update/Delete Queriesโ
@Modifying
@Transactional
@Query("UPDATE User u SET u.status = :status WHERE u.lastLoginAt < :date")
int deactivateInactiveUsers(@Param("status") UserStatus status,
@Param("date") LocalDateTime date);
Pagination and Sortingโ
Page<User> findByStatus(UserStatus status, Pageable pageable);
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
Page<User> page = userRepository.findByStatus(UserStatus.ACTIVE, pageable);
Relationships and Fetchingโ
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
}
Many-to-Manyโ
@Entity
public class Student {
@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
}
Fetch Typesโ
| FetchType | Behavior | Default For |
|---|---|---|
EAGER | Loads related entities immediately with parent | @ManyToOne, @OneToOne |
LAZY | Loads related entities only when accessed | @OneToMany, @ManyToMany |
Avoiding Bidirectional Serialization Issuesโ
@JsonManagedReference
private List<Order> orders;
@JsonBackReference
private User user;
Or better, use DTOs for API contracts.
Composite Primary Keysโ
@Embeddable
public class OrderItemId implements Serializable {
private Long orderId;
private Long productId;
}
@Entity
public class OrderItem {
@EmbeddedId
private OrderItemId id;
private int quantity;
private BigDecimal price;
}
Best practice:
- Default to
LAZY. - Fetch explicitly with
JOIN FETCHor@EntityGraphwhen required. - Prefer DTOs for API responses to avoid recursive serialization issues.
Query By Example (QBE)โ
User probe = new User();
probe.setStatus(UserStatus.ACTIVE);
ExampleMatcher matcher = ExampleMatcher.matching()
.withIgnorePaths("id", "createdAt");
Example<User> example = Example.of(probe, matcher);
List<User> users = userRepository.findAll(example);
Specifications for Dynamic Searchโ
public interface UserRepository extends JpaRepository<User, Long>,
JpaSpecificationExecutor<User> {
}
public static Specification<User> hasStatus(UserStatus status) {
return (root, query, cb) -> cb.equal(root.get("status"), status);
}
Use Specifications when filter fields are optional and combinable.
Projectionsโ
Interface Projectionโ
public interface UserSummary {
String getUsername();
String getEmail();
@Value("#{target.firstName + ' ' + target.lastName}")
String getFullName();
}
DTO Projectionโ
@Query("SELECT new com.example.dto.UserDto(u.username, u.email) FROM User u WHERE u.status = :status")
List<UserDto> findUserDtosByStatus(@Param("status") UserStatus status);
Dynamic Projectionโ
<T> List<T> findByStatus(UserStatus status, Class<T> type);
Projection Comparisonโ
| Type | Performance | Join support | SpEL | Use case |
|---|---|---|---|---|
| Interface (closed) | Best | No | Yes | Simple field subsets |
| Class (DTO) | Good | Yes via JPQL | No | Aggregated response shapes |
| Open interface | Moderate | No | Yes | Computed fields |
| Dynamic | Varies | Depends | Depends | Flexible APIs |
Common CrudRepository Methodsโ
| Method | Description |
|---|---|
save(entity) | Insert or update depending on identifier state |
saveAll(entities) | Save a collection |
findById(id) | Returns Optional<T> |
existsById(id) | Returns existence boolean |
findAll() | Returns all rows |
count() | Returns total row count |
deleteById(id) | Delete by identifier |
delete(entity) | Delete one entity |
deleteAll() | Delete all rows |
findById() vs getReferenceById()โ
| Method | Behavior |
|---|---|
findById() | Immediate fetch with Optional |
getReferenceById() | Lazy proxy, may throw on access if missing |
delete() vs deleteInBatch()โ
delete() triggers lifecycle callbacks and cascades. deleteInBatch() is efficient for bulk delete but skips lifecycle callbacks.
Auditingโ
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig { }
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}