Spring Data JPA with Spring Boot

In simple terms: Spring Data JPA generates the repository implementation for you at runtime — you declare an interface, and Spring wires up the SQL.

Real-world domain example

We will use an employee-management application to connect the main JPA ideas. An employee belongs to a department, may have one profile, and can work on many projects. Customers place orders containing products. These are common relationship patterns in business systems.

Employee and commerce domain
Departmenthas many Employees
Employeehas one Profile
Projectmany Employees

Required dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- Choose one relational database driver -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- Or use MySQL instead of PostgreSQL -->
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

The application normally uses one relational driver for a deployment. Do not add PostgreSQL and MySQL as competing runtime databases unless you have deliberately configured separate data sources.

Entity and Repository

@Entity
class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;
}

interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByNameContainingIgnoreCase(String name);
}

JPA maps the entity to a table. A repository provides common operations such as save, findById, findAll, and delete without writing basic SQL. Spring Data also parses method names like findByNameContainingIgnoreCase into a query automatically.

Employee, Department, Customer, and Product

Entities represent relational tables. Keep entity classes focused on persistence and use DTOs at the API boundary. The following simplified model uses com.javacodeex.orders as the application package.

@Entity
public class Department {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
    private List<Employee> employees = new ArrayList<>();
}

@Entity
public class Employee {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")
    private Department department;
}

@Entity
public class Customer {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private String email;
}

@Entity
public class Product {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private BigDecimal price;
    private int stockQuantity;
}

Entity Relationships

Real schemas are rarely a single table. A common pattern is a parent entity with many child rows — for example, an Order that has many OrderItem rows. Model this with @OneToMany on the parent and @ManyToOne on the child.

@Entity
class Order {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<OrderItem> items = new ArrayList<>();
}

@Entity
class OrderItem {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private String productName;
    private int quantity;
}

The @ManyToOne side is the owning side of the relationship because it holds the foreign key column (order_id). The @OneToMany side uses mappedBy to say “the other entity already manages this column, I am just the inverse view.” Only the owning side’s changes are written to the foreign key.

cascade = CascadeType.ALL means operations on the Order (persist, remove, merge) propagate to its OrderItem rows — convenient for a true parent/child relationship, but dangerous on relationships that are merely a reference (you would not want deleting a Product to cascade-delete every OrderItem that ever referenced it). fetch = FetchType.LAZY defers loading the collection until it is actually accessed; @ManyToOne defaults to EAGER in plain JPA, so it is common to set it to LAZY explicitly to avoid loading data you do not need.

Tip: Default to LAZY for collections and large associations, and only fetch eagerly when a specific query needs the related data. You can always widen a query with JOIN FETCH or @EntityGraph when you do need it.

Relationship end-to-end examples

The examples below use the employee-management domain. In a real application, expose DTOs instead of directly accepting entities so clients cannot accidentally change relationships or create recursive JSON responses.

One-to-many: Department to Employees

One department can have many employees. The foreign key is stored in the employee table, so the employee side owns the relationship.

@Entity
class Department {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @OneToMany(mappedBy = "department",
               cascade = CascadeType.ALL,
               orphanRemoval = true)
    private List<Employee> employees = new ArrayList<>();

    public void addEmployee(Employee employee) {
        employees.add(employee);
        employee.setDepartment(this);
    }
}
@RestController
@RequestMapping("/api/departments")
class DepartmentController {
    private final DepartmentService service;

    @PostMapping
    DepartmentResponse create(
            @Valid @RequestBody CreateDepartmentRequest request) {
        return service.create(request);
    }

    @GetMapping("/{id}/employees")
    List<EmployeeResponse> employees(@PathVariable Long id) {
        return service.findEmployees(id);
    }
}
POST /api/departments
{ "name": "Engineering" }

POST /api/employees
{ "name": "Anand", "email": "anand@example.com", "departmentId": 1 }

GET /api/departments/1/employees

Many-to-one: Employees to Department

Many employees can reference one department. This is the owning side because it contains the department_id foreign key.

@Entity
class Employee {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "department_id", nullable = false)
    private Department department;
}
@Service
class EmployeeService {
    private final EmployeeRepository employees;
    private final DepartmentRepository departments;

    @Transactional
    EmployeeResponse create(CreateEmployeeRequest request) {
        Department department = departments.findById(request.departmentId())
                .orElseThrow(DepartmentNotFoundException::new);
        Employee employee = new Employee();
        employee.setName(request.name());
        employee.setDepartment(department);
        return EmployeeResponse.from(employees.save(employee));
    }
}

Keep this association lazy and fetch the department only for queries that need it. Use JOIN FETCH or @EntityGraph for an employee list that must include department names.

One-to-one: Employee to Employee Profile

