package com.shop.catalog;
import org.springframework.data.jpa.domain.Specification;
import java.math.BigDecimal;
public final class ProductSpecifications {
private ProductSpecifications() {
}
public static Specification<Product> nameContains(String name) {
if (name == null || name.isBlank()) {
return null;
}
String pattern = "%" + name.toLowerCase() + "%";
return (root, query, cb) ->
cb.like(cb.lower(root.get("name")), pattern);
}
public static Specification<Product> inCategory(Long categoryId) {
if (categoryId == null) {
return null;
}
return (root, query, cb) ->
cb.equal(root.get("category").get("id"), categoryId);
}
public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
if (min == null && max == null) {
return null;
}
return (root, query, cb) -> {
if (min != null && max != null) {
return cb.between(root.get("price"), min, max);
}
if (min != null) {
return cb.greaterThanOrEqualTo(root.get("price"), min);
}
return cb.lessThanOrEqualTo(root.get("price"), max);
};
}
public static Specification<Product> inStock(Boolean inStock) {
if (inStock == null || !inStock) {
return null;
}
return (root, query, cb) ->
cb.greaterThan(root.get("stockQuantity"), 0);
}
}
package com.shop.catalog;
import java.math.BigDecimal;
public class ProductFilter {
private String name;
private Long categoryId;
private BigDecimal minPrice;
private BigDecimal maxPrice;
private Boolean inStock;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Long getCategoryId() {
return categoryId;
}
public void setCategoryId(Long categoryId) {
this.categoryId = categoryId;
}
public BigDecimal getMinPrice() {
return minPrice;
}
public void setMinPrice(BigDecimal minPrice) {
this.minPrice = minPrice;
}
public BigDecimal getMaxPrice() {
return maxPrice;
}
public void setMaxPrice(BigDecimal maxPrice) {
this.maxPrice = maxPrice;
}
public Boolean getInStock() {
return inStock;
}
public void setInStock(Boolean inStock) {
this.inStock = inStock;
}
}
package com.shop.catalog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
@Repository
public interface ProductRepository
extends JpaRepository<Product, Long>, JpaSpecificationExecutor<Product> {
}
package com.shop.catalog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static com.shop.catalog.ProductSpecifications.*;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository repository;
public ProductController(ProductRepository repository) {
this.repository = repository;
}
@GetMapping
public Page<Product> list(ProductFilter filter,
@PageableDefault(size = 20, sort = "name") Pageable pageable) {
Specification<Product> spec = Specification
.where(nameContains(filter.getName()))
.and(inCategory(filter.getCategoryId()))
.and(priceBetween(filter.getMinPrice(), filter.getMaxPrice()))
.and(inStock(filter.getInStock()));
return repository.findAll(spec, pageable);
}
}
This snippet shows how a product-listing endpoint can accept a handful of optional query parameters and translate only the ones that are present into a composed JPA Specification. The core idea is that filtering logic should not be a growing tangle of if/else branches building HQL strings; instead each filter is an independent, reusable predicate that the framework combines dynamically at query time.
In ProductSpecifications, each static factory returns a Specification<Product> for a single concern — nameContains, inCategory, priceBetween, and inStock. Every method guards against a null input and returns null in that case, which is the signal Spring Data uses to skip a predicate when specifications are combined. nameContains lowercases both sides so the match is case-insensitive, and priceBetween handles the min-only, max-only, and both-bounds cases with criteriaBuilder.greaterThanOrEqualTo and lessThanOrEqualTo. Writing predicates this granular keeps them composable and independently testable.
ProductFilter is a plain carrier for the optional parameters. Because Spring MVC binds request parameters onto its fields, absent params simply stay null, which lines up perfectly with the null-skipping behavior in the specifications. Keeping this separate from the entity avoids leaking query concerns into the domain model.
ProductRepository extends both JpaRepository and JpaSpecificationExecutor<Product>; the latter is what unlocks the findAll(Specification, Pageable) overload used for paged, filtered queries. No custom query methods are needed.
ProductController is where composition happens. Using Specification.where(...) as a null-safe starting point, it chains .and(...) across every filter. Because and treats a null spec as a no-op, only the parameters actually supplied contribute to the generated SQL — a request with no params yields an unrestricted, paginated fetch. The Pageable argument is resolved automatically from page, size, and sort params, so pagination and sorting come for free.
The main trade-off is that Criteria-based specifications are more verbose than a hand-written query, but they scale far better as filter combinations grow and eliminate the SQL-injection risk of string concatenation. A common pitfall is forgetting the null guard inside a specification, which would force an always-true or broken predicate; returning null is the idiomatic way to opt out. This pattern is the natural choice whenever an endpoint has many optional, orthogonal filters.
Related snips
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
package com.example.demo.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
Messaging with Apache Kafka
module Paginatable
extend ActiveSupport::Concern
MAX_PER_PAGE = 100
DEFAULT_PER_PAGE = 25
API Pagination Headers (Link + Total)
package com.example.demo.controller;
import com.example.demo.dto.FileMetadata;
import com.example.demo.service.FileStorageService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
File upload and download handling
Share this code
Here's the card — post it anywhere.