View source on GitHub

Module 33 - Spring Data JPA

Spring Data JPA sits on top of JPA/Hibernate and eliminates most boilerplate. You define an interface that extends JpaRepository and Spring generates the implementation at runtime - no SQL, no JPQL for common queries, no transaction management code.


Repository Hierarchy

  ┌─────────────────────────────────────────────────────────────────────┐
  │  Repository<T, ID>              (marker interface)                  │
  │    └── CrudRepository<T, ID>    (save, findById, findAll, delete…)  │
  │          └── PagingAndSortingRepository<T, ID>  (findAll(Pageable)) │
  │                └── JpaRepository<T, ID>                             │
  │                      ├── saveAndFlush, saveAllAndFlush              │
  │                      ├── deleteAllInBatch, deleteInBatch            │
  │                      ├── flush, getById                             │
  │                      └── findAll(Example<S>)  (Query-By-Example)    │
  └─────────────────────────────────────────────────────────────────────┘

  Your interface:
  public interface EmployeeRepository extends JpaRepository<Employee, Long> { … }

  Spring generates a proxy at startup that implements every method.
  Zero lines of persistence code needed for standard CRUD.

Derived Query Methods

Spring Data parses the method name at startup and generates JPQL. No @Query annotation needed - the name IS the query.

  Method name anatomy:
  ┌──────────┬───────┬─────────────────┬──────────────┬──────────────┐
  │ findBy   │       │  NameContaining │ IgnoreCase   │              │
  │ countBy  │ And   │  SalaryBetween  │              │              │
  │ existsBy │ Or    │  Active         │              │              │
  │ deleteBy │       │  DepartmentName │ (traversal)  │              │
  └──────────┴───────┴─────────────────┴──────────────┴──────────────┘

  Generated JPQL examples:
  findByEmail(email)
    → SELECT e FROM Employee e WHERE e.email = ?1

  findByNameContainingIgnoreCase(name)
    → SELECT e FROM Employee e WHERE LOWER(e.name) LIKE %?1%

  findBySalaryBetween(min, max)
    → SELECT e FROM Employee e WHERE e.salary BETWEEN ?1 AND ?2

  findByDepartmentName(name)           ← traverses ManyToOne association
    → SELECT e FROM Employee e WHERE e.department.name = ?1

  countByActive(active)
    → SELECT COUNT(e) FROM Employee e WHERE e.active = ?1

@Query - JPQL and Native SQL

When derived names become unwieldy, or you need full query control:

// JPQL - entity/field names (not table/column):
@Query("SELECT e FROM Employee e WHERE e.salary > :threshold ORDER BY e.salary DESC")
List<Employee> findHighEarners(@Param("threshold") BigDecimal threshold);

// JOIN FETCH - avoids N+1 when you'll access a LAZY association:
@Query("SELECT e FROM Employee e JOIN FETCH e.department d WHERE d.name = :deptName")
List<Employee> findByDepartmentNameFetched(@Param("deptName") String deptName);

// Native SQL - raw SQL; useful for DB-specific syntax:
@Query(value = "SELECT * FROM employees WHERE salary > :threshold ORDER BY salary DESC",
       nativeQuery = true)
List<Employee> findHighEarnersNative(@Param("threshold") BigDecimal threshold);

@Modifying - Bulk UPDATE and DELETE

// Bulk UPDATE - skips loading entities into memory:
@Modifying
@Query("UPDATE Employee e SET e.active = false WHERE e.department.id = :deptId")
int deactivateByDepartmentId(@Param("deptId") Long deptId);

// Bulk DELETE - removes rows without loading them:
@Modifying
@Query("DELETE FROM Employee e WHERE e.active = false AND e.salary < :threshold")
int deleteInactiveBelow(@Param("threshold") BigDecimal threshold);
  Why @Modifying?
  ─────────────────────────────────────────────────────────────────────
  Without it, Spring Data treats the query as a SELECT and wraps it in
  a read-only transaction.  @Modifying switches to a write transaction
  and by default clears the persistence context after execution to
  prevent stale in-memory entities diverging from the updated DB state.

Projections - Fetch Only What You Need

