Specification Pattern for Composable Business Rules
Business rules often begin as a few readable conditions. Then the same decisions appear in validation, eligibility checks, filtering, and workflow code. Small differences creep in: one path checks account status but forgets the credit limit; another copies the whole expression and changes only one threshold.
The Specification pattern gives a business rule a name and an interface, then allows rules to be combined into larger decisions. It is useful when the same rule matters in several places or when complex policy is easier to understand as a composition of smaller concepts.
This article develops the pattern from a simple example, shows what composition buys you, and explains when an ordinary function or conditional is the better design.
Start with the decision, not the pattern
Suppose an order may use invoice payment only when three conditions hold:
if customer.active
and customer.creditLimit >= order.total
and not customer.hasOverdueInvoices:
allowInvoicePayment()For one call site, this may be perfectly adequate. The code is short, the conditions are visible, and introducing several classes would make the reader jump through more definitions to understand the same decision.
The design pressure appears when the rules have independent meaning and independent reuse.
Imagine that account activity is also checked before placing an order, sufficient credit is used in a credit review, and the overdue-invoice rule appears in both payment and account-management workflows. Now copying expressions creates duplicated knowledge. Changing what “active” means requires finding every place that encoded the old definition.
A specification represents one such rule explicitly:
interface Specification<T>:
isSatisfiedBy(candidate: T) -> booleanA concrete rule can then carry a business name:
class ActiveCustomer implements Specification<Customer>:
isSatisfiedBy(customer):
return customer.status == ACTIVEThe interface is deliberately small. A specification answers a question about a candidate: does this candidate satisfy this rule?
A specification is a named predicate
Mathematically, a predicate is a function that maps a value to true or false. A specification is essentially a predicate represented as a domain concept.
That distinction matters because not every predicate deserves an object. This is ordinary code:
isAdult = person.age >= 18If the check occurs once and has no richer role, extracting AdultPersonSpecification adds ceremony without solving a problem.
A specification becomes useful when naming and reuse matter. Consider a credit rule that depends on both the customer and the order. Instead of forcing unrelated data into one parameter, model the thing being judged:
record InvoiceRequest(customer, order)
class HasEnoughCredit implements Specification<InvoiceRequest>:
isSatisfiedBy(request):
return request.customer.creditLimit >= request.order.totalThe specification now states one policy and receives all the information needed to evaluate it. It does not fetch hidden data or mutate the request. Given the same request state, it produces the same answer.
Keeping evaluation free of side effects is not a formal requirement of every implementation called a Specification, but it makes specifications easier to compose, test, and reason about.
Composition is the main payoff
The pattern becomes more than a collection of named predicates when specifications can be combined.
A minimal abstraction can support logical operations:
Specification.and(other)
Specification.or(other)
Specification.not()The invoice-payment policy can then be assembled from smaller rules:
invoiceEligible =
activeCustomer
.and(hasEnoughCredit)
.and(noOverdueInvoices)
if invoiceEligible.isSatisfiedBy(request):
allowInvoicePayment()This changes how the code communicates. The caller no longer owns the details of each rule. It owns the higher-level policy: invoice eligibility requires these three named conditions.
The component specifications remain independently reusable. A different workflow might require an active customer and no overdue invoices but not perform a credit check.
How AND should behave
For two specifications A and B, A.and(B) is satisfied only when both are satisfied:
class AndSpecification<T> implements Specification<T>:
left
right
isSatisfiedBy(candidate):
return left.isSatisfiedBy(candidate)
and right.isSatisfiedBy(candidate)In a language where and short-circuits, the right specification is not evaluated when the left one is false. That is normally harmless when specifications are pure predicates. If evaluation performs logging, network calls, mutation, or other observable work, short-circuiting changes more than the boolean result. That is another reason to keep side effects outside the rule objects.
OR and NOT follow the corresponding boolean operations. The implementation is simple; the value comes from making policy composition explicit in domain terms.
Keep the rule’s inputs visible
A specification can look clean while hiding expensive or surprising work.
Consider this design:
class HasNoOverdueInvoices:
isSatisfiedBy(customer):
invoices = invoiceRepository.findFor(customer.id)
return noneAreOverdue(invoices)The call site looks like an in-memory boolean check, but evaluating it performs I/O. Combining five such specifications could trigger several database or network operations. Reordering an AND expression might even change latency significantly because of short-circuit behavior.
A clearer design usually separates data acquisition from decision-making:
request = InvoiceRequest(
customer = customer,
order = order,
overdueInvoices = invoiceRepository.findOverdue(customer.id)
)
invoiceEligible.isSatisfiedBy(request)Now the caller can see that data must be loaded before the rule is evaluated. The specification remains focused on policy.
This separation is not free. The input object may become larger, and some applications deliberately let domain services query repositories. The useful question is whether hidden I/O makes the specification harder to understand, test, batch, or reuse. If it does, make the dependency explicit.
Reuse rules without creating one giant policy object
A common mistake is to extract specifications and then collect every rule into a single class such as BusinessRules or EligibilitySpecifications. That recreates the original coupling under a different name.
Prefer specifications that correspond to stable concepts in the problem domain:
ActiveCustomer
HasEnoughCredit
HasNoOverdueInvoicesThen compose them near the policy that needs the combination:
InvoicePaymentEligibility =
ActiveCustomer
AND HasEnoughCredit
AND HasNoOverdueInvoicesA different decision can use a different composition without modifying the individual rules.
This is particularly helpful when two policies share ingredients but are not identical. Reusing the named rules preserves the shared knowledge without pretending that the complete policies are the same.
Decide where changing values belong
Specifications often contain thresholds, dates, or other policy values:
class MinimumAccountAge:
minimumDays
isSatisfiedBy(account):
return account.ageInDays >= minimumDaysPassing minimumDays into the specification makes the policy parameter explicit. It also avoids burying a configurable business value in the implementation.
Be careful not to turn every value into runtime configuration. If a threshold is a stable rule owned by the code and changes only with a software release, a named constant may be simpler. Configuration is useful when operators or business processes genuinely need to vary the value independently of deployment.
The pattern does not decide where policy data comes from. It gives the evaluation rule a clear boundary.
Boolean answers are sometimes too small
A specification naturally answers yes or no. That is enough for filtering or simple eligibility decisions, but some workflows also need to explain failure.
Suppose the user interface must show why invoice payment is unavailable. Re-running each specification after the combined check works, but it can lead to awkward APIs and duplicated evaluation.
One option is to return a richer result from a separate policy evaluator:
result = invoicePolicy.evaluate(request)
result.allowed
result.reasonsFor example, reasons might contain INSUFFICIENT_CREDIT and OVERDUE_INVOICE.
At that point, the abstraction is no longer a simple boolean Specification. That is fine. Do not stretch the pattern until it stops matching the problem.
There is also a semantic choice to make. An AND specification only needs one false component to determine that the whole expression is false. A validation screen may instead need every failing rule. Short-circuit boolean composition and complete error collection solve different problems.
Use a specification when a boolean decision is the real abstraction. Use a validation or policy-result model when callers need structured explanations.
Do not confuse in-memory rules with query translation
A tempting extension is to use the same specification both to evaluate an object in memory and to generate a database query:
activeCustomers.and(hasCredit).toQuery()Some libraries and frameworks support expression trees or query objects that make this practical. A specification expressed as arbitrary executable code, however, cannot automatically be translated into every query language.
For example, a rule that calls a custom function, reads the current clock, or depends on data already loaded into memory may have no direct database equivalent.
If query translation is a requirement, design for it explicitly. The specification may need to represent an expression structure rather than only expose isSatisfiedBy. That adds constraints and complexity, so it should be driven by a real need such as reusable repository filtering rather than by the pattern itself.
Test the rules at the level where they can fail
Small specifications are straightforward to test because each one has a narrow responsibility:
HasEnoughCredit:
credit 100, order 80 -> true
credit 100, order 100 -> true
credit 100, order 120 -> falseThe equality case is worth stating because it defines the boundary of the rule.
Composition also deserves a few focused tests. You do not need to retest boolean algebra exhaustively, but you should verify that the policy combines the intended rules. A missing HasNoOverdueInvoices component is a policy defect even if every individual specification is correct.
Tests should use names that describe the business case rather than the implementation structure. customer_with_exactly_enough_credit_is_eligible communicates more than test_has_enough_credit_returns_true.
If specifications are parameterized, test meaningful boundary values. If they depend on time, pass an explicit date or clock-derived value rather than reading ambient current time inside the rule. That keeps tests deterministic and makes the temporal assumption visible.
Common mistakes with the Specification pattern
The first mistake is extracting too early. Three lines of local conditional logic are often easier to read than three classes plus combinators. Extract when a rule has a useful name, independent reuse, independent change, or enough complexity to benefit from isolation.
The second is hiding orchestration inside a predicate. A specification that loads records, sends metrics, updates state, and then returns a boolean is difficult to reason about as a rule. Keep decision logic separate from effects when practical.
The third is creating specifications at the wrong granularity. A class such as AmountGreaterThanZero may be too mechanical to carry domain meaning, while ValidCustomerForAnyOperation may combine so many unrelated policies that it cannot be reused safely. Prefer rules that correspond to decisions people working on the system can name.
The fourth is forcing every decision into boolean composition. Some policies need scores, priorities, explanations, or transformations. Those deserve abstractions that model those outputs directly.
Finally, avoid treating composition as proof that two rules belong together permanently. A.and(B) is cheap to write precisely because the combination can remain local to the policy that needs it.
When a specification is worth introducing
The Specification pattern tends to pay for itself when several of these conditions are present:
- a business rule has a stable, meaningful name;
- the same rule is needed in multiple decisions;
- larger policies are naturally expressed with AND, OR, or NOT;
- individual rules change independently;
- isolated tests make policy boundaries easier to verify.
Stay with a direct conditional or small function when the decision is local, obvious, and unlikely to be reused. A function such as isInvoiceEligible(request) can also be an excellent middle ground when you want a named rule but do not need runtime composition or a family of rule objects.
Patterns are useful when they reduce the cost of change or understanding. They are not a target architecture by themselves.
Make policy visible at the right level
When business conditions are duplicated, the risk is not merely repeated syntax. The same policy can slowly acquire several definitions.
The Specification pattern addresses that problem by giving reusable rules names and making larger decisions explicit compositions of those rules. Start small: identify one condition that has independent meaning, represent it as a side-effect-free predicate, and compose it only where composition clarifies the policy.
If the resulting design makes a reader understand the decision with less searching and gives future changes one clear home, the pattern is doing useful work. If it turns a simple if statement into a maze of tiny classes, keep the if statement.