Store a unique foreign key on the dependent profile table when each employee has one profile.

@Entity
class EmployeeProfile {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String phoneNumber;
    private String address;

    @OneToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "employee_id", nullable = false, unique = true)
    private Employee employee;
}

interface EmployeeProfileRepository
        extends JpaRepository<EmployeeProfile, Long> {
    Optional<EmployeeProfile> findByEmployeeId(Long employeeId);
}
PUT /api/employees/10/profile
{ "phoneNumber": "+91-9876543210", "address": "Hyderabad" }

GET /api/employees/10/profile

Many-to-many: Employees to Projects

Many employees can work on many projects. A join table stores the two foreign keys. For role or assignment date, create an explicit EmployeeProject entity instead.

@Entity
class Project {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ManyToMany
    @JoinTable(
        name = "employee_project",
        joinColumns = @JoinColumn(name = "project_id"),
        inverseJoinColumns = @JoinColumn(name = "employee_id"))
    private Set<Employee> employees = new HashSet<>();
}

interface ProjectRepository
        extends JpaRepository<Project, Long> { }
PUT /api/projects/4/employees/10
GET /api/projects/4/employees
DELETE /api/projects/4/employees/10
Relationship rule: Decide which side owns the foreign key or join table, update both sides with helper methods, and fetch relationships deliberately to avoid N+1 queries.

One-to-one relationship

Use one-to-one when one row has exactly one related row, such as an employee and an employee profile. The foreign key normally belongs on the dependent table.

@Entity
class EmployeeProfile {
    @Id @GeneratedValue
    private Long id;
    private String phoneNumber;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "employee_id", unique = true)
    private Employee employee;
}

Many-to-many relationship

Use a join table when employees can work on many projects and each project can have many employees. In large systems, an explicit EmployeeProject entity is often better because it can store assignment date, role, or hours.

@Entity
class Project {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToMany
    @JoinTable(name = "employee_project",
        joinColumns = @JoinColumn(name = "project_id"),
        inverseJoinColumns = @JoinColumn(name = "employee_id"))
    private Set<Employee> employees = new HashSet<>();
}
Relationship Example Database shape
@OneToOne Employee - Profile Foreign key with a unique constraint
@OneToMany Department - Employees Foreign key on the employee table
@ManyToOne Employee - Department Owning side contains department_id
@ManyToMany Employee - Project Separate join table

Complete CRUD operations

A normal CRUD flow separates the HTTP controller, business service, repository, and persistence entity. The same pattern works for employees, departments, customers, and products.

public record CreateEmployeeRequest(
        @NotBlank String name,
        @Email String email,
        @NotNull Long departmentId) { }

public record EmployeeResponse(
        Long id, String name, String email, String departmentName) { }

@Repository
interface EmployeeRepository extends JpaRepository<Employee, Long> { }

@Service
class EmployeeService {
    private final EmployeeRepository employees;
    private final DepartmentRepository departments;

    EmployeeService(EmployeeRepository employees,
                    DepartmentRepository departments) {
        this.employees = employees;
        this.departments = departments;
    }

    EmployeeResponse create(CreateEmployeeRequest request) {
        Department department = departments.findById(request.departmentId())
                .orElseThrow(DepartmentNotFoundException::new);
        Employee employee = new Employee(request.name(), request.email(), department);
        Employee saved = employees.save(employee);
        return toResponse(saved);
    }

    EmployeeResponse findById(Long id) {
        return employees.findById(id)
                .map(this::toResponse)
                .orElseThrow(EmployeeNotFoundException::new);
    }

    EmployeeResponse update(Long id, CreateEmployeeRequest request) {
        Employee employee = employees.findById(id)
                .orElseThrow(EmployeeNotFoundException::new);
        employee.setName(request.name());
        employee.setEmail(request.email());
        return toResponse(employees.save(employee));
    }

    void delete(Long id) {
        if (!employees.existsById(id)) {
            throw new EmployeeNotFoundException();
        }
        employees.deleteById(id);
    }
}
@RestController
@RequestMapping("/api/employees")
class EmployeeController {
    private final EmployeeService service;

    EmployeeController(EmployeeService service) {
        this.service = service;
    }

    @PostMapping
    ResponseEntity<EmployeeResponse> create(
            @Valid @RequestBody CreateEmployeeRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(service.create(request));
    }

    @GetMapping("/{id}")
    EmployeeResponse find(@PathVariable Long id) {
        return service.findById(id);
    }

