skip to content

Show how to build reusable, null-safe Specification factory methods for an optional-filter search, and explain the null-predicate contract.

level: middleimportance: should knowfreq 40%

answer

  1. static factory returns Specification<T>
  2. null input -> return null predicate (no restriction)
  3. compose with allOf/and, no if/else
  4. blank string also = inactive
  5. all-null -> no WHERE -> guard with paging/tenant filter

basics

~10 s

Write static methods returning Specification<T>. Each checks its input: if the filter value is null, return a lambda whose toPredicate returns null (no restriction); otherwise build the predicate. Compose the active ones with and().

solid answer

~40 s

The idiom is a class of static factories, each returning Specification<T> and guarding its own input. When the argument is absent, the returned specification's toPredicate returns null — Spring's composition treats a null predicate as 'no restriction' and drops it, so callers can chain every filter unconditionally with and()/allOf() without if/else. Non-null inputs build the actual predicate via CriteriaBuilder (cb.equal, cb.like, cb.between, cb.greaterThanOrEqualTo). This keeps each fragment independently testable and reusable across findAll, count, exists, and delete. Care: use the static metamodel or consistent attribute strings; lower-case both sides for case-insensitive like; and remember that if every filter is null, the whole spec is null and returns all rows — so keep a mandatory constraint or enforce paging.

code

java · 31 lines
java
public final class UserSpecs {
    private UserSpecs() {}

    public static Specification<User> hasStatus(UserStatus status) {
        return (root, q, cb) -> status == null ? null : cb.equal(root.get("status"), status);
    }
    public static Specification<User> emailContains(String v) {
        return (root, q, cb) -> (v == null || v.isBlank()) ? null
                : cb.like(cb.lower(root.get("email")), "%" + v.toLowerCase() + "%");
    }
    public static Specification<User> createdBetween(Instant from, Instant to) {
        return (root, q, cb) -> {
            if (from != null && to != null) return cb.between(root.get("createdAt"), from, to);
            if (from != null) return cb.greaterThanOrEqualTo(root.get("createdAt"), from);
            if (to != null)   return cb.lessThanOrEqualTo(root.get("createdAt"), to);
            return null;
        };
    }
    // Mandatory tenant guard so all-null filters never leak across tenants
    public static Specification<User> ownedBy(Long tenantId) {
        return (root, q, cb) -> cb.equal(root.get("tenantId"), tenantId);
    }
}

// Compose unconditionally; inactive filters drop out via null predicates.
Specification<User> spec = UserSpecs.ownedBy(tenantId)
        .and(Specification.allOf(
                UserSpecs.hasStatus(filter.status()),
                UserSpecs.emailContains(filter.email()),
                UserSpecs.createdBetween(filter.from(), filter.to())));
Page<User> page = userRepository.findAll(spec, pageable);

go deeper

for a junior

Can write a single static factory returning a predicate.

for a middle

Builds a null-safe factory set and composes optional filters cleanly.

for a senior

Adds range/IN/like escaping and guards against unbounded all-null queries.

for a principal

Institutionalizes a mandatory tenant guard and a reusable spec library; ties it to multi-tenant security and testing conventions.

**Goal.** A search endpoint receives a filter DTO where each field is optional. You want clean composition without a forest of if-statements, and each predicate reusable elsewhere. **The null-predicate contract.** Spring Data's specification composition (`and`, `or`, `allOf`, `anyOf`, and `where`) treats a `toPredicate` returning `null` as "contributes no restriction." So: - Returning `null` from an inactive filter is safe and idiomatic. - Two null-returning specs `and`-ed together still yield null. - If the *final* composed spec is null, `findAll` produces no WHERE clause and returns everything — a real footgun for unbounded result sets. **Two styles.** 1. **Guard inside the factory** (recommended): the factory takes the raw optional value and returns a null-yielding predicate when absent: ``` public static Specification<User> emailContains(String q) { return (root, cq, cb) -> (q == null || q.isBlank()) ? null : cb.like(cb.lower(root.get("email")), "%" + q.toLowerCase() + "%"); } ``` Callers then compose unconditionally: `Specification.allOf(emailContains(f.email()), hasStatus(f.status()), createdBetween(f.from(), f.to()))`. 2. **Guard at the call site**: factories assume non-null and the caller wraps each `.and()` in an `if`. More verbose; only when a factory can't sensibly represent 'inactive'. **Common predicate builders.** - Equality: `cb.equal(root.get("status"), status)` - Case-insensitive contains: `cb.like(cb.lower(root.get("email")), "%"+v.toLowerCase()+"%")` - Range: `cb.between(root.get("createdAt"), from, to)`; open-ended -> `cb.greaterThanOrEqualTo`/`lessThanOrEqualTo` - IN: `root.get("status").in(statuses)` - Null check: `cb.isNull(root.get("deletedAt"))` - Negation: `cb.notEqual(...)` or `Specification.not(spec)` **Escaping LIKE wildcards.** If user input may contain `%` or `_`, escape them or the LIKE behaves unexpectedly; `cb.like(expr, pattern, '\\')` with a defined escape char. **Blank vs null.** Treat blank strings as inactive too (`isBlank()`), otherwise an empty search box filters on `%%` (harmless) or `''` equality (matches nothing) unexpectedly. **Testability.** Because each factory returns a plain object, unit-test composition logic by building specs and asserting the produced SQL via an integration test, or test the DTO->spec mapping directly. Factories are also reusable across `count(spec)` for totals and `exists(spec)` for cheap presence checks. **Guardrail against unbounded queries.** Since all-null composes to no WHERE, either (a) always call `findAll(spec, pageable)` with a bounded page, or (b) start the chain from a mandatory tenant/owner predicate (`ownedBy(currentUserId)`) so multi-tenant isolation is never accidentally dropped — an important security point.

  • Why start the chain from a mandatory ownedBy(tenantId) predicate?
    If all optional filters are null the composed spec becomes null (no WHERE), returning every row. A mandatory tenant/owner predicate ensures the query is always bounded and isolation is never accidentally dropped — a security safeguard.
  • Why treat blank strings as inactive, not just null?
    An empty search box often sends "" rather than null. Building a predicate on it filters on '%%' or ''-equality, producing surprising results. Checking isBlank() makes empty input mean 'no filter', matching user intent.

saying these in an interview costs you the question

  • Returning cb.conjunction() everywhere instead of null — works, but null is the idiomatic 'no restriction' and composes cleanly.
  • Forgetting that all-null filters yield an unbounded (no-WHERE) query.
  • Not lower-casing both sides for case-insensitive LIKE, or not escaping % / _ in user input.
  • Treating empty string as a real equality filter instead of 'no filter'.

context