Engineering Atlas › Low-Level Design

Specification: composable business rules

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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) >= 18

Every 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

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

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

  1. Business rules are checked in several places and change often, so each rule needs one named home.
  2. Rules are built from smaller conditions, so they are composed from objects with a shared interface rather than copied if chains.
  3. People need to know why a rule failed, so every node can describe itself and the tree can explain a rejection.
  4. Evaluating in memory requires loading every candidate, which is too expensive at scale, so specifications are translated into queries that use indexes.
  5. 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

OptionStrengthsWeaknessesChoose when
Inline if statementsSimplest; fastestDuplicated across call sites; no explanationRule used once and rarely changes
Predicate functions (lambda c: ...)Composable with higher-order functions; lightOpaque: cannot describe, translate or storeSimple in-process filtering
Specification objectsNamed rules; composable; can explain and translateMore classes; translation must stay faithfulShared rules checked in several places
Query-translatable specificationsOne rule for validation and indexed selectionTwo evaluators to keep consistentRules used to select from large tables
Policy engine (CEL, OPA)Rules as data; sandboxed; toolingNew language for the team; operational componentRules owned by non-developers or many services

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. 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.

  3. 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.

  4. 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 borrower explains at the right level of detail.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →