Use Case Overview
Imagine you’re building an Product Catalog Service — the core API that powers your online store.
Your front-end team wants:
- Products to be listed page by page (not all at once),
- Filters by category or brand (like “Mobiles” or “Apple”),
- Sorting by price or name (ascending/descending).
As a backend engineer, your job is to design an efficient REST API that:
- Handles large datasets efficiently,
- Supports pagination, filtering, and sorting in one API call,
- Returns clean, predictable JSON responses.
A Spring Boot REST API that exposes /api/productsendpoint.
This endpoint allows clients (UI, mobile, or other APIs) to:
-
Get paginated results (e.g., 5 records per page)
-
Sort results (by price, name, etc.)
-
Filter results (by category or brand)
So instead of returning thousands of records in one call, it returns smaller, structured, and queryable chunks of data.
Why we need Pagination, Filtering, and Sorting
Let’s say your product table has 100,000 products.
If you call /api/products without pagination:
- The API might take 10–20 seconds.
- It uses too much memory and bandwidth.
- The frontend (like React or Angular) can’t render that many rows efficiently.
Pagination → returns results in pages, e.g. /api/products?page=2&size=10
Sorting → lets you order results, e.g. /api/products?sort=price,desc
Filtering → lets you focus results, e.g. /api/products?category=Electronics&brand=Samsung
You decide to use Java Spring Boot with Spring Data JPA for this — since it gives you built-in power to handle all three.
Solution Architecture
Here’s how the system fits together:
|
Layer |
Responsibility |
|---|---|
|
Entity (Product) |
Defines product data model |
|
Repository |
Connects to DB and handles queries |
|
Service |
Implements filter + pagination logic |
|
Controller |
Exposes API endpoint |
|
DTO (Paged Response) |
Wraps paginated JSON results |
|
H2 Database |
Lightweight test data storage |
Step 1: Setup Your Spring Boot Project
Create a Spring Boot project (you can use Spring Initializer) with:
- Spring Web
- Spring Data JPA
- H2 Database
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
Step 2: Configure Your Database
application.properties
============= H2 Config =============
spring.datasource.url=jdbc:h2:mem:productdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password= spring.h2.console.enabled=true
============= JPA / Hibernate =============
spring.jpa.hibernate.ddl-auto=create
spring.jpa.defer-datasource-initialization=true
============= SQL Initialization =============
spring.sql.init.mode=always
Step 3: Create the Product Entity
Our API will serve data for a Product object with fields like ID, name, category, brand, and price.
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "products")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String category;
private double price;
private String brand;
// Constructors
public product() {}
public product(Long id, String name, String category, String brand, double price) {
this.id = id;
this.name = name;
this.category = category;
this.brand = brand;
this.price = price;
}
// Getters & Setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public double getPrice() { return price; }
public void setPrice(double price) { this.price = price; }
}
Step 4: Define the Repository
This interface handles all DB communication.
Spring Data JPA auto-generates methods based on the name pattern.
import com.example.demo.entity.product;
import org.springframework.data.domain.*;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductRepository extends JpaRepository<product, Long> {
// Filter by category
Page<product> findByCategoryContainingIgnoreCase(String category, Pageable pageable);
Page<product> findByBrandContainingIgnoreCase(String brand, Pageable pageable);
}
**Why this matters: \ These methods let you query by category or brand dynamically, without writing SQL.
Step 5: Implement the Service Layer
This layer applies business logic — i.e., which filter to apply and how to paginate.
import com.example.demo.entity.product;
import com.example.demo.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.*;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
public Page<product> getAllProducts(String category, String brand, Pageable pageable) {
if (category != null && !category.isEmpty()) {
return productRepository.findByCategoryContainingIgnoreCase(category, pageable);
} else if (brand != null && !brand.isEmpty()) {
return productRepository.findByBrandContainingIgnoreCase(brand, pageable);
} else {
return productRepository.findAll(pageable);
}
}
}
Step 6: Build the Controller (Main API Endpoint)
This endpoint:
-
Accepts query parameters (page, size, sort, category, brand)
-
Converts them to a Page Request
-
Calls the service
-
Returns a Paged Response DTO
import com.example.demo.dto.PagedResponse; import com.example.demo.entity.product; import com.example.demo.service.ProductService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.*; import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/api/products") public class ProductController { @Autowired private ProductService productService; @GetMapping public PagedResponse<product> getAllProducts( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "5") int size, @RequestParam(defaultValue = "id,asc") String[] sort, @RequestParam(required = false) String category, @RequestParam(required = false) String brand) { // Handle sorting (field and direction) String sortField = sort[0]; Sort.Direction direction = (sort.length > 1 && sort[1].equalsIgnoreCase("desc")) ? Sort.Direction.DESC : Sort.Direction.ASC; Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortField)); Page<product> productPage = productService.getAllProducts(category, brand, pageable); return new PagedResponse<product>( productPage.getContent(), productPage.getNumber(), productPage.getSize(), productPage.getTotalElements(), productPage.getTotalPages(), productPage.isLast() ); } }
Step 7: Create a Paged Response DTO
We wrap our paginated results in a DTO for consistent JSON structure.
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class PagedResponse<T> {
private List<T> content;
private int pageNumber;
private int pageSize;
private long totalElements;
private int totalPages;
private boolean last;
public PagedResponse(List<T> content,
int pageNumber,
int pageSize,
long totalElements,
int totalPages,
boolean last) {
this.content = content;
this.pageNumber = pageNumber;
this.pageSize = pageSize;
this.totalElements = totalElements;
this.totalPages = totalPages;
this.last = last;
}
// Getters and setters
public List<T> getContent() { return content; }
public void setContent(List<T> content) { this.content = content; }
public int getPageNumber() { return pageNumber; }
public void setPageNumber(int pageNumber) { this.pageNumber = pageNumber; }
public int getPageSize() { return pageSize; }
public void setPageSize(int pageSize) { this.pageSize = pageSize; }
public long getTotalElements() { return totalElements; }
public void setTotalElements(long totalElements) { this.totalElements = totalElements; }
public int getTotalPages() { return totalPages; }
public void setTotalPages(int totalPages) { this.totalPages = totalPages; }
public boolean isLast() { return last; }
public void setLast(boolean last) { this.last = last; }
}
Step 8: Initialize Sample Data Automatically
Instead of manually inserting data, let’s pre-load it when the app starts.
import com.example.demo.entity.product;
import com.example.demo.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component
public class DataInitializer implements CommandLineRunner {
private final ProductRepository repository;
public DataInitializer(ProductRepository repository) {
this.repository = repository;
}
@Override
public void run(String... args) {
repository.save(new product(null, "iPhone 15", "Mobile", "Apple", 999.99));
repository.save(new product(null, "Galaxy S23", "Mobile", "Samsung", 899.99));
repository.save(new product(null, "MacBook Air", "Laptop", "Apple", 1299.00));
repository.save(new product(null, "ThinkPad X1", "Laptop", "Lenovo", 1399.00));
repository.save(new product(null, "OnePlus 12", "Mobile", "OnePlus", 749.99));
repository.save(new product(null, "Sony Bravia 55\"", "TV", "Sony", 1099.00));
repository.save(new product(null, "iPad Air", "Tablet", "Apple", 599.00));
}
}
Step 9: Run and Test
Start your application:
Run As à Java Application
and start the Application, once the Application Started.
Now test the endpoints:
|
Description |
URL |
|---|---|
|
Get first page (3 items) |
http://localhost:8080/api/products?page=0&size=3 |
|
Sort by price descending |
http://localhost:8080/api/products?sort=price,desc |
|
Filter by category |
http://localhost:8080/api/products?category=Mobile |
|
Filter by brand |
http://localhost:8080/api/products?brand=Apple |
|
Combine filter + sort |
http://localhost:8080/api/products?category=Laptop&sort=price,asc |
Sample Output
{
"content": [
{
"id": 8,
"name": "iPhone 15",
"category": "Mobile",
"price": 999.99,
"brand": "Apple"
},
{
"id": 9,
"name": "Galaxy S23",
"category": "Mobile",
"price": 899.99,
"brand": "Samsung"
},
{
"id": 12,
"name": "OnePlus 12",
"category": "Mobile",
"price": 749.99,
"brand": "OnePlus"
}
],
"pageNumber": 0,
"pageSize": 5,
"totalElements": 3,
"totalPages": 1,
"last": true
}
This gives the frontend everything it needs to build a table with pagination buttons:
content→ actual records to showpageNumberandpageSize→ which page user is viewingtotalElementsandtotalPages→ how many total records existlast→ flag if this is the last page
Technical Insight (How Spring Does This)
- The magic is done by
PageableandPageinterfaces in Spring Data JPA. - They inject
LIMIT,OFFSET, andORDER BYautomatically in SQL. @RequestParamparsing lets users dynamically control the query without changing backend code.
Why This Matters (Real-World Context)
This same pattern is used in:
- E-Commerce: Paginating thousands of products
- Admin Dashboards: Listing users, orders, logs
- Microservices: Returning partial results efficiently
Conclusion
With this use case, you’ve mastered:
- Pagination and sorting with Spring Data JPA
- Clean JSON responses with DTOs
- Auto data initialization
- Efficient filtering logic
This setup forms the backbone of any enterprise API that needs to handle data efficiently while remaining scalable and maintainable.