I spent three years watching a repository file grow into something nobody wanted to touch. One Spring Data JPA pattern finally fixed it, and I wish I'd applied it on day one. If your repositories are turning into dumping grounds, this fix is worth five minutes of your time.
I opened a JobApplicationRepository file a while back and counted the methods before I even started reading the logic. Twenty-three. Twenty-three variations of "find applications by X and maybe Y and sometimes Z if it's not null." Method names like findByStatusAndCompanyIdAndCreatedAtBetween and findByStatusAndCompanyIdAndSkillsContainingAndSalaryGreaterThan . Some differed by a single optional parameter. It was ugly, but it worked, so nobody touched it for years. That's usually how repository debt piles up. Nobody sets out to write a 20-method repository. It just happens, one filter request at a time. This is the story of how I cleaned that mess up on ITJobOpportunities using Spring Data JPA Specifications, and why I think any Java team building search with more than three optional filters should reach for this pattern earlier than most teams do. How the Mess Actually Forms Nobody plans a bloated repository. It creeps up on you. You start with something reasonable: List<Job> findByStatus(JobStatus status); Then a product ask comes in: "can recruiters filter by location too?" Fine. List<Job> findByStatusAndLocation(JobStatus status, String location); Then salary range. Then skill tags. Then "can we search by company and exclude expired postings, but only if the user is a COMPANY_ADMIN ." At some point you're writing method nineteen and Spring Data's derived query naming starts looking like a regex you're afraid to touch. I hit this exact wall on the job search endpoints while building out featured job filtering for ITJobOpportunities. Recruiters wanted to slice job listings by status, company, skill set, salary band, and posting date, in every combination, and derived query methods just couldn't keep up. The annoying part isn't even the method count. It's that half those methods are near duplicates of each other, so when you need to fix a bug in the salary comparison logic, you have to find and fix it in six different places. And that's exactly the kind of thing that turns a "small fix" into three broken filters nobody remembers exist. The Options I Actually Considered Before landing on Specifications, I looked at a few paths. Worth walking through, because the tradeoffs aren't obvious until you've lived with them. Keep writing derived query methods. Fast to start, terrible past four or five optional filters. Once you need conditional logic (only apply this filter if the value is present), Spring Data's naming convention can't express it cleanly. Drop to native SQL or JPQL with string concatenation. I've done this. It works, technically, but building dynamic WHERE clauses with string concatenation invites injection bugs and a maintenance nightmare. Hard pass unless you enjoy debugging quote escaping at 11pm. Use QueryDSL. Genuinely good, and I've used it successfully on a prior FinTech program. But it adds a code-generation step (the Q-classes) and another dependency to manage. For a small platform team, that's overhead I didn't want. Spring Data JPA Specifications. Already in the framework if you're using spring-boot-starter-data-jpa . No extra dependency, no code generation. Just implement Specification<T> and compose predicates with the JPA Criteria API. I went with Specifications. The fact that it required zero new dependencies tipped the decision. When you're a founder shipping features on nights and weekends, "one less thing to configure" matters more than people admit. What the Pattern Actually Looks Like Here's the core idea: instead of one method per filter combination, you write small, composable predicate builders and combine them at runtime based on what the caller actually provided. First, make your entity's repository extend JpaSpecificationExecutor : public interface JobRepository extends JpaRepository<Job, Long>, JpaSpecificationExecutor<Job> { } That one interface unlocks findAll(Specification<Job> spec, Pageable pageable) on the repository, no extra methods needed. Then you build small, single-purpose specifications: public class JobSpecifications { public static Specification<Job> hasStatus(JobStatus status) { return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status); } public static Specification<Job> hasCompanyId(Long companyId) { return (root, query, cb) -> companyId == null ? null : cb.equal(root.get("companyId"), companyId); } public static Specification<Job> salaryAtLeast(BigDecimal minSalary) { return (root, query, cb) -> minSalary == null ? null : cb.greaterThanOrEqualTo(root.get("salary"), minSalary); } public static Specification<Job> hasAnySkill(List<String> skills) { return (root, query, cb) -> { if (skills == null || skills.isEmpty()) return null; Join<Job, Skill> skillJoin = root.join("skills"); return skillJoin.get("name").in(skills); }; } } That return null when a value is missing isn't a bug, it's the whole trick. Specification.where() and .and() just skip null predicates, so you don't need a pile of if statements checking whether each filter was provided. And then the service layer, where the composition happens: public Page<Job> searchJobs(JobSearchCriteria criteria, Pageable pageable) { Specification<Job> spec = Specification .where(JobSpecifications.hasStatus(criteria.getStatus())) .and(JobSpecifications.hasCompanyId(criteria.getCompanyId())) .and(JobSpecifications.salaryAtLeast(criteria.getMinSalary())) .and(JobSpecifications.hasAnySkill(criteria.getSkills())); return jobRepository.findAll(spec, pageable); } One method. Handles every combination of the four filters, including all of them being null, which just returns everything, paginated. Compare that to the 23-method file I mentioned earlier. Night and day. Why This Mattered More Than I Expected I'll be honest, when I first refactored the repository this way, I expected a nice code-cleanliness win and not much else. It ended up being bigger than that. The real payoff showed up a few weeks later when we were shaping the Job Fit scoring work and wanted to filter applications by relevance threshold on top of everything else. Under the old derived-method approach, that would've meant writing another six or seven method variants (relevance combined with every existing filter permutation). With Specifications, it was a five-line addition: public static Specification<JobApplication> hasMinRelevanceScore(Integer minScore) { return (root, query, cb) -> minScore == null ? null : cb.greaterThanOrEqualTo(root.get("relevanceScore"), minScore); } Add it to the .and() chain in the service, done. No new repository method, no touching existing tests for the other filters, because they're isolated. That's the part that actually saved me time: not the initial migration, but every filter added after it. There's a testing angle I didn't fully appreciate going in, either. Each Specification is a function you can unit test in isolation, or verify with a quick integration test against Postgres and assert on the query result. Testing 23 derived methods individually is tedious. Testing six small predicate builders is quick, and you get far more confidence per test written. Where It Gets Awkward (Because It Does) I don't want to make this sound like a silver bullet, because it isn't. A few things bit me. Debugging generated SQL gets harder. When a derived query method breaks, the method name basically tells you what's wrong. When a Specification chain breaks, you're staring at a Criteria API predicate tree wondering which .and() produced a weird join. Turning on spring.jpa.show-sql=true and reading the actual generated SQL becomes a habit fast. Joins need care. That hasAnySkill example does a join, and if you're not careful about fetch type and distinct-ness, a job with multiple matching skills produces duplicate rows in your...