Spring BootSpring Data JPA
Spring Boot

Spring Data JPA

Spring Data JPA eliminates boilerplate data access code by generating repository implementations at startup from interface definitions. Declare a method named findByEmailAndRole and Spring Data writes the query. Add @Query for custom JPQL or native SQL. Layer in Pageable for pagination and Sort for ordering — all without writing a single line of JDBC or Hibernate session management code.

Repository Hierarchy

Spring Data JPA provides a hierarchy of repository interfaces. Each level adds more functionality. Choose the lowest level that provides what you need — CrudRepository for basic CRUD, PagingAndSortingRepository when pagination is required, JpaRepository for the full feature set including bulk operations and flushing.
Java
// ── Repository hierarchy (each extends the one above): ───────────────
//
// Repository<T, ID>                        (marker interface — no methods)
//   └── CrudRepository<T, ID>              (save, findById, findAll, delete, count)
//         └── PagingAndSortingRepository   (findAll(Pageable), findAll(Sort))
//               └── JpaRepository<T, ID>  (flush, saveAllAndFlush, deleteAllInBatch)

// ── JpaRepository — the standard choice for Spring Boot applications: ─
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    // JpaRepository<User, Long>:
    //   User  = the entity type
    //   Long  = the primary key type

    // Inherited methods (no implementation needed):
    // save(User)                  → INSERT or UPDATE
    // saveAll(Iterable<User>)     → batch INSERT or UPDATE
    // findById(Long)              → Optional<User>
    // findAll()                   → List<User>
    // findAll(Pageable)           → Page<User>
    // findAll(Sort)               → List<User>
    // findAllById(Iterable<Long>) → List<User>
    // existsById(Long)            → boolean
    // count()                     → long
    // deleteById(Long)
    // delete(User)
    // deleteAll()
    // deleteAllById(Iterable<Long>)
    // flush()                     → flush pending changes to DB
    // saveAndFlush(User)          → save + immediate flush
}

// ── CrudRepository — when pagination is not needed: ───────────────────
@Repository
public interface TagRepository extends CrudRepository<Tag, Long> { }

// ── ListCrudRepository (Spring Data 3+) — returns List instead of Iterable:
@Repository
public interface CategoryRepository extends ListCrudRepository<Category, Long> { }

Derived Query Methods

Spring Data JPA generates query implementations from method names at startup. The method name is parsed into a JPQL query — no @Query annotation required. This covers the majority of simple query needs.
Java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // ── Find by single field: ─────────────────────────────────────────
    Optional<User> findByEmail(String email);
    List<User> findByRole(User.Role role);
    List<User> findByName(String name);

    // ── Find by multiple fields (AND): ────────────────────────────────
    Optional<User> findByEmailAndRole(String email, User.Role role);
    List<User> findByNameAndActiveTrue(String name);

    // ── Find by multiple fields (OR): ─────────────────────────────────
    List<User> findByNameOrEmail(String name, String email);

    // ── Comparison operators: ─────────────────────────────────────────
    List<User> findByAgGreaterThan(int age);
    List<User> findByAgeGreaterThanEqual(int age);
    List<User> findByAgeLessThan(int age);
    List<User> findByAgeBetween(int min, int max);
    List<User> findByCreatedAtAfter(LocalDateTime date);
    List<User> findByCreatedAtBefore(LocalDateTime date);

    // ── String matching: ──────────────────────────────────────────────
    List<User> findByNameContaining(String fragment);      // LIKE %fragment%
    List<User> findByNameStartingWith(String prefix);      // LIKE prefix%
    List<User> findByNameEndingWith(String suffix);        // LIKE %suffix
    List<User> findByNameContainingIgnoreCase(String fragment); // case-insensitive

    // ── Null checks: ──────────────────────────────────────────────────
    List<User> findByBioIsNull();
    List<User> findByBioIsNotNull();

    // ── Boolean flags: ────────────────────────────────────────────────
    List<User> findByActiveTrue();
    List<User> findByActiveFalse();

    // ── In / Not In: ──────────────────────────────────────────────────
    List<User> findByRoleIn(Collection<User.Role> roles);
    List<User> findByIdNotIn(Collection<Long> ids);

    // ── Sorting built into the method name: ───────────────────────────
    List<User> findByRoleOrderByNameAsc(User.Role role);
    List<User> findByActiveOrderByCreatedAtDesc(boolean active);

    // ── Existence and count: ──────────────────────────────────────────
    boolean existsByEmail(String email);
    long countByRole(User.Role role);
    long countByActiveTrue();

    // ── Delete: ───────────────────────────────────────────────────────
    void deleteByEmail(String email);
    long deleteByActiveFalse();   // returns count of deleted rows

    // ── Top / First — limit results: ──────────────────────────────────
    Optional<User> findFirstByOrderByCreatedAtDesc();  // most recent
    List<User> findTop5ByRoleOrderByCreatedAtDesc(User.Role role);
    List<User> findFirst10ByActiveTrue();

    // ── With Pageable — any finder can accept Pageable: ───────────────
    Page<User> findByRole(User.Role role, Pageable pageable);
    List<User> findByActiveTrue(Sort sort);
}

