Spring Data Jpa

Why Spring Data JPA Matters

JPA provides object-relational mapping for database access. Manual EntityManager usage requires repetitive CRUD code for every entity. In production systems with dozens of entities and hundreds of queries, Spring Data JPA generates repository implementations automatically—reducing boilerplate from 50 lines per entity to a single interface.

JPA EntityManager Baseline

Manual EntityManager requires explicit JPQL for every operation:

import javax.persistence.*;
import java.util.List;

// => ZakatAccount entity: JPA entity mapped to database table
@Entity  // => Marks class as JPA entity
@Table(name = "zakat_accounts")  // => Maps to zakat_accounts table
public class ZakatAccount {

    @Id  // => Primary key field
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // => Auto-generated ID
    private Long id;

    @Column(name = "account_number", nullable = false, unique = true)
    // => Maps to account_number column, NOT NULL, UNIQUE constraint
    private String accountNumber;

    @Column(name = "owner_name", nullable = false)
    private String ownerName;

    @Column(name = "balance", nullable = false)
    // => BigDecimal for precise decimal calculations
    private BigDecimal balance;

    @Version  // => Optimistic locking: prevents concurrent modification
    private Long version;

    // => Constructors, getters, setters omitted for brevity
}

// => Repository: manual EntityManager operations
public class ZakatAccountRepository {

    @PersistenceContext  // => Injects EntityManager from persistence context
    private EntityManager entityManager;  // => EntityManager: JPA API for database operations

    // => Save new account: manual persist
    public ZakatAccount save(ZakatAccount account) {
        // => persist(): inserts new entity into database
        // => Entity must be in transient state (no ID)
        entityManager.persist(account);
        // => After persist: entity in managed state, ID populated
        return account;
    }

    // => Update existing account: manual merge
    public ZakatAccount update(ZakatAccount account) {
        // => merge(): updates existing entity in database
        // => Returns managed copy of entity
        // => Original entity remains detached
        return entityManager.merge(account);
    }

    // => Find account by ID: manual find
    public ZakatAccount findById(Long id) {
        // => find(): retrieves entity by primary key
        // => Returns null if not found (not Optional)
        // => Entity in managed state after retrieval
        ZakatAccount account = entityManager.find(ZakatAccount.class, id);
        if (account == null) {
            throw new EntityNotFoundException("Account not found: " + id);
        }
        return account;
    }

    // => Find all accounts: manual JPQL query
    public List<ZakatAccount> findAll() {
        // => JPQL query: object-oriented query language
        // => SELECT a FROM ZakatAccount a (not SELECT * FROM zakat_accounts)
        // => Returns List<ZakatAccount>, not ResultSet
        TypedQuery<ZakatAccount> query = entityManager.createQuery(
            "SELECT a FROM ZakatAccount a",  // => JPQL: entity-based query
            ZakatAccount.class  // => Result type
        );
        // => getResultList(): executes query, returns all results
        return query.getResultList();
    }

    // => Find accounts by owner: manual JPQL with parameter
    public List<ZakatAccount> findByOwnerName(String ownerName) {
        // => JPQL with named parameter: :ownerName
        TypedQuery<ZakatAccount> query = entityManager.createQuery(
            "SELECT a FROM ZakatAccount a WHERE a.ownerName = :ownerName",
            ZakatAccount.class
        );
        // => setParameter(): binds parameter value
        query.setParameter("ownerName", ownerName);
        return query.getResultList();
    }

    // => Find accounts with minimum balance: manual JPQL with comparison
    public List<ZakatAccount> findByMinimumBalance(BigDecimal minBalance) {
        // => JPQL comparison: >= for BigDecimal
        TypedQuery<ZakatAccount> query = entityManager.createQuery(
            "SELECT a FROM ZakatAccount a WHERE a.balance >= :minBalance ORDER BY a.balance DESC",
            ZakatAccount.class
        );
        query.setParameter("minBalance", minBalance);
        return query.getResultList();
    }

    // => Count accounts: manual JPQL count query
    public long count() {
        // => JPQL COUNT: returns Long (not int)
        TypedQuery<Long> query = entityManager.createQuery(
            "SELECT COUNT(a) FROM ZakatAccount a",
            Long.class
        );
        // => getSingleResult(): expects exactly one result
        return query.getSingleResult();
    }

    // => Delete account: manual remove
    public void delete(Long id) {
        // => Find entity first: remove() requires managed entity
        ZakatAccount account = findById(id);
        // => remove(): deletes entity from database
        // => Entity must be in managed state
        entityManager.remove(account);
    }

    // => Pagination: manual JPQL with setFirstResult/setMaxResults
    public List<ZakatAccount> findAll(int page, int size) {
        TypedQuery<ZakatAccount> query = entityManager.createQuery(
            "SELECT a FROM ZakatAccount a ORDER BY a.id",
            ZakatAccount.class
        );
        // => setFirstResult(): offset (0-indexed)
        query.setFirstResult(page * size);
        // => setMaxResults(): limit (number of results)
        query.setMaxResults(size);
        return query.getResultList();
    }
}

Limitations:

  • Boilerplate: 50+ lines per entity for basic CRUD
  • Repetitive: Same patterns for every entity (save, findById, findAll)
  • Manual pagination: setFirstResult/setMaxResults every time
  • No derived queries: Can’t generate queries from method names
  • Type-unsafe strings: JPQL as strings, no compile-time validation

Spring Data JPA Repository

Spring Data JPA generates repository implementations automatically:

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

// => Repository interface: Spring Data generates implementation
// => JpaRepository<Entity, ID>: generic repository with CRUD methods
public interface ZakatAccountRepository extends JpaRepository<ZakatAccount, Long> {
    // => No implementation needed: Spring Data generates at runtime

    // => Inherited methods from JpaRepository (automatic):
    // - save(entity): insert or update
    // - findById(id): find by primary key, returns Optional
    // - findAll(): find all entities
    // - count(): count all entities
    // - delete(entity): delete entity
    // - existsById(id): check existence

    // => Derived query methods: Spring Data generates JPQL from method name
    // => Method name pattern: findBy[Property][Operator]
    List<ZakatAccount> findByOwnerName(String ownerName);
    // => Generated JPQL: SELECT a FROM ZakatAccount a WHERE a.ownerName = ?1

    // => Comparison operators: GreaterThanEqual
    List<ZakatAccount> findByBalanceGreaterThanEqual(BigDecimal minBalance);
    // => Generated JPQL: SELECT a FROM ZakatAccount a WHERE a.balance >= ?1

    // => Multiple conditions: And operator
    List<ZakatAccount> findByOwnerNameAndBalanceGreaterThan(String owner, BigDecimal balance);
    // => Generated JPQL: SELECT a FROM ZakatAccount a WHERE a.ownerName = ?1 AND a.balance > ?2

    // => Sorting: OrderBy clause
    List<ZakatAccount> findByBalanceGreaterThanEqualOrderByBalanceDesc(BigDecimal minBalance);
    // => Generated JPQL: SELECT a FROM ZakatAccount a WHERE a.balance >= ?1 ORDER BY a.balance DESC

    // => Pattern matching: Containing operator (LIKE %value%)
    List<ZakatAccount> findByOwnerNameContaining(String namePart);
    // => Generated JPQL: SELECT a FROM ZakatAccount a WHERE a.ownerName LIKE %?1%

    // => Top N results: First/Top keyword
    List<ZakatAccount> findTop10ByBalanceGreaterThanOrderByBalanceDesc(BigDecimal minBalance);
    // => Generated JPQL with LIMIT: top 10 results ordered by balance

    // => Existence check: exists keyword
    boolean existsByAccountNumber(String accountNumber);
    // => Generated JPQL: SELECT COUNT(a) > 0 FROM ZakatAccount a WHERE a.accountNumber = ?1

    // => Count query: count keyword
    long countByBalanceGreaterThan(BigDecimal minBalance);
    // => Generated JPQL: SELECT COUNT(a) FROM ZakatAccount a WHERE a.balance > ?1

    // => Delete query: delete keyword
    void deleteByAccountNumber(String accountNumber);
    // => Generated JPQL: DELETE FROM ZakatAccount a WHERE a.accountNumber = ?1
}

// => Service: uses repository with zero implementation
@Service  // => Spring-managed service bean
@Transactional  // => All methods in transaction
public class ZakatAccountService {

    private final ZakatAccountRepository accountRepository;

    // => Constructor injection: Spring provides generated repository implementation
    public ZakatAccountService(ZakatAccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // => Create account: automatic save
    public ZakatAccount createAccount(String accountNumber, String ownerName, BigDecimal initialBalance) {
        // => Creates new entity (transient state)
        ZakatAccount account = new ZakatAccount();
        account.setAccountNumber(accountNumber);
        account.setOwnerName(ownerName);
        account.setBalance(initialBalance);

        // => save(): Spring Data generates persist or merge based on ID
        // => If ID is null: persist (INSERT)
        // => If ID exists: merge (UPDATE)
        return accountRepository.save(account);
    }

    // => Find account by ID: automatic findById
    public ZakatAccount getAccount(Long id) {
        // => findById(): returns Optional<ZakatAccount>
        // => orElseThrow(): throws exception if not found
        return accountRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Account not found: " + id));
    }

    // => Find accounts by owner: derived query method
    public List<ZakatAccount> getAccountsByOwner(String ownerName) {
        // => Spring Data generates query from method name
        return accountRepository.findByOwnerName(ownerName);
    }

    // => Find accounts above nisab threshold: derived query with sorting
    public List<ZakatAccount> getAccountsAboveNisab(BigDecimal nisab) {
        // => Derived query: GreaterThanEqual + OrderBy
        return accountRepository.findByBalanceGreaterThanEqualOrderByBalanceDesc(nisab);
    }

    // => Check account exists: derived query
    public boolean accountExists(String accountNumber) {
        // => Spring Data generates existence check
        return accountRepository.existsByAccountNumber(accountNumber);
    }

    // => Update balance: automatic save (merge)
    public void updateBalance(Long accountId, BigDecimal newBalance) {
        // => Find account: returns Optional
        ZakatAccount account = getAccount(accountId);
        // => Modify managed entity
        account.setBalance(newBalance);
        // => save(): Spring Data generates UPDATE
        // => ID exists, so merge() used instead of persist()
        accountRepository.save(account);
    }

    // => Delete account: automatic delete
    public void deleteAccount(Long id) {
        // => deleteById(): Spring Data generates DELETE
        // => Throws EmptyResultDataAccessException if not found
        accountRepository.deleteById(id);
    }
}

Benefits:

  • Zero boilerplate: No implementation needed for basic CRUD
  • Derived queries: Generate queries from method names
  • Type safety: Method names validated at compile time (via proxying)
  • Automatic pagination: Built-in pagination support
  • Optional support: findById returns Optional, no null checks

Custom Queries with @Query

Complex queries require custom JPQL or native SQL:

public interface ZakatAccountRepository extends JpaRepository<ZakatAccount, Long> {

    // => Custom JPQL query: @Query annotation
    @Query("SELECT a FROM ZakatAccount a WHERE a.balance >= :nisab AND a.balance < :limit")
    // => @Param: binds method parameter to named parameter in JPQL
    List<ZakatAccount> findAccountsInRange(
        @Param("nisab") BigDecimal nisab,
        @Param("limit") BigDecimal limit
    );

    // => Aggregation query: SUM function
    @Query("SELECT SUM(a.balance) FROM ZakatAccount a WHERE a.ownerName = :owner")
    // => Returns BigDecimal: sum of balances
    BigDecimal getTotalBalanceForOwner(@Param("owner") String ownerName);

    // => Native SQL query: nativeQuery = true
    @Query(value = "SELECT * FROM zakat_accounts WHERE balance >= ?1 ORDER BY balance DESC LIMIT ?2",
           nativeQuery = true)
    // => Native SQL: database-specific syntax (PostgreSQL LIMIT)
    // => Returns List<ZakatAccount>: Spring Data maps to entity
    List<ZakatAccount> findTopAccountsNative(BigDecimal minBalance, int limit);

    // => Update query: @Modifying annotation
    @Modifying  // => Required for UPDATE/DELETE queries
    @Query("UPDATE ZakatAccount a SET a.balance = a.balance + :amount WHERE a.id = :id")
    // => Returns int: number of affected rows
    int incrementBalance(@Param("id") Long id, @Param("amount") BigDecimal amount);

    // => Delete query: @Modifying annotation
    @Modifying  // => Required for DELETE queries
    @Query("DELETE FROM ZakatAccount a WHERE a.balance < :threshold")
    // => Bulk delete: removes all accounts below threshold
    int deleteAccountsBelowThreshold(@Param("threshold") BigDecimal threshold);

    // => Join query: fetch related entities
    @Query("SELECT DISTINCT a FROM ZakatAccount a LEFT JOIN FETCH a.transactions WHERE a.id = :id")
    // => LEFT JOIN FETCH: eagerly loads transactions collection
    // => DISTINCT: prevents duplicate rows from join
    Optional<ZakatAccount> findByIdWithTransactions(@Param("id") Long id);

    // => Projection query: select specific columns
    @Query("SELECT a.accountNumber, a.balance FROM ZakatAccount a WHERE a.balance >= :nisab")
    // => Returns List<Object[]>: array per row [accountNumber, balance]
    List<Object[]> findAccountNumberAndBalanceAboveNisab(@Param("nisab") BigDecimal nisab);

    // => DTO projection: interface-based projection
    @Query("SELECT a.accountNumber as accountNumber, a.balance as balance FROM ZakatAccount a")
    // => Returns List<AccountProjection>: interface projection
    List<AccountProjection> findAllProjections();

    // Interface for projection
    interface AccountProjection {
        String getAccountNumber();  // => Maps to accountNumber
        BigDecimal getBalance();  // => Maps to balance
    }
}

Pagination and Sorting

Spring Data JPA provides built-in pagination:

public interface ZakatAccountRepository extends JpaRepository<ZakatAccount, Long> {

    // => Pagination: returns Page<T>
    // => Pageable: page number, page size, sorting
    Page<ZakatAccount> findByBalanceGreaterThan(BigDecimal minBalance, Pageable pageable);
    // => Returns Page: contains results + metadata (total pages, total elements)

    // => Slice pagination: less expensive than Page
    // => Slice: doesn't count total elements, only knows if next page exists
    Slice<ZakatAccount> findByOwnerNameContaining(String namePart, Pageable pageable);
}

@Service
public class ZakatAccountService {

    private final ZakatAccountRepository accountRepository;

    // => Paginate accounts above nisab
    public Page<ZakatAccount> getAccountsAboveNisab(BigDecimal nisab, int page, int size) {
        // => PageRequest.of(): creates Pageable (page, size, sort)
        // => page: 0-indexed (page 0 = first page)
        // => size: number of results per page
        // => Sort.by(): creates Sort object for ordering
        Pageable pageable = PageRequest.of(
            page,
            size,
            Sort.by("balance").descending()  // => ORDER BY balance DESC
        );

        // => findByBalanceGreaterThan(): returns Page<ZakatAccount>
        Page<ZakatAccount> accountPage = accountRepository.findByBalanceGreaterThan(nisab, pageable);

        // => Page methods:
        // - getContent(): List<ZakatAccount> for current page
        // - getTotalElements(): total number of matching accounts
        // - getTotalPages(): total number of pages
        // - getNumber(): current page number
        // - hasNext(): true if next page exists
        // - hasPrevious(): true if previous page exists

        return accountPage;
    }

    // => Sort by multiple fields
    public List<ZakatAccount> getAllAccountsSorted() {
        // => Sort.by(): multiple fields with directions
        Sort sort = Sort.by(
            Sort.Order.desc("balance"),  // => Primary sort: balance DESC
            Sort.Order.asc("ownerName")  // => Secondary sort: ownerName ASC
        );

        // => findAll(Sort): returns List<ZakatAccount> sorted
        return accountRepository.findAll(sort);
    }

    // => Slice pagination: efficient for infinite scroll
    public Slice<ZakatAccount> searchAccounts(String namePart, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);

        // => Slice: doesn't execute count query (faster)
        Slice<ZakatAccount> slice = accountRepository.findByOwnerNameContaining(namePart, pageable);

        // => Slice methods:
        // - getContent(): List<ZakatAccount> for current page
        // - hasNext(): true if next page exists (cheaper than Page)
        // - No getTotalElements() or getTotalPages()

        return slice;
    }
}

Progression Diagram

  graph TD
    A[Manual EntityManager<br/>50 Lines per Entity] -->|Boilerplate| B[Repetitive CRUD]
    A -->|Manual JPQL| C[String Queries]
    A -->|Manual Pagination| D[setFirstResult/setMaxResults]

    E[Spring Data JPA<br/>Interface Only] -->|Zero Implementation| F[Generated CRUD]
    E -->|Derived Queries| G[Type-Safe Methods]
    E -->|Built-in Pagination| H[Page/Slice Support]

    I[Custom Queries<br/>@Query] -->|Complex JPQL| J[Custom Logic]
    I -->|Native SQL| K[Database-Specific]
    I -->|Projections| L[DTO Mapping]

    style A fill:#DE8F05,stroke:#333,stroke-width:2px,color:#fff
    style E fill:#029E73,stroke:#333,stroke-width:2px,color:#fff
    style I fill:#0173B2,stroke:#333,stroke-width:2px,color:#fff

Production Patterns

Specification Pattern for Dynamic Queries

import org.springframework.data.jpa.domain.Specification;

// => Specification: dynamic query building
public class ZakatAccountSpecifications {

    // => Specification for owner name filter
    public static Specification<ZakatAccount> hasOwnerName(String ownerName) {
        // => Lambda: (root, query, cb) → Predicate
        // => root: entity root (ZakatAccount)
        // => cb: CriteriaBuilder for building predicates
        return (root, query, cb) ->
            ownerName == null ? null : cb.equal(root.get("ownerName"), ownerName);
    }

    // => Specification for minimum balance filter
    public static Specification<ZakatAccount> hasMinimumBalance(BigDecimal minBalance) {
        return (root, query, cb) ->
            minBalance == null ? null : cb.greaterThanOrEqualTo(root.get("balance"), minBalance);
    }

    // => Combine specifications: dynamic AND conditions
    public static Specification<ZakatAccount> searchAccounts(String owner, BigDecimal minBalance) {
        // => Specification.where(): starts specification chain
        // => and(): combines specifications with AND
        return Specification.where(hasOwnerName(owner))
            .and(hasMinimumBalance(minBalance));
    }
}

// => Repository: extends JpaSpecificationExecutor
public interface ZakatAccountRepository extends JpaRepository<ZakatAccount, Long>,
                                                 JpaSpecificationExecutor<ZakatAccount> {
    // => JpaSpecificationExecutor adds:
    // - findAll(Specification): dynamic query
    // - findOne(Specification): single result
    // - count(Specification): count with filter
}

@Service
public class ZakatAccountService {