Two flavours - interface proxy (no extra class) vs record DTO (type-safe, no proxy).

Interface Projection

// Declare the interface:
public interface EmployeeSummary {
    String getName();
    String getEmail();

    // Default method - runs in Java, no extra query:
    default String getDisplayName() {
        return getName() + " <" + getEmail() + ">";
    }
}

// Use it as the return type:
List<EmployeeSummary> findByActiveTrue();

// Spring generates: SELECT name, email FROM employees WHERE active = true
// Only the declared columns are fetched - no SELECT *.

DTO Projection (Record)

// Declare a record:
public record EmployeeNameDto(String name, BigDecimal salary) {}

// Use the JPQL 'new' expression:
@Query("SELECT new com.example.EmployeeNameDto(e.name, e.salary) " +
       "FROM Employee e WHERE e.active = true ORDER BY e.salary DESC")
List<EmployeeNameDto> findActiveSalaries();
// Constructs the record directly in the query - no proxy overhead.
  Comparison:
  ┌─────────────────────┬────────────────────────┬────────────────────────┐
  │                     │  Interface projection  │  DTO projection        │
  ├─────────────────────┼────────────────────────┼────────────────────────┤
  │ Class needed?       │ No (just interface)    │ Yes (record or class)  │
  │ Default methods?    │ Yes                    │ No                     │
  │ Type safety         │ JDK proxy              │ Compile-time           │
  │ JPQL expression     │ Not needed             │ new Dto(fields)        │
  │ Proxy overhead      │ Small                  │ None                   │
  └─────────────────────┴────────────────────────┴────────────────────────┘

Pagination and Sorting

// PageRequest.of(page, size)             - 0-indexed page number
// PageRequest.of(page, size, Sort.by())  - with sorting

Page<Employee> page = repo.findByActiveTrue(
    PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "salary")));

page.getContent();          // List<Employee> - entities on this page
page.getTotalElements();    // long - total count across ALL pages
page.getTotalPages();       // int  - ceil(total / pageSize)
page.isFirst();             // boolean
page.isLast();              // boolean
page.hasNext();             // boolean
page.hasPrevious();         // boolean
  SQL generated (H2 / PostgreSQL):
  ─────────────────────────────────────────────────────────────────────
  SELECT * FROM employees WHERE active = true
    ORDER BY salary DESC
    LIMIT 5 OFFSET 0;                      ← page 0

  SELECT COUNT(e.id) FROM employees e WHERE active = true;
                                           ← extra COUNT for metadata

Sorting standalone

// Sort without pagination:
List<Employee> all = repo.findAll(Sort.by("name").ascending());

// Multi-field:
Sort sort = Sort.by(Direction.ASC, "department.name")
               .and(Sort.by(Direction.DESC, "salary"));

Auditing - @CreatedDate / @LastModifiedDate

// 1. Enable auditing:
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {}

// 2. Register the listener on the entity:
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Employee {

    @CreatedDate
    @Column(nullable = false, updatable = false)   // never changes after insert
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;
}
  Lifecycle:
  ─────────────────────────────────────────────────────────────────────
  INSERT  → AuditingEntityListener sets createdAt = now(), updatedAt = now()
  UPDATE  → AuditingEntityListener sets updatedAt = now()
            createdAt is NOT touched (updatable = false)

Auditing in @DataJpaTest

@DataJpaTest is a slice - it does NOT load JpaAuditingConfig from src/main. Two options to activate auditing inside the slice:

// Option A - import the production config:
@DataJpaTest
@Import(JpaAuditingConfig.class)
class MyTest {  }

// Option B - inner @TestConfiguration (supplements, does not replace):
@DataJpaTest
class AuditingTest {
    @TestConfiguration
    @EnableJpaAuditing
    static class AuditConfig {}
}

@DataJpaTest Slice

  @DataJpaTest loads:                        @DataJpaTest does NOT load:
  ─────────────────────────────────────────────────────────────────────
  ✓ @Entity classes                          ✗ @Service / @Controller
  ✓ JpaRepository interfaces                 ✗ @Component (unless @Repository)
  ✓ TestEntityManager                        ✗ Security config
  ✓ In-memory H2 by default                 ✗ JpaAuditingConfig (manual import)
  ✓ Transactional (rollback after each test) ✗ Full application context
