1. What Specification actually does
If before, Specification was perceived as "just another Spring feature that somehow builds queries," then now we will remove the magic cloak and leave only the mechanics. Inside Specification<T>, there is one key task: turn your filter idea into a piece of a SQL condition (more precisely, for JPA Criteria Query), so that the ORM can assemble where and send the query to the database.
You can think of it in the simplest way: a specification is a function that receives an "entry point" to the entity (Root), a "condition builder" (CriteriaBuilder), and a query object (CriteriaQuery), and returns a Predicate — a logical condition that will end up in where. Spring Data JPA builds specifications directly on top of JPA Criteria API, so these types are not an accident, but the foundation of the interface.
To avoid being scared by the signature, you can think of it like this: "Give me access to the fields of Product, give me the tools to compare values, and I will return one logical condition."
A small "skeleton" of what we will keep writing (do not copy as is — this is for understanding the shape):
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
public interface MySpecLikeThing<T> {
/**
* @param root "entry point" into the entity and its fields/relations
* @param query query object (sometimes needed, for example, for distinct, etc.)
* @param cb "builder" of conditions (equal/like/and/or/...)
* @return one logical condition for the future WHERE
*/
Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);
}
Pay attention to an important idea: Specification<Product> is not the whole query, and not a "search service", and not a "read use case". It is exactly one piece of a filter that can later be combined with other pieces.
2. Root<T>: access to entity fields and relations
When you first see root.get("status"), you want to ask: "Why a string? We're in Java, where everything is typed." That is a good question, and that is exactly why Root<T> needs to be understood properly, not memorized like a spell. Root<Product> is an object that represents the current query entity and allows building paths to its fields and relations.
Inside toPredicate(...), you do not write product.getStatus(). You build a "path description": first the field status, then, if needed, a field of a related entity (category.id), then, if necessary, even deeper. This is similar to writing p.status or p.category_id in SQL, only in object form.
The most common pattern is root.get("fieldName"). For simple fields of Product, it reads almost like English: "take the status field." For relations, a chain works: root.get("category").get("id") — "take the category field, and from it take id." Yes, it sounds like a command for a robot vacuum, but this time the robot is useful.
The problem with strings here is also practical: if you renamed a field in the entity, the error will show up at runtime, not at compile time. That is why we make small specifications in one place, with good names: fewer chances for string paths to spread throughout the project.
3. CriteriaBuilder: tools for SQL conditions
CriteriaBuilder is your condition "constructor." If Root answers the question "what are we addressing?", then CriteriaBuilder answers the question "how exactly are we comparing?" And here it is convenient to think like this: you assemble a condition roughly like SQL, but in the form of method calls.
Important: CriteriaBuilder does not execute the query. It builds a description of the condition. This is still a "blueprint," not a "house." The house will be the SQL that Hibernate eventually sends to PostgreSQL.
Here is a small cheat-sheet table to connect CriteriaBuilder methods with familiar SQL. Keep it nearby until your hand gets used to it:
| CriteriaBuilder method | "Human" meaning | Approximate SQL meaning |
|---|---|---|
| cb.equal(a, b) | equals | |
| cb.like(a, pattern) | string matches a pattern | |
| cb.greaterThanOrEqualTo(a, x) | greater than or equal to | |
| cb.lessThanOrEqualTo(a, x) | less than or equal to | |
| cb.or(p1, p2) | logical OR | |
| cb.and(p1, p2) | logical AND | |
| cb.lower(expr) | convert string to lower case | |
That table is enough for today. If you start looking for "where is group by and having here?", then that is already a different movie, a different franchise, and possibly a different course.
Predicate: a clear and small "piece of where"
The word Predicate sounds as if you suddenly ended up in a lecture on mathematical logic. In practice, it is just an object that means: "here is the condition that must be true for the row to end up in the result." From the SQL point of view, this is part of where.
The key point for architecture: one specification should return one clear rule. Not ten rules inside one lambda block and not a "universal query for all occasions." When you return one Predicate, you get a building block that can be safely combined with others.
And here a very important style appears: most often we build the overall filter through AND (that is, we add conditions one after another), while OR we try to keep local inside one rule. For example, "text search by name OR by sku" is one meaningful block. But "this OR that OR the third OR the fourth" at the level of the whole filter quickly turns into a mess.
4. ProductSpecifications: class skeleton
Before writing the specifications themselves, let us agree on the place in the project. Our shop-data-jpa is organized as package-by-feature, and the catalog has a separate package. Therefore, it makes sense to keep the specifications for Product near the rest of the catalog read code, for example in com.example.shopdatajpa.catalog.query.
The idea is simple: one utility class with small static methods. This is not a "service", not a "repository", not "magic" — just a set of factories that return Specification<Product>.
Here is the minimal class skeleton:
import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.jpa.domain.Specification;
public final class ProductSpecifications {
private ProductSpecifications() {
// Utility class: instances are not needed
}
}
Next, we will add small methods to this class, each of which does exactly one condition. And these are the methods that later are pleasant to read when assembling a filter: they sound like normal Russian/English, not like "here somewhere is a piece of Criteria API."
5. Micro-specs: status and price bounds
Let us start with the coziest and most understandable thing: filtering by status and by price. This is like "Hello, world" for specifications: no navigation through relations, no join, just a field and a comparison.
The hasStatus specification looks almost indecently short — and that is good. Short code is easier to verify by eye, easier to test, and harder to ruin:
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.entity.ProductStatus;
import org.springframework.data.jpa.domain.Specification;
public static Specification<Product> hasStatus(ProductStatus status) {
return (root, query, cb) ->
// status is a regular field of the Product entity
cb.equal(root.get("status"), status);
}
Now price bounds. We do not make a "between" in one method, because our filters are optional: there may be only minPrice, there may be only maxPrice. That is why two micro-specs read better and are easier to compose:
import java.math.BigDecimal;
public static Specification<Product> priceGte(BigDecimal minPrice) {
return (root, query, cb) ->
// price >= minPrice
cb.greaterThanOrEqualTo(root.get("price"), minPrice);
}
And the symmetric "upper bound":
import java.math.BigDecimal;
public static Specification<Product> priceLte(BigDecimal maxPrice) {
return (root, query, cb) ->
// price <= maxPrice
cb.lessThanOrEqualTo(root.get("price"), maxPrice);
}
Notice an important methodological point: these methods assume that the input parameters are already valid. If minPrice is somehow negative or minPrice > maxPrice, that is not the job of Specification. A specification is about the where, not about "sorting out all the mess in the input data."
6. Filters by relations: category and availability
Now let us add a bit of "life": a filter by a related entity and a filter by related StockItem. This is usually the point where panic begins: "How do I traverse a relation in Criteria API?" Calm down: technically it is still the same path, just not to a field, but to a field through a relation.
Category is a ManyToOne in Product. That means the path to category.id looks like this: root.get("category").get("id"). And the condition remains a regular equal:
public static Specification<Product> inCategory(Long categoryId) {
return (root, query, cb) ->
// Navigation through relation: product.category.id = :categoryId
cb.equal(root.get("category").get("id"), categoryId);
}
Now "only products that are in stock." In our project, this may mean that Product has stockItem, and stockItem has availableQuantity. We want availableQuantity > 0.
Yes, the comparison "greater than zero" sounds very basic, but in real admin panels these are exactly the filters that live for years:
public static Specification<Product> inStock() {
return (root, query, cb) ->
// Important: a condition on a relation may "tighten" the selection,
// if stockItem is absent (a join may be possible, and such records will not pass the filter).
cb.greaterThan(root.get("stockItem").get("availableQuantity"), 0);
}
There is a subtle point here that is important to understand, but we do not need to turn it into a separate dissertation right now. When you navigate through a relation, JPA may build a join. And if the related entity is absent (for example, a product without StockItem), the filter behavior may turn out to be "stricter" than you expected: such products will not get into the selection. Within our educational domain, this is normal, because a product without stock is not the most useful product. But the fact itself is worth keeping in mind.
7. Text search: like, normalization, and local or
Text search is an eternal classic. The user enters "iph", and wants to see "iPhone", not the strict mathematics of "the string must match completely." In SQL this is usually like '%iph%', and to avoid depending on case, people add lower(...) or use PostgreSQL-specific ILIKE. In specifications, we will make a portable variant through lower.
Very important: we keep or inside the rule "textSearch", not in the overall filter assembly. Then the "text search" rule remains one meaningful building block: "matched either by name or by SKU."
public static Specification<Product> textSearch(String text) {
// Convert to lower case on the application side,
// and compare in the DB through lower(...) — this makes the search case-insensitive.
String pattern = "%" + text.toLowerCase() + "%";
return (root, query, cb) -> cb.or(
// (lower(name) like :pattern) OR (lower(sku) like :pattern)
cb.like(cb.lower(root.get("name")), pattern),
cb.like(cb.lower(root.get("sku")), pattern)
);
}
A couple of practical notes. If text is empty or consists of spaces, it is better not to apply this method at all (that is, not to add the specification to the filter assembly). Also, such lower(...) can affect index usage in the database. That is already a question of optimization and schema, not of today's lecture, but it is useful to know so that you do not think, "ORM will speed everything up by itself."
8. Composition: assembling a readable filter
Now the moment has come for which the whole "small specifications" approach exists at all. We want to write code that reads like a phrase: "take products with status ACTIVE, in category N, with price from X, and also make the text match." Not like one huge lambda machine where the logic is hidden inside three levels of parentheses.
Specifications are intentionally combinable through and(...) and or(...), and for a neutral start in Spring Data JPA 4 there is a static specification Specification.unrestricted(): this means "do not filter anything."
Here is a small example of "manual assembly" (not a full ProductAdminFilter — we will build it later, for now only the principle):
import java.math.BigDecimal;
import org.springframework.data.jpa.domain.Specification;
// Start with "do not filter anything" so it is convenient to stack AND conditions in a chain
Specification<Product> spec = Specification.unrestricted()
.and(ProductSpecifications.hasStatus(ProductStatus.ACTIVE)) // status = ACTIVE
.and(ProductSpecifications.priceGte(new BigDecimal("10.00"))); // price >= 10.00
And this is what it may look like in the catalog service when you apply the specification to the repository:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
public Page<Product> findActiveExpensiveProducts() {
// At the service level, we already decide which specs to apply (and which not to)
Specification<Product> spec = Specification.unrestricted()
.and(ProductSpecifications.hasStatus(ProductStatus.ACTIVE));
// The repository receives a ready-made "piece of WHERE", not a monolithic 50-line logic block
return productRepository.findAll(spec, PageRequest.of(0, 20));
}
If SQL logs are enabled, you will see something very similar to this SQL (the shape may differ, but the meaning will be the same):
select p.*
from product p
where p.status = 'ACTIVE'
limit 20;
And here you finally get engineering happiness: your conditions live in small methods, their names say "what we are doing," and the filter assembly turns into a readable chain.
9. Typical mistakes when writing Specification and micro-conditions
Mistake #1: writing one "giant" specification.
When all filtering logic is assembled into one method with dozens of lines, you lose readability and control. Over time, even the author has trouble understanding what exactly is happening. Specifications should be small and compositional, not monolithic.
Mistake #2: turning Specification into a mini-service.
If checks for null, normalization, validation, and side effects appear inside, then this is no longer a "condition for where." A specification should describe only the predicate. The decision "apply it or not" and data preparation should happen outside it.
Mistake #3: spreading string paths root.get("...") across the project.
String field names are not checked by the compiler. During refactoring, they break easily and produce errors only at runtime. If such strings are centralized (for example, in ProductSpecifications), at least they can be found and fixed quickly.
Mistake #4: using or too broadly.
When or is applied at the level of the whole specification, the filter logic becomes blurred: records start passing by one condition while ignoring the others. It is much safer to keep or local inside a specific rule, and build the overall filter through and.
Mistake #5: not considering the effect of relations on the selection result.
Filtering by related entities (stockItem.availableQuantity) can implicitly exclude records without a relation. This changes the semantics of the query. If data suddenly "disappeared," the first thing to check is the joins and conditions on the related tables.
GO TO FULL VERSION