CodeGym /Courses /Spring Data JPA /Boundaries Specification: filter and response

Boundaries Specification: filter and response

Spring Data JPA
Level 15, Lesson 4
Available

1. Roles: filter and result shape

It is very easy to confuse the role of Specification and expect everything from it at once: dynamic conditions, sorting, pagination, and “let’s also have a nice DTO response.” In practice, these are two different axes. Specification is about WHERE, and the result shape is about SELECT/projection, as well as whether we return List, Page, Slice, a single element, or even a count.

To avoid mixing everything into one mess, keep a simple responsibility map in mind (it really saves your brain when queries start piling up):

Question Who is responsible Where it lives in code
Which records match? (the WHERE conditions) Specification<Product> ProductSpecifications.*
How to sort? Sort /
Pageable
service method arguments
Is pagination needed? Pageable service method arguments
Do we need total count? (Page vs Slice) result shape choice service / fluent query
In what form should data be returned? (entity vs projection) projection / fluent query service / repository

Visually, this can be represented like this:

flowchart TD
    F["ProductAdminFilter"] --> S["Specification<Product>"]
    S --> R["ProductRepository JpaSpecificationExecutor"]
    R --> Q["findAll(spec, pageable) or findBy(spec, q -> ...)"]
    Q --> O["Result: Page / Slice / List; Entity / Projection"]

The point of the diagram is simple: the filter assembles the conditions, the repository knows how to apply them, and the result shape is chosen above, where you actually understand the use case.

2. When Specification is not needed

Lookup and derived queries

Specification has a very tricky side effect: once you learn it, your hands start itching to use it everywhere. It is roughly like learning Streams and trying to write even if (x > 0) with them. Technically it is possible, but your colleagues will be sad, and you will be too a month later.

If a query is stable and narrow (one or two fixed checks), a derived query is usually the most readable solution. For our mini-shop, this is a classic case: finding a product by sku. We do not need to dynamically turn conditions on or off, we do not need to assemble a filter, and we do not need to heroically defeat null — we just need to find the record.

import java.util.Optional;

public interface ProductRepository {

    // Lookup method: reads like a phrase and does not require any Criteria/Specification magic
    Optional<Product> findBySku(String sku);
}

Yes, technically you could create a Specification “skuEquals(sku)” and call findOne(spec). But that would be more expensive in terms of cognitive load: the code would get longer, and the meaning would not become clearer. Specification is good when you truly have bounded dynamic search (like our ProductAdminFilter), not when you want “everything through one tool” out of principle.

A complex but stable query

There is the opposite situation: the query is genuinely complex, but it is not dynamic. For example, a “product card” may require specific joins, ordering, fetching related data, or a careful read model. We already covered this in the JPQL block: sometimes it is simpler and more honest to write one @Query than to build Criteria logic that even the author will be waving away a week later like “that scary file”.

Here is an example of a “stable, but more complex than derived” query, where @Query can be clearer than specifications (and definitely simpler than trying to assemble it into one giant toPredicate(...)):

import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface ProductRepository {

    // We explicitly fix the query "business rule" in JPQL: archived products are not returned
    @Query("select p from Product p where p.sku = :sku and p.status <> 'ARCHIVED'")
    Optional<Product> findActiveBySku(
            @Param("sku") String sku // named parameter for readability and safety
    );
}

The boundary idea is this: Specification is not “the only correct way to write queries,” but simply a convenient tool for the case where conditions are switched on and off. If the scenario is fixed, you will often find it easier to live with a derived query or JPQL. And if it is a report or an “SQL-first” piece (like a low-stock report), then you have already seen that a native query can be the most honest solution.

3. Result shape: containers and projections

Pagination: Page vs Slice

When we add dynamic search, the next natural desire is “let’s make it a Page.” And that is normal: admin lists often want to know how many matches there are in total. But Page has a price: almost always, in addition to fetching the data, an extra count query will also be executed. If you do not actually use total count, you are paying for it for nothing — like buying a combo pizza every time, but eating only the napkin.

The basic canon is already familiar: the filter object comes from outside, a Specification is assembled inside, and then only the result container changes.

If you use the classic JpaSpecificationExecutor API, the most direct path looks like this:

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public Page<Product> search(ProductAdminFilter filter, Pageable pageable) {
    validate(filter);

    var spec = ProductSpecifications.byFilter(filter);

    // Important: Page almost always triggers an additional COUNT query
    return productRepository.findAll(spec, pageable);
}

Sometimes that is exactly what you need. But if your UI is fine with “a chunk of data + is there a next page,” then it makes more sense to return to the idea of Slice. And this is where a very practical feature of modern Spring Data appears: fluent query API via findBy(spec, q -> ...). It lets you keep the filter in Specification, but choose the result shape separately.

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

public Slice<Product> searchSlice(ProductAdminFilter filter, Pageable pageable) {
    validate(filter);

    var spec = ProductSpecifications.byFilter(filter);

    // Slice: without total count, usually cheaper for infinite scroll and similar UI scenarios
    return productRepository.findBy(spec, q -> q.slice(pageable));
}

Notice that Specification did not become “smarter” here. We simply told the repository: the filter stays the same, but package the result as a Slice. And that is exactly the healthy boundary: Specification is about conditions, findBy(..., q -> ...) is about the result shape.

Projections: entity vs DTO

In admin search, most of the time you do not need to expose the entire Product entity with its entire potential graph. Usually you need a compact read model: a few fields for a table. Dynamic filtering does not go away because of that — it is just that on top of the same where we choose a lighter response form.

For a catalog row, let us take a simple projection form:

import java.math.BigDecimal;

public record ProductCatalogRow(String sku, String name, BigDecimal price) {
    // Read model for a table/list: a small DTO, not an entity and not a write model
}

Now for the best part: we do not want to create a separate repository method for every result shape. We want to say: “Here is a Specification, and now return me a Page<ProductCatalogRow>.” That is exactly what the fluent query API does:

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public Page<ProductCatalogRow> searchRows(ProductAdminFilter filter, Pageable pageable) {
    validate(filter);

    var spec = ProductSpecifications.byFilter(filter);

    // Specification remains "about WHERE", and the projection and result container are chosen here
    return productRepository.findBy(
            spec,
            q -> q.as(ProductCatalogRow.class) // "about SELECT": which fields/form of data do we need
                  .page(pageable)              // "about container": Page => total count will be calculated
    );
}

The meaning is almost readable like a sentence: “find by the filter, but return as ProductCatalogRow and paginate.” And this is where it is very important not to mix the levels: Specification does not know what ProductCatalogRow is. It is still “about WHERE.” The projection is the decision “about SELECT and the shape.”

If you need the same filter, but without pagination, only the last stage of the call changes:

import java.util.List;

public List<ProductCatalogRow> searchAllRows(ProductAdminFilter filter) {
    validate(filter);

    var spec = ProductSpecifications.byFilter(filter);

    return productRepository.findBy(spec, q -> q.as(ProductCatalogRow.class).all());
}

The same specification, a different result container. That is exactly the healthy boundary: one filter, and the result shape is chosen based on the use case.

4. Boundaries: do not turn Specification into a combine harvester

The most common architectural degradation after getting acquainted with specifications looks like this: someone creates a common/spec package and starts putting “universal” conditions there, then tries to compose any query in the whole application from them. After a couple of weeks, this turns into a “spaceship control panel” where half the buttons are not labeled, and the other half are labeled, but in Latin.

A healthy discipline for our shop-data-jpa is much simpler and friendlier. Specifications should live near their bounded context and their use case. For the catalog — inside catalog, for orders — inside ordering, and so on. And even more importantly: specifications should be a read-side thing, without business orchestration.

For example, for admin product search, it may look like this (not as dogma, but as a readable practice):

com.example.shopdatajpa.catalog
├── entity
├── repository
│   └── ProductRepository
├── query
│   ├── ProductAdminFilter
│   └── ProductSpecifications
└── service
    └── ProductAdminService

Here the boundary is very clear. ProductAdminFilter is the input contract (conditions), ProductSpecifications is the “WHERE builder”, ProductAdminService is the layer that chooses the result shape (Page/Slice, entity/projection) and passes Pageable/Sort. The repository remains an ordinary repository, not the “god of queries.”

If you keep these roles separate, Specification remains a useful, pragmatic, junior-friendly technique. If you mix them, you get Criteria noodles that even the people who wrote them will be afraid to touch.

5. Typical mistakes when working with Specification

Mistake #1: dragging Specification in where a derived query is enough.
When you build a Specification for finding a product by sku and call findOne(spec), you get more code, more abstraction layers, and more places to make a mistake. At the same time, there is almost no gain: the query is not dynamic, there is one criterion, and the meaning is expressed more simply with the findBySku method.

Mistake #2: treating Page as the “default container” for any admin list.
If the UI really does not use totalElements and totalPages, then Page becomes “expensive packaging”: you pay for a count query that nobody reads. In such cases, Slice via findBy(spec, q -> ...) often gives more honest and cheaper semantics.

Mistake #3: making Specification responsible for everything at once — for WHERE, sorting, and result shape.
The desire to “hide everything inside the specification” usually ends with toPredicate(...) growing not only conditions, but also side decisions. In the end, the specification stops being a small reusable block and turns into a mini-service that cannot be combined properly.

Mistake #4: always returning an entity, even when the use case asks for a “table row.”
An entity is a write model and part of persistence logic. When you return an entity where three fields are needed, you increase code coupling, make maintenance harder, and often create extra risks (at least: “we accidentally started touching relationships”). Projections (ProductCatalogRow) or other read models help keep the boundaries.

Mistake #5: confusing the filter and the response DTO, and putting everything into one structure.
Sometimes you want to create one ProductAdminRequest, where there are minPrice, page, sort, and response fields as well. It looks “convenient,” but it quickly turns into an unclear combine harvester that is hard to reuse and test. The filter should describe the conditions (text, status, minPrice...), and the result shape should be specified separately via Pageable, Slice, and projection.

1
Task
Spring Data JPA, level 15, lesson 4
Locked
Derived query for lookup and Specification for the list
Derived query for lookup and Specification for the list
1
Task
Spring Data JPA, level 15, lesson 4
Locked
Slice with projection through findBy(spec, q -> ...)
Slice with projection through findBy(spec, q -> ...)
1
Survey/quiz
Dynamic Search, level 15, lesson 4
Unavailable
Dynamic Search
Filters and JPA Specifications
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION