How do composable specifications work - what does it mean to combine two Specification objects with AND, OR, and NOT, and why is that composability valuable?
answer
- and/or/not return new Specification
- Composite pattern on predicates
- primitives + combinators
- De Morgan's for NOT translation
basics
~20 sYou can glue simple rules together like Lego: "expensive" AND "overdue" makes a new rule that's true only when both are true. Each combined rule is itself a Specification, so you can keep combining without ever rewriting the original checks.
solid answer
~30 sComposability means specifications implement a common interface (typically `isSatisfiedBy(candidate)`) plus combinator methods - `and`, `or`, `not` - that return a *new* Specification wrapping both operands and evaluating them with the corresponding boolean logic. Because the combinator returns the same interface type, composites can nest arbitrarily: `(bigOrder.and(overdue)).or(vip)`. This is the Composite pattern applied to predicates. It's valuable because it lets you build complex eligibility rules from small, independently-tested primitives instead of writing and re-testing one giant conditional per combination you need.
go deeper
Should grasp that specifications can be combined like puzzle pieces to build bigger rules from smaller ones, even without naming the Composite pattern explicitly.
Should describe the and/or/not contract precisely (returns a new Specification, same interface) and give an example of composing two named primitives into a product-specific rule.
Should identify the De Morgan's/NULL-handling trap when composites are translated to SQL, and weigh readability/debuggability trade-offs of deep nesting versus flat conditionals.
Should be able to design an explainability mechanism (failure-reason reporting) for composite trees and set team guidance on when composition depth becomes a maintainability risk worth pushing back on in review.
## The mechanics Mechanically, composability starts with a common contract — usually an interface or abstract base like `Specification<T>` with a single method `isSatisfiedBy(candidate: T): boolean`. On top of that contract, you add combinator operations: - `and(other: Specification<T>): Specification<T>` - `or(other)` - `not()` Each of these doesn't evaluate anything immediately — it returns a *new* Specification object (commonly `AndSpecification`, `OrSpecification`, `NotSpecification`) that stores references to its operand(s) and, when its own `isSatisfiedBy` is eventually called, delegates to the operands and combines their results with the matching boolean operator. `AndSpecification.isSatisfiedBy(x)` is literally `left.isSatisfiedBy(x) && right.isSatisfiedBy(x)`. Because the composite implements the exact same `Specification<T>` interface as its leaves, you can nest these calls indefinitely — `a.and(b).or(c.not())` — and the caller of the outermost composite never needs to know whether it's talking to a single primitive rule or a five-level-deep tree of combined ones. This is a direct application of the **Composite** structural pattern to boolean predicates specifically. ## Why build it this way Why build it this way instead of just writing bigger conditionals? The value is in decomposition and reuse at the level of *individual named rules*. Consider an underwriting system with primitives like `HighRiskZipSpecification`, `PoorCreditScoreSpecification`, and `RecentClaimSpecification`. Different products need different combinations: auto insurance might decline on `(highRiskZip.and(poorCredit)).or(recentClaim)`, while a different product only cares about `poorCredit.and(recentClaim)`. Without composability, you'd either duplicate the underlying boolean expressions across products (the same drift problem plain Specifications solve) or write one sprawling method with a parameter per product that branches internally — a maintenance hazard as products multiply. With composable specifications, each primitive rule is written and unit-tested exactly once, and each product's eligibility rule is expressed by wiring primitives together, which itself is: - **easy to read**; - **easy to unit-test** — you can substitute stub Specifications to test the combinator logic in isolation from the real rules; - **easy to extend** when a new product needs yet another combination. ## The trade-off: indirection and discoverability The trade-off is indirection and discoverability. A five-level-deep composite tree built at runtime can be genuinely hard to read from a debugger or a stack trace — you see `AndSpecification.isSatisfiedBy` called from `OrSpecification.isSatisfiedBy` with no obvious indication of *which* business rule actually failed unless you've invested in giving each composite a readable `toString()` or explanation-of-failure mechanism. Compare that to a single flat `if` with all conditions inline, which — while less reusable — is trivially traceable in a debugger because you can see every operand's value in one stack frame. Composability also invites accidental complexity: nothing stops a team from building `a.and(b.or(c.and(d.not()))).or(e)` for a rule that would read far more clearly as three named intermediate variables with comments, or even as a plain conditional; the pattern makes deep nesting *possible* but doesn't make it *readable* on its own, so naming intermediate composites matters as much as building them. ## Where it blows up: the query boundary A concrete failure mode shows up specifically at the query-translation boundary (the third leg of this pattern, alongside validation and selection). If your specifications are meant to also generate a database query (e.g., a `toPredicate` method for a JPA `CriteriaBuilder`, or building a SQL `WHERE` clause), then `NOT` and deeply nested `OR` combinations are exactly where naive translations blow up: - `NOT (A OR B)` requires De Morgan's law to translate correctly to `NOT A AND NOT B`. - Specifications that were fine to evaluate in memory over a list can silently produce an incorrect or wildly inefficient query once translated, because the combinator's boolean semantics and the query engine's boolean semantics (NULL handling in SQL being the classic trap) don't always agree. ## Where you have already seen this A well-known real-world usage: many ORMs' criteria/specification APIs (for instance, Spring Data JPA's `Specification<T>` interface) directly implement this combinator pattern — `Specification.where(spec1).and(spec2).or(spec3)` — letting application code build dynamic search filters (e.g., an admin "advanced search" screen where the user picks any combination of status, date range, and assigned-to filters) by composing small, independently defined specifications rather than hand-writing a different query method for every combination of filter checkboxes the UI exposes. Each checkbox maps to one primitive Specification; the combinators handle wiring them together based on which boxes are actually checked, without exploding into 2^N query methods.
- What's a concrete bug that shows up when translating a NOT-composed specification into a SQL WHERE clause?A naive translator might turn `not(A.or(B))` into `NOT (A_sql OR B_sql)`, which is logically correct in pure boolean algebra but can misbehave once SQL's three-valued NULL logic is involved - if A_sql or B_sql evaluates to UNKNOWN for rows with NULL columns, the negation doesn't behave like De Morgan's law would predict, silently excluding or including rows the in-memory version wouldn't have.
- If a deeply nested composite specification fails, how do you make it debuggable in production?Give each composite (and each leaf) a way to report which specific sub-rule failed, not just an overall boolean - e.g., an `explain()` or `unsatisfiedReasons()` method that walks the tree and collects human-readable failure messages from the leaves that returned false. Without this, a false result from a five-level AND/OR tree tells you nothing about which business rule was actually violated.
- Why might you choose a flat, single-method conditional over composed specifications even when you have several boolean sub-rules?If the sub-rules are used together exactly once, never reused independently, and never need to be tested or queried in isolation, a flat conditional is more directly readable and debuggable in a stack trace than a composite tree. Composability pays off specifically when the primitives get reused or recombined across more than one caller.
Composable specifications are like circuit logic gates - AND-gates and OR-gates plugged together from simple wires; each gate is itself a component you can feed into another gate, so complex circuits are built from a small set of primitives rather than hand-wired from scratch each time.
saying these in an interview costs you the question
- Thinks and()/or() mutate the original specification instead of returning a new one
- Can't explain that the composite implements the same interface as the leaves
- Unaware that NOT over OR/AND needs De Morgan's law when translating to a query
- Builds deeply nested composites with no intermediate naming or explainability
- Believes composability is only relevant to in-memory filtering, never to query translation