DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Demystifying Sorting Assertions With AssertJ
  • Querydsl vs. JPA Criteria, Part 4: Pagination
  • Upgrade Guide To Spring Boot 3.0 for Spring Data JPA and Querydsl
  • Projections/DTOs in Spring Data R2DBC

Trending

  • Building a Spring AI Assistant With MCP Servers: A Step-by-Step Tutorial
  • Building a Voice-Controlled Graph Assistant With Neo4j, LiveKit, and OpenAI
  • Harness Engineering for AI: Why the Model Is Only Half the System
  • Building Production-Safe Agentic Remediation With Docker MCP Gateway: Lessons From 43% to 100% Accuracy
  1. DZone
  2. Data Engineering
  3. Databases
  4. This One Spring Data JPA Pattern Cleaned Up to 3 Years of Repository Debt

This One Spring Data JPA Pattern Cleaned Up to 3 Years of Repository Debt

Stop adding repository methods every time a filter changes. JPA Specifications let you compose queries cleanly at runtime.

By 
Ramesh Bellamkonda user avatar
Ramesh Bellamkonda
·
Jul. 29, 26 · Analysis
Likes (0)
Comment
Save
Tweet
Share
233 Views

Join the DZone community and get the full member experience.

Join For Free

If you've spent more than a year building enterprise Java apps, you've probably felt this specific kind of pain: a product manager asks for a new search filter, and you open your repository file to find it already has 18 methods. You write number 19, then 20, and somewhere around method 25 you start wondering if there's a better way.

There is. It's called Spring Data JPA Specifications, and it's been sitting quietly in the framework the whole time.

The Problem With Hard-Coded Query Methods

Spring Data JPA's derived query methods are great for simple lookups. findByEmail is clean, readable, and requires zero SQL. But enterprise search rarely stays simple.

Your CRM users want to filter customers by name and status. Then by date range. Then by city. Then by a keyword that could match name or email. Before long, you're maintaining a repository that looks like this:

Java
 
findByNameAndStatus(...)
findByNameAndStatusAndCreatedDateBetween(...)
findByNameOrEmailAndStatus(...)
findByNameContainingIgnoreCaseAndStatusAndCreatedDateBetween(...)


Each new requirement means a new method. The repository becomes a dumping ground. Testing it becomes a chore. Onboarding someone new becomes a conversation about which of the 30 methods to use.

Specifications solve this by letting you define small, composable query predicates and combine them at runtime based on what filters the user actually provided.

What a Specification Actually Is

Under the hood, a Specification wraps the JPA Criteria API, the programmatic, type-safe way to build queries without writing raw SQL or JPQL. The Criteria API is powerful but verbose and tricky to read. Specifications give you that power with a cleaner surface area.

Each Specification is just a lambda that produces a predicate:

Java
 
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("status"), "ACTIVE")


That's it. One condition, one method, composable with anything else.

Building It: A Customer Search Example

Let's make this concrete. Imagine a Customer entity with name, email, status, and createdDate. Users can filter by any combination of these or none at all.

The Entity

Java
 
@Entity
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;
    private String status;
    private LocalDate createdDate;
}


A Specifications Utility Class

Rather than scattering predicates across services, I keep them in a dedicated class:

Java
 
public class CustomerSpecifications {

    public static Specification<Customer> nameContains(String name) {
        return (root, query, cb) ->
            name == null ? null : cb.like(cb.lower(root.get("name")), "%" + name.toLowerCase() + "%");
    }

    public static Specification<Customer> emailContains(String email) {
        return (root, query, cb) ->
            email == null ? null : cb.like(cb.lower(root.get("email")), "%" + email.toLowerCase() + "%");
    }

    public static Specification<Customer> statusEquals(String status) {
        return (root, query, cb) ->
            status == null ? null : cb.equal(root.get("status"), status);
    }

    public static Specification<Customer> createdBetween(LocalDate start, LocalDate end) {
        return (root, query, cb) -> {
            if (start == null || end == null) return null;
            return cb.between(root.get("createdDate"), start, end);
        };
    }
}


The null returns are intentional; Spring Data JPA ignores null predicates, which means you get automatic "skip this filter if not provided" behavior for free.

The Repository

Your repository needs to extend JpaSpecificationExecutor:

Java
 
public interface CustomerRepository
    extends JpaRepository<Customer, Long>, JpaSpecificationExecutor<Customer> {
}


Wiring It Together in the Service

Java
 
public List<Customer> searchCustomers(CustomerSearchRequest request) {
    Specification<Customer> spec = Specification
        .where(CustomerSpecifications.nameContains(request.getName()))
        .and(CustomerSpecifications.emailContains(request.getEmail()))
        .and(CustomerSpecifications.statusEquals(request.getStatus()))
        .and(CustomerSpecifications.createdBetween(request.getStartDate(), request.getEndDate()));

    return customerRepository.findAll(spec);
}


That single findAll call dynamically adapts to whatever combination of filters the caller provides. No branching logic, no 20 repository methods. When product asks for a fifth filter next sprint, you add one method to CustomerSpecifications and one .and() line in the service. Done.

Going Further: OR Conditions, Joins, and Pagination

OR Conditions

The .or() combinator works exactly as you'd expect. A global search bar that checks name or email:

Java
 
Specification<Customer> spec = Specification
    .where(CustomerSpecifications.nameContains(keyword))
    .or(CustomerSpecifications.emailContains(keyword));


Filtering Across Joins

If your Customer has a nested Address, you can reach into it without any joins in your service layer:

Java
 
public static Specification<Customer> cityEquals(String city) {
    return (root, query, cb) ->
        city == null ? null : cb.equal(root.join("address").get("city"), city);
}


The join happens inside the Specification. Your service code stays clean.

Pagination

Because JpaSpecificationExecutor exposes a findAll(Specification, Pageable) overload, adding pagination is one line:

Java
 
Page<Customer> page = customerRepository.findAll(spec, PageRequest.of(0, 20, Sort.by("name")));


Mistakes I've Seen in the Wild

Returning non-null predicates for null filters: This is the most common gotcha. If you forget the null check and return a valid predicate anyway, you'll silently filter out data that should be returned. Always guard at the top of the lambda.

Mixing business logic into Specifications: A Specification should do one thing: produce a predicate. I've seen Specifications that log, that call services, that check permissions. Don't. Keep them pure.

Creating a single "God Specification" that handles all filters: This trades the bloated repository problem for a bloated Specification problem. Small, single-purpose Specifications stay testable and reusable. A statusEquals Specification can serve your search screen, your reporting module, and your admin dashboard without any of them knowing about each other.

Skipping case normalization for string searches: cb.like(root.get("name"), "%dzone%") won't match "DZone" or "DZONE." Always normalize: cb.lower(root.get("name")) paired with a lowercased input.

Why This Pays Off Over Time

The real dividend from Specifications shows up six months after you introduce them, when requirements change, and they always do.

Adding a filter? One new static method, one .and(). Removing a filter? Delete the method and the combinator line. Reusing a filter across two features? Import the same Specification class. Unit testing a filter? Instantiate the Specification, pass a mock CriteriaBuilder, assert the predicate. No Spring context required.

In complex enterprise codebases, the kind with multiple development teams, evolving product requirements, and a long maintenance tail, that kind of modularity is worth a lot more than it sounds at first.

Final Thought

Specifications aren't exotic. They're part of the Spring Data JPA standard library; they work with everything you already have, and they solve a problem that every team with a search screen eventually hits. If your repository is starting to look like an alphabetized index of every filter combination your users have ever requested, it's a good time to make the switch.

Spring Data Repository (version control) Framework

Opinions expressed by DZone contributors are their own.

Related

  • Demystifying Sorting Assertions With AssertJ
  • Querydsl vs. JPA Criteria, Part 4: Pagination
  • Upgrade Guide To Spring Boot 3.0 for Spring Data JPA and Querydsl
  • Projections/DTOs in Spring Data R2DBC

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook