Tutorials Logic, IN info@tutorialslogic.com

Spring Data JPA Repositories, Queries, Pagination

Persistence Boundary

Spring Data JPA creates repository implementations from interfaces, but the database contract still belongs to your application. A reliable design defines entity identity and constraints carefully, exposes use-case-oriented repository methods, places transaction boundaries in services, and controls which relationships are loaded for each query.

After this lesson, you can model one entity, write derived and explicit queries, return pages without loading an entire table, distinguish persistence entities from API payloads, and diagnose common transaction and N+1-query failures.

Entity Mapping

An entity represents persistent state and needs a stable identity. Map nullability, length, uniqueness, and relationships to match the database schema rather than relying only on Java validation. Use a protected no-argument constructor for JPA and keep invariants in constructors or behavior methods that application code uses.

Avoid exposing entities directly from REST controllers. Lazy relationships, persistence-specific annotations, bidirectional graphs, and internal columns make entities poor transport contracts. Map them to request and response DTOs at the service boundary.

Customer Entity

Customer Entity
import jakarta.persistence.*;

@Entity
@Table(name = "customers", uniqueConstraints =
    @UniqueConstraint(name = "uk_customer_email", columnNames = "email"))
public class Customer {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 120)
    private String name;

    @Column(nullable = false, length = 254)
    private String email;

    protected Customer() {}

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

Repository Contracts

JpaRepository supplies common persistence operations and paging support. Derived method names are useful while the condition remains obvious. Use @Query for a query that needs explicit joins, projections, or a name that would otherwise become unreadable. Repository methods should express persistence questions, not contain controller concerns.

Optional communicates that one result may be absent. List suits a bounded collection. Page includes content plus total-count metadata and normally issues a count query; Slice avoids the total count when the caller only needs to know whether another segment exists.

JPA Entity

JPA Entity
package com.example.entity;

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

@Entity
@Table(name = "users", indexes = {
    @Index(name = "idx_email", columnList = "email", unique = true)
})
public class User {

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

    @Column(name = "username", nullable = false, length = 50, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false, unique = true)
    private String email;

    @Enumerated(EnumType.STRING) // Store enum as string in DB
    @Column(nullable = false)
    private Role role = Role.USER;

    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @Transient // Not persisted to DB
    private String fullDisplayName;

    // One user has many orders
    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<Order> orders;

    @PrePersist
    protected void onCreate() { createdAt = LocalDateTime.now(); }

    @PreUpdate
    protected void onUpdate() { updatedAt = LocalDateTime.now(); }

    // Constructors, getters, setters...
    public enum Role { USER, ADMIN, MODERATOR }
}

JpaRepository and Custom Queries

JpaRepository and Custom Queries
package com.example.repository;

import com.example.entity.User;
import org.springframework.data.jpa.repository.*;
import org.springframework.data.domain.*;
import org.springframework.data.repository.query.Param;
import java.util.*;

// JpaRepository provides: save, findById, findAll, delete, count, exists, etc.
public interface UserRepository extends JpaRepository<User, Long> {

    // Spring generates query from method name
    Optional<User> findByEmail(String email);
    Optional<User> findByUsername(String username);
    List<User> findByRole(User.Role role);
    boolean existsByEmail(String email);
    long countByRole(User.Role role);

    // Derived query with multiple conditions
    List<User> findByRoleAndEmailContaining(User.Role role, String emailPart);

    // Custom JPQL query
    @Query("SELECT u FROM User u WHERE u.email = :email AND u.role = :role")
    Optional<User> findByEmailAndRole(@Param("email") String email,
                                       @Param("role") User.Role role);

    // Native SQL query
    @Query(value = "SELECT * FROM users WHERE created_at > :date", nativeQuery = true)
    List<User> findUsersCreatedAfter(@Param("date") java.time.LocalDateTime date);

    // Pagination and sorting
    Page<User> findByRole(User.Role role, Pageable pageable);

    // Modifying query (UPDATE/DELETE)
    @Modifying
    @Transactional
    @Query("UPDATE User u SET u.password = :password WHERE u.id = :id")
    int updatePassword(@Param("id") Long id, @Param("password") String password);

    // Delete by field
    void deleteByEmail(String email);
}

Transaction Ownership

Place @Transactional on a service method that represents one business operation. The transaction should cover the reads, invariant checks, and writes that must succeed or fail together. A read-only transaction communicates intent and may allow optimizations, but it is not an authorization or immutability guarantee.

Do not depend on a lazy relationship after the transaction has closed. Either fetch the data needed by the use case inside the transaction, return a projection, or map to a DTO before leaving the service. Catch exceptions only when you can translate or recover; hiding a constraint violation usually produces misleading success.

UserService with Pagination

UserService with Pagination
@Service
@Transactional
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User createUser(User user) {
        if (userRepository.existsByEmail(user.getEmail())) {
            throw new RuntimeException("Email already exists: " + user.getEmail());
        }
        return userRepository.save(user);
    }

    @Transactional(readOnly = true) // Optimization for read-only operations
    public User getUserById(Long id) {
        return userRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("User not found: " + id));
    }

    @Transactional(readOnly = true)
    public Page<User> getUsers(int page, int size, String sortBy) {
        Pageable pageable = PageRequest.of(page, size, Sort.by(sortBy).ascending());
        return userRepository.findAll(pageable);
    }

    public User updateUser(Long id, User updatedUser) {
        User existing = getUserById(id);
        existing.setUsername(updatedUser.getUsername());
        existing.setEmail(updatedUser.getEmail());
        return userRepository.save(existing); // save() = INSERT or UPDATE
    }

    public void deleteUser(Long id) {
        userRepository.deleteById(id);
    }
}

Pagination and Sorting

Accept a bounded page size and an allowlist of sortable fields. Unbounded findAll() calls eventually become memory and latency problems. Stable pagination needs a deterministic order, commonly a business sort plus id as a tie-breaker.

Bounded Customer Search

Bounded Customer Search
public Page<CustomerSummary> search(String prefix, int page, int size) {
    int boundedSize = Math.min(Math.max(size, 1), 100);
    Pageable request = PageRequest.of(
        Math.max(page, 0),
        boundedSize,
        Sort.by("name").ascending().and(Sort.by("id"))
    );
    return repository.findByNameStartingWithIgnoreCase(prefix, request);
}

Query Diagnosis

The N+1 problem appears when one query loads parent rows and later access triggers one additional query per parent. Confirm it with SQL logging or metrics, then choose a projection, entity graph, join fetch, or batch strategy that matches the use case. Do not mark every relationship eager; that moves the cost to unrelated queries and can create large joins.

Test repository behavior against a real relational engine when SQL dialect, constraints, locking, or migrations matter. An in-memory database is useful for fast feedback but may accept syntax or behavior that differs from production.

  • Inspect generated SQL and bind values in a safe development environment.
  • Add database indexes for measured filter, join, and ordering patterns.
  • Use schema migrations rather than relying on automatic production DDL.
  • Test uniqueness and foreign-key failures, not only successful saves.
Before you move on

JPA Boundary Review

5 checks
  • Map entity constraints to the database schema and migration.
  • Return DTOs or projections instead of exposing persistence entities directly.
  • Keep one business operation inside a clear service transaction.
  • Bound page sizes and apply deterministic sorting.
  • Inspect query counts before choosing fetch strategies.

Persistence Mistakes

  • Controller returns entities

    Map to stable DTOs before leaving the service transaction.
  • Lazy data accessed after transaction

    Fetch or project the required fields inside the use-case boundary.
  • Pagination without stable order

    Add a deterministic sort with a unique tie-breaker such as id.

Try this next

Exercise the Repository

0 of 3 completed

  1. Add database-level email uniqueness and test the duplicate failure.
  2. Create a query that returns only id, name, and email for a list screen.
  3. Load parents with children, observe N+1 behavior, then fix it for that use case.

Repository Design Questions

No. Use a transaction where a persistence operation needs a consistent unit of work; keep pure computation independent.

Use Page when the client needs total counts; use Slice when next-segment information is enough and a count query is unnecessary.

It loads the relationship for every use case and can create excessive joins; choose fetching per query instead.

Browse Free Tutorials

Explore 500+ free tutorials across 20+ languages and frameworks.