@Query — Custom JPQL and Native SQL

When derived method names become unwieldy or the query requires features not expressible through naming conventions, @Query lets you write JPQL or native SQL directly. JPQL operates on entities and fields; native SQL operates on tables and columns.
Java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // ── JPQL query — operates on entity fields, not column names: ─────
    @Query("SELECT u FROM User u WHERE u.email = :email")
    Optional<User> findByEmailJpql(@Param("email") String email);

    // ── JPQL with JOIN FETCH — prevents N+1: ─────────────────────────
    @Query("SELECT u FROM User u LEFT JOIN FETCH u.orders WHERE u.id = :id")
    Optional<User> findByIdWithOrders(@Param("id") Long id);

    // ── JPQL with multiple conditions: ───────────────────────────────
    @Query("""
        SELECT u FROM User u
        WHERE (:role IS NULL OR u.role = :role)
          AND (:active IS NULL OR u.active = :active)
          AND (:search IS NULL OR LOWER(u.name) LIKE LOWER(CONCAT('%', :search, '%')))
        """)
    Page<User> search(
        @Param("role") User.Role role,
        @Param("active") Boolean active,
        @Param("search") String search,
        Pageable pageable);

    // ── @Query with Pageable — must provide countQuery for Page<T>: ───
    @Query(value = "SELECT u FROM User u WHERE u.role = :role",
           countQuery = "SELECT COUNT(u) FROM User u WHERE u.role = :role")
    Page<User> findByRolePaged(@Param("role") User.Role role, Pageable pageable);

    // ── Native SQL — operates on actual table/column names: ───────────
    @Query(value = "SELECT * FROM users WHERE email = :email",
           nativeQuery = true)
    Optional<User> findByEmailNative(@Param("email") String email);

    // ── Native SQL with pagination: ───────────────────────────────────
    @Query(value = "SELECT * FROM users WHERE role = :role ORDER BY created_at DESC",
           countQuery = "SELECT COUNT(*) FROM users WHERE role = :role",
           nativeQuery = true)
    Page<User> findByRoleNative(@Param("role") String role, Pageable pageable);

    // ── Modifying query — UPDATE or DELETE: ───────────────────────────
    @Modifying
    @Transactional
    @Query("UPDATE User u SET u.active = false WHERE u.lastLoginAt < :cutoff")
    int deactivateInactiveUsers(@Param("cutoff") LocalDateTime cutoff);

    @Modifying
    @Transactional
    @Query("DELETE FROM User u WHERE u.active = false AND u.createdAt < :cutoff")
    int deleteOldInactiveUsers(@Param("cutoff") LocalDateTime cutoff);

    // ── Projections — select specific fields only: ────────────────────
    @Query("SELECT u.id AS id, u.name AS name, u.email AS email FROM User u")
    List<UserSummary> findAllSummaries();
}

// ── Projection interface — maps query results to fields: ───────────────
public interface UserSummary {
    Long getId();
    String getName();
    String getEmail();
}

Pagination and Sorting

Pageable and Sort are first-class parameters in Spring Data JPA. Pass a Pageable to any repository method to get a Page<T> back — containing the content, total element count, total page count, and navigation flags. Use @PageableDefault in controllers to set fallback values.
Java
// ── Service — build Pageable programmatically: ───────────────────────
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;

    public Page<UserResponse> findAll(int page, int size, String sortBy) {
        Pageable pageable = PageRequest.of(
            page,                           // zero-based page index
            size,                           // page size
            Sort.by(Sort.Direction.DESC, sortBy)  // sort
        );
        return userRepository.findAll(pageable).map(UserResponse::from);
    }

    // ── Multi-column sort: ────────────────────────────────────────────
    public Page<UserResponse> findAllMultiSort() {
        Sort sort = Sort.by(
            Sort.Order.asc("role"),
            Sort.Order.desc("createdAt"),
            Sort.Order.asc("name")
        );
        return userRepository.findAll(PageRequest.of(0, 20, sort))
            .map(UserResponse::from);
    }

    // ── Sort without pagination: ──────────────────────────────────────
    public List<UserResponse> findAllSorted() {
        return userRepository.findAll(Sort.by("name"))
            .stream().map(UserResponse::from).toList();
    }
}