    @PutMapping("/{id}")
    EmployeeResponse update(@PathVariable Long id,
                            @Valid @RequestBody CreateEmployeeRequest request) {
        return service.update(id, request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void delete(@PathVariable Long id) {
        service.delete(id);
    }
}
Operation HTTP endpoint Repository method
Create POST /api/employees save(entity)
Read one GET /api/employees/{id} findById(id)
Read all GET /api/employees findAll(pageable)
Update PUT /api/employees/{id} save(entity)
Delete DELETE /api/employees/{id} deleteById(id)

Customer and Product CRUD

The same repository pattern can expose departments, customers, and products. Keep one service per business area so validation and rules do not accumulate in one large service.

@Repository
interface DepartmentRepository
        extends JpaRepository<Department, Long> { }

@Repository
interface CustomerRepository
        extends JpaRepository<Customer, Long> {
    Optional<Customer> findByEmailIgnoreCase(String email);
}

@Repository
interface ProductRepository
        extends JpaRepository<Product, Long> {
    Page<Product> findByNameContainingIgnoreCase(
            String name, Pageable pageable);
}

// Typical endpoints
POST   /api/departments
GET    /api/departments/{id}
PUT    /api/departments/{id}
DELETE /api/departments/{id}

POST   /api/customers
GET    /api/customers/{id}
PUT    /api/customers/{id}
DELETE /api/customers/{id}

POST   /api/products
GET    /api/products/{id}
PUT    /api/products/{id}
DELETE /api/products/{id}

For PostgreSQL and MySQL, these endpoints can use the same Java code. Only the active database profile and driver normally change. For MongoDB, create equivalent MongoRepository interfaces and document models rather than JPA entities.

End-to-end use cases

An end-to-end feature follows the complete path from an HTTP request to the database and back to a response:

Complete Spring Data request flow
POST /api/employees
Controller validates DTO
Service applies business rules
Repository executes SQL or document query
Response DTO returns JSON

Use case 1: Employee and Department

A department can contain many employees. The employee owns the foreign key, so the service first verifies the department and then saves the employee.

@RestController
@RequestMapping("/api/employees")
class EmployeeController {
    private final EmployeeService service;

    EmployeeController(EmployeeService service) {
        this.service = service;
    }

    @PostMapping
    ResponseEntity<EmployeeResponse> create(
            @Valid @RequestBody CreateEmployeeRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(service.create(request));
    }

    @GetMapping("/{id}")
    EmployeeResponse find(@PathVariable Long id) {
        return service.find(id);
    }

    @GetMapping
    Page<EmployeeResponse> findAll(Pageable pageable) {
        return service.findAll(pageable);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void delete(@PathVariable Long id) {
        service.delete(id);
    }
}
@Service
class EmployeeService {
    private final EmployeeRepository employees;
    private final DepartmentRepository departments;

    @Transactional
    EmployeeResponse create(CreateEmployeeRequest request) {
        Department department = departments.findById(request.departmentId())
                .orElseThrow(DepartmentNotFoundException::new);

        Employee employee = new Employee(
                request.name(), request.email(), department);
        return EmployeeResponse.from(employees.save(employee));
    }

    @Transactional(readOnly = true)
    Page<EmployeeResponse> findAll(Pageable pageable) {
        return employees.findAll(pageable)
                .map(EmployeeResponse::from);
    }

    @Transactional
    void delete(Long id) {
        if (!employees.existsById(id)) {
            throw new EmployeeNotFoundException();
        }
        employees.deleteById(id);
    }
}

Test the complete flow with HTTP requests:

POST http://localhost:8080/api/employees
Content-Type: application/json

{
  "name": "Anand",
  "email": "anand@example.com",
  "departmentId": 1
}

GET http://localhost:8080/api/employees?page=0&size=10&sort=name,asc

DELETE http://localhost:8080/api/employees/10

Use case 2: Customer and Product

Customers and products are usually separate aggregates. A product service can create, update, search, and delete products, while a customer service owns customer email uniqueness and profile rules.

@Repository
interface ProductRepository extends JpaRepository<Product, Long> {
    Page<Product> findByNameContainingIgnoreCase(
            String name, Pageable pageable);
}

@Service
class ProductService {
    private final ProductRepository products;

    ProductService(ProductRepository products) {
        this.products = products;
    }

    Product create(Product product) {
        return products.save(product);
    }

    Page<Product> search(String name, Pageable pageable) {
        return products.findByNameContainingIgnoreCase(name, pageable);
    }

    Product update(Long id, Product changes) {
        Product current = products.findById(id)
                .orElseThrow(ProductNotFoundException::new);
        current.setName(changes.getName());
        current.setPrice(changes.getPrice());
        current.setStockQuantity(changes.getStockQuantity());
        return products.save(current);
    }
}
POST   /api/products
GET    /api/products?name=laptop&page=0&size=20
GET    /api/products/5
PUT    /api/products/5
DELETE /api/products/5

POST   /api/customers
GET    /api/customers/20
PUT    /api/customers/20
DELETE /api/customers/20

Use case 3: PostgreSQL deployment

The employee, department, customer, and product code can run against PostgreSQL by adding the driver and activating a production profile.

# pom.xml
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

# application-prod.properties
spring.datasource.url=${POSTGRES_URL}
spring.datasource.username=${POSTGRES_USER}
spring.datasource.password=${POSTGRES_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
spring.flyway.enabled=true
java -jar order-service.jar --spring.profiles.active=prod

curl -X POST http://localhost:8080/api/products +  -H "Content-Type: application/json" +  -d '{"name":"Laptop","price":75000,"stockQuantity":12}'

Use case 4: MySQL deployment

MySQL uses the same entity and repository code. Change the runtime driver and profile properties, then run the same REST requests.

# pom.xml
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>

# application-mysql.properties
spring.datasource.url=jdbc:mysql://localhost:3306/orders?serverTimezone=UTC
spring.datasource.username=${MYSQL_USER}
spring.datasource.password=${MYSQL_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false
java -jar order-service.jar --spring.profiles.active=mysql

curl http://localhost:8080/api/employees?page=0&size=10

Use case 5: MongoDB product catalog

Use MongoDB when product attributes vary by category or when a document-shaped catalog is more natural than a fixed relational schema. This flow uses a document, Mongo repository, service, and controller.

@Document("products")
record ProductDocument(
        @Id String id,
        String name,
        BigDecimal price,
        Map<String, Object> attributes) { }

interface ProductMongoRepository
        extends MongoRepository<ProductDocument, String> {
    List<ProductDocument> findByNameContainingIgnoreCase(String name);
}

@RestController
@RequestMapping("/api/catalog/products")
class ProductCatalogController {
    private final ProductMongoRepository products;

    @GetMapping
    List<ProductDocument> search(
            @RequestParam String name) {
        return products.findByNameContainingIgnoreCase(name);
    }

    @PostMapping
    ProductDocument create(@RequestBody ProductDocument product) {
        return products.save(product);
    }
}
# application-mongo.properties
spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017/catalog}

curl -X POST http://localhost:8080/api/catalog/products +  -H "Content-Type: application/json" +  -d '{"name":"Laptop","price":75000,
       "attributes":{"ram":"16GB","screen":"15 inch"}}'

curl "http://localhost:8080/api/catalog/products?name=laptop"
Database choice: Use PostgreSQL or MySQL for strongly relational employee, department, customer, order, and inventory data. Use MongoDB for flexible documents such as product catalogs and event records. Do not use JPA annotations on MongoDB documents.

MongoDB end-to-end example

In this example, we build a product catalog API. A request enters the controller, the service applies business rules, the repository talks to MongoDB, and the saved document is returned as JSON.

MongoDB request flow
POST /api/products
ProductController
ProductService
ProductMongoRepository
products collection

MongoDB database structure

MongoDB does not use tables and rows in the relational sense. It uses a database, collections, and documents:

Relational idea MongoDB equivalent Example
Database Database catalog
Table Collection products
Row Document One product JSON document
Column Document field price, attributes
Primary key _id MongoDB-generated ObjectId
catalog
└── products
    └── {
          "_id": "65f1a8...",
          "name": "Laptop",
          "price": 75000,
          "stockQuantity": 12,
          "attributes": {
            "ram": "16GB",
            "screen": "15 inch"
          }
        }

1. Create the document model

@Document(collection = "products")
public class ProductDocument {
    @Id
    private String id;

    @NotBlank
    private String name;

    @Positive
    private BigDecimal price;

    @PositiveOrZero
    private int stockQuantity;

    private Map<String, Object> attributes = new HashMap<>();

    // constructors, getters, and setters
}

@Document maps the Java class to the products collection. MongoDB can store different fields in different documents, but a consistent document shape is easier to validate and maintain.

2. Create the repository

public interface ProductMongoRepository
        extends MongoRepository<ProductDocument, String> {

    Page<ProductDocument> findByNameContainingIgnoreCase(
            String name, Pageable pageable);

    List<ProductDocument> findByStockQuantityLessThan(int quantity);
}

Spring Data creates the repository implementation. The first method searches product names and supports pagination; the second finds products that need restocking.

3. Create the service

@Service
public class ProductCatalogService {
    private final ProductMongoRepository repository;

    public ProductCatalogService(ProductMongoRepository repository) {
        this.repository = repository;
    }

    public ProductDocument create(ProductDocument product) {
        return repository.save(product);
    }

    public ProductDocument findById(String id) {
        return repository.findById(id)
                .orElseThrow(ProductNotFoundException::new);
    }

    public Page<ProductDocument> search(
            String name, Pageable pageable) {
        return repository.findByNameContainingIgnoreCase(name, pageable);
    }

    public ProductDocument update(
            String id, ProductDocument changes) {
        ProductDocument current = findById(id);
        current.setName(changes.getName());
        current.setPrice(changes.getPrice());
        current.setStockQuantity(changes.getStockQuantity());
        current.setAttributes(changes.getAttributes());
        return repository.save(current);
    }

    public void delete(String id) {
        if (!repository.existsById(id)) {
            throw new ProductNotFoundException();
        }
        repository.deleteById(id);
    }
}

4. Create the controller

@RestController
@RequestMapping("/api/mongo/products")
public class ProductCatalogController {
    private final ProductCatalogService service;

    public ProductCatalogController(
            ProductCatalogService service) {
        this.service = service;
    }

    @PostMapping
    ResponseEntity<ProductDocument> create(
            @Valid @RequestBody ProductDocument product) {
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(service.create(product));
    }

    @GetMapping("/{id}")
    ProductDocument find(@PathVariable String id) {
        return service.findById(id);
    }

    @GetMapping
    Page<ProductDocument> search(
            @RequestParam(defaultValue = "") String name,
            @PageableDefault(size = 20) Pageable pageable) {
        return service.search(name, pageable);
    }

    @PutMapping("/{id}")
    ProductDocument update(@PathVariable String id,
                           @Valid @RequestBody ProductDocument product) {
        return service.update(id, product);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    void delete(@PathVariable String id) {
        service.delete(id);
    }
}

5. Configure the connection

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

# application-mongo.properties
spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017/catalog}
spring.data.mongodb.auto-index-creation=false

The URI can point to local MongoDB, Docker, MongoDB Atlas, or a managed cloud deployment. Keep credentials in environment variables or a secret manager.

6. Test all CRUD operations

# Create
curl -X POST http://localhost:8080/api/mongo/products +  -H "Content-Type: application/json" +  -d '{"name":"Laptop","price":75000,"stockQuantity":12,
       "attributes":{"ram":"16GB","screen":"15 inch"}}'

# Read one
curl http://localhost:8080/api/mongo/products/65f1a8

# Search with pagination
curl "http://localhost:8080/api/mongo/products?name=laptop&page=0&size=10"

# Update
curl -X PUT http://localhost:8080/api/mongo/products/65f1a8 +  -H "Content-Type: application/json" +  -d '{"name":"Business Laptop","price":82000,"stockQuantity":8}'

# Delete
curl -X DELETE http://localhost:8080/api/mongo/products/65f1a8

The repository converts these operations into MongoDB insert, find, update, and delete commands. The controller does not know how MongoDB stores the document, and the service remains the right place for validation and business rules.

Derived Query Methods

Spring Data JPA reads the repository method name and builds a query from it. This avoids boilerplate for straightforward lookups.

Method pattern Behaves like
findByLastName(String lastName) WHERE last_name = ?
findByLastNameAndFirstName(String a, String b) WHERE last_name = ? AND first_name = ?
findByNameContainingIgnoreCase(String name) WHERE LOWER(name) LIKE LOWER('%?%')
findByPriceGreaterThan(BigDecimal price) WHERE price > ?
existsByEmail(String email) SELECT COUNT(*) > 0 ... WHERE email = ?
countByStatus(String status) SELECT COUNT(*) ... WHERE status = ?
deleteByStatus(String status) DELETE ... WHERE status = ?

These patterns compose well for simple filters, but once you need joins, subqueries, aggregation, or conditional logic that does not map cleanly to a method name, switch to @Query.

Using @Query

Reach for @Query when the query is too complex for a derived method name, when you want a specific projection instead of the full entity, or when you need native SQL features that JPQL does not expose (window functions, database-specific syntax, hints).

// JPQL - works against entity names and fields, database-independent
@Query("select o from Order o where o.status = :status order by o.createdDate desc")
List<Order> findRecentByStatus(@Param("status") String status);

// Native SQL - works against actual table and column names
@Query(value = "select * from orders where total_amount > :min", nativeQuery = true)
List<Order> findAboveAmount(@Param("min") BigDecimal min);

Prefer JPQL when possible because it stays portable across databases and understands entity relationships. Fall back to native SQL only when you need a database-specific feature or a hand-tuned query.

Pagination and Sorting

Loading an entire table for a listing page does not scale. Pageable and Sort let the repository ask the database for one page of rows in a given order, and Page<T> carries back the content plus paging metadata.

Pageable pageable = PageRequest.of(0, 20, Sort.by("name").ascending());
Page<Product> page = productRepository.findAll(pageable);

page.getContent();       // the 20 products on this page
page.getTotalElements(); // total rows across all pages
page.getTotalPages();    // total number of pages

Any repository method can also accept a trailing Pageable or Sort parameter, including derived query methods, e.g. findByStatus(String status, Pageable pageable).

End-to-end example: employee directory

Imagine an HR application with thousands of employees. The employee directory must show 20 employees at a time and allow users to sort by name, joining date, or salary. Returning every employee in one response wastes memory and makes the page slow. The API should ask the database for only the requested slice of data.

Request flow
Clientpage=0&size=20
Controllervalidates request
Servicecreates Pageable
DatabaseLIMIT/OFFSET + ORDER BY

1. Create the employee entity

@Entity
@Table(name = "employees")
public class Employee {

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

    @Column(nullable = false)
    private String name;

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

    @Column(nullable = false)
    private LocalDate joiningDate;

    @Column(nullable = false, precision = 12, scale = 2)
    private BigDecimal salary;

    // constructors, getters, and setters
}

2. Add repository methods

Extending JpaRepository gives the application the standard findAll(Pageable) method. A derived query can also be paginated by adding Pageable as the last parameter.

public interface EmployeeRepository
        extends JpaRepository<Employee, Long> {

    Page<Employee> findByDepartmentId(
            Long departmentId,
            Pageable pageable);

    Page<Employee> findByNameContainingIgnoreCase(
            String name,
            Pageable pageable);
}

3. Return a small API response

Returning the JPA Page object directly can expose internal entity details. A response DTO keeps the API contract stable and sends only the fields required by the employee list.

public record EmployeeSummary(
        Long id,
        String name,
        String email,
        LocalDate joiningDate,
        BigDecimal salary) {
}

public record PageResponse<T>(
        List<T> content,
        int page,
        int size,
        long totalElements,
        int totalPages,
        boolean first,
        boolean last,
        String sortBy,
        String direction) {
}

4. Implement the service

Page numbers are zero-based: page=0 is the first page. The service caps the page size and allows only approved fields for sorting. This prevents clients from requesting an expensive unlimited result or sorting by an unexpected database property.

@Service
@Transactional(readOnly = true)
public class EmployeeService {

    private static final int MAX_PAGE_SIZE = 100;

    private static final Set<String> ALLOWED_SORT_FIELDS = Set.of(
            "name", "joiningDate", "salary");

    private final EmployeeRepository employeeRepository;

    public EmployeeService(EmployeeRepository employeeRepository) {
        this.employeeRepository = employeeRepository;
    }

    public PageResponse<EmployeeSummary> search(
            int page,
            int size,
            String sortBy,
            String direction,
            String name) {

        if (page < 0) {
            throw new IllegalArgumentException("Page cannot be negative");
        }

        int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
        String safeSortBy = ALLOWED_SORT_FIELDS.contains(sortBy)
                ? sortBy : "name";

        Sort.Direction sortDirection =
                "desc".equalsIgnoreCase(direction)
                        ? Sort.Direction.DESC
                        : Sort.Direction.ASC;

        Pageable pageable = PageRequest.of(
                page,
                safeSize,
                Sort.by(sortDirection, safeSortBy));

        Page<Employee> employees = name == null || name.isBlank()
                ? employeeRepository.findAll(pageable)
                : employeeRepository
                        .findByNameContainingIgnoreCase(name, pageable);

        Page<EmployeeSummary> summaries = employees.map(employee ->
                new EmployeeSummary(
                        employee.getId(),
                        employee.getName(),
                        employee.getEmail(),
                        employee.getJoiningDate(),
                        employee.getSalary()));

        return new PageResponse<>(
                summaries.getContent(),
                summaries.getNumber(),
                summaries.getSize(),
                summaries.getTotalElements(),
                summaries.getTotalPages(),
                summaries.isFirst(),
                summaries.isLast(),
                safeSortBy,
                sortDirection.name());
    }
}

5. Create the REST controller

@RestController
@RequestMapping("/api/employees")
public class EmployeeController {

    private final EmployeeService employeeService;

    public EmployeeController(EmployeeService employeeService) {
        this.employeeService = employeeService;
    }

    @GetMapping
    public PageResponse<EmployeeSummary> search(
            @RequestParam(defaultValue = "0") int page,
            @RequestParam(defaultValue = "20") int size,
            @RequestParam(defaultValue = "name") String sortBy,
            @RequestParam(defaultValue = "asc") String direction,
            @RequestParam(required = false) String name) {

        return employeeService.search(
                page, size, sortBy, direction, name);
    }
}

6. Test the API

Get the first page, containing up to 20 employees:

GET /api/employees?page=0&size=20&sortBy=name&direction=asc

Get the third page, sorted by highest salary first:

GET /api/employees?page=2&size=10&sortBy=salary&direction=desc

Search employees whose names contain an:

GET /api/employees?name=an&page=0&size=20&sortBy=joiningDate&direction=desc

Example JSON response

{
  "content": [
    {
      "id": 42,
      "name": "Anand Kumar",
      "email": "anand@example.com",
      "joiningDate": "2024-06-10",
      "salary": 85000.00
    }
  ],
  "page": 0,
  "size": 20,
  "totalElements": 247,
  "totalPages": 13,
  "first": true,
  "last": false,
  "sortBy": "name",
  "direction": "ASC"
}

What SQL does Hibernate execute?

For a page request, Hibernate normally sends a data query with ORDER BY, LIMIT, and OFFSET, plus a count query to calculate the metadata.

select e.id, e.name, e.email, e.joining_date, e.salary
from employees e
order by e.name asc
limit 20 offset 0;

select count(e.id)
from employees e;

Production best practices

  • Use a stable sort, especially when records can be inserted during paging. Add id as a tie-breaker when needed.
  • Set a maximum page size such as 50 or 100.
  • Whitelist sortable fields instead of passing arbitrary property names to Sort.
  • Add database indexes for frequently filtered and sorted columns such as name, department_id, and joining_date.
  • Use Slice<T> when total-page counts are unnecessary; it avoids the count query.
  • Use keyset or cursor pagination for very large tables where high offset values become slow.
  • Use DTO projections when the list needs only a few columns.

Lazy loading and eager loading

Lazy loading loads an association only when code accesses it. It reduces the first query size and is usually the safer default for collections. Eager loading loads the association immediately, which can be useful for a small always-needed relationship but can create large joins and unexpected queries.

@ManyToOne(fetch = FetchType.LAZY)
private Department department;

@OneToMany(fetch = FetchType.LAZY, mappedBy = "department")
private List<Employee> employees;

// Fetch only for this query when the API needs the department
@EntityGraph(attributePaths = "department")
Optional<Employee> findWithDepartmentById(Long id);

Do not switch every relationship to EAGER to fix a lazy loading exception. Fetch the required graph in a repository query, map the result inside a transaction, and return a DTO.

Pagination in a REST API

@GetMapping
Page<EmployeeResponse> findEmployees(
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        @RequestParam(defaultValue = "name") String sort) {
    Pageable pageable = PageRequest.of(
            page, Math.min(size, 100), Sort.by(sort).ascending());
    return employeeService.findAll(pageable);
}

Limit the maximum page size, whitelist sortable fields, and return metadata such as total elements, total pages, current page, and page size. For very large tables, consider keyset pagination instead of increasingly expensive offsets.

The N+1 Query Problem

The N+1 problem happens when you fetch a list of N parent rows, and then, because an association is lazily loaded, the application triggers one extra query per row to fetch each parent’s related data — N+1 queries instead of one. It is easy to miss in development with small datasets and painful in production with large ones.

Why does N+1 happen?

Suppose the database contains 100 orders. The application first runs one query to load the orders. A loop then calls order.getItems() for every order. Because the collection is lazy, Hibernate sends one additional query for each order:

-- Query 1: load the parent rows
select * from orders;

-- Query 2 through Query 101: one query for every order
select * from order_items where order_id = 1;
select * from order_items where order_id = 2;
select * from order_items where order_id = 3;
-- ... repeated for every order

The result is 1 + N queries. With 100 orders, the application may execute 101 queries instead of one or two. Each query adds database network latency, connection-pool usage, CPU work, and object-mapping overhead.

Typical code that causes it

@Transactional(readOnly = true)
List<OrderResponse> listOrders() {
    List<Order> orders = orderRepository.findAll();

    return orders.stream()
            .map(order -> new OrderResponse(
                    order.getId(),
                    order.getItems().size())) // lazy query per order
            .toList();
}

The problem is not lazy loading by itself. Lazy loading is useful when related data is not always needed. The problem occurs when lazy data is accessed repeatedly inside a loop or after the persistence context has closed.

How the N+1 problem happens
1 queryLoad all orders
Loop over ordersAccess order.getItems()
N queriesOne per order, lazily

Two common fixes:

// 1. JOIN FETCH in JPQL - loads the association in the same query
@Query("select o from Order o join fetch o.items where o.status = :status")
List<Order> findWithItemsByStatus(@Param("status") String status);

// 2. @EntityGraph - declare which associations to fetch eagerly for this call
@EntityGraph(attributePaths = "items")
List<Order> findByStatus(String status);

Both approaches turn the N extra queries into a single query with a join. JOIN FETCH is explicit and lives with the query; @EntityGraph keeps the derived query method but lets you opt in to eager loading only for that call, without changing the entity’s default fetch type.

Ways to resolve N+1

Solution How it works Advantages Disadvantages
JOIN FETCH Fetches the relationship in a JPQL join. Explicit, fast, and easy to understand. Can duplicate parent rows and complicate pagination on collections.
@EntityGraph Defines the relationships to fetch for one repository method. Reusable and keeps the entity default lazy. Large graphs can still create expensive joins.
DTO projection Selects only the fields required by the response. Small responses, efficient SQL, no lazy serialization issue. Requires query and DTO maintenance for each view.
Batch fetching Groups lazy loads into fewer IN queries. Useful when relationships are optional or large. Usually produces more than one query and needs tuning.
Eager loading Loads the relationship whenever the entity is loaded. Simple for a small, always-required relationship. Can cause over-fetching and new performance problems globally.

DTO projection example

A DTO query is often the best choice for an API list because it selects exactly the columns required by the response.

public record OrderSummary(
        Long id, String customerName, long itemCount) { }

@Query("""
       select new com.javacodeex.orders.OrderSummary(
           o.id, o.customerName, count(i))
       from Order o
       left join o.items i
       group by o.id, o.customerName
       """)
Page<OrderSummary> findOrderSummaries(Pageable pageable);

Batch fetching example

Batch fetching keeps lazy loading but asks Hibernate to load several relationships together. The exact batch size should be measured for your workload.

@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
@BatchSize(size = 25)
private List<OrderItem> items;

# application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25

How to detect N+1

  • Enable SQL logging temporarily in development.
  • Count SQL statements in repository integration tests.
  • Use Hibernate statistics or a JDBC proxy such as datasource-proxy.
  • Inspect slow requests with application metrics and database monitoring.
  • Check list endpoints with realistic data, not only one or two rows.
# Development only: do not use verbose SQL logging as a production default
spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.orm.jdbc.bind=TRACE
Best practice: Keep relationships lazy by default, choose the fetch plan per use case, return DTOs from API boundaries, and verify query counts with realistic integration tests. Changing every relationship to eager loading hides the symptom but can make the whole application slower.

PostgreSQL configuration

PostgreSQL is a strong default for production relational applications. Use environment variables for credentials and let Flyway or Liquibase manage schema changes.

# application-prod.properties
spring.datasource.url=${POSTGRES_URL:jdbc:postgresql://localhost:5432/orders}
spring.datasource.username=${POSTGRES_USER:orders_app}
spring.datasource.password=${POSTGRES_PASSWORD}
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.hibernate.ddl-auto=validate
spring.flyway.enabled=true

MySQL configuration

MySQL uses the same JPA entities and repository interfaces. Normally only the JDBC URL, driver, dialect, and a few database settings change.

# application-mysql.properties
spring.datasource.url=jdbc:mysql://localhost:3306/orders?serverTimezone=UTC
spring.datasource.username=orders_app
spring.datasource.password=${MYSQL_PASSWORD}
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto=validate

Keep database-specific differences in profiles. The controller, service, entity, and repository code should remain portable whenever possible.

Using multiple relational databases

Supporting PostgreSQL and MySQL as alternative deployments is simple: activate a different profile. Connecting to both at the same time is a separate multi-data-source design requiring two data sources, two entity-manager factories, and separate repository packages.

# Run with PostgreSQL
java -jar order-service.jar --spring.profiles.active=prod

# Run the same application with MySQL
java -jar order-service.jar --spring.profiles.active=mysql

MongoDB with Spring Data MongoDB

MongoDB is not a JPA database. It stores documents rather than relational tables, so use Spring Data MongoDB repositories and @Document instead of @Entity. MongoDB is a good fit for flexible product catalogs, activity events, or document shaped customer preferences.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>

# application-mongo.properties
spring.data.mongodb.uri=${MONGODB_URI:mongodb://localhost:27017/orders}
@Document("products")
public class ProductDocument {
    @Id
    private String id;
    private String name;
    private BigDecimal price;
    private Map<String, Object> attributes;
}

public interface ProductMongoRepository
        extends MongoRepository<ProductDocument, String> {
    List<ProductDocument> findByNameContainingIgnoreCase(String name);
}
Database Spring Boot module Modeling style Good examples
PostgreSQL Spring Data JPA + PostgreSQL driver Entities and relationships Employees, departments, orders
MySQL Spring Data JPA + MySQL driver Entities and relationships Customers, products, inventory
MongoDB Spring Data MongoDB Documents and collections Product attributes, events, catalogs

Auditing and Schema Management

Spring Data can stamp entities with creation and modification metadata automatically once auditing is enabled with @EnableJpaAuditing on a configuration class.

@EntityListeners(AuditingEntityListener.class)
@Entity
class Order {
    @CreatedDate
    private Instant createdDate;

    @LastModifiedDate
    private Instant lastModifiedDate;
}
Warning: Hibernate’s ddl-auto=update is convenient for local development, but it should not manage your schema in production. Use a versioned migration tool such as Flyway or Liquibase so schema changes are reviewed, repeatable, and rollback-friendly.
Next: Spring Security →

Continue learning