Skip to main content

Specification

complexitypopularity

Encapsulate a business rule as an object with an isSatisfiedBy(candidate) method, instead of scattering boolean logic through the codebase, so rules can be named, tested alone, and combined with AND/OR/NOT.

The problem

LoanUnderwriter.isEligible() checks credit score, bankruptcy history, and debt-to-income ratio in one long boolean expression. It works, right up until a second product - a promotional card with slightly different thresholds - needs almost, but not exactly, the same logic.

Copy the expression and tweak two numbers, and now the two rules drift independently every time someone edits one and forgets the other. The underlying business rules were never actually separate things; they were fused into whichever if statement happened to need them first.

The solution

Give each atomic business rule its own object with one method: isSatisfiedBy(candidate). MinimumCreditScoreSpec knows about credit scores and nothing else. NoRecentBankruptcySpec knows about bankruptcy history and nothing else. Neither has any idea the other exists.

Then let specifications combine. An AndSpecification wraps two specifications and requires both to pass - and because it implements the exact same interface it wraps, you can build an AndSpecification out of two other composites just as easily as out of two atomic rules. The eligibility rule for a whole product becomes one line built from named, independently testable pieces.

LoanUnderwriterAndSpecificationMinimumCreditScoreSpecNoRecentBankruptcySpeceligibility = creditSpec.and(bankruptcySpec)1eligibility.isSatisfiedBy(applicant)2isSatisfiedBy(applicant)3applicant.score >= 6804isSatisfiedBy(applicant)5no filing in last 7 years6return true7
  1. 1Two independent rules get combined into one at setup time, without either rule knowing the other exists.
  2. 2The underwriter asks the combined rule a single yes/no question about one applicant.
  3. 3AndSpecification delegates to its first child first.
  4. 4The rule checks only what it was built to check - nothing about bankruptcy history.
  5. 5Since the first rule passed, AndSpecification checks the second one too.
  6. 6This rule is equally reusable on its own - a different product could check it alone.
  7. 7Both children passed, so the combined rule reports true. The underwriter never wrote an AND by hand.

Structure

AndSpecification both implements Specification and has two of them. That double relationship - the same shape appearing as both the whole and its parts - is what lets rules nest to any depth without new code.

«interface»SpecificationisSatisfiedBy(candidate)PREDICATEMinimumCreditScoreSpecminScoreisSatisfiedBy(applicant)CONCRETENoRecentBankruptcySpecisSatisfiedBy(applicant)CONCRETEAndSpecificationleft: Specificationright: SpecificationisSatisfiedBy(candidate)COMPOSITE
implements

Code

Same example three ways: a loan eligibility check built from two independently testable rules.

// The eligibility rule is one big boolean expression, copy-pasted anywhere it is needed.
class LoanUnderwriter is
method isEligible(applicant) is
return applicant.creditScore >= 680
and applicant.yearsSinceBankruptcy > 7
and applicant.debtToIncome < 0.4
// A second screen (say, a promo offer) needs a slightly different
// combination of these same checks - and now it is copied, not shared.
// Each rule is testable on its own, and the combination reads like the policy it models.
const eligibleForLoan = creditScore(680).and(noBankruptcyWithin(7));
 
if (eligibleForLoan.isSatisfiedBy(applicant)) {
approve(applicant);
}
// A promo product reuses one of the same rules with a different partner:
const eligibleForPromo = creditScore(720).and(noBankruptcyWithin(3));
// The one interface every rule implements.
interface Specification is
method isSatisfiedBy(candidate)
method and(other)
method or(other)
 
// A reusable base that adds AND/OR/NOT for free to any concrete rule.
abstract class BaseSpecification implements Specification is
method and(other) is
return new AndSpecification(this, other)
 
method or(other) is
return new OrSpecification(this, other)
 
// Atomic rules. Each one checks exactly one thing.
class MinimumCreditScoreSpec extends BaseSpecification is
field minScore
 
constructor MinimumCreditScoreSpec(minScore) is
this.minScore = minScore
 
method isSatisfiedBy(applicant) is
return applicant.creditScore >= minScore
 
class NoRecentBankruptcySpec extends BaseSpecification is
field yearsRequired
 
constructor NoRecentBankruptcySpec(yearsRequired) is
this.yearsRequired = yearsRequired
 
method isSatisfiedBy(applicant) is
return applicant.yearsSinceBankruptcy > yearsRequired
 
// Composite rules. Each is itself a Specification, so it composes further.
class AndSpecification extends BaseSpecification is
field left: Specification
field right: Specification
 
constructor AndSpecification(left, right) is
this.left = left
this.right = right
 
method isSatisfiedBy(candidate) is
return left.isSatisfiedBy(candidate) and right.isSatisfiedBy(candidate)
 
class OrSpecification extends BaseSpecification is
field left: Specification
field right: Specification
 
constructor OrSpecification(left, right) is
this.left = left
this.right = right
 
method isSatisfiedBy(candidate) is
return left.isSatisfiedBy(candidate) or right.isSatisfiedBy(candidate)
 
// Assembly reads like the business rule it represents.
class LoanUnderwriter is
method buildEligibilityRule() is
return new MinimumCreditScoreSpec(680)
.and(new NoRecentBankruptcySpec(7))
 
method isEligible(applicant) is
return buildEligibilityRule().isSatisfiedBy(applicant)

When to use it

  • The same eligibility, filtering, or validation logic needs to run in more than one place (a list filter, a form validator, a discount gate) and copies are already drifting apart.
  • Rules need to be combined differently for different contexts (a stricter version for one product, a looser one for another) without duplicating the atomic checks themselves.

Pitfalls

  • Ceremony without reuse. Wrapping every single-use if in its own class buys nothing. The pattern pays off once a rule is reused, tested alone, or recombined - not before.
  • Hidden performance cost. A deep tree of specifications evaluated per row over a large in-memory collection can be noticeably slower than one inlined boolean expression; profile before assuming it is free.
  • Specifications that reach outside the candidate. A rule that queries a database or calls a network service inside isSatisfiedBy() stops being a pure predicate and gets much harder to test or combine safely.

Don't confuse it with

  • Strategy. Both hide a piece of logic behind one interface method. Strategy is chosen to swap an algorithm that produces a result; Specification evaluates a yes/no predicate and is explicitly built to combine with AND/OR/NOT, which Strategy objects are not.
  • Chain of Responsibility. CoR passes one request along a chain until a single handler acts and the chain stops; a composite Specification evaluates every child every time and combines their booleans - nothing "handles and stops" the rest.
  • Validation frameworks. Framework annotations like @NotNull or @Min solve the same surface problem for simple field checks, but Specification is meant for genuine business rules that combine, get named, and get reused across unrelated validation, filtering, and gating contexts.

Check yourself

Question 1 of 5

What is the single method every Specification, atomic or composite, must implement?