// ── Controller — Pageable from HTTP query parameters: ─────────────────
@GetMapping
public ResponseEntity<Page<UserResponse>> findAll(
        @PageableDefault(size = 20, sort = "createdAt",
                         direction = Sort.Direction.DESC)
        Pageable pageable) {
    return ResponseEntity.ok(userService.findAll(pageable));
}
// GET /users                        → page 0, size 20, sort createdAt DESC
// GET /users?page=2&size=10         → page 2, size 10
// GET /users?sort=name,asc          → sort by name ascending
// GET /users?sort=role,asc&sort=name,asc  → multi-column sort

// ── Page<T> response structure: ───────────────────────────────────────
// {
//   "content": [...],
//   "pageable": { "pageNumber": 0, "pageSize": 20 },
//   "totalElements": 148,
//   "totalPages": 8,
//   "first": true,
//   "last": false,
//   "size": 20,
//   "number": 0,
//   "numberOfElements": 20,
//   "empty": false
// }

// ── Limit max page size globally: ─────────────────────────────────────
spring:
  data:
    web:
      pageable:
        max-page-size: 100
        default-page-size: 20
        one-indexed-parameters: false  # keep 0-based (default)

Projections — Selecting Specific Fields

Loading full entities when only a few fields are needed wastes memory and generates unnecessarily wide SELECT statements. Spring Data JPA projections let you define interfaces or classes that map query results to a subset of fields.
Java
// ── Interface projection — Spring generates an implementation: ────────
public interface UserSummary {
    Long getId();
    String getName();
    String getEmail();

    // Computed property — @Value with SpEL:
    @Value("#{target.name + ' <' + target.email + '>'}")
    String getDisplayName();
}

// ── Class projection (DTO) — constructor expression in JPQL: ──────────
public record UserDto(Long id, String name, String email) { }

// ── Repository — return projections directly: ─────────────────────────
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // Interface projection — Spring generates SELECT id, name, email:
    List<UserSummary> findByRole(User.Role role);

    // Generic projection — caller specifies the projection type:
    <T> List<T> findByRole(User.Role role, Class<T> type);

    // DTO projection via @Query JPQL constructor expression:
    @Query("SELECT new com.example.dto.UserDto(u.id, u.name, u.email) " +
           "FROM User u WHERE u.role = :role")
    List<UserDto> findDtosByRole(@Param("role") User.Role role);

    // Interface projection with Pageable:
    Page<UserSummary> findByActiveTrue(Pageable pageable);
}

// ── Usage — caller picks the projection: ──────────────────────────────
List<UserSummary> summaries = userRepository.findByRole(User.Role.ADMIN);
List<UserDto>     dtos      = userRepository.findByRole(
                                User.Role.ADMIN, UserDto.class);

// ── Dynamic projection — same method, different return shapes: ─────────
<T> Page<T> findByActiveTrue(Pageable pageable, Class<T> type);

// Call with full entity:
Page<User>        full     = repo.findByActiveTrue(pageable, User.class);
// Call with summary projection:
Page<UserSummary> summaries = repo.findByActiveTrue(pageable, UserSummary.class);

Specifications — Dynamic Queries

JPA Specifications implement the Specification pattern for building type-safe, composable dynamic queries. They are the correct replacement for hand-built JPQL string concatenation when query conditions vary at runtime.
Java
// ── Enable Specifications — extend JpaSpecificationExecutor: ──────────
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
                                         JpaSpecificationExecutor<User> { }

// ── Define reusable Specification predicates: ─────────────────────────
public class UserSpecifications {

    public static Specification<User> hasRole(User.Role role) {
        return (root, query, cb) ->
            role == null ? null : cb.equal(root.get("role"), role);
    }

    public static Specification<User> isActive(Boolean active) {
        return (root, query, cb) ->
            active == null ? null : cb.equal(root.get("active"), active);
    }

