Specification: composable business rules
Open in the interactive app: practice, quiz, mock interview →In one minute
- A specification is an object that answers one question about a candidate: "does this order, customer or file satisfy the rule?" It has one core method,
is_satisfied_by(candidate), and a name from the business vocabulary, such asOverdueInvoiceorEligibleForFreeShipping. - Specifications compose with
and,orandnotinto larger rules, so a complex business rule becomes a tree of small, separately tested pieces instead of a 40-lineif. - Eric Evans and Martin Fowler listed three uses for the same rule object: validation (does this object meet the rule, and if not, why), selection (find all objects that meet it) and construction to order (build something that meets it).
- The hard part is selection at scale: a rule evaluated in memory forces you to load every row. Production systems translate specifications into a query (Django
Q, Spring Data JPASpecification, SQLAlchemy expressions) so the database filters with its indexes. - Do not use it for a rule that appears once. It earns its keep when the same rule is checked in several places, changes often, or is configured by non-developers as data.
How it works
The problem
A lending app decides who may apply for a loan. The rule started as age >= 18 and country == "ID", then gained "or has a guarantor", "and not on the fraud watchlist", and "and monthly income at least 5 million rupiah unless the customer is a salaried employee of a partner company". It now lives in three places: the signup API (to show the apply button), a nightly job (to email eligible customers) and the underwriting service (to recheck at submission). The three copies disagree on the partner-company exception, and when a customer is rejected, support cannot tell them which condition failed.
One object per rule
A specification wraps one condition behind a uniform interface:
class Adult(Spec):
def is_satisfied_by(self, c):
return c.get("age", 0) >= 18Every rule now has a name, a test file and a single home. The signup API, the nightly job and the underwriting service all call the same Eligible specification. Changing the rule is one change.
Composition
The value comes from combinators. And, Or and Not are themselves specifications that hold other specifications, which is the Composite pattern applied to predicates:
eligible = Adult().and_(Resident()).or_(HasGuarantor()).and_(OnWatchlist().not_())The result is a tree. Evaluating it is a recursive walk: an And node is satisfied when every child is, an Or when any child is, a Not when its child is not. Because each node also knows how to describe itself, the tree can explain itself: "failed: age >= 18, country in ID|SG". That explanation is what support needed and what three hand-written if blocks never produced.
Three uses for one rule
- Validation: before accepting a loan application, check
eligible.is_satisfied_by(customer)and return the failed conditions to the user. - Selection: the nightly job wants every eligible customer. A naive version loads all customers and filters in memory. A better one asks the specification to produce a query predicate, so the repository runs
WHERE age >= 18 AND country IN ('ID','SG') ...with indexes. Spring Data JPA builds this in: aSpecification<T>hastoPredicate(root, query, builder)and can be combined withand,orandnotbefore being passed to a repository. - Construction to order: generate a test fixture or a default configuration that satisfies the rule. This is rarer in application code but common in property-based testing.
Rules as data
Once rules are trees of small objects, they can be stored as data and built at runtime: ["all", ["age", ">=", 18], ["ref", "resident"]]. Product or risk teams can then change a threshold without a deploy, with the code providing a fixed vocabulary of leaf conditions and named, reviewed building blocks. This is the road that leads to policy engines: Google's Common Expression Language (CEL) evaluates such expressions in Kubernetes ValidatingAdmissionPolicy and in Google Cloud IAM conditions, and Open Policy Agent evaluates Rego policies. Both are specifications with a parser, a type checker and a sandbox in front.
When not to use it
- A rule used once, in one place, that nobody configures: an
ifstatement is clearer. - Hot paths where the rule is fixed at compile time: a plain function avoids a tree walk per candidate.
- Selection over large tables when your specifications cannot be translated into queries. In-memory filtering of a million rows is a performance bug waiting to happen.
- A generic "rule engine" built before the second rule exists. Build named specifications for real rules first; generalise when rules really do become data.
Reviewing agent-written code
Asked to "also exclude customers on the watchlist", an agent will usually add one more condition to whichever if it finds first, often only one of the three copies. Asked to "make eligibility configurable", it may write a string-parsing mini-language with eval. Steer it towards one named specification per business rule, combinators instead of new if branches, a description on every leaf so failures explain themselves, and a translation to the query layer for any rule used in selection. In review, check that the rule exists exactly once and that a test pins each leaf and the combined tree.
The same shape elsewhere
A specification is the finding things fast and trust but verify shapes meeting in one object: the same predicate filters a collection and validates a single item. Database query planners treat a WHERE clause as a tree of predicates and push the cheap, selective ones down to an index. Firewalls and Kubernetes network policies are ordered predicate trees over packet fields. Email filtering rules, feature-flag targeting and alert routing in Alertmanager are specification trees configured as data. Each faces the same issues: explaining why something matched, evaluating cheaply, and avoiding rules that only work in one evaluator.
Down the stack
Layer 1The contract
A specification is a pure predicate: for the same candidate it always returns the same answer and has no side effects, so it can be evaluated in any order, cached, or run in a database instead of in memory. Composition obeys Boolean algebra: a and b equals b and a, not not a equals a. When a specification is translated to a query, the translation must select exactly the candidates the in-memory version accepts, including the edge cases: missing fields, nulls and type mismatches. That last clause is where real systems break, because SQL's three-valued logic treats NULL differently from most languages' None.
Layer 2The mechanism
In memory, a composite specification is an abstract syntax tree interpreted by recursion. And and Or short-circuit: once one child of an And fails, the rest are skipped, so ordering children cheapest and most selective first saves work. Explanations need the opposite: to list every failed condition, the explaining walk must not short-circuit.
Translation to queries works the same way the in-memory walk does, emitting a predicate instead of a Boolean. Django's Q objects build a tree of WhereNodes with AND/OR connectors and a negation flag; the compiler walks it into SQL with bound parameters. Spring Data JPA Specification produces JPA Criteria Predicate objects that Hibernate renders into SQL. CEL goes further: an expression is parsed, type-checked against a declared schema, then evaluated by a small interpreter with cost limits, so a policy cannot loop forever or consume unbounded CPU inside the Kubernetes API server.
Layer 3The machine
Each node in an in-memory specification is a heap object, and evaluation is a virtual call per node: a vtable call in Java or C#, an itab lookup in Go, a property lookup along the prototype chain in JavaScript. Each costs a few nanoseconds when predicted, but the nodes are scattered across the heap, so a deep tree evaluated over many candidates pays cache misses (about 100 ns each) chasing pointers. A 12-node rule over 1 million in-memory customers is 12 million calls, roughly 20 to 60 ms, plus the cost of having loaded the million rows, which is the dominant term: at 1 KB per row, 1 GB pulled from the database across the network.
The same rule pushed into SQL lets the database use an index on (country, age) to read perhaps 30,000 matching rows instead of 1 million. That is why selection-heavy code translates specifications instead of evaluating them. For hot in-memory paths, a common optimisation is to compile the tree once into nested closures or bytecode, which removes per-node dispatch and lets the JIT inline small comparisons.
Why it's built this way
- Business rules are checked in several places and change often, so each rule needs one named home.
- Rules are built from smaller conditions, so they are composed from objects with a shared interface rather than copied
ifchains. - People need to know why a rule failed, so every node can describe itself and the tree can explain a rejection.
- Evaluating in memory requires loading every candidate, which is too expensive at scale, so specifications are translated into queries that use indexes.
- Rules that change faster than deploys become data, which demands parsing, validation and cost limits, so mature systems end up as policy engines.
Diagram
flowchart TD RULE["rule data: all(age >= 18, ref resident, not watchlist)"] --> B["build(rule, catalog)"] B --> T["And"] T --> L1["Compare age >= 18"] T --> L2["Named resident"] T --> L3["Not"] L3 --> L4["Compare watchlist == yes"] T --> V["validate: failures(customer)"] T --> S["select: filter many customers"] T --> Q["translate: SQL WHERE with indexes"] V --> R["failed: age >= 18"]
Worked example
An e-commerce company decides who gets free shipping. Marketing changes the rule about twice a month: basket at least 150,000 rupiah, or member tier gold, and not a marketplace seller's item from overseas, and not during a flash sale unless the customer is a new user. The rule existed in the checkout service, the cart banner and the order history page, and each copy was hand-edited. In a quarter, 6 of 25 changes reached only some copies, and customers saw "free shipping" in the cart and a shipping fee at checkout. Support handled about 1,400 tickets a month about it.
The team defined eight leaf specifications with descriptions and a catalog of named blocks (gold_member, overseas_item, flash_sale_window), stored the combined rule as data in the config service, and built it at startup and on config change. All three surfaces evaluate the same tree. When a customer is denied, the cart shows the first failed condition in plain language. Tickets about mismatched shipping fees dropped to about 60 a month. Each evaluation takes about 2 microseconds for an 11-node tree, irrelevant next to a 120 ms checkout. The one place that needed more work was a marketing report selecting eligible past orders from a 40-million-row table: the team added a SQL translation for the four leaf types the report used, and the report went from a 25-minute full scan with in-memory filtering to a 40-second indexed query.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
Inline if statements | Simplest; fastest | Duplicated across call sites; no explanation | Rule used once and rarely changes |
Predicate functions (lambda c: ...) | Composable with higher-order functions; light | Opaque: cannot describe, translate or store | Simple in-process filtering |
| Specification objects | Named rules; composable; can explain and translate | More classes; translation must stay faithful | Shared rules checked in several places |
| Query-translatable specifications | One rule for validation and indexed selection | Two evaluators to keep consistent | Rules used to select from large tables |
| Policy engine (CEL, OPA) | Rules as data; sandboxed; tooling | New language for the team; operational component | Rules owned by non-developers or many services |
Pitfalls in production
- In-memory selection over large tables:
[c for c in repo.all() if spec.is_satisfied_by(c)]loads everything. Fix: translate specifications into the query layer and restrict in-memory evaluation to single objects or small sets. - Two evaluators disagreeing on nulls: the in-memory
not (country == "ID")accepts a customer with no country, while SQLNOT (country = 'ID')rejects it because the comparison isNULL. Fix: define missing-value semantics explicitly and test both evaluators on the same fixtures. - Explanations that leak internals or are useless ("rule 7 failed"). Fix: give every leaf a business description and wrap composite policies in named specifications for customer-facing messages.
- Rule data with no validation: a typo in an operator or a reference to a deleted rule discovered at evaluation time. Fix: build and validate the whole tree when the configuration is loaded and reject bad configurations before they go live.
- Side effects inside predicates, such as a specification that calls a remote fraud API. Fix: keep specifications pure; fetch data first and pass it in as part of the candidate.
Leadership lens
- Ask how many places check a rule and who changes it. One place and one team: an
ifis fine. Several places, or a business team that changes it weekly: a specification, possibly as data. - Treat rule data like code: version it, review changes, run the rule against a fixed set of sample customers in CI, and keep an audit log of who changed what.
- Budget for the query translation when rules drive selection. It is where most of the engineering effort and most of the bugs live.
- Before building an in-house rules engine, evaluate CEL or OPA. They come with parsers, type checking and cost limits you would otherwise build and maintain.
Check yourself
Why must an explanation walk not short-circuit, while evaluation should?
Show answer
Evaluation only needs the Boolean answer, so it stops at the first decisive child. An explanation must list every failed condition, so it has to evaluate all children of a failing
And.Your in-memory specification and its SQL translation return different customers. What is the most likely cause?
Show answer
Different handling of missing or null values (SQL's three-valued logic) or of type mismatches; both evaluators must share explicit semantics and tests.
How is a specification different from a lambda predicate?
Show answer
A specification is an object with a name and structure, so besides being evaluated it can describe itself, explain failures, be stored as data and be translated into a query. A lambda can only be called.
When is a named specification better than exposing the full rule tree in an error message?
Show answer
When the internals are long, sensitive (fraud thresholds) or meaningless to the user. A named block such as
prime borrowerexplains at the right level of detail.
Watch
- Specification Pattern for Object Oriented Programming and Domain Driven Design (Coding Blocks): the pattern's structure and its role in domain-driven design, with example code.
Free resources
- Specifications (Eric Evans and Martin Fowler): the original paper, with validation, selection and construction to order, and subsumption between specifications.
- Specification pattern (Wikipedia): a compact summary with the composite structure and criticisms.
- Specifications (Spring Data JPA reference): specifications that compile to database predicates in a mainstream framework.
- Making queries: complex lookups with Q objects (Django docs): composable query predicates with
&,|and~. - Common Expression Language (cel.dev): a sandboxed, typed expression language for rules stored as data.
- Validating Admission Policy (Kubernetes docs): CEL rules evaluated inside the API server, with cost limits.