What you’ll learn:
- The real pain point every developer hits
- Why hardcoded queries stop scaling
- Common mistakes (and why they happen)
- Spring Data JPA Specifications — the right tool
- QueryDSL for complex systems
- Performance: indexes, scans, and native queries
- What big systems actually do
In modern web applications, handling large datasets efficiently is crucial for optimal performance and a better user experience. While handling with large amount of data, returning all data at once can cause a performance issue. and What If we have to add a serach features?. along with filter i.e. filter by Status, role, date range, price, etc.
In a CRUD operation, one operation, one outcome. Search and filtering are dynamic by nature. The query changes based on what the user asks for. That’s a fundamentally different problem.
I’ve seen junior developers write 15-parameter repository methods. I’ve seen a developers create 8 separate endpoints for the same data, each handling a different filter combination. Neither holds up. Let me show you what it actually does.
What dynamic querying really means.
In real-world applications, users don’t interact with your data the way you interact with your database directly. They search, they filter, they sort, they page through results. And crucially, they pick and choose; a user might filter by status only, or by name + data range, or by role alone. We can’t predict which combination they’ll use.
Let’s say you have a Product entity. Users on an e-commerce dashboard might want to:
- Search by partial name: “Show me everything with ‘electronic gadgets’ in the title.”
- Filter by category and status: “Only active products in Electronics.”
- Filter by price range: “between NPR 500 and 5000.”
- Sort by price (low to high), or by newest first
- See results 20 at a time (pagination)
Now imagine writing a separate repository method for each combination of those five dimensions. It’s not just impractical — it’s genuinely impossible. The permutations are in the dozens. And when business requirements change (they always do), you’d need to touch repository, service, and controller layers everywhere.
This is what dynamic querying solves. Instead of writing a fixed SQL query per use case, you build a query at runtime based on whichever filters the user provides.
The mistakes developers make first
Let me be honest, most of us go through a phase of doing this the wrong way. Here’s what that looks like:
// Don't do this. Seriously.List<Product> findByNameContaining(String name);List<Product> findByStatus(String status);List<Product> findByNameContainingAndStatus(String name, String status);List<Product> findByStatusAndCategory(String status, String category);List<Product> findByNameContainingAndStatusAndCategory(String name, String status, String category);// ... and this keeps growing forever
This approach breaks down fast. Every new filter requirement means a new method. Methods with four or five parameters become hard to read and impossible to test properly. The real problem is you’re encoding business logic into method names, and method names don’t scale.
The other common mistake is the service-layer if-else maze:
public List<Product> search(String name, String status, String category) {if (name != null && status != null && category != null) {return repo.findByNameContainingAndStatusAndCategory(name, status, category);} else if (name != null && status != null) {return repo.findByNameContainingAndStatus(name, status);} else if (status != null) {return repo.findByStatus(status);}// This goes on for 50+ lines. You will hate yourself.}
Why this is dangerous: It’s not just ugly; it’s a maintenance trap. When a new developer joins your team (or you come back to this code in 6 months), this code is nearly impossible to follow. Adding one new filter requires touching multiple branches. Testing it properly means covering every combination. And it will drift, branches get missed, bugs get introduced silently.
The right way: JPA Specifications
Spring Data JPA has some special things which are known as the Specification pattern, and once you understand it, you’ll wonder how you ever lived without it. The idea is elegant: instead of writing one big query, you write small, composable “predicate” builders that can be combined at runtime.
Let’s build this from the ground up with a “Product” Entity.
- The entity
@Entity@Table(name = "products")public class Product {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;private String name;private String category;private String status; // "ACTIVE", "INACTIVE", "DRAFT"private BigDecimal price;private LocalDate createdAt;// getters, setters, constructors}
- Make the repository Specification-aware
public interface ProductRepositoryextends JpaRepository<Product, Long>,JpaSpecificationExecutor<Product> {// That's it. The JpaSpecificationExecutor gives you// findAll(Specification, Pageable) for free.}
- . Write your Specification class
This is where the real magic lives. Each static method returns a small, focused predicate. Think of them like LEGO blocks; you snap them together to build whatever query you need.
public class ProductSpecification {public static Specification<Product> hasNameLike(String name) {return (root, query, cb) -> {if (name == null || name.isBlank()) return null;return cb.like(cb.lower(root.get("name")),"%" + name.toLowerCase() + "%");};}public static Specification<Product> hasStatus(String status) {return (root, query, cb) -> {if (status == null) return null;return cb.equal(root.get("status"), status);};}public static Specification<Product> hasCategory(String category) {return (root, query, cb) -> {if (category == null) return null;return cb.equal(root.get("category"), category);};}public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {return (root, query, cb) -> {if (min == null && max == null) return null;if (min == null) return cb.lessThanOrEqualTo(root.get("price"), max);if (max == null) return cb.greaterThanOrEqualTo(root.get("price"), min);return cb.between(root.get("price"), min, max);};}public static Specification<Product> createdAfter(LocalDate date) {return (root, query, cb) -> {if (date == null) return null;return cb.greaterThanOrEqualTo(root.get("createdAt"), date);};}}
Notice the pattern: each method guards against null and returns null if the filter wasn’t provided. JPA treats a null predicate as “no condition”; it simply skips it. This is what makes the whole thing composable without a single if-else in your service layer.
4. A clean search request DTO
public class ProductSearchRequest {private String name;private String status;private String category;private BigDecimal minPrice;private BigDecimal maxPrice;private LocalDate createdAfter;// getters, setters}
5. The service: clean as it gets
@Servicepublic class ProductService {@Autowiredprivate ProductRepository repository;public Page<Product> search(ProductSearchRequest req, Pageable pageable) {Specification<Product> spec = Specification.where(ProductSpecification.hasNameLike(req.getName())).and(ProductSpecification.hasStatus(req.getStatus())).and(ProductSpecification.hasCategory(req.getCategory())).and(ProductSpecification.priceBetween(req.getMinPrice(), req.getMaxPrice())).and(ProductSpecification.createdAfter(req.getCreatedAfter()));return repository.findAll(spec, pageable);}}
Look at how clean the service method is. No if-else. No branching logic. Just a declarative chain of “add this filter if it’s provided.” The JPA engine handles building the actual SQL. You handle the business intent.
6. The controller with pagination and sorting
6. The controller with pagination and sorting
@RestController@RequestMapping("/api/products")public class ProductController {@Autowiredprivate ProductService productService;@GetMapping("/search")public ResponseEntity<Page<Product>> search(@ModelAttribute ProductSearchRequest request,@RequestParam(defaultValue = "0") int page,@RequestParam(defaultValue = "20") int size,@RequestParam(defaultValue = "createdAt") String sortBy,@RequestParam(defaultValue = "desc") String direction) {Sort sort = direction.equalsIgnoreCase("asc")? Sort.by(sortBy).ascending(): Sort.by(sortBy).descending();Pageable pageable = PageRequest.of(page, size, sort);return ResponseEntity.ok(productService.search(request, pageable));}}
Now you can call: /api/products/search?name=wireless&status=ACTIVE&minPrice=500&page=0&size=20&sortBy=price&direction=asc , and it just works. No extra repository methods needed.
You are good to go with this. let me know your thoughts and interpretation in the comment section.