@DataJpaTest
@Import(JpaAuditingConfig.class)
class EmployeeRepositoryTest {

    @Autowired TestEntityManager tem;  // flush/clear without going through repos
    @Autowired EmployeeRepository employees;

    @Test
    void findByEmail_returns_matching_employee() {
        Employee e = tem.persist(new Employee("Alice", "a@t.com", new BigDecimal("70000")));
        tem.flush();

        assertThat(employees.findByEmail("a@t.com"))
            .isPresent()
            .get().extracting(Employee::getName).isEqualTo("Alice");
    }
}

Transaction Management

Spring Data repositories are transactional by default:

  • findAll, findById, etc. run in a read-only transaction.
  • save, delete, @Modifying queries run in a read-write transaction.
// Explicit @Transactional for service-layer spanning multiple repo calls:
@Service
public class EmployeeService {

    @Transactional
    public void transferEmployee(Long employeeId, Long newDeptId) {
        Employee emp = employees.findById(employeeId).orElseThrow();
        Department dept = departments.findById(newDeptId).orElseThrow();
        // Both changes committed together or both rolled back.
        emp.setDepartment(dept);
    }
}

Module 33 - What Was Built

  module-33-spring-data/
  ├── pom.xml           (Spring Boot 3.3.5, spring-boot-starter-data-jpa, H2, starter-test)
  └── src/
      ├── main/java/com/javatraining/springdata/
      │   ├── SpringDataApplication.java   - @SpringBootApplication entry point
      │   ├── entity/
      │   │   ├── Department.java          - @OneToMany(cascade=ALL, orphanRemoval=true)
      │   │   └── Employee.java            - @ManyToOne(LAZY), @CreatedDate, @LastModifiedDate
      │   ├── repository/
      │   │   ├── EmployeeRepository.java  - derived queries, @Query JPQL/native,
      │   │   │                              @Modifying, projections, pagination
      │   │   └── DepartmentRepository.java
      │   ├── projection/
      │   │   ├── EmployeeSummary.java     - interface projection with default method
      │   │   └── EmployeeNameDto.java     - record DTO projection
      │   └── config/
      │       └── JpaAuditingConfig.java   - @EnableJpaAuditing
      └── test/java/com/javatraining/springdata/
          ├── DerivedQueriesTest.java  12 tests - findByEmail, findByName, findBySalaryBetween,
          │                                       findByDepartmentName, countByActive, …
          ├── CustomQueryTest.java      8 tests - @Query JPQL, native SQL, @Modifying
          │                                       UPDATE/DELETE, JOIN FETCH
          ├── ProjectionTest.java       6 tests - interface projection, default method,
          │                                       DTO record projection, ordering
          ├── PaginationSortingTest.java 8 tests - Page metadata, last page, empty beyond range,
          │                                       sort ascending/descending, multi-field sort
          └── AuditingTest.java         5 tests - @CreatedDate populated, @LastModifiedDate
                                                  updated, updatable=false, @TestConfiguration

Total: 39 tests, all passing.


Key Takeaways

  JpaRepository         - extends CrudRepository + PagingAndSortingRepository
  Derived query methods - method name parsed to JPQL at startup; zero boilerplate
  @Query JPQL           - full control; uses entity/field names (not table/column)
  @Query nativeQuery    - raw SQL; database-specific syntax
  @Modifying            - required for UPDATE/DELETE; clears persistence context
  Interface projection  - JDK proxy; only declared columns fetched
  DTO projection        - JPQL 'new' expression; record; no proxy
  Page / Pageable       - LIMIT+OFFSET SQL + total COUNT; hasNext/hasPrevious
  Sort                  - ORDER BY; single or multi-field; ASC/DESC
  @CreatedDate          - set once on INSERT; updatable = false
  @LastModifiedDate     - updated on every flush
  @DataJpaTest          - JPA slice; transactional rollback; import auditing config
  @TestConfiguration    - supplements slice context; does NOT replace auto-config