    // => Dynamic search with specifications
    public List<ZakatAccount> searchAccounts(String owner, BigDecimal minBalance) {
        // => Build specification from parameters
        Specification<ZakatAccount> spec = ZakatAccountSpecifications.searchAccounts(owner, minBalance);
        // => findAll(Specification): executes dynamic query
        return accountRepository.findAll(spec);
    }
}

Audit Metadata with @EntityListeners

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

// => Entity with audit fields
@Entity
@EntityListeners(AuditingEntityListener.class)  // => Enables audit field population
public class ZakatAccount {

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

    // ... other fields ...

    @CreatedDate  // => Automatically set on insert
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate  // => Automatically updated on every save
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;
}

// => Enable JPA auditing in configuration
@Configuration
@EnableJpaAuditing  // => Activates audit field population
public class JpaConfig {
}

Optimistic Locking with @Version

@Entity
public class ZakatAccount {

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

    @Version  // => Optimistic locking: incremented on every update
    private Long version;

    private BigDecimal balance;
}

@Service
public class ZakatAccountService {

    // => Concurrent update handling
    @Transactional
    public void updateBalance(Long id, BigDecimal newBalance) {
        // => Find account: loads version field
        ZakatAccount account = accountRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Account not found"));

        // => Modify balance
        account.setBalance(newBalance);

        try {
            // => save(): checks version, throws exception if changed
            accountRepository.save(account);
            // => Success: version incremented automatically

        } catch (OptimisticLockException e) {
            // => OptimisticLockException: another transaction modified entity
            // => Handle conflict: retry, merge, or fail
            throw new ConcurrentModificationException("Account was modified by another user", e);
        }
    }
}

Trade-offs and When to Use

ApproachBoilerplateFlexibilityPerformanceType SafetyLearning Curve
Manual EntityManagerHighFullFastLowMedium
Spring Data JPAVery LowLimitedGoodHighLow
Spring Data JPA + @QueryLowHighGoodHighMedium
JdbcTemplateLowFullFastestMediumLow

When to Use Manual EntityManager:

  • Learning JPA fundamentals
  • Custom entity lifecycle management
  • Debugging framework issues

When to Use Spring Data JPA:

  • Standard CRUD operations (90% of use cases)
  • Simple queries (findById, findAll, findBy…)
  • Rapid development with minimal code
  • JPA abstraction over SQL preferred
  • Database portability required

When to Use @Query:

  • Complex joins not expressible in method names
  • Aggregation queries (SUM, AVG, COUNT)
  • Native SQL for database-specific features
  • Bulk updates/deletes
  • Custom projections and DTOs

When to Use JdbcTemplate Instead:

  • Performance-critical queries (no ORM overhead)
  • Complex SQL with subqueries, CTEs, window functions
  • Bulk operations with custom SQL
  • Database-specific features (PostgreSQL arrays, JSON)

Best Practices

1. Extend JpaRepository for Standard CRUD

Let Spring Data generate basic operations:

public interface ZakatAccountRepository extends JpaRepository<ZakatAccount, Long> {
    // Automatic: save, findById, findAll, count, delete
}

2. Use Derived Query Methods for Simple Filters

Method names generate queries automatically:

List<ZakatAccount> findByOwnerNameAndBalanceGreaterThan(String owner, BigDecimal balance);

3. Use @Query for Complex Logic

Custom JPQL for complex queries:

@Query("SELECT a FROM ZakatAccount a WHERE a.balance >= :nisab ORDER BY a.balance DESC")
List<ZakatAccount> findAccountsAboveNisab(@Param("nisab") BigDecimal nisab);

4. Use Pageable for Large Result Sets

Built-in pagination support:

Page<ZakatAccount> findAll(Pageable pageable);

5. Use @Version for Concurrent Modification Protection

Optimistic locking prevents data loss:

@Version
private Long version;

6. Use Specifications for Dynamic Queries

Build queries programmatically:

Specification<ZakatAccount> spec = Specification
    .where(hasOwnerName(owner))
    .and(hasMinimumBalance(minBalance));

See Also

Last updated