    public static Specification<User> nameContains(String search) {
        return (root, query, cb) ->
            search == null || search.isBlank() ? null :
            cb.like(cb.lower(root.get("name")),
                    "%" + search.toLowerCase() + "%");
    }

    public static Specification<User> createdAfter(LocalDateTime date) {
        return (root, query, cb) ->
            date == null ? null : cb.greaterThan(root.get("createdAt"), date);
    }
}

// ── Service — compose specifications at runtime: ──────────────────────
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;

    public Page<UserResponse> search(UserSearchRequest request, Pageable pageable) {
        Specification<User> spec = Specification
            .where(UserSpecifications.hasRole(request.role()))
            .and(UserSpecifications.isActive(request.active()))
            .and(UserSpecifications.nameContains(request.search()))
            .and(UserSpecifications.createdAfter(request.createdAfter()));

        return userRepository.findAll(spec, pageable).map(UserResponse::from);
    }
}

// ── Request DTO: ───────────────────────────────────────────────────────
public record UserSearchRequest(
    User.Role role,        // null = all roles
    Boolean active,        // null = both active and inactive
    String search,         // null = no name filter
    LocalDateTime createdAfter  // null = no date filter
) { }

Auditing with Spring Data

Spring Data JPA's auditing support automatically populates created/updated timestamps and the creating/modifying user on entity save — without @PrePersist/@PreUpdate lifecycle callbacks on every entity.
Java
// ── 1. Enable auditing on the main application class: ────────────────
@SpringBootApplication
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class MyApplication { }

// ── 2. Provide the current auditor (who is making the change): ────────
@Bean
public AuditorAware<String> auditorProvider() {
    return () -> Optional.ofNullable(
        SecurityContextHolder.getContext().getAuthentication()
    ).map(Authentication::getName);
}

// ── 3. Define an auditable base class: ────────────────────────────────
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Auditable {

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

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;

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

    @LastModifiedBy
    @Column(length = 100)
    private String updatedBy;

    // getters
}

// ── 4. Extend entities from the base class: ───────────────────────────
@Entity
@Table(name = "products")
public class Product extends Auditable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    private BigDecimal price;

    // Spring Data automatically sets createdAt, updatedAt,
    // createdBy, updatedBy on save — no manual lifecycle callbacks needed.
}

Custom Repository Implementations

When derived methods and @Query are insufficient — complex dynamic queries, bulk operations, Hibernate-specific APIs — you can add custom implementations to a repository by creating a fragment interface and its implementation. Spring Data merges the fragment into the main repository automatically.
Java
// ── 1. Fragment interface — declares the custom methods: ─────────────
public interface UserRepositoryCustom {
    List<User> searchWithComplexCriteria(UserFilter filter);
    int bulkUpdateStatus(List<Long> ids, String status);
}

// ── 2. Implementation — Impl suffix is required by convention: ─────────
@RequiredArgsConstructor
public class UserRepositoryCustomImpl implements UserRepositoryCustom {

    private final EntityManager em;

    @Override
    public List<User> searchWithComplexCriteria(UserFilter filter) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<User> query = cb.createQuery(User.class);
        Root<User> root = query.from(User.class);

        List<Predicate> predicates = new ArrayList<>();

        if (filter.getName() != null) {
            predicates.add(cb.like(cb.lower(root.get("name")),
                "%" + filter.getName().toLowerCase() + "%"));
        }
        if (filter.getRole() != null) {
            predicates.add(cb.equal(root.get("role"), filter.getRole()));
        }
        if (filter.getMinAge() != null) {
            predicates.add(cb.ge(root.get("age"), filter.getMinAge()));
        }

        query.where(predicates.toArray(new Predicate[0]));
        query.orderBy(cb.desc(root.get("createdAt")));

        return em.createQuery(query)
            .setMaxResults(filter.getLimit())
            .getResultList();
    }

    @Override
    @Transactional
    public int bulkUpdateStatus(List<Long> ids, String status) {
        return em.createQuery(
            "UPDATE User u SET u.status = :status WHERE u.id IN :ids")
            .setParameter("status", status)
            .setParameter("ids", ids)
            .executeUpdate();
    }
}

// ── 3. Main repository — extends both JpaRepository and the fragment: ─
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
                                         UserRepositoryCustom {
    // All JpaRepository methods + searchWithComplexCriteria + bulkUpdateStatus
    // available through a single injection point.
}